0.83.4: one window per visit, and the ident holds its last frame until the next picture is on screen
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m39s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m40s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-03 11:08:05 +00:00
co-authored by Claude Opus 5.5
parent 0dfa70bc45
commit d3d79999fc
8 changed files with 72 additions and 27 deletions
+10
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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)
@@ -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<String?>(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() }
}
@@ -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,
@@ -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)
@@ -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<SampleBroadcast?>(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
@@ -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()) {