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) + } +}