From 5981c2e355cf789213b6e0596bec9cd8e47ca094 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Thu, 5 Nov 2026 19:59:39 +0000 Subject: [PATCH] 0.108.22: the sleep timer and Catch Up Paul, 5 November 2026. The sleep timer sits in the Watching panel (right while watching): Off, 30, 60 or 90 minutes, or after this programme. A minute before, a warning says Orbit is switching off; any button keeps you watching for the same length again. Then Orbit closes and the TV sleeps as it normally would. Nothing is saved: it starts off each time Orbit opens. Catch Up: anything a scheduled channel showed in the last two days can be watched from its start. It replays the channel from that moment (a negative clock offset), so what followed follows again and a two-part story carries straight on. Left and right skip 30 seconds, OK pauses (the replay clock stands still with the picture), up and down go back to live, Back leaves. The guide scrolls back two days, with finished programmes dimmed and marked; OK on one opens the Catch Up pop-up. The top bar gains a Catch Up tab between TV Guide and Drive-In: Just missed, Earlier today, Yesterday and the day before, newest first, each programme once. The box now keeps 49 hours of each plan's past instead of 24, so Catch Up only ever offers what the committed plan says aired. Bookings ahead are untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- android/app/build.gradle.kts | 4 +- .../src/main/java/tv/orbit/classic/CatchUp.kt | 221 ++++++++++++++++++ .../java/tv/orbit/classic/ChannelSchedule.kt | 3 +- .../tv/orbit/classic/ClassicProgrammeStrip.kt | 24 +- .../main/java/tv/orbit/classic/EmbyClient.kt | 2 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 26 ++- .../tv/orbit/classic/EmbyPlaybackDialog.kt | 63 ++++- .../main/java/tv/orbit/classic/GuideGrid.kt | 64 +++-- .../java/tv/orbit/classic/MainActivity.kt | 17 +- .../tv/orbit/classic/SampleChannelScreens.kt | 7 +- .../tv/orbit/classic/SecureMediaDataSource.kt | 2 +- .../main/java/tv/orbit/classic/SleepTimer.kt | 76 ++++++ .../java/tv/orbit/classic/WatchingControls.kt | 9 +- .../test/java/tv/orbit/classic/CatchUpTest.kt | 130 +++++++++++ 14 files changed, 604 insertions(+), 44 deletions(-) create mode 100644 android/app/src/main/java/tv/orbit/classic/CatchUp.kt create mode 100644 android/app/src/main/java/tv/orbit/classic/SleepTimer.kt create mode 100644 android/app/src/test/java/tv/orbit/classic/CatchUpTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bb7d03d..1b08d05 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,8 +9,8 @@ android { applicationId = "tv.orbit.classic" minSdk = 26 targetSdk = 36 - versionCode = 181 - versionName = "0.108.21-centred-nav" + versionCode = 182 + versionName = "0.108.22-catch-up" } buildFeatures { compose = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } diff --git a/android/app/src/main/java/tv/orbit/classic/CatchUp.kt b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt new file mode 100644 index 0000000..f099402 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/CatchUp.kt @@ -0,0 +1,221 @@ +package tv.orbit.classic + +import android.view.KeyEvent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.Text +import kotlinx.coroutines.delay +import java.time.Instant +import java.time.ZoneId + +/** + * Catch Up (Paul, 5 November 2026): anything a scheduled channel showed in the last two days can be watched from its start. + * It replays the channel itself from that moment, so what followed follows again: a two-part story carries straight on into + * part 2, as it aired. Only programmes the channel's committed plan still holds are offered, so it's always what really aired. + * Nothing is booked or changed: Catch Up only reads the plans. + */ +internal const val CATCH_UP_MS = 48 * 60 * 60_000L +/** Left or right while catching up. */ +internal const val CATCH_UP_SKIP_MS = 30_000L +/** "Just missed": ended within this long. */ +internal const val JUST_MISSED_MS = 3 * 60 * 60_000L +private const val SHELF_MAX = 30 + +internal val CatchUpGreen = Color(0xFF6EDCAA) +private val CatchUpDim = Color(0xFF0C2046) + +/** The earliest moment [channel] can be caught up from: its plan's first programme inside the last two days, else now. */ +internal fun catchUpEarliest(channel: SampleChannel, nowMs: Long): Long = + (channel as? ScheduledChannel)?.plan?.entries?.firstOrNull { it.startMs >= nowMs - CATCH_UP_MS && it.startMs < nowMs }?.startMs?.coerceAtMost(nowMs) ?: nowMs + +/** True when [show] has finished, aired in the last two days, and is in [channel]'s committed plan. */ +internal fun catchUpAvailable(channel: SampleChannel, show: SampleBroadcast, nowMs: Long): Boolean = + show.endMs <= nowMs && show.startMs >= nowMs - CATCH_UP_MS && (channel as? ScheduledChannel)?.plan?.entries?.any { it.startMs == show.startMs && it.media.item.id == show.media.item.id } == true + +/** One programme to catch up on: [next] is what followed it on the channel, which plays on after it. */ +internal class CatchUpEntry(val channel: SampleChannel, val show: SampleBroadcast, val next: SampleBroadcast?) { + /** The clock offset that replays the channel from the programme's start. */ + fun offsetFrom(nowMs: Long) = show.startMs - nowMs + /** When it leaves Catch Up. */ + val untilMs get() = show.startMs + CATCH_UP_MS + /** The next programme straight after it is the same series: the next part or episode carries on. */ + val continues get() = next != null && next.startMs == show.endMs && guideTitleParts(next.media.item.title).first == guideTitleParts(show.media.item.title).first +} + +internal fun catchUpEntry(channel: SampleChannel, show: SampleBroadcast) = CatchUpEntry(channel, show, channel.at(show.endMs).takeIf { it.startMs == show.endMs }) + +internal class CatchUpShelf(val title: String, val entries: List) + +/** + * The Catch Up page's rows, newest first: Just missed, Earlier today, Yesterday and the day before. A programme aired more + * than once shows only its latest airing. + */ +internal fun catchUpShelves(channels: List, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): List { + val all = channels.filterIsInstance().filter { it.number != DRIVE_IN_NUMBER }.flatMap { channel -> + channel.plan.entries.filter { it.endMs <= nowMs && it.startMs >= nowMs - CATCH_UP_MS }.map { e -> catchUpEntry(channel, SampleBroadcast(e.media, e.startMs, e.presentation)) } + }.sortedByDescending { it.show.endMs }.distinctBy { it.show.media.item.id } + val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + fun day(e: CatchUpEntry) = Instant.ofEpochMilli(e.show.startMs).atZone(zone).toLocalDate() + val just = all.filter { nowMs - it.show.endMs <= JUST_MISSED_MS } + val rest = all - just.toSet() + return listOf( + CatchUpShelf("JUST MISSED", just), + CatchUpShelf("EARLIER TODAY", rest.filter { day(it) == today }), + CatchUpShelf("YESTERDAY", rest.filter { day(it) == today.minusDays(1) }), + CatchUpShelf("THE DAY BEFORE", rest.filter { day(it) < today.minusDays(1) })) + .map { CatchUpShelf(it.title, it.entries.take(SHELF_MAX)) }.filter { it.entries.isNotEmpty() } +} + +/** "aired 2 hours ago", for the guide's panel and the pop-up. */ +internal fun airedAgo(endMs: Long, nowMs: Long): String { + val minutes = ((nowMs - endMs) / 60_000).coerceAtLeast(0) + return when { + minutes < 1 -> "just ended" + minutes < 60 -> "ended $minutes min ago" + minutes < 120 -> "ended an hour ago" + else -> "ended ${minutes / 60} hours ago" + } +} + +/** The small arc on a programme that can be caught up. */ +@Composable +internal fun CatchUpMark(colour: Color = CatchUpGreen, size: Int = 12) { + Text("↺", color = colour, fontSize = size.sp, fontWeight = FontWeight.Bold) +} + +/** The Catch Up pop-up, from the guide or the Catch Up page. */ +@Composable +internal fun CatchUpCard(entry: CatchUpEntry, nowMs: Long, onWatch: () -> Unit, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + val corner = RoundedCornerShape(12.dp) + val watchFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { watchFocus.requestFocus() } + Column(Modifier.width(560.dp).background(Color(0xFF0A1E42), corner).border(2.dp, Yellow, corner).padding(24.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("CATCH UP", color = CatchUpGreen, fontFamily = Anton, fontSize = 18.sp) + Spacer(Modifier.weight(1f)) + Text("${entry.channel.number} ${ChannelBrands.displayName(entry.channel).uppercase()}", color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + } + val (name, detail) = guideTitleParts(entry.show.media.item.title) + Text(name.uppercase(), color = Color.White, fontFamily = Anton, fontSize = 34.sp, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (detail != null) Text(detail, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Aired ${guideDayPrefix(entry.show.startMs, nowMs).trim().ifEmpty { "today" }} ${clock(entry.show.startMs)} to ${clock(entry.show.endMs)} • ${entry.show.media.durationMs / 60_000} min • " + + "watchable until ${guideDayPrefix(entry.untilMs, nowMs).trim().ifEmpty { "today" }} ${clock(entry.untilMs)}", color = Muted, fontSize = 13.sp) + entry.next?.let { next -> + Text(if (entry.continues) "${guideTitleParts(next.media.item.title).let { it.second ?: it.first }} carries straight on after it, as it aired." + else "Then the channel carries on as it aired: ${guideTitleParts(next.media.item.title).first} at ${clock(next.startMs)}.", color = Color.White, fontSize = 14.sp, maxLines = 2) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OrbitButton("Watch from the start", Modifier.focusRequester(watchFocus), onClick = onWatch) + OrbitButton("Back", onClick = onDismiss) + } + Text("While watching: ◄► skip 30 s OK pause ▲▼ back to live Back leave Catch Up", color = Muted, fontSize = 11.sp) + } + } +} + +/** The Catch Up tab on the top bar: the last two days in rows, newest first. */ +@Composable +internal fun CatchUpScreen(channels: List, modifier: Modifier = Modifier, onOpen: (CatchUpEntry) -> Unit) { + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { while (true) { delay(30_000); if (!FullScreen.active) now = System.currentTimeMillis() } } + val shelves = remember(channels, now / 60_000) { catchUpShelves(channels, now) } + var row by remember { mutableIntStateOf(0) } + val columns = remember { mutableStateMapOf() } + val focus = remember { FocusRequester() } + var focused by remember { mutableStateOf(false) } + val nav = LocalNavFocus.current + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("CATCH UP", color = CatchUpGreen, fontFamily = Anton, fontSize = 26.sp) + Text("Anything shown on Orbit in the last two days, from the start", color = Muted, fontSize = 14.sp) + if (shelves.isEmpty()) { + Text("Nothing to catch up on yet. Programmes appear here once they've aired.", color = Color.White, fontSize = 16.sp, + modifier = Modifier.focusRequester(focus).focusProperties { if (nav != null) up = nav }.focusable()) + return@Column + } + val r = row.coerceIn(shelves.indices) + Column(Modifier.weight(1f).fillMaxWidth().focusRequester(focus).focusProperties { if (nav != null) up = nav }.onFocusChanged { focused = it.isFocused }.focusable().onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + if (native.action != KeyEvent.ACTION_DOWN) return@onPreviewKeyEvent false + val col = (columns[r] ?: 0).coerceIn(shelves[r].entries.indices) + when (native.keyCode) { + // At the top row, up leaves for the top bar. + KeyEvent.KEYCODE_DPAD_UP -> if (r > 0) { row = r - 1; true } else false + KeyEvent.KEYCODE_DPAD_DOWN -> { if (r < shelves.lastIndex) row = r + 1; true } + KeyEvent.KEYCODE_DPAD_LEFT -> { columns[r] = (col - 1).coerceAtLeast(0); true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { columns[r] = (col + 1).coerceAtMost(shelves[r].entries.lastIndex); true } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { if (native.repeatCount == 0) onOpen(shelves[r].entries[col]); true } + else -> false + } + }, verticalArrangement = Arrangement.spacedBy(6.dp)) { + // Three rows at a time, moving with the cursor. + val first = (r - 1).coerceIn(0, (shelves.size - 3).coerceAtLeast(0)) + shelves.subList(first, minOf(first + 3, shelves.size)).forEachIndexed { offset, shelf -> + val index = first + offset + val col = (columns[index] ?: 0).coerceIn(shelf.entries.indices) + Text(shelf.title, color = if (index == r) Yellow else Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + val start = (col - 2).coerceIn(0, (shelf.entries.size - 5).coerceAtLeast(0)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + shelf.entries.subList(start, minOf(start + 5, shelf.entries.size)).forEachIndexed { k, entry -> + CatchUpTile(entry, now, focused && index == r && start + k == col, Modifier.weight(1f)) + } + repeat(5 - minOf(5, shelf.entries.size - start)) { Spacer(Modifier.weight(1f)) } + } + } + } + Text("▲▼ Rows ◄► Move OK Catch Up Back Home", color = Muted, fontSize = 11.sp) + } +} + +@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)) { + 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) + } + 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("${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) + } +} + +/** The badge in the corner while catching up. */ +@Composable +internal fun CatchUpBadge(modifier: Modifier = Modifier) { + Text("CATCH UP", modifier.background(CatchUpGreen, RoundedCornerShape(5.dp)).padding(horizontal = 12.dp, vertical = 4.dp), color = Navy, fontFamily = Anton, fontSize = 18.sp) +} + +/** True while a channel is being replayed from the past. */ +internal fun ScheduledPlayback.catchingUp() = clockOffsetMs < 0 + +internal fun catchUpDim() = CatchUpDim diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt index 46f90cd..ac43a77 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt @@ -301,7 +301,8 @@ private const val CHANNEL_POOL_VERSION = 5 internal const val SCHEDULE_AHEAD_MS = 7 * 24 * 60 * 60_000L internal const val SCHEDULE_TOP_UP_MS = 6 * 24 * 60 * 60_000L -internal const val SCHEDULE_KEEP_PAST_MS = 24 * 60 * 60_000L +/** Two days for Catch Up ([CATCH_UP_MS]) and an hour over, so the oldest programme in it is never cut in half. */ +internal const val SCHEDULE_KEEP_PAST_MS = 49 * 60 * 60_000L /** The next episode in season order, starting the show again once it has finished. */ internal fun nextEpisode(series: PoolSeries, last: EpisodeMark?): PoolEpisode = diff --git a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt index aa397c8..1083212 100644 --- a/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt +++ b/android/app/src/main/java/tv/orbit/classic/ClassicProgrammeStrip.kt @@ -25,16 +25,24 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier: val paper = Color(0xFFE8EDF5) Column(modifier.border(1.dp, paper)) { Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 12.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically) { - Text(scheduled?.let { "${it.channel.number} ${ChannelBrands.displayName(it.channel)}${if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp) + Text(scheduled?.let { "${it.channel.number} ${ChannelBrands.displayName(it.channel)}${if (it.catchingUp()) " / Catch Up" else if (it.clockOffsetMs != 0L) " / Transition check" else ""}" } ?: "Orbit Preview", color = Navy, fontWeight = FontWeight.Bold, fontSize = 14.sp) Spacer(Modifier.weight(1f)) Text(SimpleDateFormat("HH:mm EEE d MMM", Locale.UK).format(Date(now)), color = Navy, fontSize = 13.sp) } Column(Modifier.fillMaxWidth().background(Blue).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(if (playing || scheduled != null) "NOW" else "PAUSED", Modifier.width(70.dp), color = Yellow, fontWeight = FontWeight.Bold, fontSize = 14.sp) + val catching = scheduled?.catchingUp() == true + Text(if (catching) (if (playing) "FROM START" else "PAUSED") else if (playing || scheduled != null) "NOW" else "PAUSED", Modifier.width(70.dp), color = Yellow, fontWeight = FontWeight.Bold, fontSize = 14.sp) Text(item.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } Row(verticalAlignment = Alignment.CenterVertically) { + if (scheduled != null && scheduled.catchingUp()) { + // How far in, and what's on the channel live meanwhile. + val into = scheduled.programme.offsetAt(scheduled.nowMs()) + Text("${minutesSeconds(into)} of ${minutesSeconds(scheduled.programme.media.durationMs)}", Modifier.width(150.dp), color = Muted, fontWeight = FontWeight.Bold, fontSize = 13.sp) + Text("Live now: ${guideTitleParts(scheduled.channel.at(now).media.item.title).first}", color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + return@Row + } Text(if (scheduled == null) "INFO" else "NEXT", Modifier.width(70.dp), color = Muted, fontWeight = FontWeight.Bold, fontSize = 13.sp) Text(scheduled?.let { "${clock(it.next.startMs)} ${it.next.media.item.title}" } ?: listOf(item.type, item.minutes?.let { "$it min" }.orEmpty()).filter { it.isNotBlank() }.joinToString(" • "), color = Color.White, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -42,11 +50,15 @@ internal fun ClassicProgrammeStrip(item: SampleItem, playing: Boolean, modifier: } Row(Modifier.fillMaxWidth().background(paper).padding(horizontal = 14.dp, vertical = 5.dp), horizontalArrangement = Arrangement.spacedBy(30.dp), verticalAlignment = Alignment.CenterVertically) { + if (scheduled != null && scheduled.catchingUp()) { + Legend("◄ ►", "Skip 30 s"); Legend("OK", "Pause"); Legend("▲ ▼", "Back to live"); Legend("↶", "Leave Catch Up") + return@Row + } Legend("▲ ▼", "Channel") Legend("↶", "TV guide") if (scheduled != null) { Legend("◄", "Last channel") - Legend("►", "Subtitles and audio") + Legend("►", "Subtitles, audio, sleep") Legend("Hold OK", "Number") } } @@ -60,3 +72,9 @@ private fun Legend(icon: String, label: String) { Text(label, color = Navy, fontSize = 14.sp, fontWeight = FontWeight.Bold) } } + +/** 07:42, or 1:07:42 for a long film. */ +internal fun minutesSeconds(ms: Long): String { + val total = ms / 1000; val h = total / 3600; val m = total % 3600 / 60; val sec = total % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec) +} diff --git a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt index 9fc91ca..be091f9 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyClient.kt @@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI connection.connectTimeout = 10_000 connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000 connection.setRequestProperty("Accept", "application/json") - connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.108.21\"") + connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.108.22\"") request.token?.let { connection.setRequestProperty("X-Emby-Token", it) } if (request.method == "POST") { val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8) 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 519bd23..ed91829 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt @@ -56,7 +56,7 @@ private const val RESTORE_QUIET_TRIES = 4 @Composable internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolean, - mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, mainWhatsOn: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, + mainGuide: Boolean, mainHome: Boolean = false, mainDriveIn: Boolean = false, mainWhatsOn: Boolean = false, mainCatchUp: Boolean = false, onChannelAvailable: (Boolean) -> Unit, onRestoreChecked: () -> Unit = {}, onGuide: () -> Unit, onHome: () -> Unit = {}, onDriveIn: () -> Unit = {}) { val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext val savedStore = remember { RememberedConnectionStore.get(appContext) } @@ -93,6 +93,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea var reminders by remember { mutableStateOf>(emptyList()) } var programmeReminder by remember { mutableStateOf(null) } var windingDown by remember { mutableStateOf(false) } + var catchUpOpen by remember { mutableStateOf(null) } // Each visit opens on the next of the three scenes. var driveInVisits by remember { mutableIntStateOf((System.currentTimeMillis() / 60_000 % DRIVE_IN_SCENES).toInt()) } var guideCategory by remember { mutableStateOf(ChannelCategory.All) } @@ -292,8 +293,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea } } } - LaunchedEffect(mainGuide, mainHome, mainWhatsOn) { - if (!mainGuide && !mainHome && !mainWhatsOn) { watchingChannel = false; channelClockOffset = 0 } + LaunchedEffect(mainGuide, mainHome, mainWhatsOn, mainCatchUp) { + if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) { watchingChannel = false; channelClockOffset = 0 } } LaunchedEffect(mainDriveIn) { if (mainDriveIn) { driveInVisits++; if (pendingDriveInWatch) { pendingDriveInWatch = false; watchingDriveIn = true } } else watchingDriveIn = false @@ -334,7 +335,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea answered() if (selectedNumber != due.channel) { lastNumber = selectedNumber; selectedNumber = due.channel } channelClockOffset = 0; watchingChannel = true - if (!mainGuide && !mainHome && !mainWhatsOn) onGuide() + if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide() }) } reminder?.let { due -> @@ -366,7 +367,7 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea onTicket = { saveTickets(tickets + (it.startMs to 0)) }, onHandBack = { saveTickets(tickets - it.startMs) }, onWatch = { watchingDriveIn = true }) return } - if ((mainGuide || mainHome || mainWhatsOn) && liveSession != null && liveChannel != null) { + if ((mainGuide || mainHome || mainWhatsOn || mainCatchUp) && liveSession != null && liveChannel != null) { if (watchingChannel) ScheduledChannelPlayer(liveSession, liveChannel, channelClockOffset, intro = remember { CurtainIntro.takeIf { IntroTrial.take() } }, onClose = { watchingChannel = false; channelClockOffset = 0 }, @@ -382,20 +383,31 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea val exists = availableChannels.any { it.number == number } if (exists && number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number; channelClockOffset = 0 } exists - }, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) + }, handoff = liveHandoff, onUnplayable = ::rememberUnplayable, + // Catch Up's replay clock: never ahead of live, and back at live it's the channel as normal. + onShift = { delta -> channelClockOffset = (channelClockOffset + delta).coerceAtMost(0) }, + onLive = { channelClockOffset = 0 }) fun tune(number: Int, offset: Long) { if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number } if (offset == 0L) liveHandoff.handOver(number) channelClockOffset = offset; watchingChannel = true } + catchUpOpen?.let { entry -> + CatchUpCard(entry, System.currentTimeMillis(), onDismiss = { catchUpOpen = null }, onWatch = { + catchUpOpen = null + // Still in Catch Up by the time OK is pressed: replay the channel from the programme's start. + if (entry.show.startMs >= System.currentTimeMillis() - CATCH_UP_MS) tune(entry.channel.number, entry.offsetFrom(System.currentTimeMillis())) + }) + } val picture = @Composable { GuidePicture(liveSession, liveChannel, live = !watchingChannel, handoff = liveHandoff, onUnplayable = ::rememberUnplayable) } when { mainHome -> HomeScreen(availableChannels, modifier, onCategory = { guideCategory = it; onGuide() }) + mainCatchUp -> CatchUpScreen(availableChannels, modifier, onOpen = { catchUpOpen = it }) mainWhatsOn -> WhatsOnScreen(liveSession, scheduledChannels.mapValues { it.value.plan }, driveInPlan, modifier, reminders, 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() }) + onCategory = { guideCategory = it }, onWatch = ::tune, onBack = { guideCategory = ChannelCategory.All; onHome() }, onCatchUp = { catchUpOpen = it }) } 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 366b3ac..c798223 100644 --- a/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt +++ b/android/app/src/main/java/tv/orbit/classic/EmbyPlaybackDialog.kt @@ -74,7 +74,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl /** False for the hand-run playback test: nothing is reported to Emby, so it records no progress or resume point. */ reportPlayback: Boolean = true, /** The playback test's own listener on the player, for its diagnostics. */ - onPlayer: ((ExoPlayer) -> Unit)? = null) { + onPlayer: ((ExoPlayer) -> Unit)? = null, + /** 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 = {}) { val context = LocalContext.current val logoSetting = remember { OrbitSettings(context).channelLogo } var failure by remember { mutableStateOf(null) } @@ -98,6 +102,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl var subtitleIndex by remember { mutableStateOf(null) } var trackMenu by remember { mutableStateOf?>(null) } var trackCursor by remember { mutableIntStateOf(0) } + // The key listener is made once, so it reads these as they are now. + val currentScheduled by rememberUpdatedState(scheduled) + val shift by rememberUpdatedState(onShift) + val live by rememberUpdatedState(onLive) val player = remember(session, plan) { if (runningPlayer != null) return@remember runningPlayer val factory = DataSource.Factory { SecureMediaDataSource(session) } @@ -138,7 +146,13 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl val format = group.getTrackFormat(0) TrackRow("Audio", trackLabel(format.label, format.language, index), group.isSelected) { chooseAudio(group) } } - return subtitleRows + audioRows + // The sleep timer sits with them (Paul, 5 November 2026). A programme-length timer needs a programme with an end. + val sleepRows = SleepChoice.entries.filter { it != SleepChoice.AfterThis || scheduled != null }.map { choice -> + val on = SleepTimer.choice == choice + val left = if (on && choice != SleepChoice.Off) sleepLeft(SleepTimer.endsAt, System.currentTimeMillis())?.let { " ($it)" }.orEmpty() else "" + TrackRow("Sleep timer", choice.label + left, on) { SleepTimer.set(choice, System.currentTimeMillis(), scheduled?.let { it.programme.endMs - it.clockOffsetMs }) } + } + return subtitleRows + audioRows + sleepRows } fun submitNumber(entry: ChannelEntry) { if (onNumber(entry.number)) { numberEntry = null; numberProblem = null } @@ -297,6 +311,12 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl } } } + // Catch Up: while the picture stands still, paused or loading, so does the replay clock, so the programme never moves on under it. + LaunchedEffect(playing, scheduled?.catchingUp() == true) { + if (playing || scheduled?.catchingUp() != true) return@LaunchedEffect + var last = SystemClock.elapsedRealtime() + while (true) { delay(500); val at = SystemClock.elapsedRealtime(); shift(-(at - last)); last = at } + } LaunchedEffect(infoVisible, lastInteraction, playing, ready) { if (infoVisible && playing && ready) { delay(5_000); infoVisible = false } } @@ -318,6 +338,11 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl setOnKeyListener { _, code, event -> val isOk = code == KeyEvent.KEYCODE_DPAD_CENTER || code == KeyEvent.KEYCODE_ENTER val typedDigit = if (code in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9) code - KeyEvent.KEYCODE_0 else null + // The sleep timer's last minute: any button keeps you watching, and does nothing else. + if (SleepTimer.warning) { + if (event.action == KeyEvent.ACTION_UP) SleepTimer.keepWatching(System.currentTimeMillis()) + return@setOnKeyListener true + } // OK skips the intro. Nothing else reaches the controls until it's gone. if (intro?.showing == true) { if (isOk && event.action == KeyEvent.ACTION_DOWN) intro.skip() @@ -361,6 +386,37 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl } return@setOnKeyListener isOk || typedDigit != null || code in listOf(KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT) } + // Catch Up (Paul, 5 November 2026): skip, pause, back to live. The replay clock moves with the picture. + if (currentScheduled?.catchingUp() == true) { + val back = code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_MEDIA_REWIND + val forward = code == KeyEvent.KEYCODE_DPAD_RIGHT || code == KeyEvent.KEYCODE_MEDIA_FAST_FORWARD + val pause = isOk || code == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || code == KeyEvent.KEYCODE_MEDIA_PLAY || code == KeyEvent.KEYCODE_MEDIA_PAUSE + val toLive = code == KeyEvent.KEYCODE_DPAD_UP || code == KeyEvent.KEYCODE_DPAD_DOWN || code == KeyEvent.KEYCODE_CHANNEL_UP || code == KeyEvent.KEYCODE_CHANNEL_DOWN + if (back || forward || pause || toLive) { + val now = currentScheduled ?: return@setOnKeyListener true + if ((back || forward) && event.action == KeyEvent.ACTION_DOWN) { + val delta = if (forward) CATCH_UP_SKIP_MS else -CATCH_UP_SKIP_MS + if (now.clockOffsetMs + delta >= 0) { live(); player.seekTo(now.programme.offsetAt(System.currentTimeMillis())) } + else { + val target = player.currentPosition + delta + shift(delta) + // Past either end, the channel moves to the programme there, which opens at its own place. + if (target >= 0 && target < now.programme.media.durationMs) player.seekTo(target) + } + showInfo() + } + if (event.action == KeyEvent.ACTION_UP && pause) { + when (code) { + KeyEvent.KEYCODE_MEDIA_PLAY -> player.play() + KeyEvent.KEYCODE_MEDIA_PAUSE -> player.pause() + else -> if (player.playWhenReady) player.pause() else player.play() + } + showInfo() + } + if (event.action == KeyEvent.ACTION_UP && toLive) { live(); player.seekTo(now.programme.offsetAt(System.currentTimeMillis())); showInfo() } + return@setOnKeyListener true + } + } val supported = code in listOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_CHANNEL_UP, KeyEvent.KEYCODE_CHANNEL_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, @@ -400,6 +456,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl }, update = { it.player = player }, onRelease = { it.player = null }) if (scheduled != null && !ready && failure == null && foreground) ChannelTuningBackdrop(scheduled.channel.number) { ProgrammeArtwork(session, scheduled.channel, scheduled.programme) } + if (scheduled?.catchingUp() == true && ready && failure == null) CatchUpBadge(Modifier.align(Alignment.TopStart).padding(top = 28.dp, start = 40.dp)) if (scheduled != null && ready && tuningNumberVisible && failure == null) ClassicTuningNumber(scheduled.channel.number, Modifier.align(Alignment.TopEnd).padding(top = 32.dp, end = 44.dp)) @@ -414,6 +471,8 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl trackMenu?.let { TrackPanel(it, trackCursor, Modifier.align(Alignment.CenterEnd).padding(end = 28.dp)) } trackProblem?.let { Text(it, Modifier.align(Alignment.TopCenter).padding(top = 44.dp).background(Navy.copy(alpha = .85f)).padding(horizontal = 14.dp, vertical = 8.dp), color = Color.White, fontSize = 16.sp) } numberEntry?.let { ChannelNumberBox(it, numberProblem, Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) } + // In a window of its own; a scheduled channel draws it over its idents instead. + if (window == null && SleepTimer.warning) SleepWarning(Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) if (failure != null) Column(Modifier.align(Alignment.Center).widthIn(max = 620.dp).background(Navy).padding(24.dp)) { Text(failure.orEmpty(), color = Color.White, fontSize = 20.sp) Spacer(Modifier.height(16.dp)) 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 712ccd7..711c33c 100644 --- a/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt +++ b/android/app/src/main/java/tv/orbit/classic/GuideGrid.kt @@ -60,17 +60,19 @@ internal fun guideDayPrefix(timeMs: Long, nowMs: Long): String { return if (day.format(java.util.Date(timeMs)) == day.format(java.util.Date(nowMs))) "" else java.text.SimpleDateFormat("EEE d MMM ", java.util.Locale.UK).format(java.util.Date(timeMs)) } -/** Holding left or right jumps a day at a time, staying between now and a week ahead. */ -internal fun guideDayJump(cursorMs: Long, nowMs: Long, forward: Boolean): Long = - (maxOf(cursorMs, nowMs) + if (forward) 24 * 60 * 60_000L else -24 * 60 * 60_000L).coerceIn(nowMs, nowMs + GUIDE_MAX_SLOTS_AHEAD * GUIDE_SLOT_MS - 1) +/** Holding left or right jumps a day at a time, staying between [earliest] (now, or back into Catch Up) and a week ahead. */ +internal fun guideDayJump(cursorMs: Long, nowMs: Long, forward: Boolean, earliest: Long = nowMs): Long = + (maxOf(cursorMs, earliest) + if (forward) 24 * 60 * 60_000L else -24 * 60 * 60_000L).coerceIn(earliest, nowMs + GUIDE_MAX_SLOTS_AHEAD * GUIDE_SLOT_MS - 1) /** - * What the grid draws: finished programmes go, and whatever's on now fills back to the start of the window. - * Every block is then something you can watch or look forward to. + * What the grid draws: finished programmes go, unless they can be caught up (from [earliest] on), and whatever's on now + * fills back to the start of the window when nothing earlier is drawn. Every block is then something you can watch, catch + * up on, or look forward to. */ -internal fun guideWatchableCells(cells: List, nowMs: Long): List { - val left = cells.filter { it.show.endMs > nowMs } +internal fun guideWatchableCells(cells: List, nowMs: Long, earliest: Long = nowMs): List { + val left = cells.filter { it.show.endMs > nowMs || it.show.startMs >= earliest } val first = left.firstOrNull() ?: return left + if (first.show.endMs <= nowMs) return left return listOf(first.copy(startFraction = 0f, widthFraction = first.startFraction + first.widthFraction)) + left.drop(1) } @@ -107,15 +109,20 @@ internal fun guideFirstRow(row: Int, first: Int, visible: Int, count: Int): Int }.coerceIn(0, last) } -/** Cursor time after moving one programme along a channel, never earlier than now. */ -internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forward: Boolean): Long { - val show = channel.at(maxOf(cursorMs, nowMs)) - return if (forward) show.endMs else maxOf(show.startMs - 1, nowMs) +/** Cursor time after moving one programme along a channel, never earlier than [earliest]: now, or back into Catch Up. */ +internal fun guideStep(channel: SampleChannel, cursorMs: Long, nowMs: Long, forward: Boolean, earliest: Long = nowMs): Long { + val show = channel.at(maxOf(cursorMs, earliest)) + return if (forward) show.endMs else maxOf(show.startMs - 1, earliest) } +/** How far back the guide scrolls: two days, for Catch Up. */ +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) { + categories: List = listOf(ChannelCategory.All), picture: @Composable () -> Unit = {}, onCategory: (ChannelCategory) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit, + /** OK on a finished programme that can be caught up. */ + onCatchUp: (CatchUpEntry) -> Unit = {}) { val title = category.label var now by remember { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(Unit) { while (true) { delay(1000); if (!FullScreen.active) now = System.currentTimeMillis() } } @@ -132,13 +139,16 @@ 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)] - val focused = channel.at(maxOf(cursor, now)) + // 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) + val focused = channel.at(maxOf(cursor, earliest)) + val focusedPast = focused.endMs <= now && catchUpAvailable(channel, focused, now) fun keepCursorInWindow(time: Long) { cursor = time val base = guideWindowStart(now, 0) var ahead = slotsAhead while (time >= base + (ahead + GUIDE_SLOTS) * GUIDE_SLOT_MS && ahead < GUIDE_MAX_SLOTS_AHEAD) ahead++ - while (time < base + ahead * GUIDE_SLOT_MS && ahead > 0) ahead-- + while (time < base + ahead * GUIDE_SLOT_MS && ahead > -GUIDE_MAX_SLOTS_BACK) ahead-- slotsAhead = ahead } Column(modifier.fillMaxWidth().background(Navy).padding(horizontal = 14.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { @@ -155,7 +165,11 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif val (focusedName, focusedDetail) = guideTitleParts(focused.media.item.title) Text(focusedName, color = Color.White, fontFamily = Anton, fontSize = 20.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focusedDetail != null) Text(focusedDetail, color = Color.White, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}", color = Muted, fontSize = 13.sp, maxLines = 1) + Text("${guideDayPrefix(focused.startMs, now)}${clock(focused.startMs)} to ${clock(focused.endMs)} • ${channel.number} ${ChannelBrands.displayName(channel)}" + + if (focusedPast) " • ${airedAgo(focused.endMs, now)}" else "", color = Muted, fontSize = 13.sp, maxLines = 1) + if (focusedPast) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + 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)) { picture() @@ -184,14 +198,16 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_LEFT -> { val forward = native.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT when { - native.repeatCount == 0 -> if (!forward || slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, forward)) + native.repeatCount == 0 -> if (!forward || slotsAhead < GUIDE_MAX_SLOTS_AHEAD) keepCursorInWindow(guideStep(channel, cursor, now, forward, earliest)) // Key repeats come quickly, so a day for every ten of them. - native.repeatCount % 10 == 0 -> keepCursorInWindow(guideDayJump(cursor, now, forward)) + native.repeatCount % 10 == 0 -> keepCursorInWindow(guideDayJump(cursor, now, forward, earliest)) } true } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { - if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0); true + if (native.repeatCount == 0 && focusedPast) onCatchUp(catchUpEntry(channel, focused)) + else if (native.repeatCount == 0 && focused.startMs <= now) onWatch(channel.number, 0) + true } else -> false } @@ -206,21 +222,23 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif else Text(ChannelBrands.displayName(line), color = Color.White, fontSize = 13.sp, maxLines = 1) } BoxWithConstraints(Modifier.weight(1f).fillMaxHeight()) { - guideWatchableCells(guideCells(line, windowStart, windowEnd), now).forEach { cell -> + guideWatchableCells(guideCells(line, windowStart, windowEnd), now, catchUpEarliest(line, now)).forEach { cell -> val isFocus = gridFocused && index == row && cell.show.startMs == focused.startMs - val onNow = cell.show.startMs <= now + val past = cell.show.endMs <= now + val onNow = cell.show.startMs <= now && !past val cellWidth = maxWidth * cell.widthFraction - 3.dp // 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 (onNow) Panel else GuideLater, androidx.compose.foundation.shape.RoundedCornerShape(4.dp)), contentAlignment = Alignment.CenterStart) { + .background(if (isFocus) Yellow else if (past) catchUpDim() else if (onNow) Panel else GuideLater, androidx.compose.foundation.shape.RoundedCornerShape(4.dp)), contentAlignment = Alignment.CenterStart) { val runsOn = cell.show.endMs > windowEnd - val ink = if (isFocus) Navy else Color.White + 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)) 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) } } } } @@ -230,7 +248,7 @@ internal fun GuideGrid(channels: List, selectedNumber: Int, modif } } Row(Modifier.fillMaxWidth()) { - Text("▲▼ Channel ◄► Time Hold ◄► Day OK Watch", Modifier.weight(1f), color = Muted, fontSize = 11.sp) + Text("▲▼ Channel ◄► Time (◄ back two days for Catch Up) Hold ◄► Day OK Watch", Modifier.weight(1f), color = Muted, fontSize = 11.sp) if (rowsShown < channels.size) Text("Channels ${firstRow + 1} to ${firstRow + rowsShown} of ${channels.size}", color = Muted, fontSize = 11.sp) } } diff --git a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt index 8dd3b5f..a0ccc75 100644 --- a/android/app/src/main/java/tv/orbit/classic/MainActivity.kt +++ b/android/app/src/main/java/tv/orbit/classic/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -105,6 +106,8 @@ private fun OrbitApp() { val observer = LifecycleEventObserver { _, event -> // Opening the app always lands on the main menu, wherever it was left. if (event == Lifecycle.Event.ON_STOP && realChannelAvailable && section != "Connect Emby") section = "Home" + // The sleep timer is for this evening's viewing only. + if (event == Lifecycle.Event.ON_STOP) SleepTimer.clear() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } @@ -113,7 +116,15 @@ private fun OrbitApp() { BackHandler(realChannelAvailable && section != "TV Guide" && section != "Home" && section != "What's On") { section = "Home" } val navFocus = remember { FocusRequester() } val driveInFocus = remember { FocusRequester() } - Box(Modifier.fillMaxSize().background(Navy)) { + // The sleep timer switches Orbit off wherever you are in it; the TV then sleeps as it normally would. + val activity = androidx.activity.compose.LocalActivity.current + LaunchedEffect(Unit) { while (true) { delay(1000); if (SleepTimer.tick(System.currentTimeMillis())) { SleepTimer.clear(); activity?.finish() } } } + Box(Modifier.fillMaxSize().background(Navy).onPreviewKeyEvent { event -> + // During the timer's last minute any button keeps you watching, and does nothing else. + if (!SleepTimer.warning) return@onPreviewKeyEvent false + if (event.nativeKeyEvent.action == android.view.KeyEvent.ACTION_UP) SleepTimer.keepWatching(System.currentTimeMillis()) + true + }) { CompositionLocalProvider(LocalNavFocus provides navFocus, LocalDriveInFocus provides driveInFocus) { // The Drive-In and a game in play take the whole screen; the bar sits over the Drive-In's sky. val fullBleed = section == "Drive-In" || playingGame @@ -125,6 +136,7 @@ private fun OrbitApp() { mainHome = section == "Home" && realChannelAvailable, mainDriveIn = section == "Drive-In" && realChannelAvailable, mainWhatsOn = section == "What's On" && realChannelAvailable, + mainCatchUp = section == "Catch Up" && realChannelAvailable, onChannelAvailable = { realChannelAvailable = it }, onRestoreChecked = { restoreChecked = true }, onGuide = { section = "TV Guide" }, onHome = { section = "Home" }, onDriveIn = { section = "Drive-In" }) } @@ -136,6 +148,7 @@ private fun OrbitApp() { NavItem("Home", section == "Home", navFocus, below) { section = "Home" } NavItem("What's On", section == "What's On", null, below) { section = "What's On" } NavItem("TV Guide", section == "TV Guide", null, below) { section = "TV Guide" } + NavItem("Catch Up", section == "Catch Up", null, below) { section = "Catch Up" } NavItem("Drive-In", section == "Drive-In", null, below) { section = "Drive-In" } NavItem("Arcade", section == "Arcade", null, below) { section = "Arcade" } } @@ -151,6 +164,8 @@ private fun OrbitApp() { } } } + // Full screen draws its own, over the programme. + if (SleepTimer.warning && !FullScreen.active) SleepWarning(Modifier.align(Alignment.TopCenter).padding(top = 64.dp)) } } } 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 1c8cc27..080b39d 100644 --- a/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt +++ b/android/app/src/main/java/tv/orbit/classic/SampleChannelScreens.kt @@ -38,7 +38,9 @@ internal fun scheduledRuntimeMatches(openedMs: Long?, scheduledMs: Long) = opene internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleChannel, clockOffsetMs: Long, onClose: () -> Unit, onStep: (Int) -> Unit = {}, onLast: () -> Unit = {}, onNumber: (Int) -> Boolean = { false }, handoff: LiveHandoff? = null, onUnplayable: ((String) -> Unit)? = null, /** A short film over the start of this visit, such as the Drive-In's curtains. The programme opens underneath at its proper place. */ - intro: IntroSource? = null) { + intro: IntroSource? = null, + /** Catch Up: see [EmbyPlaybackDialog]. */ + onShift: (Long) -> Unit = {}, onLive: () -> Unit = {}) { // Outlives each programme and channel, and ends when the viewer leaves full screen. val settingsContext = androidx.compose.ui.platform.LocalContext.current val subtitlePreference = remember { mutableStateOf(if (OrbitSettings(settingsContext).subtitlesOn) SUBTITLES_BY_DEFAULT else null) } @@ -147,7 +149,7 @@ 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, onFirstFrame = { if (heldIdent?.startMs == current.startMs) heldIdent = null }, + window = window, onShift = onShift, onLive = onLive, onFirstFrame = { if (heldIdent?.startMs == current.startMs) heldIdent = null }, onEndedEarly = { val at = System.currentTimeMillis() + clockOffsetMs if (channel is ScheduledChannel && next.startMs == current.endMs && finishedEarly(at, current.endMs) && heldIdent == null) { @@ -182,6 +184,7 @@ internal fun ScheduledChannelPlayer(session: EmbySampleSession, channel: SampleC } } introPlayback?.let { IntroOverlay(it) } + if (SleepTimer.warning) SleepWarning(Modifier.align(Alignment.TopCenter).padding(top = 44.dp)) } } } diff --git a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt index 160577b..2948ef2 100644 --- a/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt +++ b/android/app/src/main/java/tv/orbit/classic/SecureMediaDataSource.kt @@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B next.instanceFollowRedirects = false next.connectTimeout = 10_000 next.readTimeout = 20_000 - next.setRequestProperty("User-Agent", "OrbitTV/0.108.21") + next.setRequestProperty("User-Agent", "OrbitTV/0.108.22") next.setRequestProperty("Accept-Encoding", "identity") session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) } if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) { diff --git a/android/app/src/main/java/tv/orbit/classic/SleepTimer.kt b/android/app/src/main/java/tv/orbit/classic/SleepTimer.kt new file mode 100644 index 0000000..5f55c99 --- /dev/null +++ b/android/app/src/main/java/tv/orbit/classic/SleepTimer.kt @@ -0,0 +1,76 @@ +package tv.orbit.classic + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text + +/** + * The sleep timer (Paul, 5 November 2026): chosen in the Watching panel, it switches Orbit off and lets the TV sleep. A minute + * before, a warning says so, and any button keeps you watching for the same length again. It lives only while Orbit is open: + * nothing is saved, so each time Orbit opens it starts off. + */ +internal const val SLEEP_WARNING_MS = 60_000L + +internal enum class SleepChoice(val label: String, val minutes: Int?) { + Off("Off", null), Thirty("30 min", 30), Sixty("60 min", 60), Ninety("90 min", 90), AfterThis("After this programme", null) +} + +internal object SleepTimer { + var choice by mutableStateOf(SleepChoice.Off) + private set + /** Wall-clock time Orbit switches off, or null. */ + var endsAt by mutableStateOf(null) + private set + var warning by mutableStateOf(false) + + /** [programmeEndMs] is when the programme being watched ends on the wall clock, for [SleepChoice.AfterThis]. */ + fun set(choice: SleepChoice, nowMs: Long, programmeEndMs: Long?) { + this.choice = choice; warning = false + endsAt = sleepEnd(choice, nowMs, programmeEndMs) + if (endsAt == null) this.choice = SleepChoice.Off + } + + /** Any button during the warning: the same length again, or off after a programme-length timer. */ + fun keepWatching(nowMs: Long) = set(if (choice == SleepChoice.AfterThis) SleepChoice.Off else choice, nowMs, null) + + /** Checked every second: true when it's time to switch off. */ + fun tick(nowMs: Long): Boolean { + val end = endsAt ?: return false + warning = end - nowMs <= SLEEP_WARNING_MS + return nowMs >= end + } + + fun clear() { choice = SleepChoice.Off; endsAt = null; warning = false } +} + +internal fun sleepEnd(choice: SleepChoice, nowMs: Long, programmeEndMs: Long?): Long? = when { + choice.minutes != null -> nowMs + choice.minutes * 60_000L + // A programme that ends within the minute's warning gets the warning in full. + choice == SleepChoice.AfterThis && programmeEndMs != null -> maxOf(programmeEndMs, nowMs + SLEEP_WARNING_MS) + else -> null +} + +/** What the Watching panel shows beside the timer's choice: how long is left. */ +internal fun sleepLeft(endsAt: Long?, nowMs: Long): String? = endsAt?.let { ((it - nowMs + 59_999) / 60_000).coerceAtLeast(1).let { m -> "$m min left" } } + +@Composable +internal fun SleepWarning(modifier: Modifier = Modifier) { + val corner = RoundedCornerShape(10.dp) + Column(modifier.width(400.dp).background(Navy.copy(alpha = .96f), corner).border(2.dp, Yellow, corner).padding(horizontal = 22.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("SLEEP TIMER", color = Yellow, fontFamily = Anton, fontSize = 18.sp) + Text("Orbit switches off in 1 minute", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + Text("Press any button to keep watching", color = Muted, fontSize = 15.sp) + } +} diff --git a/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt index 2871758..e73f385 100644 --- a/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt +++ b/android/app/src/main/java/tv/orbit/classic/WatchingControls.kt @@ -87,17 +87,24 @@ internal fun trackLabel(label: String?, language: String?, position: Int): Strin return "Track ${position + 1}" } +private const val TRACK_PANEL_ROWS = 12 + /** Sits to one side, so the programme keeps playing while you choose. */ @Composable internal fun TrackPanel(rows: List, cursor: Int, modifier: Modifier = Modifier) { val corner = RoundedCornerShape(6.dp) + // Subtitles, sound and the sleep timer can be more than the screen holds, so it shows a stretch around the cursor. + val first = (cursor - TRACK_PANEL_ROWS / 2).coerceIn(0, (rows.size - TRACK_PANEL_ROWS).coerceAtLeast(0)) Column(modifier.width(280.dp).background(Navy.copy(alpha = .94f), corner).border(1.dp, Panel, corner).padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (first > 0) Text("▲ more", color = Muted, fontSize = 11.sp) rows.forEachIndexed { index, row -> - if (index == 0 || rows[index - 1].section != row.section) Text(row.section, color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp)) + if (index < first || index >= first + TRACK_PANEL_ROWS) return@forEachIndexed + if (index == first || rows[index - 1].section != row.section) Text(row.section, color = Muted, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp)) val on = index == cursor Text((if (row.selected) "✓ " else " ") + row.label, Modifier.fillMaxWidth().background(if (on) Yellow else Color.Transparent, RoundedCornerShape(4.dp)).padding(horizontal = 8.dp, vertical = 4.dp), color = if (on) Navy else Color.White, fontSize = 15.sp, fontWeight = if (on || row.selected) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1) } + if (first + TRACK_PANEL_ROWS < rows.size) Text("▼ more", color = Muted, fontSize = 11.sp) Text("▲▼ Move OK Choose Back Close", color = Muted, fontSize = 11.sp, modifier = Modifier.padding(top = 4.dp)) } } diff --git a/android/app/src/test/java/tv/orbit/classic/CatchUpTest.kt b/android/app/src/test/java/tv/orbit/classic/CatchUpTest.kt new file mode 100644 index 0000000..a782a05 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/CatchUpTest.kt @@ -0,0 +1,130 @@ +package tv.orbit.classic + +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +/** Catch Up reads what the committed plans say aired in the last two days, and the sleep timer switches Orbit off. */ +class CatchUpTest { + private val minute = 60_000L + private val hour = 60 * minute + private val day = 24 * hour + private val london = ZoneId.of("Europe/London") + // Thursday 5 November 2026, 20:14. + private val now = ZonedDateTime.of(2026, 11, 5, 20, 14, 0, 0, london).toInstant().toEpochMilli() + private fun show(id: String, title: String, episodes: Int, minutes: Long = 45) = PoolSeries(id, title, (1..episodes).map { n -> + PoolEpisode("$id-$n", "src-$id-$n", EpisodeMark(1, n), "$title / S1 E$n / Part $n", minutes * minute) + }) + private val loop = SampleChannel(listOf(media("x"), media("y")), "Paradox", 106) + private fun media(id: String) = ScheduledMedia(SampleItem(id, "Episode", 1, "", id, minute), "s-$id", minute) + /** A plan that began three days ago and runs a week ahead, with everything older than the keep window trimmed. */ + private fun channel(): ScheduledChannel { + val pool = listOf(show("dw", "Doctor Who", 40), show("xf", "The X-Files", 40)) + val plan = extendSchedule(pool, ScheduleCursor(), now - 3 * day, now + 7 * day) + return ScheduledChannel(loop, ChannelPlan(plan.entries.dropWhile { it.endMs < now - SCHEDULE_KEEP_PAST_MS }, plan.tail)) + } + + @After fun clearTimer() = SleepTimer.clear() + + @Test fun theBoxKeepsTwoDaysOfWhatAired() { + assertTrue(SCHEDULE_KEEP_PAST_MS >= CATCH_UP_MS) + val c = channel() + val earliest = catchUpEarliest(c, now) + assertTrue(earliest >= now - CATCH_UP_MS && earliest < now - CATCH_UP_MS + 45 * minute) + // A plain looping channel has nothing to catch up on. + assertEquals(now, catchUpEarliest(loop, now)) + } + + @Test fun onlyFinishedProgrammesInTheLastTwoDaysAndInThePlan() { + val c = channel() + val entries = c.plan.entries + val past = entries.last { it.endMs <= now } + val live = entries.first { it.startMs <= now && it.endMs > now } + assertTrue(catchUpAvailable(c, SampleBroadcast(past.media, past.startMs), now)) + assertFalse(catchUpAvailable(c, SampleBroadcast(live.media, live.startMs), now)) + // The loop's programmes before the plan began never aired. + assertFalse(catchUpAvailable(c, loop.at(now - 4 * day), now)) + assertFalse(catchUpAvailable(loop, loop.at(now - hour), now)) + } + + @Test fun watchingReplaysTheChannelFromTheStartAndCarriesOn() { + val c = channel() + val past = c.plan.entries.last { it.endMs <= now - 2 * hour } + val entry = catchUpEntry(c, SampleBroadcast(past.media, past.startMs)) + val offset = entry.offsetFrom(now) + assertTrue(offset < 0) + // At the replay clock the channel is at the programme's very start, then the next one follows as it aired. + assertEquals(past.startMs, c.at(now + offset).startMs) + assertEquals(0L, c.at(now + offset).offsetAt(now + offset)) + assertEquals(entry.next!!.startMs, c.at(now + offset + past.media.durationMs).startMs) + assertEquals(past.startMs + CATCH_UP_MS, entry.untilMs) + assertTrue(ScheduledPlayback(c, c.at(now + offset), entry.next!!, offset).catchingUp()) + assertFalse(ScheduledPlayback(c, c.at(now), c.at(c.at(now).endMs), 0).catchingUp()) + } + + @Test fun theNextPartCarriesOnWhenItFollows() { + val c = channel() + val dw = c.plan.entries.filter { it.endMs <= now } + val pair = dw.zipWithNext().firstOrNull { (a, b) -> a.seriesId == b.seriesId } + val split = dw.zipWithNext().first { (a, b) -> a.seriesId != b.seriesId } + if (pair != null) assertTrue(catchUpEntry(c, SampleBroadcast(pair.first.media, pair.first.startMs)).continues) + assertFalse(catchUpEntry(c, SampleBroadcast(split.first.media, split.first.startMs)).continues) + } + + @Test fun thePageHasRowsNewestFirstWithEachProgrammeOnce() { + val c = channel() + val shelves = catchUpShelves(listOf(c, loop), now, london) + assertEquals(listOf("JUST MISSED", "EARLIER TODAY", "YESTERDAY", "THE DAY BEFORE"), shelves.map { it.title }) + val just = shelves.first().entries + assertTrue(just.all { now - it.show.endMs <= JUST_MISSED_MS && it.show.endMs <= now }) + assertEquals(just.sortedByDescending { it.show.endMs }, just) + val all = shelves.flatMap { it.entries } + assertEquals(all.size, all.map { it.show.media.item.id }.distinct().size) + assertTrue(all.all { it.show.startMs >= now - CATCH_UP_MS && catchUpAvailable(c, it.show, now) }) + // Nothing from a channel without a plan, and nothing at all when nothing has aired. + assertTrue(catchUpShelves(listOf(loop), now, london).isEmpty()) + } + + @Test fun theGuideScrollsBackIntoCatchUpOnlyWhereThePlanHoldsIt() { + val c = channel() + val earliest = catchUpEarliest(c, now) + val live = c.at(now) + // Left from what's on now goes to the programme before it; it never goes before Catch Up's earliest. + assertEquals(live.startMs - 1, guideStep(c, now, now, false, earliest)) + assertEquals(earliest, guideStep(c, earliest, now, false, earliest)) + assertEquals(earliest, guideDayJump(now - day - hour, now, false, earliest)) + // Finished programmes stay in the grid when they can be caught up, and the live one isn't stretched over them. + val start = guideWindowStart(now, -4) + val cells = guideWatchableCells(guideCells(c, start, start + 2 * hour), now, earliest) + assertTrue(cells.first().show.endMs <= now) + assertEquals(guideCells(c, start, start + 2 * hour), cells) + // Without Catch Up (a looping channel), as before. + assertTrue(guideWatchableCells(guideCells(loop, start, start + 2 * hour), now).none { it.show.endMs <= now }) + } + + @Test fun sleepTimerCountsDownWarnsAndSwitchesOff() { + SleepTimer.set(SleepChoice.Thirty, now, null) + assertEquals(now + 30 * minute, SleepTimer.endsAt) + assertFalse(SleepTimer.tick(now + 28 * minute)); assertFalse(SleepTimer.warning) + assertFalse(SleepTimer.tick(now + 29 * minute + 1)); assertTrue(SleepTimer.warning) + // Any button: the same length again. + SleepTimer.keepWatching(now + 29 * minute + 5_000) + assertFalse(SleepTimer.warning); assertEquals(now + 59 * minute + 5_000, SleepTimer.endsAt) + assertTrue(SleepTimer.tick(now + 60 * minute)) + assertEquals("2 min left", sleepLeft(now + 90_000, now)) + } + + @Test fun afterThisProgrammeEndsWithItAndAlwaysWarnsFirst() { + SleepTimer.set(SleepChoice.AfterThis, now, now + 20 * minute) + assertEquals(now + 20 * minute, SleepTimer.endsAt) + assertEquals(now + SLEEP_WARNING_MS, sleepEnd(SleepChoice.AfterThis, now, now + 10_000)) + // Keeping watching after a programme-length timer turns it off. + SleepTimer.keepWatching(now + 19 * minute + 30_000) + assertEquals(SleepChoice.Off, SleepTimer.choice); assertNull(SleepTimer.endsAt); assertFalse(SleepTimer.tick(now + day)) + // With no programme to end, there's nothing to time. + SleepTimer.set(SleepChoice.AfterThis, now, null) + assertEquals(SleepChoice.Off, SleepTimer.choice) + } +}