Orbit Companion phase 3 and long logo names (from release 0.108.81)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-07 00:09:35 +00:00
co-authored by Claude Opus 5.5
parent d4e6a4b1d3
commit 9160b7fcd2
8 changed files with 314 additions and 68 deletions
@@ -229,8 +229,17 @@ function rooms() {
return h;
}
function more() {
return head('More') + `<div class="label">Your channels</div>
<a class="line" href="/create" style="text-decoration:none;color:inherit">Create a channel <span>Name, shows, style, hours</span></a>
const yours = state.channels, pr = state.profiles, kids = pr?.current?.kind === 'Kids';
const who = pr ? `<div class="label">Who's watching</div><div class="chips" style="flex-wrap:wrap">${pr.profiles.map(p => `<button class="${pr.current?.id === p.id ? 'on' : ''}" data-profile="${esc(p.id)}">${esc(p.name)}${p.kind === 'Grown' && pr.pinSet ? ' · PIN' : ''}</button>`).join('')}</div>
<div class="small">${pr.current ? esc(pr.current.name) + ' is watching on the TV.' : 'Nobody chosen yet on the TV.'}${kids ? ' Making channels and the Director are for Adult.' : ''}</div>` : '';
if (kids) return head('More') + who + `<div class="label">About</div><div class="card small">Orbit Companion runs on the TV itself, over your home Wi-Fi. It works while Orbit is open on the TV.</div>`;
return head('More') + who + `<div class="label">Director's Chair</div>
<button class="line" data-director="1">What the Director suggests <span>Approve, place or drop</span></button>
<div class="label">Your channels</div>
${(yours?.channels || []).map(c => `<div class="ch" style="cursor:default"><div class="num"><b>${c.number}</b><small>${c.on ? 'On' : 'Off'}</small></div>
<div class="grow"><div class="what">${esc(c.name)}</div><div class="then">${esc(c.styleTitle)} · ${esc(c.hoursLabel)}</div></div>
<button class="round" style="width:auto;padding:0 14px;height:36px;font-size:12px" data-edit="${c.number}">Edit</button></div>`).join('')}
${!yours || yours.next ? '<button class="btn gold" data-create="1" style="margin-bottom:8px">+ Create a channel</button>' : '<div class="small">Every number from 701 to 799 is taken.</div>'}
${standalone() ? '' : '<button class="line" data-home="1">Add to home screen <span>One tap to the remote</span></button>'}
<div class="label">Look</div>
<div class="chips" style="flex-wrap:wrap"><button class="${keptLook() ? '' : 'on'}" data-look="">Follow this TV${state.now?.look ? ' (' + esc(state.now.look.name) + ')' : ''}</button>
@@ -239,6 +248,98 @@ function more() {
<div class="label">About</div><div class="card small">Orbit Companion runs on the TV itself, over your home Wi-Fi. Nothing to install, no account. It works while Orbit is open on the TV.
To stop this phone, remove it in Settings › Rooms and phones.</div>`;
}
// Create a channel (phase 3, from design/companion v2): five steps, Name, Shows, Style, Hours and Review, in the TV's look.
// It saves through the same route as before, so the TV makes it exactly as Settings › Create your own does.
const STYLES = [['Standard','Standard TV',"As Orbit's own channels: shows take turns, episodes in order"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'],
['Blocks','Blocks','A timetable: each show at its own times, every day'],['Marathon','Marathon','One show at a time, several episodes back to back']];
const HOURS = [[null,'All day'],[{from:18,to:0},'Evenings, 18:00 to midnight'],[{from:6,to:12},'Mornings, 06:00 to noon'],[{from:9,to:17},'Daytime, 09:00 to 17:00']];
const STEPS = ['Name','Shows','Style','Hours','Review'];
const sameHours = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
function create() {
const c = state.create, d = c.draft;
const bar = `<div style="display:flex;gap:6px;margin:4px 0 4px">${STEPS.map((n, i) => `<div style="flex:1"><div style="height:4px;border-radius:2px;background:${i <= c.step ? 'var(--accent)' : 'var(--surface)'}"></div>
<div class="small" style="font-size:10px;margin-top:4px;${i === c.step ? 'color:var(--ink);font-weight:700' : ''}">${n}</div></div>`).join('')}</div>`;
const top = `<header><button data-cback="1" aria-label="Back" style="padding:4px 6px 4px 0">${I.chev('left')}</button><span class="title" style="font-size:20px;margin:0">${d.number ? 'Edit ' + esc(d.name) : 'Create a channel'}</span></header>` + bar;
const note = c.note ? `<div class="card small" style="margin:10px 0;color:var(--accent)">${esc(c.note)}</div>` : '';
let body = '', next = '';
if (c.step === 0) {
body = `<div class="title" style="margin-top:14px">Name it</div><div class="small">It goes in Yours${d.number ? ' as ' + d.number : ''}. Keep it short, like 90s Sci-Fi.</div>
<input id="cname" maxlength="40" value="${esc(d.name)}" placeholder="Name, like 90s Sci-Fi" style="width:100%;margin-top:12px;padding:14px;border-radius:12px;border:2px solid var(--raised);background:var(--surface);color:var(--ink);font:inherit;font-size:18px">`;
next = '<button class="btn gold" data-cnext="1">Next: shows</button>';
} else if (c.step === 1) {
const offered = c.offered.filter(s => !d.shows.some(x => x.id === s.id) && !d.skipped.includes(s.id));
const row = (s, on) => `<button class="ch" data-ctoggle="${esc(s.id)}"><div class="grow"><div class="what">${esc(s.title)}</div>
<div class="then">${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''}${s.reason && !on ? ' · ' + esc(s.reason) : ''}</div></div>
<span style="width:26px;height:26px;border-radius:7px;flex:none;display:flex;align-items:center;justify-content:center;${on ? 'background:var(--accent);color:var(--on-accent)' : 'border:2px solid var(--accent)'}">${on ? '✓' : ''}</span></button>`;
body = `<div class="title" style="margin-top:14px">Pick the shows</div><div class="small">${esc(d.description || 'Describe it, or search for a title')} · ${d.shows.length} chosen</div>
<div class="row" style="gap:8px;margin:12px 0"><input id="cdesc" value="${esc(d.description)}" placeholder="90s sci-fi, or a title" style="flex:1;min-width:0;padding:12px;border-radius:12px;border:2px solid var(--raised);background:var(--surface);color:var(--ink);font:inherit">
<button class="btn" data-csuggest="1" style="width:auto;flex:none">${c.busy ? 'Looking…' : 'Suggest'}</button></div>` + note +
d.shows.map(s => row(s, true)).join('') + offered.map(s => row(s, false)).join('') +
(offered.length ? '<div class="small" style="margin:6px 0 4px">The Director suggests these from what you described and the shows you chose. Tap to add.</div>' : '');
next = `<button class="btn gold" data-cnext="1" ${d.shows.length ? '' : 'disabled style="opacity:.5"'}>Next: style</button>`;
} else if (c.step === 2) {
body = `<div class="title" style="margin-top:14px">How it plays</div><div class="small">It shapes what's planned from then on, never what's already booked.</div><div style="margin-top:12px">` +
STYLES.map(([k, t, l]) => `<button class="ch ${d.style === k ? 'on' : ''}" data-cstyle="${k}"><div class="grow"><div class="what">${t}</div><div class="then" style="white-space:normal">${l}</div></div></button>`).join('') + '</div>';
next = '<button class="btn gold" data-cnext="1">Next: hours</button>';
} else if (c.step === 3) {
body = `<div class="title" style="margin-top:14px">When it's on</div><div class="small">Outside its hours the channel shows a "back at" card.</div><div style="margin-top:12px">` +
HOURS.map(([h, l], i) => `<button class="ch ${sameHours(h, d.hours) ? 'on' : ''}" data-chours="${i}"><div class="grow"><div class="what">${l}</div></div></button>`).join('') + '</div>';
next = '<button class="btn gold" data-cnext="1">Next: review</button>';
} else {
body = `<div class="title" style="margin-top:14px">Review</div><div class="card" style="margin-top:12px"><div class="title">${esc(d.name)}</div>
<div class="small" style="margin:4px 0 10px">Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}</div>
${d.shows.slice(0, 8).map(s => `<div style="font-weight:600">${esc(s.title)}</div>`).join('')}${d.shows.length > 8 ? `<div class="small">and ${d.shows.length - 8} more</div>` : ''}</div>` + note;
next = `<button class="btn gold" data-cmake="1">${c.busy ? 'Saving…' : d.number ? 'Save changes' : 'Create and switch on'}</button>`;
}
return top + body + `<div style="margin-top:16px">${next}</div>`;
}
// Who's watching (phase 3): Kids goes straight in; Adult asks for the PIN on the phone when one's set, as the TV does.
async function switchTo(id, pin) {
buzz();
try { state.profiles = await api('/api/profile', {id, pin}); closeSheet(); toast(state.profiles.current?.name + ' is watching'); load('now'); render(); }
catch (e) {
if (e.message === 'PIN') openSheet(`<div class="title">Adult PIN</div><div class="small" style="margin:6px 0 12px">The four digits that unlock After Dark on the TV.</div>
<input id="pin" type="password" inputmode="numeric" maxlength="8" autocomplete="off" style="width:100%;font-size:26px;letter-spacing:8px;text-align:center;padding:12px;border-radius:12px;border:2px solid var(--raised);background:var(--surface);color:var(--ink)">
<button class="btn gold" data-pinok="${esc(id)}" style="margin-top:12px">Switch to Adult</button>`);
else { toast(e.message); }
}
}
function startCreate(draft) { state.create = {step: 0, draft, offered: [], note: '', busy: false}; render(); }
async function suggestShows() {
const c = state.create, d = c.draft; d.description = ($('#cdesc')?.value || '').trim(); if (!d.description || c.busy) return;
c.busy = true; render();
try { c.offered = await api('/api/suggest?d=' + encodeURIComponent(d.description) + '&chosen=' + d.shows.map(s => s.id).join(',') + '&skipped=' + d.skipped.join(','));
c.note = c.offered.length ? '' : 'Nothing in your library fits that yet. Try other words, or a title.'; }
catch (e) { c.note = e.message; }
c.busy = false; render();
}
async function makeChannel() {
const c = state.create, d = c.draft; if (c.busy) return; c.busy = true; render();
try { const r = await api('/api/channels', {number: d.number, name: d.name, description: d.description, style: d.style, hours: d.hours, shows: d.shows});
state.create = null; toast(r.message || 'Saved'); load('more'); }
catch (e) { c.note = e.message; c.busy = false; render(); }
}
// The Director's Chair on the phone (phase 3): Drive-In event ideas to approve, defer or dismiss, and titles to place on a
// channel or drop, saved exactly as Settings › Director's Chair saves them. Nothing is booked from here.
function director() {
const top = `<header><button data-chairback="1" aria-label="Back" style="padding:4px 6px 4px 0">${I.chev('left')}</button><span class="title" style="font-size:20px;margin:0">Director's Chair</span></header>`;
const r = state.director;
if (!r) return top + '<div class="empty">Asking the Director…</div>';
if (!r.ready) return top + '<div class="empty">The Director is still reading your library. Try again in a little while.</div>';
let h = top;
if (r.ideas.length) h += '<div class="label">Drive-In event ideas</div>' + r.ideas.map(i => `<div class="rm"><h3>${esc(i.theme)}${i.decision === 'approved' ? '<span class="pill mint">APPROVED</span>' : ''}</h3>
<div class="small">${esc(i.format)} · ${esc(i.evening)}</div><div style="margin:8px 0">${i.films.map(f => `<div style="font-weight:600">${esc(f)}</div>`).join('')}</div>
${i.reasons.length ? `<div class="small">${esc(i.reasons[0])}</div>` : ''}
${i.decision === 'approved' ? '' : `<div class="row" style="gap:8px;margin-top:10px"><button class="btn gold" data-idea="${esc(i.signature)}:approved">Approve</button><button class="btn" data-idea="${esc(i.signature)}:deferred">Defer</button><button class="btn" data-idea="${esc(i.signature)}:dismissed">Dismiss</button></div>`}</div>`).join('');
h += '<div class="label">Titles for your channels</div>';
h += r.choices.length ? r.choices.map(c => `<div class="rm"><h3 style="display:block">${esc(c.title)}</h3><div class="small">${c.type === 'Movie' ? 'Film' : 'Series'}${c.year ? ' · ' + c.year : ''}${c.line ? ' · ' + esc(c.line) : ''}</div>
<div class="chips" style="flex-wrap:wrap;margin:10px 0 0">${c.options.map((o, k) => `<button class="${k === 0 ? 'on' : ''}" data-place="${esc(c.id)}:${o.number}">${k === 0 ? 'Place on ' : ''}${o.number} ${esc(o.name)}</button>`).join('')}
<button data-drop="${esc(c.id)}">Drop it</button></div>
${c.options[0]?.reason ? `<div class="small" style="margin-top:6px">${esc(c.options[0].reason)}</div>` : ''}</div>`).join('') : '<div class="empty">Nothing new to place this week.</div>';
return h + '<div class="small" style="margin:10px 0">Placing adds a title to that channel\'s list; it joins as the channel plans ahead, and nothing already booked changes.</div>';
}
// Add to home screen (Paul, 6 November 2026): offered once after joining, and any time from More. The TV's page is plain
// http on the home Wi-Fi, so most phones add it from their own menu; Chrome's own button is used where it's offered.
const standalone = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
@@ -260,7 +361,7 @@ function offerHome() {
function render() {
tabs();
$('#app').className = tab === 'remote' ? 'fit' : '';
$('#app').innerHTML = ({remote, guide, catchup, rooms, more})[tab]();
$('#app').innerHTML = tab === 'more' && state.create ? create() : tab === 'more' && state.chair ? director() : ({remote, guide, catchup, rooms, more})[tab]();
}
function openSheet(html) { $('#sheetIn').innerHTML = html; $('#sheet').classList.add('open'); $('#shade').classList.add('open'); }
function closeSheet() { $('#sheet').classList.remove('open'); $('#shade').classList.remove('open'); }
@@ -272,10 +373,13 @@ async function load(what) {
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); }
if (what === 'more') { const [l, c, p] = await Promise.all([api('/api/looks'), api('/api/channels').catch(() => null), api('/api/profiles').catch(() => null)]);
state.looks = l.looks; state.channels = c; state.profiles = p; look(state.now?.look); }
if (what === 'director') state.director = await api('/api/director');
offline = false;
} catch (e) { if (!offline) toast(e.message === 'Failed to fetch' ? "Orbit isn't open on the TV" : e.message); offline = true; }
render();
// Making a channel isn't redrawn by the background refresh, so typing is never lost.
if (!(state.create && tab === 'more')) render();
}
let offline = false;
const send = (path, body, done) => { buzz(); api(path, body).then(j => { done?.(j); setTimeout(() => load('now'), 600); }).catch(e => toast(e.message === 'Failed to fetch' ? "Orbit isn't open on the TV" : e.message)); };
@@ -283,13 +387,37 @@ const send = (path, body, done) => { buzz(); api(path, body).then(j => { done?.(
document.addEventListener('click', e => {
const b = e.target.closest('button,[data-tab]'); if (!b) return;
const d = b.dataset;
if (d.tab) { tab = d.tab; try { localStorage.setItem('orbit.tab', tab); } catch {} closeSheet(); render(); if (tab !== 'remote') load(tab); return; }
if (d.tab) { if (d.tab !== 'more' || tab === 'more') { state.create = null; state.chair = false; } tab = d.tab; try { localStorage.setItem('orbit.tab', tab); } catch {} closeSheet(); render(); if (tab !== 'remote') load(tab); return; }
if (d.key) send('/api/key', {key: d.key});
if (d.vol) send('/api/volume', {change: d.vol});
if (d.ch) send('/api/channel', {step: +d.ch});
if (d.sheet) openSheet(moreControls());
if (d.go) send('/api/section', {section: d.go}, () => { closeSheet(); toast('On the TV now'); });
if (d.look !== undefined) { try { d.look ? localStorage.setItem('orbit.look', d.look) : localStorage.removeItem('orbit.look'); } catch {} look(state.now?.look); render(); return; }
if (d.director) { state.chair = true; state.director = null; render(); load('director'); return; }
if (d.chairback) { state.chair = false; render(); return; }
if (d.idea) { const cut = d.idea.lastIndexOf(':'), signature = d.idea.slice(0, cut), decision = d.idea.slice(cut + 1); send('/api/director/idea', {signature, decision}, j => { toast(j.message); load('director'); }); return; }
if (d.place) { const [id, channel] = d.place.split(':'); send('/api/director/place', {id, channel: +channel}, j => { toast(j.message); state.director.choices = state.director.choices.filter(c => c.id !== id); render(); }); return; }
if (d.drop) { send('/api/director/drop', {id: d.drop}, j => { toast(j.message); state.director.choices = state.director.choices.filter(c => c.id !== d.drop); render(); }); return; }
if (d.profile) { switchTo(d.profile); return; }
if (d.pinok) { switchTo(d.pinok, $('#pin').value); return; }
if (d.create) { startCreate({name: '', description: '', style: 'Standard', hours: null, shows: [], skipped: []}); return; }
if (d.edit) { const ch = state.channels?.channels.find(x => x.number === +d.edit); if (!ch) return;
api('/api/shows?number=' + ch.number).then(shows => startCreate({number: ch.number, name: ch.name, description: ch.description || '', style: ch.style, hours: ch.hours || null, shows, skipped: []}))
.catch(e => toast(e.message)); return; }
if (d.cback) { const c = state.create; if (c.step === 0) state.create = null; else { if ($('#cdesc')) c.draft.description = $('#cdesc').value.trim(); c.step--; c.note = ''; } render(); return; }
if (d.cnext) { const c = state.create;
if (c.step === 0) { c.draft.name = ($('#cname')?.value || '').trim(); if (!c.draft.name) { toast('Give it a name first'); return; } }
if (c.step === 1 && !c.draft.shows.length) return;
c.step++; c.note = ''; render(); scrollTo(0, 0); return; }
if (d.csuggest) { suggestShows(); return; }
if (d.ctoggle) { const dr = state.create.draft, id = d.ctoggle; if ($('#cdesc')) dr.description = $('#cdesc').value.trim();
if (dr.shows.some(s => s.id === id)) dr.shows = dr.shows.filter(s => s.id !== id);
else { const s = state.create.offered.find(s => s.id === id); if (s) dr.shows.push(s); }
render(); return; }
if (d.cstyle) { state.create.draft.style = d.cstyle; render(); return; }
if (d.chours !== undefined) { state.create.draft.hours = HOURS[+d.chours][0]; render(); return; }
if (d.cmake) { makeChannel(); return; }
if (d.homedone) { try { localStorage.setItem('orbit.home', '1'); } catch {} closeSheet(); return; }
if (d.install) { installAsk?.prompt(); installAsk = null; try { localStorage.setItem('orbit.home', '1'); } catch {} closeSheet(); return; }
if (d.home) { openSheet(homeSteps()); return; }
@@ -501,6 +501,67 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
if (lockedOut(selectedNumber) || profileHides(selectedNumber)) { watchingChannel = false; channelClockOffset = 0; selectedNumber = availableChannels.firstOrNull()?.number ?: 101 }
}
val liveChannel = availableChannels.firstOrNull { it.number == selectedNumber }
fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) {
val active = session ?: return
val existing = smartLoop(programming)
// A second press straight after the first is taken as a slip.
if (busy || now - lastSmartPress < 5_000) return
lastSmartPress = now
busy = true; busyLabel = "Reading ${programming.name}'s whole series and planning seven days…"; message = null
job = scope.launch {
try {
if (turnOn) {
val report = withContext(Dispatchers.IO) {
val context = coroutineContext
scheduler.setEnabled(active.accountId, programming.number, true)
val loop = existing ?: scheduler.seedLoop(active, active.accountId, programming) { context.ensureActive() }.also { extraChannels = extraChannels + it }
scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also {
scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (programming.number to on) }
}
}
onChannelAvailable(true)
message = "${programming.name} is on, with ${report.programmes} programmes planned from ${report.shows.size} ${if (report.shows.size == 1) "title" else "titles"}."
} else {
withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }
scheduledChannels = scheduledChannels - programming.number
message = "${programming.name} is off, and back to a short repeat. Its schedule and place are kept."
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = failure.message }
catch (_: Exception) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = "${programming.name} could not be scheduled, so it stays off." }
finally { busy = false }
}
}
// Create your own from a phone and the Companion: kept here, before any screen returns early, so the phone can make
// and edit channels from every section, not only while Settings is open (Paul, 7 November 2026).
val channelMaker = remember(session, workspace) { session?.let { active -> ChannelMaker(
suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
val wish = wishOf(description)
val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
create = { channel, shows ->
withContext(Dispatchers.IO) {
CustomChannels.upsert(workspace.scheduleDir, channel)
val before = scheduleStore.loadChoices(active.accountId, channel.number)
scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
}
android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
"${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
}) } }
// The phone page (Create your own from a phone) asks the same of this screen as Settings does, and can tune the TV.
DisposableEffect(channelMaker) {
PhoneLink.handler = channelMaker?.let { m -> PhoneHandler(m, isOn = { n -> scheduledChannels.containsKey(n) }, watch = { n ->
if (!lockedOut(n) && !profileHides(n) && SmartChannels.any { it.number == n }) {
if (selectedNumber != n) { lastNumber = selectedNumber; selectedNumber = n }
channelClockOffset = 0; watchingChannel = true
if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide()
}
}) }
onDispose { PhoneLink.handler = null }
}
fun tune(number: Int, offset: Long) {
if (number != selectedNumber) { lastNumber = selectedNumber; selectedNumber = number }
if (offset == 0L) liveHandoff.handOver(number)
@@ -513,6 +574,8 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
if (shownChannels.any { it.number == n }) { tune(n, offset); if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide() }
}
val companionTickets by rememberUpdatedState(tickets)
val companionStore by rememberUpdatedState(scheduleStore)
val companionCatalogue by rememberUpdatedState(catalogue)
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())
@@ -521,7 +584,23 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
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") } })
watchDriveIn = { if (companionDriveIn) watchingDriveIn = true else { pendingDriveInWatch = true; OrbitCompanion.open("Drive-In") } },
director = { session?.let { directorReports(companionStore, companionCatalogue, it.accountId)?.get(DirectorMode.Balanced) } },
place = { c, channel -> session?.let { active -> runCatching {
placeFromChair(companionStore, active.accountId, c.id, c.title, c.type, c.year, channel, c.options.firstOrNull()?.number, premiere = c.firstRun != null)
android.util.Log.i("OrbitSchedule", "director placed channel=$channel suggested=${channel == c.options.firstOrNull()?.number} from=phone")
} } },
drop = { c -> runCatching { dropFromChair(companionStore, c.id, c.title, c.type, c.options.firstOrNull()?.number, premiere = c.firstRun != null) }
android.util.Log.i("OrbitSchedule", "director dropped from=phone") },
decideIdea = { idea, decision ->
val films = runCatching { companionCatalogue.ideaFilms(idea.filmIds) }.getOrDefault(emptyList())
val ok = EventIdeaHistory.decide(companionStore.eventIdeaHistoryFile(), companionStore.eventIdeasFile(), idea.signature, decision, films, System.currentTimeMillis())
android.util.Log.i("OrbitSchedule", "event idea decision=$decision saved=$ok from=phone")
when { !ok -> "That decision couldn't be saved. Try again."
decision == "approved" -> "${idea.theme} approved for ${idea.evening}. It books once the Drive-In plans that evening, after its films are checked again."
decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks."
else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." }
})
}
DisposableEffect(Unit) {
PhoneLink.screen = companionScreen
@@ -590,37 +669,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
return
}
fun setSmartSchedule(programming: ChannelProgramming, turnOn: Boolean) {
val active = session ?: return
val existing = smartLoop(programming)
// A second press straight after the first is taken as a slip.
if (busy || now - lastSmartPress < 5_000) return
lastSmartPress = now
busy = true; busyLabel = "Reading ${programming.name}'s whole series and planning seven days…"; message = null
job = scope.launch {
try {
if (turnOn) {
val report = withContext(Dispatchers.IO) {
val context = coroutineContext
scheduler.setEnabled(active.accountId, programming.number, true)
val loop = existing ?: scheduler.seedLoop(active, active.accountId, programming) { context.ensureActive() }.also { extraChannels = extraChannels + it }
scheduler.topUp(active, active.accountId, programming, loop) { context.ensureActive() }.also {
scheduler.load(active.accountId, loop)?.let { on -> scheduledChannels = scheduledChannels + (programming.number to on) }
}
}
onChannelAvailable(true)
message = "${programming.name} is on, with ${report.programmes} programmes planned from ${report.shows.size} ${if (report.shows.size == 1) "title" else "titles"}."
} else {
withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }
scheduledChannels = scheduledChannels - programming.number
message = "${programming.name} is off, and back to a short repeat. Its schedule and place are kept."
}
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: EmbyCheckException) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = failure.message }
catch (_: Exception) { withContext(Dispatchers.IO) { scheduler.setEnabled(active.accountId, programming.number, false) }; message = "${programming.name} could not be scheduled, so it stays off." }
finally { busy = false }
}
}
fun openSuggestions(programming: ChannelProgramming) {
val active = session ?: return
if (busy) return
@@ -675,34 +723,6 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
}
}
// Remembered, so the phone link isn't handed a new one on every redraw.
val channelMaker = remember(session, workspace) { session?.let { active -> ChannelMaker(
suggest = { description, chosen, skipped -> withContext(Dispatchers.IO) {
val wish = wishOf(description)
val rows = catalogue.chairRows(wish.genres, wish.years, wish.type, if (wish.genres.isEmpty() && wish.years == null) description else null)
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
create = { channel, shows ->
withContext(Dispatchers.IO) {
CustomChannels.upsert(workspace.scheduleDir, channel)
val before = scheduleStore.loadChoices(active.accountId, channel.number)
scheduleStore.saveChoices(active.accountId, channel.number, ChannelChoices(shows, before.rejected))
}
android.util.Log.i("OrbitSchedule", "yours channel=${channel.number} style=${channel.style} titles=${shows.size}")
SmartChannels.firstOrNull { it.number == channel.number }?.let { setSmartSchedule(it, true) }
"${channel.name} is ready on ${channel.number}. Its first programmes are being planned now."
}) } }
// The phone page (Create your own from a phone) asks the same of this screen as Settings does, and can tune the TV.
DisposableEffect(channelMaker) {
PhoneLink.handler = channelMaker?.let { m -> PhoneHandler(m, isOn = { n -> scheduledChannels.containsKey(n) }, watch = { n ->
if (!lockedOut(n) && !profileHides(n) && SmartChannels.any { it.number == n }) {
if (selectedNumber != n) { lastNumber = selectedNumber; selectedNumber = n }
channelClockOffset = 0; watchingChannel = true
if (!mainGuide && !mainHome && !mainWhatsOn && !mainCatchUp) onGuide()
}
}) }
onDispose { PhoneLink.handler = null }
}
if (!visible) return
if (onboarding && session != null) return
suggesting?.let { programming ->
@@ -40,7 +40,12 @@ internal class CompanionScreen(
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 = {})
val watchDriveIn: () -> Unit = {},
/** Phase 3: the Director's Chair, as Settings shows it (Balanced), and its decisions, saved as Settings saves them. */
val director: () -> DeskReport? = { null },
val place: (ChairChoice, Int) -> Unit = { _, _ -> },
val drop: (ChairChoice) -> Unit = {},
val decideIdea: (DeskEventIdea, String) -> String = { _, _ -> "" })
/**
* Phase 2's queue (Paul, 6 November 2026): what plays when the programme on now ends. A channel, live from then ("switch
@@ -198,6 +203,44 @@ internal object OrbitCompanion {
})
}
// --- Phase 3: Director approvals and profiles. ---
private fun choiceJson(c: ChairChoice) = JSONObject().put("id", c.id).put("title", c.title).put("type", c.type).put("year", c.year ?: JSONObject.NULL)
.put("line", c.line).put("ask", c.ask).put("options", JSONArray(c.options.map { JSONObject().put("number", it.number).put("name", it.name).put("reason", it.reason) }))
fun choices(report: DeskReport): List<ChairChoice> =
report.picks.filter { it.id.isNotEmpty() }.map { ChairChoice(it.title, it.id, it.type, it.year, it.tag, it.line, it.options, false, it.firstRun) } +
report.asks.filter { it.id.isNotEmpty() }.map { ChairChoice(it.title, it.id, it.type, it.year, null, it.line, it.options, true, it.firstRun) }
fun director(report: DeskReport?): JSONObject {
if (report == null) return JSONObject().put("ready", false)
return JSONObject().put("ready", true).put("choices", JSONArray(choices(report).map(::choiceJson)))
.put("ideas", JSONArray(report.eventIdeas.map { i -> JSONObject().put("signature", i.signature).put("theme", i.theme).put("format", i.format).put("evening", i.evening)
.put("films", JSONArray(i.films)).put("reasons", JSONArray(i.reasons)).put("decision", i.decision) }))
}
/** Wrong Adult PINs from phones: three in a row and the phone waits five minutes. */
private var pinMisses = 0
private var pinWaitUntil = 0L
fun profiles(): JSONObject = JSONObject().put("pinSet", AfterDarkLock.pinSet).put("kidsOn", Profiles.kidsOn)
.put("current", Profiles.current?.let { JSONObject().put("id", it.id).put("name", it.name).put("kind", it.kind.name) } ?: JSONObject.NULL)
.put("profiles", JSONArray(Profiles.all.map { JSONObject().put("id", it.id).put("name", it.name).put("kind", it.kind.name) }))
/** Switches who's watching on the TV. Kids goes straight in; Adult needs the PIN when one's set, as on the TV. Null when done, else why not. */
fun switchProfile(context: Context?, id: String, pin: String?, nowMs: Long): String? {
val p = Profiles.all.firstOrNull { it.id == id } ?: return "That profile isn't on this TV."
if (p.kind == ProfileKind.Grown && AfterDarkLock.pinSet) {
if (nowMs < pinWaitUntil) return "Too many wrong PINs. Try again in a few minutes."
if (pin.isNullOrEmpty()) return "PIN"
if (context == null || !AfterDarkLock.unlock(context, pin)) {
if (++pinMisses >= 3) { pinMisses = 0; pinWaitUntil = nowMs + 5 * 60_000L }
return "That's not the PIN."
}
pinMisses = 0
}
if (p.kind == ProfileKind.Kids) AfterDarkLock.lock()
Profiles.current = p
return null
}
/** While Kids is watching, the phone keeps to what Kids may do: no making channels and no Director. */
val grownUp get() = !Profiles.kids
/** 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 }
@@ -306,6 +306,7 @@ internal object PhoneLink {
json(out, 200, JSONArray(runBlocking { h.maker.suggest(d, chosen, skipped).ifEmpty { h.maker.search(d) } }.map(::suggestionJson)))
}
"POST /api/channels" -> {
if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult to make channels."))
val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "Unreadable"))
val number = o.optInt("number").takeIf { it in YOURS_FIRST..YOURS_LAST } ?: CustomChannels.nextNumber() ?: return json(out, 400, JSONObject().put("error", "Every number is taken."))
val shows = runCatching { o.getJSONArray("shows").let { a -> (0 until minOf(a.length(), 200)).map { suggestionOf(a.getJSONObject(it)) } } }.getOrNull()
@@ -328,7 +329,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",
"GET /api/queue", "POST /api/queue", "POST /api/queue/remove", "GET /api/drivein", "POST /api/drivein/ticket", "POST /api/drivein/watch")
"GET /api/queue", "POST /api/queue", "POST /api/queue/remove", "GET /api/drivein", "POST /api/drivein/ticket", "POST /api/drivein/watch",
"GET /api/director", "POST /api/director/place", "POST /api/director/drop", "POST /api/director/idea", "GET /api/profiles", "POST /api/profile")
/** 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
@@ -371,6 +373,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/profiles" -> json(out, 200, OrbitCompanion.profiles())
"POST /api/profile" -> {
val o = body()
var why: String? = null
onScreen { why = OrbitCompanion.switchProfile(appContext, o.optString("id"), o.optString("pin").filter(Char::isDigit).take(8).ifEmpty { null }, now) }
if (why == "PIN") return json(out, 401, JSONObject().put("error", "PIN").put("pin", true))
if (why != null) return json(out, 403, JSONObject().put("error", why))
json(out, 200, OrbitCompanion.profiles())
}
"GET /api/director", "POST /api/director/place", "POST /api/director/drop", "POST /api/director/idea" -> {
if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult for the Director."))
val report = runCatching { s.director() }.getOrNull()
if (r.method == "GET") return json(out, 200, OrbitCompanion.director(report))
report ?: return json(out, 409, JSONObject().put("error", "The Director is still reading your library."))
val o = body()
if (r.path == "/api/director/idea") {
val decision = o.optString("decision").takeIf { it in setOf("approved", "deferred", "dismissed") } ?: return json(out, 400, JSONObject())
val idea = report.eventIdeas.firstOrNull { it.signature == o.optString("signature") } ?: return json(out, 404, JSONObject().put("error", "That idea has gone."))
return json(out, 200, JSONObject().put("message", s.decideIdea(idea, decision)))
}
val choice = OrbitCompanion.choices(report).firstOrNull { it.id == o.optString("id") } ?: return json(out, 404, JSONObject().put("error", "That title has gone from the Chair."))
if (r.path == "/api/director/drop") { s.drop(choice); return json(out, 200, JSONObject().put("message", "${choice.title} won't be suggested again.")) }
val channel = choice.options.firstOrNull { it.number == o.optInt("channel") } ?: return json(out, 400, JSONObject().put("error", "Pick one of its channels."))
s.place(choice, channel.number)
json(out, 200, JSONObject().put("message", "${choice.title} joins ${channel.name}. It's added to the channel's list and appears as it plans ahead."))
}
"GET /api/queue" -> json(out, 200, JSONObject().put("queue", OrbitCompanion.queueJson()))
"POST /api/queue" -> {
val o = body()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -226,4 +226,31 @@ class PhoneLinkTest {
repeat(5) { assertNotNull(OrbitCompanion.enqueue(screen, 101, null, now)) }
assertNull(OrbitCompanion.enqueue(screen, 101, null, now)); OrbitCompanion.queue = emptyList()
}
@Test fun phaseThreeKeepsKidsToWhatKidsMayDoAndTheDirectorDecidesAsSettingsDoes() {
PhoneLink.clock = { now }
val placed = mutableListOf<Pair<String, Int>>(); val dropped = mutableListOf<String>()
val report = DeskReport(0, 0, 0, 0, emptyList(), 0, listOf(DeskPick("Dune", "Sci-Fi", "", "New", "d1", "Movie", 2021, listOf(DeskOption(132, "Sci-Fi", "Fits")))),
emptyList(), 0, true, 0, now, 1)
PhoneLink.screen = CompanionScreen({ companionChannels }, { selected }, { true }, { 0 }, { _, _ -> }, director = { report },
place = { c, n -> placed += c.id to n }, drop = { dropped += it.id })
val before = Profiles.current
try {
val cookie = joinedCookie()
val profiles = JSONObject(call("/api/profiles", cookie = cookie).body).getJSONArray("profiles")
val kidsId = (0 until profiles.length()).map { profiles.getJSONObject(it) }.first { it.getString("kind") == "Kids" }.getString("id")
val adultId = (0 until profiles.length()).map { profiles.getJSONObject(it) }.first { it.getString("kind") == "Grown" }.getString("id")
assertEquals(1, JSONObject(call("/api/director", cookie = cookie).body).getJSONArray("choices").length())
assertEquals(400, call("/api/director/place", "POST", """{"id":"d1","channel":999}""", cookie = cookie).status) // only its own channels
assertEquals(200, call("/api/director/place", "POST", """{"id":"d1","channel":132}""", cookie = cookie).status); assertEquals(listOf("d1" to 132), placed)
assertEquals(404, call("/api/director/drop", "POST", """{"id":"nope"}""", cookie = cookie).status)
// Kids: no Director and no making channels from the phone.
assertEquals(200, call("/api/profile", "POST", """{"id":"$kidsId"}""", cookie = cookie).status); assertTrue(Profiles.kids)
assertEquals(403, call("/api/director", cookie = cookie).status)
assertEquals(403, call("/api/channels", "POST", """{"name":"X","shows":[]}""", cookie = cookie).status)
// Back to Adult (no PIN set here, so it goes straight in, as on the TV).
assertEquals(200, call("/api/profile", "POST", """{"id":"$adultId"}""", cookie = cookie).status); assertFalse(Profiles.kids)
assertEquals(403, call("/api/profile", "POST", """{"id":"nobody"}""", cookie = cookie).status)
} finally { PhoneLink.screen = null; Profiles.current = before }
}
}