Catch Up opens on the channel's ident; focus as on What's On (from 0.108.23)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m3s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m37s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 20:15:26 +00:00
co-authored by Claude Opus 5.5
parent b35bc2bfae
commit 0c1cbf5ed8
5 changed files with 43 additions and 14 deletions
@@ -192,20 +192,21 @@ internal fun CatchUpScreen(channels: List<SampleChannel>, modifier: Modifier = M
@Composable
private fun CatchUpTile(entry: CatchUpEntry, nowMs: Long, on: Boolean, modifier: Modifier) {
val corner = RoundedCornerShape(6.dp)
val ink = if (on) Navy else Color.White
Column(modifier.height(92.dp).background(if (on) Yellow else Panel, corner).padding(horizontal = 10.dp, vertical = 7.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
// Focused as on What's On: navy inside a gold ring, so the words stay crisp rather than washed out in gold.
val ink = Color.White
Column(modifier.height(92.dp).background(if (on) Navy else Panel, corner).border(if (on) 3.dp else 0.dp, if (on) Yellow else Color.Transparent, corner).padding(horizontal = 10.dp, vertical = 7.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val mark = ChannelBrands.forNumber(entry.channel.number)
if (mark != null) Image(painterResource(mark.logo), mark.displayName, Modifier.width(60.dp).height(18.dp))
else Text(ChannelBrands.displayName(entry.channel), color = ink, fontSize = 11.sp, maxLines = 1)
Spacer(Modifier.weight(1f))
CatchUpMark(if (on) Navy else CatchUpGreen)
CatchUpMark()
}
val (name, detail) = guideTitleParts(entry.show.media.item.title)
Text(name, color = ink, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail ?: "${entry.show.media.durationMs / 60_000} min", color = if (on) Navy else Muted, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(detail ?: "${entry.show.media.durationMs / 60_000} min", color = Muted, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${guideDayPrefix(entry.show.startMs, nowMs).trim().ifEmpty { "Ended" }.let { if (it == "Ended") "Ended ${clock(entry.show.endMs)}" else "$it ${clock(entry.show.startMs)}" }}",
color = if (on) Navy else Muted, fontSize = 11.sp, maxLines = 1)
color = if (on) Yellow else Muted, fontSize = 11.sp, maxLines = 1)
}
}
@@ -78,7 +78,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
/** Catch Up: moves the channel's replay clock by this much (a skip, or time spent paused). */
onShift: (Long) -> Unit = {},
/** Catch Up: back to the channel live. */
onLive: () -> Unit = {}) {
onLive: () -> Unit = {},
/** Catch Up: held still at its start while the channel's ident plays over it, then plays. */
holdStart: Boolean = false) {
val context = LocalContext.current
val logoSetting = remember { OrbitSettings(context).channelLogo }
var failure by remember { mutableStateOf<String?>(null) }
@@ -114,9 +116,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
setMediaItem(channelMediaItem(plan))
if (scheduled != null) seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
trackSelectionParameters = trackSelectionParameters.buildUpon().setPreferredAudioLanguage(audioPreference?.value ?: "en").build()
prepare(); playWhenReady = true
prepare(); playWhenReady = !holdStart
}
}
val wasHeld = remember { booleanArrayOf(holdStart) }
LaunchedEffect(holdStart) { if (!holdStart && wasHeld[0]) { wasHeld[0] = false; player.play() } }
LaunchedEffect(player) { onPlayer?.invoke(player) }
fun textTracks() = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT && it.isSupported }
/** The languages of the programme's sound tracks, for [subtitleChoice]. */
@@ -230,15 +230,17 @@ internal fun GuideGrid(channels: List<SampleChannel>, selectedNumber: Int, modif
// Runs along the foot of what's on now, so the time reads down the rows without crossing a title.
val elapsed = if (onNow && nowFraction != null) (maxWidth * (nowFraction - cell.startFraction)).coerceIn(0.dp, cellWidth.coerceAtLeast(0.dp)) else 0.dp
Box(Modifier.offset(x = maxWidth * cell.startFraction).width(cellWidth.coerceAtLeast(1.dp)).fillMaxHeight()
.background(if (isFocus) Yellow else if (past) catchUpDim() else if (onNow) Panel else GuideLater, androidx.compose.foundation.shape.RoundedCornerShape(4.dp)), contentAlignment = Alignment.CenterStart) {
.background(if (isFocus) Navy else if (past) catchUpDim() else if (onNow) Panel else GuideLater, androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
// Focused as on What's On: navy inside a gold ring, the title crisp in white.
.border(if (isFocus) 2.dp else 0.dp, if (isFocus) Yellow else Color.Transparent, androidx.compose.foundation.shape.RoundedCornerShape(4.dp)), contentAlignment = Alignment.CenterStart) {
val runsOn = cell.show.endMs > windowEnd
val ink = if (isFocus) Navy else if (past) Muted else Color.White
if (elapsed > 0.dp) Box(Modifier.align(Alignment.BottomStart).width(elapsed).height(3.dp).background(if (isFocus) Navy else Yellow))
val ink = if (isFocus) Color.White else if (past) Muted else Color.White
if (elapsed > 0.dp) Box(Modifier.align(Alignment.BottomStart).width(elapsed).height(3.dp).background(Yellow))
Row(Modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Text(if (cellWidth >= GuideTitleMinWidth) guideTitleParts(cell.show.media.item.title).first else "…", Modifier.weight(1f, fill = false), color = ink, fontSize = 13.sp,
fontWeight = if (isFocus || onNow) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (runsOn && cellWidth >= GuideTitleMinWidth) Text(" ►", color = ink, fontSize = 9.sp)
if (past && cellWidth >= GuideTitleMinWidth + 14.dp) { Spacer(Modifier.width(4.dp)); CatchUpMark(if (isFocus) Navy else CatchUpGreen) }
if (past && cellWidth >= GuideTitleMinWidth + 14.dp) { Spacer(Modifier.width(4.dp)); CatchUpMark() }
}
}
}
@@ -92,7 +92,9 @@ internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, ne
/** Filling time after a programme ended early: says when the next one starts, and keeps to a still picture if idents are off. */
early: Boolean = false,
/** Entering a block or event with its own ident: that film instead of the channel's, with anything drawn over it. */
block: BlockIdent? = null) {
block: BlockIdent? = null,
/** In place of "NEXT" over the title, such as Catch Up's. */
heading: String? = null) {
val context = LocalContext.current
val mood = if (channel.number == TOTS_NUMBER) totsMood(next.startMs) else null
val settings = remember { OrbitSettings(context) }
@@ -131,7 +133,7 @@ internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, ne
}
// A booked event's own lower third says what's next instead.
if (block?.overlay != BlockOverlay.TonightsEvent || eventTitle(next) == null) Column(Modifier.align(Alignment.BottomStart).padding(start = 40.dp, bottom = 32.dp).background(Navy.copy(alpha = .72f)).padding(horizontal = 14.dp, vertical = 8.dp)) {
Text(if (early) "NEXT AT ${clock(next.startMs)}" else "NEXT", color = Color(0xFFD5DCEA), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(heading ?: if (early) "NEXT AT ${clock(next.startMs)}" else "NEXT", color = Color(0xFFD5DCEA), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
Text(guideTitleParts(next.media.item.title).first, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -135,6 +135,22 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
delay((held.startMs + IDENT_HOLD_MS - System.currentTimeMillis()).coerceAtLeast(0))
if (heldIdent === held) heldIdent = null
}
// Catch Up opens on the channel's ident (Paul, 5 November 2026) while the programme loads silently underneath, held at its
// start, then cuts to it once both the ident has played and the programme's picture is up. The replay clock stands still meanwhile.
val catchUpFor = remember { if (clockOffsetMs < 0 && identsOn) current.startMs else null }
var catchUpIdentDone by remember { mutableStateOf(catchUpFor == null) }
var catchUpPicture by remember { mutableStateOf(catchUpFor == null) }
LaunchedEffect(Unit) {
if (catchUpFor == null) return@LaunchedEffect
delay(identLength(channel.number, catchUpFor)); catchUpIdentDone = true
// However the programme fares, the ident never stays up long.
delay(15_000); catchUpPicture = true
}
LaunchedEffect(plan == null, clockOffsetMs < 0) {
if (plan != null || clockOffsetMs >= 0) return@LaunchedEffect
var last = android.os.SystemClock.elapsedRealtime()
while (true) { delay(500); val at = android.os.SystemClock.elapsedRealtime(); onShift(-(at - last)); last = at }
}
// One window for the whole visit, so a changeover never shows what's behind it.
val window = remember { PlaybackWindow() }
Dialog(onDismissRequest = { window.onBack() }, properties = DialogProperties(usePlatformDefaultWidth = false,
@@ -149,7 +165,8 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
onHandBack = { running ->
if (handoff != null && clockOffsetMs == 0L) { handoff.returned = LiveStream(channel.number, current.startMs, plan, running); true } else false
},
window = window, onShift = onShift, onLive = onLive, onFirstFrame = { if (heldIdent?.startMs == current.startMs) heldIdent = null },
window = window, onShift = onShift, onLive = onLive, holdStart = !catchUpIdentDone,
onFirstFrame = { if (heldIdent?.startMs == current.startMs) heldIdent = null; catchUpPicture = true },
onEndedEarly = {
val at = System.currentTimeMillis() + clockOffsetMs
if (channel is ScheduledChannel && next.startMs == current.endMs && finishedEarly(at, current.endMs) && heldIdent == null) {
@@ -183,6 +200,9 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC
IdentOverlay(session, channel, held, length, startedLate, early, heldAs)
}
}
if (catchUpFor != null && problem == null && (!catchUpIdentDone || !catchUpPicture)) key("catch-up-ident") {
IdentOverlay(session, channel, current, identLength(channel.number, catchUpFor), 0, heading = "CATCH UP")
}
introPlayback?.let { IntroOverlay(it) }
if (SleepTimer.warning) SleepWarning(Modifier.align(Alignment.TopCenter).padding(top = 44.dp))
} }