diff --git a/VERIFICATION-0.33.0.md b/VERIFICATION-0.33.0.md new file mode 100644 index 0000000..eca2a84 --- /dev/null +++ b/VERIFICATION-0.33.0.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8c8d162..5d5e54c 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 = 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 } 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 eb10813..a6d8f56 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -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) 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 d8a76b5..13d9860 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 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 c3f4bc5..2b6adf2 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true) { + onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState? = null, runningPlayer: ExoPlayer? = null, announce: Boolean = true, onHandBack: ((ExoPlayer) -> Boolean)? = null) { val context = LocalContext.current var failure by remember { mutableStateOf(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(null) } var numberProblem by remember { mutableStateOf(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 -> 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 f66e061..3ef1b6b 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -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(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) { 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 07a5372..603d7d9 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -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" }) } } 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 993f61e..f66ff91 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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)) { 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 486768c..9606d31 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.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()) {