Play the tuned channel live in the guide's picture slot (0.27.0)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 1m37s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m26s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-01 22:46:58 +00:00
co-authored by Claude Opus 5.5
parent 4e154081bc
commit 4ae5f8d8ed
9 changed files with 154 additions and 9 deletions
+17
View File
@@ -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.
+2 -2
View File
@@ -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 }
@@ -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)
@@ -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)
@@ -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<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
internal fun GuideGrid(channels: List<SampleChannel>, 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<SampleChannel>, 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)) {
@@ -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<EmbyPlaybackPlan?>(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 }
})
}
@@ -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 }
@@ -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()) {
@@ -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))
}
}