From 0c1cbf5ed8a3d7dd8ddad558ea6f24a9f01ad1d6 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 20:15:26 +0000 Subject: [PATCH] Catch Up opens on the channel's ident; focus as on What's On (from 0.108.23) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/main/java/tv/orbit/classic/CatchUp.kt | 11 +++++----- .../tv/orbit/classic/EmbyPlaybackDialog.kt | 8 +++++-- .../main/java/tv/orbit/classic/GuideGrid.kt | 10 +++++---- .../src/main/java/tv/orbit/classic/Idents.kt | 6 +++-- .../tv/orbit/classic/SampleChannelScreens.kt | 22 ++++++++++++++++++- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt index f099402..05bc7bc 100644 --- a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt +++ b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt @@ -192,20 +192,21 @@ internal fun CatchUpScreen(channels: List, 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) } } 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 c798223..ccad8a7 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -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(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]. */ 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 711c33c..943bde5 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -230,15 +230,17 @@ internal fun GuideGrid(channels: List, 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() } } } } 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 0d37b06..97c55f9 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -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) } } diff --git a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt index 080b39d..f0fca8a 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -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)) } }