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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user