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) <noreply@anthropic.com>
This commit is contained in:
2026-11-06 22:52:51 +00:00
co-authored by Claude Opus 5.5
parent 2751c5ba27
commit e94c74ffed
9 changed files with 259 additions and 21 deletions
@@ -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() {
<div class="next"><b>NEXT</b> ${n.catchingUp ? '' : hm(n.next.startMs)} ${esc(n.next.title)}</div></div>`;
}
function remote() {
return head('Remote') + nowCard() + `
const q = state.now?.queue || [];
return head('Remote') + nowCard() + (q.length ? `<div class="small" style="margin-top:8px;flex:none;text-align:center">Then from your queue: <b style="color:var(--ink)">${esc(q[0].title)}</b>${q.length > 1 ? ` +${q.length - 1} more` : ''}</div>` : '') + `
<div class="padwrap"><div class="pad"><button class="up" data-key="up" aria-label="Up">${I.chev('up')}</button><button class="left" data-key="left" aria-label="Left">${I.chev('left')}</button>
<button class="ok" data-key="ok">OK</button><button class="right" data-key="right" aria-label="Right">${I.chev('right')}</button><button class="down" data-key="down" aria-label="Down">${I.chev('down')}</button></div></div>
<div class="row"><button class="round" data-key="back" aria-label="Back">${I.back}</button>
@@ -159,9 +160,11 @@ function moreControls() {
}
function guide() {
const g = state.guide; if (!g) return head('Guide') + '<div class="empty">Loading the guide…</div>';
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') + `<div class="chips">${cats.map(k => `<button class="${k === f ? 'on' : ''}" data-filter="${esc(k)}">${k === 'all' ? 'All' : esc(k)}</button>`).join('')}</div>` +
const chips = `<div class="chips">${cats.map(k => `<button class="${k === f ? 'on' : ''}" data-filter="${esc(k)}">${k === 'all' ? 'All' : esc(k)}</button>`).join('')}</div>`;
if (f === 'Drive-In') return head('Guide') + chips + driveIn();
return head('Guide') + chips +
(list.map(c => `<button class="ch ${c.number === g.selected ? 'on' : ''}" data-pick="${c.number}"><div class="num"><b>${c.number}</b><small>${esc(c.name)}</small></div>
<div class="grow"><div class="what">${esc(c.now.title)}</div><div class="then">${hm(c.now.startMs)}–${hm(c.now.endMs)} · then ${esc(c.next.title)}</div></div>
${c.number === g.selected && state.now?.watching ? '<span class="pill mint">ON TV</span>' : '<span class="pill">LIVE</span>'}</button>`).join('') || '<div class="empty">No channels here.</div>');
@@ -170,17 +173,45 @@ function pickSheet(n) {
const c = state.guide?.channels.find(c => c.number === n); if (!c) return '';
return `<div class="title">${esc(c.now.title)}</div><div class="small">${c.number} ${esc(c.name)} · Live now · ends ${hm(c.now.endMs)}${c.now.sub ? ' · ' + esc(c.now.sub) : ''}</div>
<div class="room" style="margin-top:12px"><span class="dot"></span>Watching on: <b>${esc(state.now?.room || 'this TV')}</b></div>
<button class="btn gold" data-tune="${c.number}">Watch on this TV</button>
<div class="small" style="margin-top:10px">Next at ${hm(c.next.startMs)}: ${esc(c.next.title)}</div>`;
<div class="row" style="gap:8px"><button class="btn gold" data-tune="${c.number}">Watch on this TV</button><button class="btn" data-queue="${c.number}">Queue next</button></div>
<div class="small" style="margin-top:10px">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)}</div>`;
}
// 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 '<div class="empty">Nothing on the Drive-In board right now.</div>';
return bills.map(b => `<div class="rm"><h3>${esc(b.event || b.kind)}<span class="pill${b.showing ? ' mint' : ''}">${b.showing ? 'NOW' : esc(b.day).toUpperCase()}</span></h3>
<div class="small">${b.event ? esc(b.kind) + ' · ' : ''}${hm(b.startMs)}–${hm(b.endMs)}</div>
<div style="margin-top:8px">${b.films.map(t => `<div style="font-weight:700">${esc(t)}</div>`).join('')}</div>
<div class="row" style="gap:8px;margin-top:10px">${b.showing ? `<button class="btn gold" data-dwatch="1">Watch on this TV</button>` : ''}
<button class="btn${b.ticket ? '' : (b.showing ? '' : ' gold')}" data-ticket="${b.startMs}:${b.ticket ? 0 : 1}">${b.ticket ? 'Hand ticket back' : 'Get a ticket'}</button></div>
${b.ticket ? '<div class="small" style="margin-top:6px">The TV reminds you before it starts.</div>' : ''}</div>`).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 `<div class="card" style="margin-bottom:12px"><div class="label" style="margin-top:0;color:var(--accent)">Up next on ${esc(state.now?.room || 'this TV')}</div>
${q.map((x, i) => `<div style="display:flex;align-items:center;gap:10px;margin:6px 0"><b style="color:var(--accent)">${i + 1}</b>
<div class="grow"><div style="font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.title)}</div>
<div class="small">${x.catchUp ? 'Catch Up · ' : 'Live · '}${x.number} ${esc(x.channel)}</div></div>
<button class="round" style="width:34px;height:34px;font-size:16px" data-unqueue="${i}" aria-label="Remove">×</button></div>`).join('')}
<div class="small">${q.length > 1 ? 'Each plays' : 'Plays'} after the programme on now, while you're watching.</div></div>`;
}
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 `<div class="title" style="white-space:normal">${esc(e.title)}</div><div class="small">${e.sub ? esc(e.sub) + ' · ' : ''}${e.number} ${esc(e.channel)} · aired ${hm(e.startMs)}</div>
<div class="row" style="gap:8px;margin-top:14px"><button class="btn gold" data-cu="${number}:${startMs}">Watch from the start</button><button class="btn" data-cuq="${number}:${startMs}">Queue next</button></div>
${e.continues ? '<div class="small" style="margin-top:10px">The next part follows, as it aired.</div>' : ''}`;
}
function catchup() {
const c = state.catchup; if (!c) return head('Catch Up') + '<div class="empty">Loading Catch Up…</div>';
const shelves = c.shelves.filter(s => s.entries.length);
if (!shelves.length) return head('Catch Up') + '<div class="empty">Nothing to catch up on yet. Programmes your channels show appear here for two days.</div>';
return head('Catch Up') + shelves.map(s => `<div class="label">${esc(s.title)}</div>` + s.entries.map(e => `
<button class="cu" data-cu="${e.number}:${e.startMs}"><div class="grow"><div class="what" style="font-weight:700">${esc(e.title)}</div>
if (!shelves.length) return head('Catch Up') + upNext() + '<div class="empty">Nothing to catch up on yet. Programmes your channels show appear here for two days.</div>';
return head('Catch Up') + upNext() + shelves.map(s => `<div class="label">${esc(s.title)}</div>` + s.entries.map(e => `
<button class="cu" data-cupick="${e.number}:${e.startMs}"><div class="grow"><div class="what" style="font-weight:700">${esc(e.title)}</div>
<div class="small">${e.sub ? esc(e.sub) + ' · ' : ''}${e.number} ${esc(e.channel)} · aired ${hm(e.startMs)}${e.continues ? ' · carries on' : ''}</div></div><span class="play">${I.play}</span></button>`).join('')).join('') +
'<div class="small" style="text-align:center;margin-top:12px">Plays from the start on this TV, and carries on as it aired.</div>';
'<div class="small" style="text-align:center;margin-top:12px">Plays from the start on this TV, and carries on as it aired. Or queue it for when the programme on now ends.</div>';
}
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.
@@ -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()
}
@@ -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)
}
@@ -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}") }
@@ -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<Long, Int>) -> 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<Int, Long>? = 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) {
@@ -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<DriveInBill> = { emptyList() },
val tickets: () -> Set<Long> = { 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<List<Queued>>(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<Int, Long>?, nowMs: Long): Pair<Int, Long>? {
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 }
@@ -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"))
}
}
@@ -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())
}
}
@@ -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()
}
}