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) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 19:59:39 +00:00
co-authored by Claude Opus 5.5
parent eefe1f26fb
commit 5981c2e355
14 changed files with 604 additions and 44 deletions
+2 -2
View File
@@ -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 }
@@ -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<CatchUpEntry>)
/**
* 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<SampleChannel>, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): List<CatchUpShelf> {
val all = channels.filterIsInstance<ScheduledChannel>().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<SampleChannel>, 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<Int, Int>() }
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
@@ -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 =
@@ -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)
}
@@ -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)
@@ -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<List<ProgrammeReminder>>(emptyList()) }
var programmeReminder by remember { mutableStateOf<ProgrammeReminder?>(null) }
var windingDown by remember { mutableStateOf(false) }
var catchUpOpen by remember { mutableStateOf<CatchUpEntry?>(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
}
@@ -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<String?>(null) }
@@ -98,6 +102,10 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
var subtitleIndex by remember { mutableStateOf<Int?>(null) }
var trackMenu by remember { mutableStateOf<List<TrackRow>?>(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))
@@ -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<GuideCell>, nowMs: Long): List<GuideCell> {
val left = cells.filter { it.show.endMs > nowMs }
internal fun guideWatchableCells(cells: List<GuideCell>, nowMs: Long, earliest: Long = nowMs): List<GuideCell> {
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<SampleChannel>, selectedNumber: Int, modifier: Modifier = Modifier, category: ChannelCategory = ChannelCategory.All,
categories: List<ChannelCategory> = listOf(ChannelCategory.All), picture: @Composable () -> Unit = {}, onCategory: (ChannelCategory) -> Unit = {}, onWatch: (Int, Long) -> Unit, onBack: () -> Unit) {
categories: List<ChannelCategory> = 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<SampleChannel>, selectedNumber: Int, modif
val windowEnd = windowStart + GUIDE_SLOTS * GUIDE_SLOT_MS
val nowFraction = guideNowFraction(now, windowStart, windowEnd)
val channel = channels[row.coerceIn(channels.indices)]
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<SampleChannel>, 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<SampleChannel>, 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<SampleChannel>, 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<SampleChannel>, 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)
}
}
@@ -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))
}
}
}
@@ -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))
} }
}
@@ -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()) {
@@ -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<Long?>(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)
}
}
@@ -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<TrackRow>, 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))
}
}
@@ -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)
}
}