From e94c74ffed8a72c477d717d95e29a2a6a0dcc12a Mon Sep 17 00:00:00 2001 From: Pauljd Date: Fri, 6 Nov 2026 22:52:51 +0000 Subject: [PATCH] Orbit Companion phase 2 and Christmas season end (from release 0.108.78) Merged with main's Advent and premiere slots: those keep their place, and the season end only caps how far the plan reaches. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/assets/phone/companion.html | 61 +++++++++++++---- .../tv/orbit/classic/ChannelIdentities.kt | 11 ++++ .../java/tv/orbit/classic/ChannelSchedule.kt | 10 ++- .../java/tv/orbit/classic/ChannelScheduler.kt | 3 +- .../tv/orbit/classic/EmbyConnectionScreen.kt | 19 +++++- .../java/tv/orbit/classic/OrbitCompanion.kt | 65 ++++++++++++++++++- .../main/java/tv/orbit/classic/PhoneLink.kt | 31 ++++++++- .../tv/orbit/classic/ChristmasSeasonTest.kt | 41 ++++++++++++ .../java/tv/orbit/classic/PhoneLinkTest.kt | 39 +++++++++++ 9 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 android/app/src/test/java/tv/orbit/classic/ChristmasSeasonTest.kt diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html index a9d3f0c..a554b96 100644 --- a/android/app/src/main/assets/phone/companion.html +++ b/android/app/src/main/assets/phone/companion.html @@ -46,7 +46,7 @@ button:active{opacity:.75} .jumps svg{color:var(--accent)} .line{display:flex;justify-content:space-between;align-items:center;background:var(--raised);border-radius:12px;padding:13px 14px;margin-bottom:8px;width:100%;text-align:left;font-weight:600} .line span{color:var(--accent);font-size:13px} -.chips{display:flex;gap:8px;overflow-x:auto;margin-bottom:10px;padding-bottom:2px} +.chips{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;margin-bottom:10px;padding-bottom:2px} .chips button{background:var(--surface);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;white-space:nowrap} .chips button.on{background:var(--accent);color:var(--on-accent)} .ch{display:flex;gap:12px;align-items:center;background:var(--surface);border-radius:14px;padding:11px 14px;margin-bottom:8px;width:100%;text-align:left} @@ -139,7 +139,8 @@ function nowCard() { `; } function remote() { - return head('Remote') + nowCard() + ` + const q = state.now?.queue || []; + return head('Remote') + nowCard() + (q.length ? `
Then from your queue: ${esc(q[0].title)}${q.length > 1 ? ` +${q.length - 1} more` : ''}
` : '') + `
@@ -159,9 +160,11 @@ function moreControls() { } function guide() { const g = state.guide; if (!g) return head('Guide') + '
Loading the guide…
'; - const cats = ['all', ...new Set(g.channels.map(c => c.category).filter(Boolean))]; + const cats = ['all', ...(state.drivein?.length ? ['Drive-In'] : []), ...new Set(g.channels.map(c => c.category).filter(Boolean))]; const f = cats.includes(state.filter) ? state.filter : 'all', list = g.channels.filter(c => f === 'all' || c.category === f); - return head('Guide') + `
${cats.map(k => ``).join('')}
` + + const chips = `
${cats.map(k => ``).join('')}
`; + if (f === 'Drive-In') return head('Guide') + chips + driveIn(); + return head('Guide') + chips + (list.map(c => ``).join('') || '
No channels here.
'); @@ -170,17 +173,45 @@ function pickSheet(n) { const c = state.guide?.channels.find(c => c.number === n); if (!c) return ''; return `
${esc(c.now.title)}
${c.number} ${esc(c.name)} · Live now · ends ${hm(c.now.endMs)}${c.now.sub ? ' · ' + esc(c.now.sub) : ''}
Watching on: ${esc(state.now?.room || 'this TV')}
- -
Next at ${hm(c.next.startMs)}: ${esc(c.next.title)}
`; +
+
Queue next switches to ${c.number} ${esc(c.name)} when the programme on the TV ends. Next on it at ${hm(c.next.startMs)}: ${esc(c.next.title)}
`; +} +// The Drive-In (phase 2): the coming bills, tickets, and out to the lot while one is showing. +function driveIn() { + const bills = state.drivein || []; + if (!bills.length) return '
Nothing on the Drive-In board right now.
'; + return bills.map(b => `

${esc(b.event || b.kind)}${b.showing ? 'NOW' : esc(b.day).toUpperCase()}

+
${b.event ? esc(b.kind) + ' · ' : ''}${hm(b.startMs)}–${hm(b.endMs)}
+
${b.films.map(t => `
${esc(t)}
`).join('')}
+
${b.showing ? `` : ''} +
+ ${b.ticket ? '
The TV reminds you before it starts.
' : ''}
`).join(''); +} +// Up next on this TV (phase 2): plays in order as each programme on the TV ends. +function upNext() { + const q = state.now?.queue || []; + if (!q.length) return ''; + return `
Up next on ${esc(state.now?.room || 'this TV')}
+ ${q.map((x, i) => `
${i + 1} +
${esc(x.title)}
+
${x.catchUp ? 'Catch Up · ' : 'Live · '}${x.number} ${esc(x.channel)}
+
`).join('')} +
${q.length > 1 ? 'Each plays' : 'Plays'} after the programme on now, while you're watching.
`; +} +function cuSheet(number, startMs) { + const e = state.catchup?.shelves.flatMap(s => s.entries).find(e => e.number === number && e.startMs === startMs); if (!e) return ''; + return `
${esc(e.title)}
${e.sub ? esc(e.sub) + ' · ' : ''}${e.number} ${esc(e.channel)} · aired ${hm(e.startMs)}
+
+ ${e.continues ? '
The next part follows, as it aired.
' : ''}`; } function catchup() { const c = state.catchup; if (!c) return head('Catch Up') + '
Loading Catch Up…
'; const shelves = c.shelves.filter(s => s.entries.length); - if (!shelves.length) return head('Catch Up') + '
Nothing to catch up on yet. Programmes your channels show appear here for two days.
'; - return head('Catch Up') + shelves.map(s => `
${esc(s.title)}
` + s.entries.map(e => ` - `).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() + } }