Open on the main menu and keep the stream when backing out of full screen (0.33.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m4s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m19s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 23:19:54 +00:00
co-authored by Claude Opus 5.5
parent a591a28c74
commit bef3bfcc7c
9 changed files with 73 additions and 25 deletions
+15
View File
@@ -0,0 +1,15 @@
# Orbit TV 0.33.0 verification
Two fixes Paul asked for after testing 0.32.
## Changed
- The app always opens on the main menu. Leaving it from any screen except Connect Emby returns it to the menu next time.
- At a cold start, the demo guide and its video no longer show while the saved connection is checked. A plain "Tuning in…" shows instead, and the demo guide only appears on a box with no saved channels.
- Backing out of full screen hands the running stream back to the picture instead of re-tuning. Subtitles are turned off at that size. The transition test, a failed stream or leaving the app don't hand back.
## Checked
- assembleDebug, testDebugUnitTest and lintDebug pass; 138 tests, 0 failures. None of these exercise the handover or the opening screen.
- APK signature verified with apksigner.
## Not checked
- Not yet run on the box. Opening cold and from the background, and backing out from both the menu and the guide, need Paul's on-TV check.
+2 -2
View File
@@ -9,8 +9,8 @@ android {
applicationId = "tv.orbit.classic"
minSdk = 26
targetSdk = 36
versionCode = 33
versionName = "0.32.0-quiet-rollover"
versionCode = 34
versionName = "0.33.0-open-home"
}
buildFeatures { compose = true }
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
@@ -41,7 +41,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.32.0\"")
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.33.0\"")
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
if (request.method == "POST") {
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
@@ -50,7 +50,7 @@ import kotlinx.coroutines.withContext
@Composable
internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean,
mainGuide: Boolean, mainHome: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onGuide: () -> Unit,
mainGuide: Boolean, mainHome: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit,
onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}, onBack: () -> Unit) {
val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
val savedStore = remember { RememberedConnectionStore.get(appContext) }
@@ -183,7 +183,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
try { withContext(Dispatchers.IO) { savedStore.clear() } } catch (_: Exception) { }
pendingSaved = null
message = "The saved connection could not be opened. Sign in again."
} finally { restoring = false }
} finally { restoring = false; onRestoreChecked() }
}
LaunchedEffect(session, seriesMatches, approvedSeries, episodeSamples, channel, toonamiChannel, selectedNumber, restoring, afterDarkChannel, darkMovies, approvedDark) {
val active = session
@@ -38,7 +38,7 @@ import kotlinx.coroutines.withContext
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true) {
onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState<String?>? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null) {
val context = LocalContext.current
var failure by remember { mutableStateOf<String?>(null) }
var tuningNumberVisible by remember { mutableStateOf(announce) }
@@ -47,6 +47,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
var infoVisible by remember { mutableStateOf(announce) }
var lastInteraction by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
fun showInfo() { infoVisible = true; lastInteraction = SystemClock.elapsedRealtime() }
var handedBack by remember { mutableStateOf(false) }
var numberEntry by remember { mutableStateOf<ChannelEntry?>(null) }
var numberProblem by remember { mutableStateOf<String?>(null) }
var okHeld by remember { mutableStateOf(false) }
@@ -133,7 +134,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
onDispose {
val position = player.currentPosition
lifecycle.removeObserver(observer)
player.removeListener(listener); player.release()
player.removeListener(listener)
if (handedBack) return@onDispose
player.release()
if (!stopReported) EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) }
}
}
@@ -155,7 +158,14 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
LaunchedEffect(infoVisible, lastInteraction, playing, ready) {
if (infoVisible && playing && ready) { delay(5_000); infoVisible = false }
}
Dialog(onDismissRequest = { if (numberEntry != null) { numberEntry = null; numberProblem = null } else onClose() }, properties = DialogProperties(usePlatformDefaultWidth = false,
Dialog(onDismissRequest = { 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)) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext ->
@@ -38,6 +38,8 @@ internal class LiveHandoff {
/** Call before going full screen. The picture then leaves its stream running for the player to take. */
fun handOver(channelNumber: Int) { handed = open?.takeIf { it.channelNumber == channelNumber }; open = null }
fun take(): LiveStream? = handed.also { handed = null }
/** Left by the full-screen player on the way out, for the picture to carry on with. */
var returned: LiveStream? = null
}
private fun closeLiveStream(session: EmbySampleSession, plan: EmbyPlaybackPlan, player: ExoPlayer) {
@@ -83,23 +85,31 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li
@Composable
private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
var plan by remember { mutableStateOf<EmbyPlaybackPlan?>(null) }
val returned = remember {
val stream = handoff.returned
handoff.returned = null
if (stream != null && stream.channelNumber == channelNumber && stream.startMs == programme.startMs) stream
else { stream?.discard(session); null }
}
var plan by remember { mutableStateOf(returned?.plan) }
LaunchedEffect(Unit) {
if (returned != null) return@LaunchedEffect
try {
val opened = withContext(Dispatchers.IO) { session.prepareForTuning(programme.media.item, programme.media.sourceId) { Thread.sleep(2_000) } }
if (scheduledRuntimeMatches(opened.durationMs, programme.media.durationMs)) plan = opened
} catch (cancelled: CancellationException) { throw cancelled }
catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ }
}
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady) }
plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady, returned?.claim()) }
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) {
private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit, runningPlayer: ExoPlayer? = null) {
val context = LocalContext.current
var ready by remember { mutableStateOf(false) }
val player = remember(session, plan) {
if (runningPlayer != null) return@remember runningPlayer
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).build().apply {
setMediaItem(channelMediaItem(plan))
@@ -110,7 +120,8 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
DisposableEffect(player) {
val stream = LiveStream(channelNumber, programme.startMs, plan, player)
handoff.open = stream
var joinedSchedule = false
var joinedSchedule = runningPlayer != null
if (player.playbackState == Player.STATE_READY) { ready = true; onReady() }
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
@@ -73,6 +73,9 @@ private fun OrbitApp() {
LaunchedEffect(Unit) { while (true) { now = System.currentTimeMillis(); delay(1000) } }
var realChannelAvailable by remember { mutableStateOf(false) }
var section by remember { mutableStateOf("TV Guide") }
// The demo guide is only for a box with no saved channels, so it waits until that's known.
var restoreChecked by remember { mutableStateOf(false) }
val showDemo = section == "TV Guide" && !realChannelAvailable && restoreChecked
var tuned by remember { mutableIntStateOf(0) }
var fullscreen by remember { mutableStateOf(false) }
var selectedChannel by remember { mutableIntStateOf(0) }
@@ -101,19 +104,23 @@ private fun OrbitApp() {
onDispose { player.removeListener(listener) }
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(player, lifecycle, section, fullscreen, realChannelAvailable) {
DisposableEffect(player, lifecycle, section, fullscreen, realChannelAvailable, showDemo) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) player.pause()
if (event == Lifecycle.Event.ON_START && ((section == "TV Guide" && !realChannelAvailable) || fullscreen)) player.play()
if (event == Lifecycle.Event.ON_STOP) {
player.pause()
// Opening the app always lands on the main menu, wherever it was left.
if (realChannelAvailable && section != "Connect Emby") section = "Home"
}
if (event == Lifecycle.Event.ON_START && (showDemo || fullscreen)) player.play()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
LaunchedEffect(section, fullscreen, realChannelAvailable) {
if ((section == "TV Guide" && !realChannelAvailable) || fullscreen) player.play() else player.pause()
LaunchedEffect(section, fullscreen, showDemo) {
if (showDemo || fullscreen) player.play() else player.pause()
}
LaunchedEffect(tuned, programme.start) {
player.seekTo(programme.offsetAt(System.currentTimeMillis())); if ((section == "TV Guide" && !realChannelAvailable) || fullscreen) player.play()
player.seekTo(programme.offsetAt(System.currentTimeMillis())); if (showDemo || fullscreen) player.play()
}
// Recheck on resume: a stopped app must rejoin the wall-clock schedule.
DisposableEffect(lifecycle, tuned) {
@@ -129,8 +136,8 @@ private fun OrbitApp() {
BackHandler(fullscreen || pinned || section != "TV Guide") {
when { fullscreen -> fullscreen = false; pinned -> pinned = false; section == "Services" || section == "Connect Emby" -> section = "Menu"; else -> section = if (realChannelAvailable) "Home" else "TV Guide" }
}
LaunchedEffect(section, fullscreen, realChannelAvailable) {
if (!fullscreen) {
LaunchedEffect(section, fullscreen, realChannelAvailable, restoreChecked) {
if (!fullscreen && restoreChecked) {
if (!realChannelAvailable) {
val tab = when (section) { "TV Guide" -> 1; "Drive-In" -> 2; else -> 0 }
tabFocus[tab].requestFocus()
@@ -163,7 +170,7 @@ private fun OrbitApp() {
}
Spacer(Modifier.height(8.dp))
// The numbered tabs only serve the demo guide. With real channels, home and the gear replace them.
if (!realChannelAvailable) {
if (!realChannelAvailable && restoreChecked) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("Menu", "TV Guide", "Drive-In").forEachIndexed { index, title ->
OrbitButton("${index + 1} $title", Modifier.focusRequester(tabFocus[index]),
@@ -172,7 +179,9 @@ private fun OrbitApp() {
}
Spacer(Modifier.height(8.dp))
}
if (section == "TV Guide" && !realChannelAvailable) {
if (!restoreChecked) {
Text("Tuning in…", color = Muted, fontSize = 15.sp)
} else if (showDemo) {
Row(Modifier.fillMaxWidth().height(120.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
Box(Modifier.width(256.dp).fillMaxHeight().border(1.dp, Muted)) {
Video(player, Modifier.fillMaxSize())
@@ -236,7 +245,7 @@ private fun OrbitApp() {
EmbyConnectionScreen(Modifier.weight(1f), visible = section == "Connect Emby",
mainGuide = section == "TV Guide" && realChannelAvailable,
mainHome = section == "Home" && realChannelAvailable,
onChannelAvailable = { realChannelAvailable = it },
onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true },
onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }, onBack = { section = "Menu" })
}
}
@@ -134,7 +134,10 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
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 = handed?.takeIf { it.plan === plan }?.claim(), announce = announce)
scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim(), announce = announce,
onHandBack = { running ->
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
})
}
} else Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, securePolicy = SecureFlagPolicy.SecureOn)) {
@@ -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.32.0")
next.setRequestProperty("User-Agent", "OrbitTV/0.33.0")
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()) {