From 0dfa70bc45a3dfd57a28740a6ff7e95359f063fe Mon Sep 17 00:00:00 2001 From: Pauljd Date: Tue, 3 Nov 2026 11:03:57 +0000 Subject: [PATCH] 0.83.3: idents and the intro on their own surface, and hidden screens pause their clocks during full-screen play Co-Authored-By: Claude Opus 5.5 (1M context) --- android/app/build.gradle.kts | 4 ++-- .../app/src/main/java/tv/orbit/classic/DriveIn.kt | 2 +- .../src/main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../java/tv/orbit/classic/EmbyConnectionScreen.kt | 2 +- .../src/main/java/tv/orbit/classic/GuideGrid.kt | 2 +- .../main/java/tv/orbit/classic/GuidePicture.kt | 2 +- .../src/main/java/tv/orbit/classic/HomeScreen.kt | 2 +- .../app/src/main/java/tv/orbit/classic/Idents.kt | 5 ++--- .../main/java/tv/orbit/classic/MainActivity.kt | 2 +- .../java/tv/orbit/classic/PresentationIntro.kt | 15 +++++++++++++-- .../java/tv/orbit/classic/SampleChannelScreens.kt | 1 + .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- 12 files changed, 26 insertions(+), 15 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 37742c0..7335cf6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 99 - versionName = "0.83.2-second-changeover" + versionCode = 100 + versionName = "0.83.3-smooth-idents" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt index 7449993..eecf5aa 100644 --- a/android/app/src/main/java/tv/orbit/classic/DriveIn.kt +++ b/android/app/src/main/java/tv/orbit/classic/DriveIn.kt @@ -379,7 +379,7 @@ internal fun DriveInReminderCard(reminder: DriveInReminder, nowMs: Long, onGo: ( internal fun DriveInScreen(session: EmbySampleSession, plan: ChannelPlan?, visit: Int, modifier: Modifier, active: Boolean, tickets: Map, onTicket: (DriveInBill) -> Unit, onHandBack: (DriveInBill) -> Unit, onWatch: () -> Unit) { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } val minute = now / 60_000 val lineup = remember(plan, minute) { plan?.let { driveInLineup(driveInBills(it), now) }.orEmpty() } var chosenKind by remember { mutableStateOf(null) } diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index b70ca7c..b5d52d2 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.83.2\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.83.3\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt index 800b4ea..0475a0a 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -236,7 +236,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea catch (_: Exception) { message = "Connected, but this setup could not be saved securely. It will need sign-in after restart." } } } - LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } LaunchedEffect(scheduledChannels) { windingDown = scheduledChannels[TOTS_NUMBER]?.stepIn?.let { it.endMs > System.currentTimeMillis() } == true } fun smartLoop(programming: ChannelProgramming) = (extraChannels + listOfNotNull(channel, afterDarkChannel, toonamiChannel)).firstOrNull { it.number == programming.number } // Keeps smart channels topped up in the background. A failure changes nothing on air. diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index 56a110e..b28d358 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -100,7 +100,7 @@ internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forw @Composable internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, title: String = "All Channels", picture: @Composable () -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) } var cursor by remember { mutableLongStateOf(now) } var slotsAhead by remember { mutableIntStateOf(0) } diff --git a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt index cccc4d4..19d28f0 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -73,7 +73,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li onDispose { lifecycle.removeObserver(observer) } } var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } val current = channel.at(now) var ready by remember(channel.number, current.startMs, live, foreground) { mutableStateOf(false) } // A file that can't play here leaves the artwork up until the next programme. diff --git a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt index 63bd35d..fac081d 100644 --- a/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/HomeScreen.kt @@ -126,7 +126,7 @@ private fun DriveInCard(session: EmbySampleSession, plan: ChannelPlan?, now: Lon internal fun HomeScreen(session: EmbySampleSession, channels: List, tuned: SampleChannel, modifier: Modifier = Modifier, picture: @Composable () -> Unit, onCategory: (ChannelCategory) -> Unit, onWatch: (Int) -> Unit, onDriveIn: () -> Unit, driveInPlan: ChannelPlan? = null) { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } val firstFocus = remember { FocusRequester() } val gear = LocalNavFocus.current LaunchedEffect(Unit) { firstFocus.requestFocus() } diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index 5b411c6..08c4bb5 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -1,7 +1,6 @@ package tv.orbit.classic import android.content.Context -import android.view.TextureView import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.Image @@ -101,8 +100,8 @@ internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, ne } } DisposableEffect(player) { onDispose { player.release() } } - // Drawn in the window itself, so it can sit over the show's own picture. - AndroidView(modifier = Modifier.fillMaxSize(), factory = { TextureView(it).also(player::setVideoTextureView) }) + // Its own surface, faded with the overlay, sitting over the show's own picture. + AndroidView(modifier = Modifier.fillMaxSize(), factory = { overlaySurface(it, player) }, update = { it.alpha = fade.value }) } else { ProgrammeArtwork(session, channel, next) ChannelBrands.forNumber(channel.number)?.let { brand -> diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 95e65e0..2666e3f 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -83,7 +83,7 @@ private fun NavItem(label: String, selected: Boolean, focus: FocusRequester?, be @Composable private fun OrbitApp() { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } } + LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } var realChannelAvailable by remember { mutableStateOf(false) } var section by remember { mutableStateOf("Home") } var restoreChecked by remember { mutableStateOf(false) } diff --git a/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt b/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt index 424d27e..ee2a238 100644 --- a/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt +++ b/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt @@ -2,7 +2,7 @@ package tv.orbit.classic import android.content.Context import android.os.SystemClock -import android.view.TextureView +import android.view.SurfaceView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -86,12 +86,23 @@ internal fun rememberIntro(source: IntroSource?, onEnd: (String) -> Unit): Intro return intro } +/** True while a programme plays full screen. Screens hidden behind it stop their clocks, so they don't churn memory under the picture. */ +internal object FullScreen { + var active by mutableStateOf(false) +} + +/** + * Video drawn straight to the screen above the programme's own picture, rather than through the app's interface, + * so a busy moment in the interface can't make it stutter. + */ +internal fun overlaySurface(context: Context, player: ExoPlayer) = SurfaceView(context).apply { setZOrderMediaOverlay(true) }.also(player::setVideoSurfaceView) + /** Drawn over everything else on the screen while the intro is showing. */ @Composable internal fun IntroOverlay(intro: IntroPlayback) { val player = intro.player ?: return if (!intro.showing) return Box(Modifier.fillMaxSize().background(Color.Black)) { - AndroidView(modifier = Modifier.fillMaxSize(), factory = { TextureView(it).also(player::setVideoTextureView) }) + AndroidView(modifier = Modifier.fillMaxSize(), factory = { overlaySurface(it, player) }) } } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 423b7e8..85ae7ed 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -61,6 +61,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC val phase = if (identsHere) identPhase(current.startMs, current.endMs, now, identMs) else IdentPhase.None // Each changeover in the playback notes, so a missing ident can be explained afterwards. val notes = remember { java.io.File(settingsContext.noBackupFilesDir, "schedule") } + DisposableEffect(Unit) { FullScreen.active = true; onDispose { FullScreen.active = false } } val introPlayback = rememberIntro(intro) { PlaybackNotes.add(notes, "channel=${channel.number} $it") } LaunchedEffect(channel, current.startMs) { PlaybackNotes.add(notes, "channel=${channel.number} programme start=${current.startMs} lengthMs=${current.endMs - current.startMs} nextFollows=${next.startMs == current.endMs} scheduled=${channel is ScheduledChannel} offset=$clockOffsetMs identsOn=$identsOn") diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index c976d48..5e2456c 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.83.2") + next.setRequestProperty("User-Agent", "OrbitTV/0.83.3") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {