Quicker tuning, a choosable picture, a guide that stops at now

Port of release 0.108.63.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:56:42 +00:00
co-authored by Claude Opus 5.5
parent 5e49922ee6
commit b0aa9fa82f
7 changed files with 39 additions and 12 deletions
@@ -10,3 +10,13 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun programmeRenderers(context: Context): DefaultRenderersFactory = DefaultRenderersFactory(context)
/**
* How much of a programme is loaded before it starts playing (Paul, 6 November 2026: tuning still took a while). Media3
* waits for 2.5 seconds of picture by default; on the home network 1 second is plenty, and after a stall it waits for 3.
* The rest of the buffer is as standard.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun programmeLoadControl(): androidx.media3.exoplayer.LoadControl = androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, androidx.media3.exoplayer.DefaultLoadControl.DEFAULT_MAX_BUFFER_MS, 1_000, 3_000)
.build()
@@ -555,7 +555,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
onWatch = { tune(it, 0) }, onDriveIn = onDriveIn, onBack = onHome,
onRemind = { item -> saveReminders(liveReminders(reminders, System.currentTimeMillis()) + ProgrammeReminder(item.channel, item.startMs, item.title)) })
else -> GuideGrid(channelsIn(guideCategory, availableChannels).ifEmpty { availableChannels }, selectedNumber, modifier, guideCategory, homeCategories(availableChannels.map { it.number }), picture,
onCategory = { guideCategory = it }, onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() }, onCatchUp = { catchUpOpen = it })
onCategory = { guideCategory = it }, onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() }, onCatchUp = { catchUpOpen = it },
onPicture = { tune(selectedNumber, 0) })
}
return
}
@@ -114,7 +114,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
val player = remember(session, plan) {
if (runningPlayer != null) return@remember runningPlayer
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory()))
ExoPlayer.Builder(context, programmeRenderers(context)).setLoadControl(programmeLoadControl()).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory()))
.setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(channelMediaItem(plan))
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
@@ -120,6 +120,8 @@ private const val GUIDE_MAX_SLOTS_BACK = (CATCH_UP_MS / GUIDE_SLOT_MS).toInt()
@Composable
internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, category: ChannelCategory = ChannelCategory.All,
categories: List<ChannelCategory> = listOf(ChannelCategory.All), picture: @Composable () -> Unit = {}, onCategory: (ChannelCategory) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit,
/** OK on the picture: full screen on what it's showing (Paul, 6 November 2026). */
onPicture: () -> Unit = {},
/** OK on a finished programme that can be caught up. */
onCatchUp: (CatchUpEntry) -> Unit = {}) {
val title = category.label
@@ -131,6 +133,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
var firstRow by remember { mutableIntStateOf(0) }
var rowsShown by remember { mutableIntStateOf(0) }
val gridFocus = remember { FocusRequester() }
val pictureFocus = remember { FocusRequester() }
var gridFocused by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { gridFocus.requestFocus() }
BackHandler(onBack = onBack)
@@ -138,8 +141,8 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
val windowEnd = windowStart + GUIDE_SLOTS * GUIDE_SLOT_MS
val nowFraction = guideNowFraction(now, windowStart, windowEnd)
val channel = channels[row.coerceIn(channels.indices)]
// Before now only where the channel's plan still holds what aired (Catch Up); otherwise the cursor stays at now.
val earliest = catchUpEarliest(channel, now)
// Never before now (Paul, 6 November 2026: going back past what's on was confusing). Catch Up has its own page.
val earliest = now
val focused = guideAt(channel, maxOf(cursor, earliest))
// OK on a shorts slot opens it: every short, each its own programme (Paul, 6 November 2026).
var openSlot by remember { mutableStateOf<Pair<ScheduledChannel, ShortsBlock>?>(null) }
@@ -174,8 +177,14 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
CatchUpMark(); Text("CATCH UP • OK to watch from the start", color = CatchUpGreen, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
}
}
Box(Modifier.width(180.dp).fillMaxHeight().background(Panel)) {
// The picture can be chosen: up from the top row reaches it, OK goes full screen on it, down comes back.
var pictureFocused by remember { mutableStateOf(false) }
Box(Modifier.width(180.dp).fillMaxHeight().focusRequester(pictureFocus).focusProperties { down = gridFocus }
.onFocusChanged { pictureFocused = it.isFocused }.clickable(onClick = onPicture)
.background(Panel).border(if (pictureFocused) 3.dp else 0.dp, if (pictureFocused) Yellow else Color.Transparent)) {
picture()
if (pictureFocused) Text("OK Full screen", Modifier.align(Alignment.BottomCenter).background(Yellow).padding(horizontal = 8.dp, vertical = 2.dp),
color = OnAccent, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
Row(Modifier.fillMaxWidth().height(20.dp).background(Blue, androidx.compose.foundation.shape.RoundedCornerShape(3.dp)), verticalAlignment = Alignment.CenterVertically) {
@@ -192,7 +201,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
firstRow = guideFirstRow(row, firstRow, visibleRows, channels.size)
rowsShown = minOf(visibleRows, channels.size)
// The rows step at once (Paul, 6 November 2026: easing them stuttered and seemed to jump back and forth).
Column(Modifier.fillMaxWidth().focusRequester(gridFocus).onFocusChanged { gridFocused = it.isFocused }.focusable().onPreviewKeyEvent { event ->
Column(Modifier.fillMaxWidth().focusRequester(gridFocus).focusProperties { up = pictureFocus }.onFocusChanged { gridFocused = it.isFocused }.focusable().onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
if (native.action != KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent false
when (native.keyCode) {
@@ -231,7 +240,7 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
else Text(ChannelBrands.displayName(line), color = Ink, fontSize = 13.sp, maxLines = 1)
}
BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) {
guideWatchableCells(guideCells(line, windowStart, windowEnd), now, catchUpEarliest(line, now)).forEach { cell ->
guideWatchableCells(guideCells(line, windowStart, windowEnd), now, now).forEach { cell ->
val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs
val past = cell.show.endMs <= now
val onNow = cell.show.startMs <= now && !past
@@ -37,7 +37,10 @@ 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 handOver(channelNumber: Int) {
android.util.Log.i("OrbitSchedule", "picture handover channel=$channelNumber pictureOpen=${open != null} sameChannel=${open?.channelNumber == channelNumber}")
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
@@ -94,6 +97,9 @@ private fun GuideLiveProgramme(session: EmbySampleSession, channelNumber: Int, p
val returned = remember {
val stream = handoff.returned
handoff.returned = null
// Counts only: whether coming back from full screen carried on without re-tuning, and if not, why.
if (stream != null) android.util.Log.i("OrbitSchedule", "picture back channel=$channelNumber kept=${stream.channelNumber == channelNumber && stream.startMs == programme.startMs} " +
"sameChannel=${stream.channelNumber == channelNumber} sameProgramme=${stream.startMs == programme.startMs}")
if (stream != null && stream.channelNumber == channelNumber && stream.startMs == programme.startMs) stream
else { stream?.discard(session); null }
}
@@ -117,7 +123,7 @@ private fun GuideLivePlayer(session: EmbySampleSession, plan: EmbyPlaybackPlan,
val player = remember(session, plan) {
if (runningPlayer != null) return@remember runningPlayer
val factory = DataSource.Factory { SecureMediaDataSource(session) }
ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).build().apply {
ExoPlayer.Builder(context, programmeRenderers(context)).setLoadControl(programmeLoadControl()).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).build().apply {
setMediaItem(channelMediaItem(plan))
seekTo(programme.offsetAt(System.currentTimeMillis()))
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage("en").build()
@@ -77,7 +77,7 @@ internal class LoadedNext(val startMs: Long, val plan: EmbyPlaybackPlan, private
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun loadNextPlayer(context: Context, session: EmbySampleSession, plan: EmbyPlaybackPlan, audio: String?): ExoPlayer {
val factory = DataSource.Factory { SecureMediaDataSource(session) }
return ExoPlayer.Builder(context, programmeRenderers(context)).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
return ExoPlayer.Builder(context, programmeRenderers(context)).setLoadControl(programmeLoadControl()).setMediaSourceFactory(DefaultMediaSourceFactory(factory, OrbitExtractorsFactory())).setSeekBackIncrementMs(10_000).setSeekForwardIncrementMs(10_000).build().apply {
setMediaItem(channelMediaItem(plan))
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage(audio ?: "en").build()
prepare(); playWhenReady = false
@@ -45,14 +45,15 @@ class BrickBreakTest {
}
// Power-ups from the cast (Paul, 6 November 2026).
private fun playing() = BrickGame.start().copy(launched = true, bricks = bricksFor(1), ballX = 160f, ballY = 150f, vx = 60f, vy = -150f)
// A fixed seed, so which broken bricks drop stars is the same every run.
private fun playing() = BrickGame.start().copy(launched = true, bricks = bricksFor(1), ballX = 160f, ballY = 150f, vx = 60f, vy = -150f, seed = 1)
@Test fun aCaughtStarGivesItsPowerAndItsSound() {
val star = BrickDrop(160f, PADDLE_Y - 6f, BrickPower.WideOrbit)
val g = playing().copy(paddleX = 160f, drops = listOf(star)).step(.2f, 0)
assertTrue(g.drops.isEmpty()); assertEquals(listOf(BrickPower.WideOrbit), g.sounds); assertTrue(g.paddleW > 48f)
// Missed, it just falls away.
assertTrue(playing().copy(paddleX = 30f, drops = listOf(star)).step(.5f, 0).let { it.drops.isEmpty() && it.sounds.isEmpty() })
assertTrue(playing().copy(paddleX = 30f, drops = listOf(star)).step(.5f, 0).let { g -> g.drops.none { it.power == BrickPower.WideOrbit && it.x == 160f } && BrickPower.WideOrbit !in g.sounds })
}
@Test fun doubleLunaCostsALifeOnlyWhenTheLastBallDrops() {