0.83.4: one window per visit, and the ident holds its last frame until the next picture is on screen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user