From 872989eb2afe7ac7cad0d3a1efd3835d6806aa9e Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 23:14:13 +0000 Subject: [PATCH] Hand the picture's stream to full screen instead of re-tuning (0.31.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.31.0.md | 16 ++++++ android/app/build.gradle.kts | 4 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 6 ++- .../tv/orbit/classic/EmbyPlaybackDialog.kt | 12 ++--- .../java/tv/orbit/classic/GuidePicture.kt | 51 +++++++++++++++---- .../tv/orbit/classic/SampleChannelScreens.kt | 15 ++++-- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- 8 files changed, 82 insertions(+), 26 deletions(-) create mode 100644 VERIFICATION-0.31.0.md diff --git a/VERIFICATION-0.31.0.md b/VERIFICATION-0.31.0.md new file mode 100644 index 0000000..529608c --- /dev/null +++ b/VERIFICATION-0.31.0.md @@ -0,0 +1,16 @@ +# Orbit TV 0.31.0 verification + +Going full screen on the channel already in the picture no longer re-tunes. Paul noticed the reload on 0.29. + +## Changed +- The picture slot's open stream is handed to the full-screen player when the viewer goes full screen on that channel, from the menu or the guide. No new request goes to the provider and the sound should carry on. +- The picture's stream now includes external subtitles, so they are still there after the handover. +- Other channels, the transition test and a handover at a programme boundary still tune as before, and any stream that isn't taken is closed. + +## Checked +- assembleDebug, testDebugUnitTest and lintDebug pass; 138 tests, 0 failures. None of these exercise the handover, which needs real playback. +- APK signature verified with apksigner. + +## Not checked +- Not yet run on the box. A short black flash is possible as the picture moves to the full-screen surface; how visible it is depends on the box. +- Coming back from full screen to the menu or guide still re-tunes the picture. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7080220..e185845 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 = 31 - versionName = "0.30.0-drive-in" + versionCode = 32 + versionName = "0.31.0-seamless-picture" } 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 e603e8e..9e4f0f0 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.30.0\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.31.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 1f46a2e..d8a76b5 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -89,6 +89,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var channel by remember { mutableStateOf(null) } var watchingChannel by remember { mutableStateOf(false) } var guideCategory by remember { mutableStateOf(ChannelCategory.All) } + val liveHandoff = remember { LiveHandoff() } var channelClockOffset by remember { mutableLongStateOf(0) } var busyLabel by remember { mutableStateOf("Connecting…") } val sessionSlot = remember { EmbySessionSlot() } @@ -235,12 +236,13 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val exists = availableChannels.any { it.number == number } if (exists && number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number; channelClockOffset = 0 } exists - }) + }, handoff = liveHandoff) fun tune(number: Int, offset: Long) { if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number } + if (offset == 0L) liveHandoff.handOver(number) channelClockOffset = offset; watchingChannel = true } - val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel) } + val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff) } if (mainHome) HomeScreen(availableChannels, liveChannel, modifier, picture, onCategory = { guideCategory = it; onGuide() }, onWatch = { tune(it, 0) }, onDriveIn = onDriveIn) else GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory.label, picture, 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 a24011c..bb92d56 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) { + onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, subtitlePreference: MutableState? = null, runningPlayer: ExoPlayer? = null) { val context = LocalContext.current var failure by remember { mutableStateOf(null) } var tuningNumberVisible by remember { mutableStateOf(true) } @@ -53,13 +53,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl var subtitleIndex by remember { mutableStateOf(null) } var subtitleNote by remember { mutableStateOf(null) } val player = remember(session, plan) { + if (runningPlayer != null) return@remember runningPlayer val factory = DataSource.Factory { SecureMediaDataSource(session) } ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)) .setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply { - setMediaItem(MediaItem.Builder().setUri(plan.url).setSubtitleConfigurations(plan.subtitles.map { subtitle -> - MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)).setMimeType(subtitle.mime) - .setLanguage(subtitle.language).setLabel(subtitle.label).build() - }).build()) + setMediaItem(channelMediaItem(plan)) if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs())) prepare(); playWhenReady = true } @@ -104,7 +102,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl } } lifecycle.addObserver(observer) - var joinedSchedule = false + // A player handed over from the picture is already on schedule and may never report ready again. + var joinedSchedule = runningPlayer != null + if (player.playbackState == Player.STATE_READY) ready = true val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { val safeReason = generateSequence(error.cause) { it.cause }.filterIsInstance().firstOrNull()?.message 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 1b41570..f66e061 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -24,12 +24,40 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +/** A stream already open in the picture slot, so going full screen can carry on with it instead of re-tuning. */ +internal class LiveStream(val channelNumber: Int, val startMs: Long, val plan: EmbyPlaybackPlan, private val player: ExoPlayer) { + private var claimed = false + val available get() = !claimed + fun claim(): ExoPlayer? = if (claimed) null else player.also { claimed = true } + fun discard(session: EmbySampleSession) { claim()?.let { closeLiveStream(session, plan, it) } } +} + +internal class LiveHandoff { + var open: LiveStream? = null + var handed: LiveStream? = null + /** 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 } +} + +private fun closeLiveStream(session: EmbySampleSession, plan: EmbyPlaybackPlan, player: ExoPlayer) { + val position = player.currentPosition + player.release() + EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) } +} + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal fun channelMediaItem(plan: EmbyPlaybackPlan): MediaItem = MediaItem.Builder().setUri(plan.url).setSubtitleConfigurations(plan.subtitles.map { subtitle -> + MediaItem.SubtitleConfiguration.Builder(android.net.Uri.parse(subtitle.url)).setMimeType(subtitle.mime) + .setLanguage(subtitle.language).setLabel(subtitle.label).build() +}).build() + /** * The guide's picture slot: the tuned channel, live, on the channel's own clock. Artwork covers it until the * first frame and stays if the programme can't open. It holds one stream, so it stops when [live] goes false. */ @Composable -internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean) { +internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, live: Boolean, handoff: LiveHandoff) { val lifecycle = LocalLifecycleOwner.current.lifecycle var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } DisposableEffect(lifecycle) { @@ -46,7 +74,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li var ready by remember(channel.number, current.startMs, live, foreground) { mutableStateOf(false) } Box(Modifier.fillMaxSize().background(Panel)) { if (live && foreground) key(channel.number, current.startMs) { - GuideLiveProgramme(session, current, onReady = { ready = true }) + GuideLiveProgramme(session, channel.number, current, handoff, onReady = { ready = true }) } val branding = ChannelBrands.forNumber(channel.number) if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) @@ -54,7 +82,7 @@ internal fun GuidePicture(session: EmbySampleSession, channel: SampleChannel, li } @Composable -private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroadcast, onReady: () -> Unit) { +private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) { var plan by remember { mutableStateOf(null) } LaunchedEffect(Unit) { try { @@ -63,23 +91,25 @@ private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroa } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { /* The artwork stays. Tuning full screen still explains any problem. */ } } - plan?.let { GuideLivePlayer(session, it, programme, onReady) } + plan?.let { GuideLivePlayer(session, it, channelNumber, programme, handoff, onReady) } } @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @Composable -private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, programme: SampleBroadcast, onReady: () -> Unit) { +private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, channelNumber: Int, programme: SampleBroadcast, handoff: LiveHandoff, onReady: () -> Unit) { val context = LocalContext.current var ready by remember { mutableStateOf(false) } val player = remember(session, plan) { val factory = DataSource.Factory { SecureMediaDataSource(session) } ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(factory)).build().apply { - setMediaItem(MediaItem.fromUri(plan.url)) + setMediaItem(channelMediaItem(plan)) seekTo(programme.offsetAt(System.currentTimeMillis())) prepare(); playWhenReady = true } } DisposableEffect(player) { + val stream = LiveStream(channelNumber, programme.startMs, plan, player) + handoff.open = stream var joinedSchedule = false val listener = object : Player.Listener { override fun onPlaybackStateChanged(state: Int) { @@ -95,9 +125,10 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, } player.addListener(listener) onDispose { - val position = player.currentPosition - player.removeListener(listener); player.release() - EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) } + player.removeListener(listener) + if (handoff.open === stream) handoff.open = null + // Handed to the full-screen player, which owns it from here. + if (handoff.handed !== stream && stream.available) stream.discard(session) } } LaunchedEffect(player, ready) { @@ -114,5 +145,5 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, // The grid keeps the remote; the picture never takes focus. AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext -> PlayerView(nativeContext).apply { useController = false; isFocusable = false; descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS; this.player = player } - }) + }, onRelease = { it.player = null }) } 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 3819ba2..d2fb6fd 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -90,20 +90,27 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene @Composable internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long, - onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }) { + onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null) { // Outlives each programme and channel, and ends when the viewer leaves full screen. val subtitlePreference = remember { mutableStateOf(null) } var now by remember { mutableLongStateOf(System.currentTimeMillis() + clockOffsetMs) } LaunchedEffect(channel, clockOffsetMs) { while (true) { now = System.currentTimeMillis() + clockOffsetMs; delay(250) } } val shows = channel.guide(now, 2) val current = shows[0] + val handed = remember { + val stream = handoff?.take() + if (stream != null && stream.channelNumber == channel.number && stream.startMs == current.startMs && clockOffsetMs == 0L) stream + else { stream?.discard(session); null } + } val prepareMutex = remember(channel) { Mutex() } - var prepared by remember(channel) { mutableStateOf(null) } - var preparedSlot by remember(channel) { mutableStateOf(null) } + val handedNow = handed?.takeIf { it.available && it.channelNumber == channel.number } + var prepared by remember(channel) { mutableStateOf(handedNow?.plan) } + var preparedSlot by remember(channel) { mutableStateOf(handedNow?.startMs) } var problem by remember(channel) { mutableStateOf(null) } var retryAttempt by remember(channel) { mutableIntStateOf(0) } // A new wall-clock slot disposes the old player before opening its successor. LaunchedEffect(channel, current.startMs, retryAttempt) { + if (handed != null && prepared === handed.plan && preparedSlot == current.startMs) return@LaunchedEffect prepared = null; preparedSlot = null; problem = null try { val plan = prepareMutex.withLock { @@ -123,7 +130,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC if (plan != null) { key(current.startMs) { EmbyPlaybackDialog(session, plan, onClose, - scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference) + scheduled = ScheduledPlayback(channel, current, shows[1], clockOffsetMs), onStep = onStep, onLast = onLast, onNumber = onNumber, subtitlePreference = subtitlePreference, runningPlayer = handed?.takeIf { it.plan === plan }?.claim()) } } 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 af1ccdc..017df2c 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.30.0") + next.setRequestProperty("User-Agent", "OrbitTV/0.31.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()) {