diff --git a/VERIFICATION-0.83.4.md b/VERIFICATION-0.83.4.md new file mode 100644 index 0000000..57e3aa7 --- /dev/null +++ b/VERIFICATION-0.83.4.md @@ -0,0 +1,10 @@ +# Orbit TV 0.83.4: smooth, seamless changeovers + +Builds on 0.83.2 (curtain intro, Tots fix, second-changeover fix). + +- 0.83.3: idents and the curtain intro draw to their own media surface above the programme, not through the app's interface, so a busy interface can't make them stutter. Screens hidden behind full-screen play stop their 1-second clocks, which were churning about 19 MB every couple of seconds. +- 0.83.4: one full-screen window for the whole visit to a channel. Before, every programme opened a new window, and the home screen showed through for a moment at each changeover. The ident now plays over the whole window and holds its last frame until the next programme's first picture is on screen, for up to 6 seconds past the changeover. + +Measured before the fix, at the Heroes changeover at 11:00: 220 frames, 14.5% late, 90th percentile 105 ms, 99th 300 ms, 32 slow UI-thread frames. The comparison measurement comes after installing. + +Unit tests: 321 passed. Lint passed. The changes are in the player's composition, so they're proven on the box. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7335cf6..e018103 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 = 100 - versionName = "0.83.3-smooth-idents" + versionCode = 101 + versionName = "0.83.4-one-window" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } 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 b5d52d2..e8a3a25 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.3\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.83.4\"") 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/EmbyPlaybackDialog.kt b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt index a46eb5d..984ec23 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -45,7 +45,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl /** Set while an ident covers the end of the show: drawn over everything, with the show itself quietened underneath. */ ident: (@Composable () -> Unit)? = null, /** Shown over the opening moments, with the programme playing silently beneath it at its proper place. */ - intro: IntroPlayback? = null) { + intro: IntroPlayback? = null, + /** A window that stays open across programmes. Without one, this opens its own. */ + window: PlaybackWindow? = null, + /** Called when the programme's first picture is on screen, so anything held over the changeover can go. */ + onFirstFrame: (() -> Unit)? = null) { val context = LocalContext.current val logoSetting = remember { OrbitSettings(context).channelLogo } var failure by remember { mutableStateOf(null) } @@ -177,6 +181,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl ready = false } override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying } + override fun onRenderedFirstFrame() { onFirstFrame?.invoke() } override fun onTracksChanged(tracks: Tracks) { tracks.groups.filter { it.isSelected && (it.type == C.TRACK_TYPE_AUDIO || it.type == C.TRACK_TYPE_VIDEO) }.forEach { group -> val format = (0 until group.length).firstOrNull(group::isTrackSelected)?.let(group::getTrackFormat) ?: return@forEach @@ -245,15 +250,16 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl LaunchedEffect(infoVisible, lastInteraction, playing, ready) { if (infoVisible && playing && ready) { delay(5_000); infoVisible = false } } - Dialog(onDismissRequest = { if (intro?.showing == true) intro.skip() else if (trackMenu != null) trackMenu = null else if (numberEntry != null) { numberEntry = null; numberProblem = null } else { + val dismiss: () -> Unit = { if (intro?.showing == true) intro.skip() else if (trackMenu != null) trackMenu = null else if (numberEntry != null) { numberEntry = null; numberProblem = null } else { // Backing out keeps the stream running in the picture, without subtitles at that size. if (ready && failure == null && foreground && onHandBack?.invoke(player) == true) { handedBack = true player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build() } onClose() - } }, properties = DialogProperties(usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { + } } + if (window != null) SideEffect { window.onBack = dismiss } + val content: @Composable () -> Unit = { Box(Modifier.fillMaxSize().background(Color.Black)) { AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext -> PlayerView(nativeContext).apply { @@ -353,7 +359,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl if (infoVisible && failure == null && ready) ClassicProgrammeStrip(plan.item, playing, Modifier.align(Alignment.BottomCenter).fillMaxWidth(.86f).padding(bottom = 26.dp), scheduled = scheduled) ident?.invoke() - intro?.let { IntroOverlay(it) } + if (window == null) intro?.let { IntroOverlay(it) } trackMenu?.let { TrackPanel(it, trackCursor, Modifier.align(Alignment.CenterEnd).padding(end = 28.dp)) } trackProblem?.let { Text(it, Modifier.align(Alignment.TopCenter).padding(top = 44.dp).background(Navy.copy(alpha = .85f)).padding(horizontal = 14.dp, vertical = 8.dp), color = Color.White, fontSize = 16.sp) } numberEntry?.let { ChannelNumberBox(it, numberProblem, Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) } @@ -364,4 +370,6 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl } } } + if (window != null) content() else Dialog(onDismissRequest = dismiss, properties = DialogProperties(usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { content() } } 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 08c4bb5..614499a 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -29,6 +29,8 @@ internal const val IDENT_VIDEO_MS = 7_000L internal const val IDENT_STING_MS = 5_000L /** Long enough to open the next show and fill its buffer before the ident starts. */ internal const val IDENT_LOAD_AHEAD_MS = 12_000L +/** How long past the changeover an ident may hold its last frame while the next programme's picture arrives. */ +internal const val IDENT_HOLD_MS = 6_000L private const val IDENT_SHORTEST_PROGRAMME_MS = 3 * 60_000L private val IdentVideos = mapOf(101 to R.raw.ident_101, 102 to R.raw.ident_102, 103 to R.raw.ident_103, 104 to R.raw.ident_104, 105 to R.raw.ident_105, 106 to R.raw.ident_106, 107 to R.raw.ident_107, 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 ee2a238..25e948f 100644 --- a/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt +++ b/android/app/src/main/java/tv/orbit/classic/PresentationIntro.kt @@ -86,6 +86,11 @@ internal fun rememberIntro(source: IntroSource?, onEnd: (String) -> Unit): Intro return intro } +/** The full-screen window for a visit to a channel. Whatever is showing in it says what Back does. */ +internal class PlaybackWindow { + var onBack: () -> Unit = {} +} + /** 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) 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 85ae7ed..2966a4f 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -115,35 +115,55 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC } // Suppress the preceding plan immediately at a boundary while the effect schedules its replacement. val plan = loadedNow?.plan ?: prepared?.takeIf { it.item.id == current.media.item.id && preparedSlot == current.startMs && problem == null } - if (plan != null) { - key(current.startMs) { + // The ident playing out, held on its last frame until the next programme's first picture is on screen. + var heldIdent by remember(channel) { mutableStateOf(null) } + LaunchedEffect(phase == IdentPhase.Showing, next.startMs) { if (phase == IdentPhase.Showing) heldIdent = next } + LaunchedEffect(heldIdent) { + val held = heldIdent ?: return@LaunchedEffect + // However the next programme fares, the screen never stays on the ident for long. + delay((held.startMs + IDENT_HOLD_MS - System.currentTimeMillis()).coerceAtLeast(0)) + if (heldIdent === held) heldIdent = null + } + // One window for the whole visit, so a changeover never shows what's behind it. + val window = remember { PlaybackWindow() } + Dialog(onDismissRequest = { window.onBack() }, properties = DialogProperties(usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { Box(Modifier.fillMaxSize().background(Color.Black)) { + if (plan != null) key(current.startMs) { val announce = remember { announcedChannel[0] != channel.number } SideEffect { announcedChannel[0] = channel.number } EmbyPlaybackDialog(session, plan, onClose, scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = loadedNow?.claim() ?: handed?.takeIf { it.plan === plan }?.claim(), - ident = if (phase == IdentPhase.Showing) { { IdentOverlay(session, channel, next, identMs, now - (current.endMs - identMs)) } } else null, intro = introPlayback, announce = announce, onUnplayable = onUnplayable, audioPreference = audioPreference, + // The ident itself is drawn below, over the whole window. This only quietens the programme under it. + ident = if (phase == IdentPhase.Showing) { {} } else null, intro = introPlayback, announce = announce, onUnplayable = onUnplayable, audioPreference = audioPreference, onHandBack = { running -> if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false - }) + }, + window = window, onFirstFrame = { if (heldIdent?.startMs == current.startMs) heldIdent = null }) + } else { + SideEffect { window.onBack = { if (introPlayback?.showing == true) introPlayback.skip() else onClose() } } + if (problem == null) ChannelTuningBackdrop(channel.number) { ProgrammeArtwork(session, channel, current) } + else Box(Modifier.fillMaxSize().background(Navy)) { + Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(problem.orEmpty(), color = Color.White, fontSize = 20.sp) + val retryFocus = remember { FocusRequester() } + LaunchedEffect(problem) { retryFocus.requestFocus() } + OrbitButton("Retry", Modifier.focusRequester(retryFocus), onClick = { + problem = null + retryAttempt++ + }) + OrbitButton("Return to TV guide", onClick = onClose) + } + } } - } else Dialog(onDismissRequest = { if (introPlayback?.showing == true) introPlayback.skip() else onClose() }, properties = DialogProperties(usePlatformDefaultWidth = false, - decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) { Box(Modifier.fillMaxSize()) { - if (problem == null) ChannelTuningBackdrop(channel.number) { ProgrammeArtwork(session, channel, current) } - else Box(Modifier.fillMaxSize().background(Navy)) { - Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).padding(32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(problem.orEmpty(), color = Color.White, fontSize = 20.sp) - val retryFocus = remember { FocusRequester() } - LaunchedEffect(problem) { retryFocus.requestFocus() } - OrbitButton("Retry", Modifier.focusRequester(retryFocus), onClick = { - problem = null - retryAttempt++ - }) - OrbitButton("Return to TV guide", onClick = onClose) + heldIdent?.let { held -> + key(held.startMs) { + val length = identLength(channel.number, held.startMs) + val startedLate = remember { (System.currentTimeMillis() + clockOffsetMs - (held.startMs - length)).coerceAtLeast(0) } + IdentOverlay(session, channel, held, length, startedLate) } } introPlayback?.let { IntroOverlay(it) } } } - } @Composable 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 5e2456c..d0053fd 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.3") + next.setRequestProperty("User-Agent", "OrbitTV/0.83.4") 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()) {