diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html
index 756f2d5..27a4d75 100644
--- a/android/app/src/main/assets/phone/companion.html
+++ b/android/app/src/main/assets/phone/companion.html
@@ -229,8 +229,17 @@ function rooms() {
return h;
}
function more() {
- return head('More') + `
${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.' : ''}
` : '';
+ if (kids) return head('More') + who + `Orbit Companion runs on the TV itself, over your home Wi-Fi. It works while Orbit is open on the TV.
`;
+ return head('More') + who + `Every number from 701 to 799 is taken.
'}
${standalone() ? '' : 'Follow this TV${state.now?.look ? ' (' + esc(state.now.look.name) + ')' : ''}
@@ -239,6 +248,98 @@ function more() {
About
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.
`;
}
+// 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 = `
${STEPS.map((n, i) => `
`).join('')}
`;
+ const top = `
${I.chev('left')} ${d.number ? 'Edit ' + esc(d.name) : 'Create a channel'} ` + bar;
+ const note = c.note ? `
${esc(c.note)}
` : '';
+ let body = '', next = '';
+ if (c.step === 0) {
+ body = `
Name it
It goes in Yours${d.number ? ' as ' + d.number : ''}. Keep it short, like 90s Sci-Fi.
+
`;
+ next = '
Next: shows ';
+ } 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) => `
${esc(s.title)}
+
${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''}${s.reason && !on ? ' · ' + esc(s.reason) : ''}
+ ${on ? '✓' : ''} `;
+ body = `
Pick the shows
${esc(d.description || 'Describe it, or search for a title')} · ${d.shows.length} chosen
+
+ ${c.busy ? 'Looking…' : 'Suggest'}
` + note +
+ d.shows.map(s => row(s, true)).join('') + offered.map(s => row(s, false)).join('') +
+ (offered.length ? '
The Director suggests these from what you described and the shows you chose. Tap to add.
' : '');
+ next = `
Next: style `;
+ } else if (c.step === 2) {
+ body = `
How it plays
It shapes what's planned from then on, never what's already booked.
` +
+ STYLES.map(([k, t, l]) => `
`).join('') + '
';
+ next = '
Next: hours ';
+ } else if (c.step === 3) {
+ body = `
When it's on
Outside its hours the channel shows a "back at" card.
` +
+ HOURS.map(([h, l], i) => `
`).join('') + '
';
+ next = '
Next: review ';
+ } else {
+ body = `
Review
${esc(d.name)}
+
Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}
+ ${d.shows.slice(0, 8).map(s => `
${esc(s.title)}
`).join('')}${d.shows.length > 8 ? `
and ${d.shows.length - 8} more
` : ''}
` + note;
+ next = `
${c.busy ? 'Saving…' : d.number ? 'Save changes' : 'Create and switch on'} `;
+ }
+ return top + body + `
${next}
`;
+}
+// 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(`
Adult PIN
The four digits that unlock After Dark on the TV.
+
+
Switch to Adult `);
+ 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 = `
${I.chev('left')} Director's Chair `;
+ const r = state.director;
+ if (!r) return top + '
Asking the Director…
';
+ if (!r.ready) return top + '
The Director is still reading your library. Try again in a little while.
';
+ let h = top;
+ if (r.ideas.length) h += '
Drive-In event ideas
' + r.ideas.map(i => `
${esc(i.theme)}${i.decision === 'approved' ? 'APPROVED ' : ''}
+
${esc(i.format)} · ${esc(i.evening)}
${i.films.map(f => `
${esc(f)}
`).join('')}
+ ${i.reasons.length ? `
${esc(i.reasons[0])}
` : ''}
+ ${i.decision === 'approved' ? '' : `
Approve Defer Dismiss
`}
`).join('');
+ h += '
Titles for your channels
';
+ h += r.choices.length ? r.choices.map(c => `
${esc(c.title)} ${c.type === 'Movie' ? 'Film' : 'Series'}${c.year ? ' · ' + c.year : ''}${c.line ? ' · ' + esc(c.line) : ''}
+
${c.options.map((o, k) => `${k === 0 ? 'Place on ' : ''}${o.number} ${esc(o.name)} `).join('')}
+ Drop it
+ ${c.options[0]?.reason ? `
${esc(c.options[0].reason)}
` : ''}
`).join('') : '
Nothing new to place this week.
';
+ return h + '
Placing adds a title to that channel\'s list; it joins as the channel plans ahead, and nothing already booked changes.
';
+}
+
// 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; }
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 b505b3c..da88b01 100644
--- a/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
+++ b/android/app/src/main/java/tv/orbit/classic/EmbyConnectionScreen.kt
@@ -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
) -> 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 ->
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 843b034..7c47d40 100644
--- a/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
+++ b/android/app/src/main/java/tv/orbit/classic/OrbitCompanion.kt
@@ -40,7 +40,12 @@ internal class CompanionScreen(
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 = {})
+ 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 =
+ 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 }
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 c3367c5..a881558 100644
--- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
+++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt
@@ -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()
diff --git a/android/app/src/main/res/drawable-nodpi/orbit_christmas_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_christmas_bug.webp
index bf4cfeb..cf18623 100644
Binary files a/android/app/src/main/res/drawable-nodpi/orbit_christmas_bug.webp and b/android/app/src/main/res/drawable-nodpi/orbit_christmas_bug.webp differ
diff --git a/android/app/src/main/res/drawable-nodpi/orbit_signature_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_signature_bug.webp
index 472f09a..272846f 100644
Binary files a/android/app/src/main/res/drawable-nodpi/orbit_signature_bug.webp and b/android/app/src/main/res/drawable-nodpi/orbit_signature_bug.webp differ
diff --git a/android/app/src/main/res/drawable-nodpi/orbit_unscripted_bug.webp b/android/app/src/main/res/drawable-nodpi/orbit_unscripted_bug.webp
index 7b124f5..31b861c 100644
Binary files a/android/app/src/main/res/drawable-nodpi/orbit_unscripted_bug.webp and b/android/app/src/main/res/drawable-nodpi/orbit_unscripted_bug.webp differ
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 8fa9aad..1e90959 100644
--- a/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
+++ b/android/app/src/test/java/tv/orbit/classic/PhoneLinkTest.kt
@@ -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>(); val dropped = mutableListOf()
+ 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 }
+ }
}