From 9160b7fcd264f27f6344e80eb9df2ae17b23fe53 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Sat, 7 Nov 2026 00:09:35 +0000 Subject: [PATCH] Orbit Companion phase 3 and long logo names (from release 0.108.81) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/src/main/assets/phone/companion.html | 140 +++++++++++++++++- .../tv/orbit/classic/EmbyConnectionScreen.kt | 140 ++++++++++-------- .../java/tv/orbit/classic/OrbitCompanion.kt | 45 +++++- .../main/java/tv/orbit/classic/PhoneLink.kt | 30 +++- .../drawable-nodpi/orbit_christmas_bug.webp | Bin 4276 -> 4144 bytes .../drawable-nodpi/orbit_signature_bug.webp | Bin 4182 -> 3840 bytes .../drawable-nodpi/orbit_unscripted_bug.webp | Bin 4164 -> 3986 bytes .../java/tv/orbit/classic/PhoneLinkTest.kt | 27 ++++ 8 files changed, 314 insertions(+), 68 deletions(-) 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') + `
Your channels
- Create a channel Name, shows, style, hours + const yours = state.channels, pr = state.profiles, kids = pr?.current?.kind === 'Kids'; + const who = pr ? `
Who's watching
${pr.profiles.map(p => ``).join('')}
+
${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 + `
About
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 + `
Director's Chair
+ +
Your channels
+ ${(yours?.channels || []).map(c => `
${c.number}${c.on ? 'On' : 'Off'}
+
${esc(c.name)}
${esc(c.styleTitle)} · ${esc(c.hoursLabel)}
+
`).join('')} + ${!yours || yours.next ? '' : '
Every number from 701 to 799 is taken.
'} ${standalone() ? '' : ''}
Look
@@ -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) => `
+
${n}
`).join('')}
`; + const top = `
${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 = ''; + } 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) => ``; + body = `
Pick the shows
${esc(d.description || 'Describe it, or search for a title')} · ${d.shows.length} chosen
+
+
` + 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 = ``; + } 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 = ''; + } 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 = ''; + } 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 = ``; + } + 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.
+ + `); + 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 = `
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' ? '' : `
`}
`).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) => ``).join('')} +
+ ${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 bf4cfeb9359441894578fce3c4218817ff4100c4..cf1862394358c351db664a226aeec75e37cf3b76 100644 GIT binary patch literal 4144 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 literal 4276 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

qR2{ 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 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 7b124f5e33899a1392ea435a39b6a05867268429..31b861cee462a06479ff8b0b1fed73b2347b8c1a 100644 GIT binary patch literal 3986 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 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 } + } }