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 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 } + } }