${esc(e.title)}
+ if (!shelves.length) return head('Catch Up') + upNext() + '
Nothing to catch up on yet. Programmes your channels show appear here for two days.
';
+ return head('Catch Up') + upNext() + shelves.map(s => `
${esc(s.title)}
` + s.entries.map(e => `
+
${esc(e.title)}
${e.sub ? esc(e.sub) + ' · ' : ''}${e.number} ${esc(e.channel)} · aired ${hm(e.startMs)}${e.continues ? ' · carries on' : ''}
${I.play} `).join('')).join('') +
- '
Plays from the start on this TV, and carries on as it aired.
';
+ '
Plays from the start on this TV, and carries on as it aired. Or queue it for when the programme on now ends.
';
}
function rooms() {
const r = state.rooms, n = state.now;
@@ -237,7 +268,7 @@ $('#shade').onclick = closeSheet;
async function load(what) {
try {
if (what === 'now') { const n = await api('/api/now'); skew = n.serverMs - Date.now(); look(n.look); state.now = n; }
- if (what === 'guide') state.guide = await api('/api/guide');
+ if (what === 'guide') { const [g, d] = await Promise.all([api('/api/guide'), api('/api/drivein').catch(() => ({bills: []}))]); state.guide = g; state.drivein = d.bills; }
if (what === 'catchup') state.catchup = await api('/api/catchup');
if (what === 'rooms') state.rooms = await api('/api/rooms');
if (what === 'more') { state.looks = (await api('/api/looks')).looks; look(state.now?.look); }
@@ -264,7 +295,13 @@ document.addEventListener('click', e => {
if (d.filter) { state.filter = d.filter; render(); }
if (d.pick) openSheet(pickSheet(+d.pick));
if (d.tune) send('/api/tune', {number: +d.tune}, () => { closeSheet(); toast('Watching on the TV'); setTimeout(() => load('guide'), 700); });
- if (d.cu) { const [number, startMs] = d.cu.split(':').map(Number); send('/api/catchup/watch', {number, startMs}, () => toast('Playing from the start on the TV')); }
+ if (d.queue) send('/api/queue', {number: +d.queue}, j => { state.now = {...state.now, queue: j.queue}; closeSheet(); toast('Queued: switches when this one ends'); });
+ if (d.cupick) { const [n, st] = d.cupick.split(':').map(Number); openSheet(cuSheet(n, st)); return; }
+ if (d.cuq) { const [number, startMs] = d.cuq.split(':').map(Number); send('/api/queue', {number, startMs}, j => { state.now = {...state.now, queue: j.queue}; closeSheet(); render(); toast('Queued for when this one ends'); }); return; }
+ if (d.unqueue) { send('/api/queue/remove', {index: +d.unqueue}, j => { state.now = {...state.now, queue: j.queue}; render(); }); return; }
+ if (d.ticket) { const [startMs, held] = d.ticket.split(':').map(Number); send('/api/drivein/ticket', {startMs, held: !!held}, j => { state.drivein = j.bills; render(); toast(held ? 'Ticket saved: the TV will remind you' : 'Ticket handed back'); }); return; }
+ if (d.dwatch) { send('/api/drivein/watch', {}, () => toast('Off to the Drive-In')); return; }
+ if (d.cu) { closeSheet(); const [number, startMs] = d.cu.split(':').map(Number); send('/api/catchup/watch', {number, startMs}, () => toast('Playing from the start on the TV')); }
});
offerHome(); render(); (keptLook() || tab === 'more' ? load('more') : Promise.resolve()).then(() => load('now')); if (tab !== 'remote' && tab !== 'more') load(tab);
// Kept fresh while the phone is looking: what's on every 15 seconds, and the open list every minute.
diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt
index 97632dc..2eddca2 100644
--- a/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt
+++ b/android/app/src/main/java/tv/orbit/classic/ChannelIdentities.kt
@@ -562,3 +562,14 @@ internal const val CHRISTMAS_NUMBER = 310
*/
internal fun inSeason(number: Int, timeMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Boolean =
number != CHRISTMAS_NUMBER || java.time.Instant.ofEpochMilli(timeMs).atZone(zone).monthValue >= 11
+
+/**
+ * How far a seasonal channel may be booked: Orbit Christmas books nothing that starts from 1 January (it planned about a
+ * week into January before, out of sight). Null for every other channel.
+ */
+internal fun seasonEndMs(number: Int, timeMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Long? {
+ if (number != CHRISTMAS_NUMBER) return null
+ val day = java.time.Instant.ofEpochMilli(timeMs).atZone(zone)
+ if (day.monthValue < 11) return timeMs
+ return java.time.LocalDate.of(day.year + 1, 1, 1).atStartOfDay(zone).toInstant().toEpochMilli()
+}
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 af0a214..2a86852 100644
--- a/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt
+++ b/android/app/src/main/java/tv/orbit/classic/ChannelSchedule.kt
@@ -1113,16 +1113,20 @@ internal fun topUpPlan(existing: ChannelPlan?, aired: ScheduleCursor, pool: List
/** See [ChannelProgramming.shorts]. */
shorts: ShortsRule? = null,
/** See [ChannelProgramming.marathon]. */
- marathon: Int = 0): ChannelPlan? {
+ marathon: Int = 0,
+ /** Nothing is booked to start from here on: a seasonal channel's end ([seasonEndMs]). */
+ seasonEndMs: Long? = null): ChannelPlan? {
val kept = existing?.let { plan -> plan.copy(entries = plan.entries.dropWhile { it.endMs < nowMs - SCHEDULE_KEEP_PAST_MS }) }
if (kept != null && kept.endMs >= nowMs + SCHEDULE_TOP_UP_MS) return kept
if (pool.isNullOrEmpty()) return kept
val from = if (kept != null && kept.endMs > nowMs) kept.endMs else nowMs
+ val untilMs = minOf(nowMs + SCHEDULE_AHEAD_MS, seasonEndMs ?: Long.MAX_VALUE)
+ if (from >= untilMs) return kept ?: ChannelPlan(emptyList(), existing?.tail ?: aired)
val trailing = kept?.entries?.lastOrNull()?.let { tail -> kept.entries.asReversed().takeWhile { it.seriesId == tail.seriesId }.size } ?: 0
val added = if (weightOf != null && filmRestMs == null) {
val recent = kept?.entries.orEmpty().filter { it.startMs >= from - fileRestMs }.associate { it.media.item.id to it.startMs }
- extendByBlock(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, weightOf, dayParts, zone, avoid, shuffle, mixed, finishTogether, recent, trailing, fileRestMs, stories, shorts)
- } else extendSchedule(pool, existing?.tail ?: aired, from, nowMs + SCHEDULE_AHEAD_MS, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow && marathon == 0, blockRotation, adaptiveRest, fixed, held, stories, shorts = shorts, marathon = marathon)
+ extendByBlock(pool, existing?.tail ?: aired, from, untilMs, weightOf, dayParts, zone, avoid, shuffle, mixed, finishTogether, recent, trailing, fileRestMs, stories, shorts)
+ } else extendSchedule(pool, existing?.tail ?: aired, from, untilMs, filmRestMs, finishTogether, dayParts, zone, avoid, shuffle, mixed, trailing, noThirdInARow && marathon == 0, blockRotation, adaptiveRest, fixed, held, stories, shorts = shorts, marathon = marathon)
return ChannelPlan(kept?.entries.orEmpty() + added.entries, added.tail)
}
diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt
index 24b4ae7..0a6efa7 100644
--- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt
+++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt
@@ -361,7 +361,8 @@ internal class ChannelScheduler(private val store: ScheduleStore,
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it + premiereRotation(number, premieres, now).filter { p -> it.none { s -> s.id == p.id } }, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes,
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, shorts = programming.shorts, marathon = programming.marathon,
- fixed = fixed, held = seasonalHeld().let { seasonal -> val premiere = premiereHeld(number); { f: PoolSeries, at: Long -> seasonal(f, at) || premiere(f, at) } })
+ fixed = fixed, held = seasonalHeld().let { seasonal -> val premiere = premiereHeld(number); { f: PoolSeries, at: Long -> seasonal(f, at) || premiere(f, at) } },
+ seasonEndMs = seasonEndMs(number, now))
?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
if (plan !== existing) plan.entries.filter { (it.why.startsWith(ADVENT_WHY) || it.why.startsWith(NAUGHTY_WHY)) && existing?.entries?.none { e -> e.startMs == it.startMs } != false }
.forEach { Log.i("OrbitSchedule", "channel=$number ${if (it.why.startsWith(ADVENT_WHY)) "advent door=" + it.why.removePrefix(ADVENT_WHY).substringBefore(';') else "naughty"} booked lateMin=${(it.startMs - (fixed.firstOrNull { f -> f.film.id == it.seriesId }?.startMs ?: it.startMs)) / 60_000}") }
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 cdeba24..b505b3c 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -512,11 +512,26 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
val companionTune by rememberUpdatedState<(Int, Long) -> Unit> { n, offset ->
if (shownChannels.any { it.number == n }) { tune(n, offset); if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide() }
}
+ val companionTickets by rememberUpdatedState(tickets)
+ val companionSaveTickets by rememberUpdatedState<(Map
) -> Unit> { changed -> saveTickets(changed) }
+ val companionBills by rememberUpdatedState(driveInPlan.takeIf { Profiles.current?.let { it.kind != ProfileKind.Kids || it.driveIn } ?: true }
+ ?.let { plan -> driveInLineup(driveInBills(plan), System.currentTimeMillis()).filter { !lockedOut(it) } }.orEmpty())
+ val companionDriveIn by rememberUpdatedState(mainDriveIn)
+ val companionScreen = remember {
+ CompanionScreen(channels = { shownChannels }, selected = { selectedNumber }, watching = { watchingChannel }, offset = { channelClockOffset },
+ tune = { n, offset -> companionTune(n, offset) }, driveIn = { companionBills }, tickets = { companionTickets.keys },
+ ticket = { start, held -> companionSaveTickets(if (held) companionTickets + (start to 0) else companionTickets - start) },
+ watchDriveIn = { if (companionDriveIn) watchingDriveIn = true else { pendingDriveInWatch = true; OrbitCompanion.open("Drive-In") } })
+ }
DisposableEffect(Unit) {
- PhoneLink.screen = CompanionScreen(channels = { shownChannels }, selected = { selectedNumber }, watching = { watchingChannel }, offset = { channelClockOffset },
- tune = { n, offset -> companionTune(n, offset) })
+ PhoneLink.screen = companionScreen
onDispose { PhoneLink.screen = null }
}
+ // Phase 2's queue: the next item plays when the programme being watched ends.
+ LaunchedEffect(Unit) {
+ var anchor: Pair? = null
+ while (true) { delay(1_000); anchor = OrbitCompanion.follow(companionScreen, anchor, System.currentTimeMillis()) }
+ }
// The Companion's jump buttons: a section on the TV closes the programme being watched, as Back would.
LaunchedEffect(OrbitCompanion.asked) { if (OrbitCompanion.asked != null) { watchingChannel = false; watchingDriveIn = false } }
if (mainDriveIn && liveSession != null) {
diff --git a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
index 52b5e9c..a4c21ed 100644
--- a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
+++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
@@ -34,7 +34,21 @@ internal class CompanionScreen(
/** Catch Up's replay clock: below zero while catching up. */
val offset: () -> Long,
/** Tune [number] from [offset] (zero is live), as the guide does. */
- val tune: (number: Int, offset: Long) -> Unit)
+ val tune: (number: Int, offset: Long) -> Unit,
+ /** The Drive-In's bills this profile may see (none for a Kids profile without it, and no Late Show while locked). */
+ val driveIn: () -> List = { emptyList() },
+ val tickets: () -> Set = { emptySet() },
+ val ticket: (startMs: Long, held: Boolean) -> Unit = { _, _ -> },
+ /** Out to the lot and onto the big screen, while a bill is showing. */
+ val watchDriveIn: () -> Unit = {})
+
+/**
+ * Phase 2's queue (Paul, 6 November 2026): what plays when the programme on now ends. A channel, live from then ("switch
+ * when this one ends"), or a Catch Up programme from its start. Kept while Orbit is open; five at most.
+ */
+internal class Queued(val number: Int, val channel: String, val title: String, val sub: String?,
+ /** A Catch Up programme's start; null to switch to the channel live. */
+ val catchUpStartMs: Long?)
internal object OrbitCompanion {
/** The buttons the phone may press, by the name the page uses. */
@@ -124,6 +138,55 @@ internal object OrbitCompanion {
fun catchUpEntry(screen: CompanionScreen, number: Int, startMs: Long, nowMs: Long): CatchUpEntry? =
catchUpShelves(screen.channels(), nowMs).flatMap { it.entries }.firstOrNull { it.channel.number == number && it.show.startMs == startMs }
+ const val QUEUE_MAX = 5
+ var queue by mutableStateOf>(emptyList())
+
+ /** Queue [number] live after the programme on now, or a Catch Up programme ([catchUpStartMs]); null when it can't be. */
+ fun enqueue(screen: CompanionScreen, number: Int, catchUpStartMs: Long?, nowMs: Long): Queued? {
+ if (queue.size >= QUEUE_MAX) return null
+ val item = if (catchUpStartMs != null) {
+ val e = catchUpEntry(screen, number, catchUpStartMs, nowMs) ?: return null
+ guideTitleParts(e.show.media.item.title).let { (t, sub) -> Queued(number, e.channel.name, t, sub, catchUpStartMs) }
+ } else {
+ val channel = screen.channels().firstOrNull { it.number == number } ?: return null
+ // What that channel will be showing when the programme on the TV now ends.
+ val tv = screen.channels().firstOrNull { it.number == screen.selected() }
+ val at = tv?.at(nowMs + screen.offset())?.endMs?.minus(screen.offset()) ?: nowMs
+ guideTitleParts(channel.at(at).media.item.title).let { (t, sub) -> Queued(number, channel.name, t, sub, null) }
+ }
+ queue = queue + item
+ return item
+ }
+ fun queueJson(): JSONArray = JSONArray(queue.map { q ->
+ JSONObject().put("number", q.number).put("channel", q.channel).put("title", q.title).put("sub", q.sub ?: JSONObject.NULL)
+ .put("catchUp", q.catchUpStartMs != null).put("startMs", q.catchUpStartMs ?: JSONObject.NULL) })
+
+ /**
+ * The TV's side: called every second while Orbit is open. [anchor] is the programme being waited on (channel and start);
+ * when the programme on the same channel changes, the queue's first item plays. Changing channel, or not watching,
+ * moves the anchor, so the queue waits for the end of whatever is watched next. Returns the new anchor.
+ */
+ fun follow(screen: CompanionScreen, anchor: Pair?, nowMs: Long): Pair? {
+ val head = queue.firstOrNull() ?: return null
+ val channel = screen.channels().firstOrNull { it.number == screen.selected() } ?: return anchor
+ val on = screen.selected() to channel.at(nowMs + screen.offset()).startMs
+ if (anchor == null || !screen.watching() || anchor.first != on.first || anchor == on) return on
+ queue = queue.drop(1)
+ val offset = head.catchUpStartMs?.let { start -> catchUpEntry(screen, head.number, start, nowMs)?.offsetFrom(nowMs) }
+ if (head.catchUpStartMs == null || offset != null) screen.tune(head.number, offset ?: 0)
+ android.util.Log.i("OrbitCompanion", "queue played catchUp=${head.catchUpStartMs != null} left=${queue.size}")
+ return null
+ }
+
+ /** Tonight's and the coming bills, as the Drive-In lists them, with this TV's tickets. */
+ fun driveIn(screen: CompanionScreen, nowMs: Long): JSONArray = screen.tickets().let { held ->
+ JSONArray(screen.driveIn().map { b ->
+ JSONObject().put("kind", b.kind.label).put("event", b.event ?: JSONObject.NULL).put("startMs", b.startMs).put("endMs", b.endMs)
+ .put("day", driveInDay(b, nowMs)).put("showing", b.startMs <= nowMs && nowMs < b.endMs).put("ticket", b.startMs in held)
+ .put("films", JSONArray(b.entries.map { guideTitleParts(it.media.item.title).first }))
+ })
+ }
+
/** The channel [step] away from the chosen one, round the guide. */
fun stepped(screen: CompanionScreen, step: Int): Int? {
val list = screen.channels().ifEmpty { return null }
diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
index 5f5470b..c3367c5 100644
--- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
+++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
@@ -327,7 +327,8 @@ internal object PhoneLink {
}
private val companionRoutes = setOf("GET /api/now", "GET /api/guide", "GET /api/catchup", "GET /api/rooms", "GET /api/looks", "POST /api/key", "POST /api/volume",
- "POST /api/channel", "POST /api/tune", "POST /api/section", "POST /api/catchup/watch")
+ "POST /api/channel", "POST /api/tune", "POST /api/section", "POST /api/catchup/watch",
+ "GET /api/queue", "POST /api/queue", "POST /api/queue/remove", "GET /api/drivein", "POST /api/drivein/ticket", "POST /api/drivein/watch")
/** The Companion's remote, guide, Catch Up and rooms (see [OrbitCompanion]); false when [r] is for Create your own. */
private fun companion(r: Request, out: OutputStream): Boolean {
if ("${r.method} ${r.path}" !in companionRoutes) return false
@@ -339,7 +340,7 @@ internal object PhoneLink {
fun body() = runCatching { JSONObject(r.body) }.getOrNull() ?: JSONObject()
fun onScreen(block: () -> Unit) = runBlocking { withContext(mainDispatcher) { block() } }
when ("${r.method} ${r.path}") {
- "GET /api/now" -> json(out, 200, OrbitCompanion.now(s, now))
+ "GET /api/now" -> json(out, 200, OrbitCompanion.now(s, now).put("queue", OrbitCompanion.queueJson()))
"GET /api/guide" -> json(out, 200, OrbitCompanion.guide(s, now))
"GET /api/catchup" -> json(out, 200, JSONObject().put("shelves", OrbitCompanion.catchUp(s, now)))
"GET /api/rooms" -> json(out, 200, OrbitCompanion.rooms(appContext))
@@ -370,6 +371,32 @@ internal object PhoneLink {
val entry = OrbitCompanion.catchUpEntry(s, o.optInt("number"), o.optLong("startMs"), now) ?: return json(out, 410, JSONObject().put("error", "That's no longer in Catch Up."))
onScreen { s.tune(entry.channel.number, entry.offsetFrom(clock())) }; json(out, 200, JSONObject().put("ok", true))
}
+ "GET /api/queue" -> json(out, 200, JSONObject().put("queue", OrbitCompanion.queueJson()))
+ "POST /api/queue" -> {
+ val o = body()
+ val start = if (o.isNull("startMs") || !o.has("startMs")) null else o.optLong("startMs")
+ var added: Queued? = null
+ onScreen { added = OrbitCompanion.enqueue(s, o.optInt("number"), start, now) }
+ if (added == null) return json(out, if (OrbitCompanion.queue.size >= OrbitCompanion.QUEUE_MAX) 409 else 404,
+ JSONObject().put("error", if (OrbitCompanion.queue.size >= OrbitCompanion.QUEUE_MAX) "The queue holds five. Remove one first." else "That isn't on this TV right now."))
+ json(out, 200, JSONObject().put("queue", OrbitCompanion.queueJson()))
+ }
+ "POST /api/queue/remove" -> {
+ val at = body().optInt("index", -1)
+ onScreen { OrbitCompanion.queue = if (at < 0) emptyList() else OrbitCompanion.queue.filterIndexed { i, _ -> i != at } }
+ json(out, 200, JSONObject().put("queue", OrbitCompanion.queueJson()))
+ }
+ "GET /api/drivein" -> json(out, 200, JSONObject().put("bills", OrbitCompanion.driveIn(s, now)))
+ "POST /api/drivein/ticket" -> {
+ val o = body(); val start = o.optLong("startMs")
+ if (s.driveIn().none { it.startMs == start }) return json(out, 404, JSONObject().put("error", "That showing isn't on the board."))
+ onScreen { s.ticket(start, o.optBoolean("held", true)) }
+ json(out, 200, JSONObject().put("bills", OrbitCompanion.driveIn(s, now)))
+ }
+ "POST /api/drivein/watch" -> {
+ if (s.driveIn().none { it.startMs <= now && now < it.endMs }) return json(out, 409, JSONObject().put("error", "Nothing's showing at the Drive-In right now."))
+ onScreen { s.watchDriveIn() }; json(out, 200, JSONObject().put("ok", true))
+ }
else -> json(out, 404, JSONObject().put("error", "Not found"))
}
}
diff --git a/android/app/src/test/java/tv/orbit/classic/ChristmasSeasonTest.kt b/android/app/src/test/java/tv/orbit/classic/ChristmasSeasonTest.kt
new file mode 100644
index 0000000..13aa934
--- /dev/null
+++ b/android/app/src/test/java/tv/orbit/classic/ChristmasSeasonTest.kt
@@ -0,0 +1,41 @@
+package tv.orbit.classic
+
+import org.junit.Assert.*
+import org.junit.Test
+import java.time.ZoneId
+import java.time.ZonedDateTime
+
+/** Orbit Christmas books nothing that starts from 1 January (Paul, 6 November 2026), and other channels plan a week as before. */
+class ChristmasSeasonTest {
+ private val london = ZoneId.of("Europe/London")
+ private val minute = 60_000L
+ private fun at(month: Int, day: Int, hour: Int, year: Int = 2026) = ZonedDateTime.of(year, month, day, hour, 0, 0, 0, london).toInstant().toEpochMilli()
+ private val newYear = at(1, 1, 0, 2027)
+ private val films = (1..60).map { n -> PoolSeries("f$n", "Film $n", listOf(PoolEpisode("f$n-1", "src-f$n", EpisodeMark(0, 1), "Film $n", 100 * minute))) }
+
+ @Test fun christmasStopsAtNewYearAndOthersPlanAWeek() {
+ assertEquals(newYear, seasonEndMs(CHRISTMAS_NUMBER, at(12, 28, 20), london))
+ assertNull(seasonEndMs(101, at(12, 28, 20), london))
+ val now = at(12, 28, 20)
+ val christmas = topUpPlan(null, ScheduleCursor(), films, now, zone = london, seasonEndMs = seasonEndMs(CHRISTMAS_NUMBER, now, london))!!
+ assertTrue(christmas.entries.isNotEmpty())
+ assertTrue(christmas.entries.all { it.startMs < newYear })
+ assertTrue(christmas.entries.last().endMs > newYear - 3 * 60 * minute) // it still runs up to New Year
+ val other = topUpPlan(null, ScheduleCursor(), films, now, zone = london)!!
+ assertTrue(other.endMs >= now + SCHEDULE_AHEAD_MS)
+ // Topping up after New Year's Eve adds nothing more.
+ val later = at(12, 31, 23)
+ val topped = topUpPlan(christmas, ScheduleCursor(), films, later, zone = london, seasonEndMs = seasonEndMs(CHRISTMAS_NUMBER, later, london))!!
+ assertEquals(christmas.entries.last(), topped.entries.last()) // only older ones dropped, nothing added
+ assertTrue(topUpPlan(christmas, ScheduleCursor(), films, later, zone = london, seasonEndMs = seasonEndMs(CHRISTMAS_NUMBER, later, london))!!.entries.all { it.startMs < newYear })
+ }
+
+ @Test fun outOfSeasonNothingIsBooked() {
+ val now = at(3, 10, 12, 2027)
+ val plan = topUpPlan(null, ScheduleCursor(), films, now, zone = london, seasonEndMs = seasonEndMs(CHRISTMAS_NUMBER, now, london))!!
+ assertTrue(plan.entries.isEmpty())
+ // From November it plans again.
+ val november = at(11, 1, 9, 2027)
+ assertTrue(topUpPlan(plan, ScheduleCursor(), films, november, zone = london, seasonEndMs = seasonEndMs(CHRISTMAS_NUMBER, november, london))!!.entries.isNotEmpty())
+ }
+}
diff --git a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
index 5f099f0..1a5d658 100644
--- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
+++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
@@ -177,6 +177,20 @@ class PhoneLinkTest {
// Catch Up only from what's really in it: these channels have no committed plan, so nothing is offered.
assertEquals(0, JSONObject(call("/api/catchup", cookie = cookie).body).getJSONArray("shelves").let { a -> (0 until a.length()).sumOf { a.getJSONObject(it).getJSONArray("entries").length() } })
assertEquals(410, call("/api/catchup/watch", "POST", """{"number":101,"startMs":${now - 60 * minute}}""", cookie = cookie).status)
+ // Phase 2: queue a channel for when the programme on now ends; five at most, and removable.
+ OrbitCompanion.queue = emptyList()
+ assertEquals(1, JSONObject(call("/api/queue", "POST", """{"number":101}""", cookie = cookie).body).getJSONArray("queue").length())
+ assertEquals(404, call("/api/queue", "POST", """{"number":999}""", cookie = cookie).status)
+ assertEquals(404, call("/api/queue", "POST", """{"number":101,"startMs":${now - 60 * minute}}""", cookie = cookie).status) // not in Catch Up
+ repeat(4) { call("/api/queue", "POST", """{"number":308}""", cookie = cookie) }
+ assertEquals(409, call("/api/queue", "POST", """{"number":308}""", cookie = cookie).status)
+ assertEquals(4, JSONObject(call("/api/queue/remove", "POST", """{"index":0}""", cookie = cookie).body).getJSONArray("queue").length())
+ assertEquals(4, JSONObject(call("/api/now", cookie = cookie).body).getJSONArray("queue").length())
+ assertEquals(0, JSONObject(call("/api/queue/remove", "POST", """{"index":-1}""", cookie = cookie).body).getJSONArray("queue").length())
+ // No Drive-In on this screen: nothing to show, ticket or watch.
+ assertEquals(0, JSONObject(call("/api/drivein", cookie = cookie).body).getJSONArray("bills").length())
+ assertEquals(404, call("/api/drivein/ticket", "POST", """{"startMs":1}""", cookie = cookie).status)
+ assertEquals(409, call("/api/drivein/watch", "POST", "{}", cookie = cookie).status)
// Titles and times only.
val everything = call("/api/now", cookie = cookie).body + call("/api/guide", cookie = cookie).body + call("/api/rooms", cookie = cookie).body
listOf("token", "api_key", "password", "X-Emby", "MediaBrowser", "s-a1", "a1").forEach { assertFalse(it, everything.contains("\"$it\"", ignoreCase = true)) }
@@ -185,4 +199,29 @@ class PhoneLinkTest {
assertEquals(409, call("/api/now", cookie = cookie).status)
} finally { PhoneLink.screen = null; OrbitCompanion.inject = { } }
}
+
+ @Test fun theQueuePlaysWhenTheProgrammeBeingWatchedEnds() {
+ var watching = true; selected = 101; tuned.clear()
+ val screen = CompanionScreen({ companionChannels }, { selected }, { watching }, { 0 }, { n, o -> tuned += n to o; selected = n })
+ OrbitCompanion.queue = emptyList()
+ val start = companionChannels[0].at(now).startMs; val end = companionChannels[0].at(now).endMs
+ assertNotNull(OrbitCompanion.enqueue(screen, 308, null, now))
+ // What 308 will be showing when 101's programme ends.
+ assertEquals(guideTitleParts(companionChannels[1].at(end).media.item.title).first, OrbitCompanion.queue[0].title)
+ var anchor = OrbitCompanion.follow(screen, null, now)
+ assertEquals(101 to start, anchor)
+ anchor = OrbitCompanion.follow(screen, anchor, end - 1_000); assertTrue(tuned.isEmpty())
+ // Changing channel moves the wait to the new programme instead of playing.
+ selected = 308; anchor = OrbitCompanion.follow(screen, anchor, end - 500); assertTrue(tuned.isEmpty())
+ selected = 101; anchor = OrbitCompanion.follow(screen, anchor, end - 400); assertTrue(tuned.isEmpty())
+ // Not watching: it waits.
+ watching = false; anchor = OrbitCompanion.follow(screen, anchor, end + 1_000); assertTrue(tuned.isEmpty())
+ watching = true; anchor = OrbitCompanion.follow(screen, anchor, end + 2_000); assertTrue(tuned.isEmpty())
+ val nextEnd = companionChannels[0].at(end + 2_000).endMs
+ OrbitCompanion.follow(screen, anchor, nextEnd + 1_000)
+ assertEquals(listOf(308 to 0L), tuned); assertTrue(OrbitCompanion.queue.isEmpty())
+ // Five at most.
+ repeat(5) { assertNotNull(OrbitCompanion.enqueue(screen, 101, null, now)) }
+ assertNull(OrbitCompanion.enqueue(screen, 101, null, now)); OrbitCompanion.queue = emptyList()
+ }
}