From 4ae5f8d8ed948b69337a78c71f4f5e3fd836f4ea Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sun, 1 Nov 2026 22:46:58 +0000 Subject: [PATCH] Play the tuned channel live in the guide's picture slot (0.27.0) Co-Authored-By: Claude Opus 5.5 (1M context) --- VERIFICATION-0.27.0.md | 17 +++ android/app/build.gradle.kts | 4 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 2 +- .../main/java/tv/orbit/classic/GuideGrid.kt | 6 +- .../java/tv/orbit/classic/GuidePicture.kt | 118 ++++++++++++++++++ .../tv/orbit/classic/SampleChannelScreens.kt | 5 +- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../java/tv/orbit/classic/GuideGridTest.kt | 7 ++ 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 VERIFICATION-0.27.0.md create mode 100644 android/app/src/main/java/tv/orbit/classic/GuidePicture.kt diff --git a/VERIFICATION-0.27.0.md b/VERIFICATION-0.27.0.md new file mode 100644 index 0000000..38c4e5d --- /dev/null +++ b/VERIFICATION-0.27.0.md @@ -0,0 +1,17 @@ +# Orbit TV 0.27.0 verification + +Live picture-in-picture in the guide's picture slot. + +## Changed +- The guide's picture slot plays the tuned channel live, joined at the channel's scheduled position, with sound. +- It stays on the tuned channel while the cursor browses other rows, so moving around never opens a new stream. +- Channel artwork covers the slot until the first frame, and stays if the programme can't open. +- The picture's stream stops before the full-screen player tunes, and when the app leaves the foreground, so only one stream is open at a time. +- The runtime check is now one tested helper, used by both the guide picture and the full-screen player. + +## Checked +- assembleDebug, testDebugUnitTest and lintDebug pass; 129 tests, 0 failures. +- APK signature verified with apksigner. + +## Not checked +- Not yet run on the box. The stream, sound, the change at a programme boundary and the return from full screen all need Paul's on-TV check. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ea2b698..fed9198 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 = 27 - versionName = "0.26.1-guide-panel" + versionCode = 28 + versionName = "0.27.0-guide-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 434b0a1..e937d83 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.26.1\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.27.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 1cd9b3e..1950b92 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -230,7 +230,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val previous = lastNumber if (previous != null && availableChannels.any { it.number == previous }) { lastNumber = selectedNumber; selectedNumber = previous; channelClockOffset = 0 } }) - GuideGrid(availableChannels, selectedNumber, modifier, onWatch = { number, offset -> + GuideGrid(availableChannels, selectedNumber, modifier, picture = { GuidePicture(liveSession, it, live = !watchingChannel) }, onWatch = { number, offset -> if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number } channelClockOffset = offset; watchingChannel = true }, onBack = onBack) diff --git a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt index dd6bfda..0ab39af 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -63,7 +62,7 @@ internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forw } @Composable -internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) { +internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, picture: @Composable (SampleChannel) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) { var now by remember { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } var row by remember { mutableIntStateOf(channels.indexOfFirst { it.number == selectedNumber }.coerceAtLeast(0)) } @@ -89,8 +88,9 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { val branding = ChannelBrands.forNumber(channel.number) + // The picture stays on the tuned channel while the cursor browses, so moving rows never re-tunes. Box(Modifier.width(170.dp).fillMaxHeight().background(Panel)) { - if (branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) + picture(channels.firstOrNull { it.number == selectedNumber } ?: channel) } // Four lines fill the panel's height. OK Watch is already in the legend below. Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { diff --git a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt new file mode 100644 index 0000000..6720df3 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -0,0 +1,118 @@ +package tv.orbit.classic + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.datasource.DataSource +import androidx.media3.ui.PlayerView +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * 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) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) foreground = true + if (event == Lifecycle.Event.ON_STOP) foreground = false + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { while (true) { delay(1000); now = System.currentTimeMillis() } } + val current = channel.at(now) + 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 }) + } + val branding = ChannelBrands.forNumber(channel.number) + if (!ready && branding != null) Image(painterResource(branding.background), null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) + } +} + +@Composable +private fun GuideLiveProgramme(session: EmbySampleSession, programme: SampleBroadcast, onReady: () -> Unit) { + var plan by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + 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, programme, onReady) } +} + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Composable +private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan, programme: SampleBroadcast, 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)) + seekTo(programme.offsetAt(System.currentTimeMillis())) + prepare(); playWhenReady = true + } + } + DisposableEffect(player) { + var joinedSchedule = false + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_READY) { + if (!joinedSchedule) { + joinedSchedule = true + val target = programme.offsetAt(System.currentTimeMillis()) + if (kotlin.math.abs(player.currentPosition - target) > 2_000) player.seekTo(target) + } + ready = true; onReady() + } + } + } + player.addListener(listener) + onDispose { + val position = player.currentPosition + player.removeListener(listener); player.release() + EmbyCleanup.run { session.report(plan, "Playing/Stopped", position, true) } + } + } + LaunchedEffect(player, ready) { + if (ready) { + val startPosition = player.currentPosition + withContext(Dispatchers.IO) { session.report(plan, "Playing", startPosition, false) } + while (true) { + delay(10_000) + val position = player.currentPosition; val paused = !player.isPlaying + withContext(Dispatchers.IO) { session.report(plan, "Playing/Progress", position, paused) } + } + } + } + // The grid keeps the remote; the picture never takes focus. + AndroidView(modifier = Modifier.fillMaxSize(), factory = { nativeContext -> + PlayerView(nativeContext).apply { useController = false; isFocusable = false; this.player = player } + }) +} 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 513f5e7..a34579e 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -85,6 +85,9 @@ internal fun SampleChannelGuide(channel: SampleChannel, modifier: Modifier = Mod } } +/** A schedule is only true if the file still runs as long as the slot it was given. */ +internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = openedMs != null && kotlin.math.abs(openedMs - scheduledMs) <= 2_000 + @Composable internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long, onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}) { @@ -106,7 +109,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC session.prepareForTuning(current.media.item, current.media.sourceId) { Thread.sleep(2_000) } } } - if (plan.durationMs == null || kotlin.math.abs(plan.durationMs - current.media.durationMs) > 2_000) + if (!scheduledRuntimeMatches(plan.durationMs, current.media.durationMs)) throw EmbyCheckException("This programme's runtime changed. Return and rebuild the sample channel.") preparedSlot = current.startMs; prepared = plan } catch (cancelled: CancellationException) { throw cancelled } 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 404ca01..731db84 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.26.1") + next.setRequestProperty("User-Agent", "OrbitTV/0.27.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()) { diff --git a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt index c8dc1e9..b6cdd03 100644 --- a/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/GuideGridTest.kt @@ -52,4 +52,11 @@ class GuideGridTest { val later = current.endMs + minute assertEquals(current.endMs - 1, guideStep(channel, later, now, false)) } + + @Test + fun aProgrammeOnlyOpensIfItStillFitsItsSlot() { + assertTrue(scheduledRuntimeMatches(1_000_000, 1_001_500)) + assertFalse(scheduledRuntimeMatches(1_000_000, 1_002_001)) + assertFalse(scheduledRuntimeMatches(null, 1_000_000)) + } }