diff --git a/android/app/src/main/java/tv/orbit/classic/AudioOutput.kt b/android/app/src/main/java/tv/orbit/classic/AudioOutput.kt index 6ffbebc..1f54429 100644 --- a/android/app/src/main/java/tv/orbit/classic/AudioOutput.kt +++ b/android/app/src/main/java/tv/orbit/classic/AudioOutput.kt @@ -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() 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 28d27ef..2ba6552 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -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 } 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 abdb10d..4156e2f 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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())) 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 de6ac88..b821f50 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -120,6 +120,8 @@ private const val GUIDE_MAX_SLOTS_BACK = (CATCH_UP_MS / GUIDE_SLOT_MS).toInt() @Composable internal fun GuideGrid(channels: List, selectedNumber: Int, modifier: Modifier = Modifier, category: ChannelCategory = ChannelCategory.All, categories: List = 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, 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, 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?>(null) } @@ -174,8 +177,14 @@ internal fun GuideGrid(channels: List, 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, 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, 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 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 75e6e86..e95e1c6 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuidePicture.kt @@ -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() diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index 09f714e..403cfa7 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -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 diff --git a/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt b/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt index 5e99acc..dabaef1 100644 --- a/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/BrickBreakTest.kt @@ -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() {