Smart setup v2 and Create a channel refined (from release 0.108.85 and 0.108.86)
Merged with main's Advent and premiere booking: those keep their place, and the shaped channels' file rest is passed alongside. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,9 @@ nav div{max-width:460px;margin:0 auto;display:flex}
|
||||
nav button{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;padding:6px 0;font-size:11px;color:var(--muted)}
|
||||
nav button i{display:flex;align-items:center;justify-content:center;width:46px;height:28px;border-radius:999px}
|
||||
nav button.on{color:var(--accent);font-weight:700}nav button.on i{background:var(--surface)}
|
||||
.kbar{position:fixed;left:0;right:0;bottom:calc(64px + env(safe-area-inset-bottom));z-index:3;background:var(--deep);border-top:1px solid var(--surface);padding:10px 16px}
|
||||
.kbarin{max-width:460px;margin:0 auto}
|
||||
main.kpad{padding-bottom:230px}
|
||||
.toast{position:fixed;left:50%;bottom:90px;transform:translateX(-50%);background:var(--accent);color:var(--on-accent);padding:9px 16px;border-radius:999px;font-weight:700;font-size:13px;z-index:9;display:none;max-width:90vw;text-align:center}
|
||||
.empty{color:var(--muted);text-align:center;padding:30px 10px}
|
||||
</style></head><body>
|
||||
@@ -229,101 +232,199 @@ function rooms() {
|
||||
return h;
|
||||
}
|
||||
function more() {
|
||||
// More, reorganised (design/companion/build-create-v3.py): who's watching as faces, what needs you with a count, your
|
||||
// channels as tiles, and settings as a short list.
|
||||
const yours = state.channels, pr = state.profiles, kids = pr?.current?.kind === 'Kids';
|
||||
const who = pr ? `<div class="label">Who's watching</div><div class="chips" style="flex-wrap:wrap">${pr.profiles.map(p => `<button class="${pr.current?.id === p.id ? 'on' : ''}" data-profile="${esc(p.id)}">${esc(p.name)}${p.kind === 'Grown' && pr.pinSet ? ' · PIN' : ''}</button>`).join('')}</div>
|
||||
<div class="small">${pr.current ? esc(pr.current.name) + ' is watching on the TV.' : 'Nobody chosen yet on the TV.'}${kids ? ' Making channels and the Director are for Adult.' : ''}</div>` : '';
|
||||
if (kids) return head('More') + who + `<div class="label">About</div><div class="card small">Orbit Companion runs on the TV itself, over your home Wi-Fi. It works while Orbit is open on the TV.</div>`;
|
||||
return head('More') + who + `<div class="label">Director's Chair</div>
|
||||
<button class="line" data-director="1">What the Director suggests <span>Approve, place or drop</span></button>
|
||||
<div class="label">Your channels</div>
|
||||
${(yours?.channels || []).map(c => `<div class="ch" style="cursor:default"><div class="num"><b>${c.number}</b><small>${c.on ? 'On' : 'Off'}</small></div>
|
||||
<div class="grow"><div class="what">${esc(c.name)}</div><div class="then">${esc(c.styleTitle)} · ${esc(c.hoursLabel)}</div></div>
|
||||
<button class="round" style="width:auto;padding:0 14px;height:36px;font-size:12px" data-edit="${c.number}">Edit</button></div>`).join('')}
|
||||
${!yours || yours.next ? '<button class="btn gold" data-create="1" style="margin-bottom:8px">+ Create a channel</button>' : '<div class="small">Every number from 701 to 799 is taken.</div>'}
|
||||
${standalone() ? '' : '<button class="line" data-home="1">Add to home screen <span>One tap to the remote</span></button>'}
|
||||
<div class="label">Look</div>
|
||||
<div class="chips" style="flex-wrap:wrap"><button class="${keptLook() ? '' : 'on'}" data-look="">Follow this TV${state.now?.look ? ' (' + esc(state.now.look.name) + ')' : ''}</button>
|
||||
${(state.looks || []).map(l => `<button class="${keptLook() === l.name ? 'on' : ''}" data-look="${esc(l.name)}">${esc(l.name)}</button>`).join('')}</div>
|
||||
<div class="small">Following the TV, each room's remote wears that room's look. Pick one to keep it on this phone instead, whatever the TV is set to.</div>
|
||||
<div class="label">About</div><div class="card small">Orbit Companion runs on the TV itself, over your home Wi-Fi. Nothing to install, no account. It works while Orbit is open on the TV.
|
||||
To stop this phone, remove it in Settings › Rooms and phones.</div>`;
|
||||
const colours = ['#45B8F5', '#FF4FA3', '#6EDCAA', '#FFB347', '#B98CFF'];
|
||||
let h = head('More');
|
||||
if (pr) h += `<div class="label">Who's watching</div><div style="display:flex;gap:18px;align-items:flex-start">${pr.profiles.map((p, i) => `<button data-profile="${esc(p.id)}" style="display:flex;flex-direction:column;align-items:center;gap:6px">
|
||||
<span style="width:54px;height:54px;border-radius:50%;background:${colours[i % colours.length]};display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#071632;${pr.current?.id === p.id ? 'outline:3px solid var(--accent);outline-offset:3px' : ''}">${esc(p.name.charAt(0))}</span>
|
||||
<span class="small" style="${pr.current?.id === p.id ? 'color:var(--ink);font-weight:700' : ''}">${esc(p.name)}</span></button>`).join('')}
|
||||
${pr.pinSet ? '<span class="small" style="margin-left:auto;align-self:center">Adult needs the PIN</span>' : ''}</div>`;
|
||||
if (kids) return h + `<div class="card small" style="margin-top:14px">Kids is watching. Making channels and the Director are for Adult.</div>`;
|
||||
const waiting = state.directorCount;
|
||||
h += `<button class="ch" data-director="1" style="margin-top:16px;background:var(--raised)"><div class="grow"><div class="what">Director's Chair</div><div class="then">${waiting ? 'Ideas and titles waiting for you' : 'Suggestions for your channels'}</div></div>
|
||||
${waiting ? `<span style="min-width:28px;height:28px;border-radius:14px;background:var(--accent);color:var(--on-accent);font-weight:800;display:flex;align-items:center;justify-content:center">${waiting}</span>` : ''}</button>`;
|
||||
h += `<div style="display:flex;justify-content:space-between;align-items:baseline"><div class="label">Your channels</div></div>
|
||||
<div style="display:flex;gap:10px;overflow-x:auto;padding-bottom:4px;scrollbar-width:none">${(yours?.channels || []).map(c => `<button data-edit="${c.number}" style="flex:0 0 140px;text-align:left;background:var(--surface);border-radius:16px;padding:14px">
|
||||
<div style="color:var(--accent);font-weight:800;font-size:18px">${c.number}</div><div style="font-weight:700;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.name)}</div>
|
||||
<div class="small">${esc((c.styleTitle || '').replace(' by the Director', ''))} · ${c.on ? 'on' : 'off'}</div><div class="small" style="color:var(--accent);font-weight:700;margin-top:8px">Edit</div></button>`).join('')}
|
||||
${!yours || yours.next ? `<button data-create="1" aria-label="Create a channel" style="flex:0 0 ${yours?.channels?.length ? '64px' : '100%'};border:2px dashed var(--accent);border-radius:16px;color:var(--accent);font-size:${yours?.channels?.length ? '28px' : '16px'};font-weight:700;padding:14px">${yours?.channels?.length ? '+' : '+ Create a channel'}</button>` : ''}</div>`;
|
||||
const line = (label, value, attr, first) => `<button ${attr} style="display:flex;width:100%;align-items:center;padding:15px 16px;${first ? '' : 'border-top:1px solid var(--raised);'}text-align:left"><span style="flex:1;font-weight:600">${label}</span><span class="small" style="margin-right:8px">${value}</span>${fold(false).replace('M6 9l6 6 6-6', 'M9 6l6 6-6 6')}</button>`;
|
||||
h += `<div class="label">Settings</div><div style="background:var(--surface);border-radius:16px;overflow:hidden">
|
||||
${line('Look', keptLook() ? esc(keptLook()) : 'Follows the TV', 'data-klook="1"', true)}
|
||||
${standalone() ? '' : line('Add to home screen', '', 'data-home="1"')}
|
||||
${line('About Orbit Companion', '', 'data-kabout="1"')}</div>`;
|
||||
return h;
|
||||
}
|
||||
// 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 = [['Shaped','Shaped by the Director',"Recommended. Daytime, evening and late, like Orbit's own channels, with your core shows more often"],['Standard','Standard TV',"Shows take turns all day, episodes in order"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'],
|
||||
// Create a channel, refined (Paul, 7 November 2026, design/companion/build-create-v3.py): start quick (the Director builds it)
|
||||
// or custom; one search for titles, collections, networks and genres; a collection page; the day in folding parts; and a
|
||||
// review where anything can be changed and two endings that never interrupt what's on. Long lists scroll; the action bar
|
||||
// stays at the foot and picks are kept. It saves through the same route as before.
|
||||
const STYLES = [['Shaped','Shaped by the Director',"Daytime, evening and late, like Orbit's own channels, with your core shows more often"],['Standard','Standard TV',"Shows take turns all day, 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 STEPS = ['Shows','Shape','When','Review'];
|
||||
const VIEWS = {shows: 0, collection: 0, shape: 1, when: 2, review: 3};
|
||||
const SLOTS = [['Daytime','06:00 to 17:00'],['Evening','17:00 to 22:00'],['Late','22:00 to 06:00']];
|
||||
const WEEK_HOURS = {Daytime: 77, Evening: 35, Late: 56};
|
||||
const sameHours = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
const room = () => state.now?.room || 'the TV';
|
||||
const backArrow = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>';
|
||||
const fold = open => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--muted)" stroke-width="2.4" stroke-linecap="round"><path d="${open ? 'M6 15l6-6 6 6' : 'M6 9l6 6 6-6'}"/></svg>`;
|
||||
const tick = on => `<span style="width:26px;height:26px;border-radius:7px;flex:none;display:flex;align-items:center;justify-content:center;${on ? 'background:var(--accent);color:var(--on-accent)' : 'border:2px solid var(--accent)'}">${on ? '✓' : ''}</span>`;
|
||||
|
||||
function cTop(title) {
|
||||
const c = state.create, i = VIEWS[c.view];
|
||||
const bar = i === undefined ? '' : `<div style="display:flex;gap:6px;margin:0 0 14px">${STEPS.map((n, k) => `<div style="flex:1"><div style="height:4px;border-radius:2px;background:${k <= i ? 'var(--accent)' : 'var(--surface)'}"></div>
|
||||
<div class="small" style="font-size:10px;margin-top:4px;${k === i ? 'color:var(--ink);font-weight:700' : ''}">${n}</div></div>`).join('')}</div>`;
|
||||
return `<header><button data-kback="1" aria-label="Back" style="padding:4px 6px 4px 0;display:flex">${backArrow}</button><span class="title" style="font-size:20px;margin:0;white-space:normal">${esc(title)}</span></header>` + bar;
|
||||
}
|
||||
// The action bar: fixed above the tabs, so the next step is always in reach however long the list.
|
||||
function cBar(primary, attr, secondary, attr2, note) {
|
||||
return `<div class="kbar"><div class="kbarin">${note ? `<div class="small" style="margin-bottom:8px">${note}</div>` : ''}
|
||||
<div style="display:flex;gap:8px">${secondary ? `<button class="btn" ${attr2} style="flex:0 0 34%">${secondary}</button>` : ''}<button class="btn gold" ${attr} style="flex:1">${primary}</button></div></div></div>`;
|
||||
}
|
||||
function rowTitle(x, on, sub) {
|
||||
return `<button class="ch" data-ktoggle="${esc(x.id)}"><div class="grow"><div class="what">${esc(x.title)}</div>
|
||||
<div class="then">${sub ?? `${x.type === 'Movie' ? 'Film' : 'Series'}${x.year ? ' · ' + x.year : ''}${x.reason ? ' · ' + esc(x.reason) : ''}`}</div></div>${tick(on)}</button>`;
|
||||
}
|
||||
const chosenNote = () => { const n = state.create.draft.shows.length; return n ? `${n} chosen · <a href="#" data-kseechosen="1" style="color:var(--accent)">see them</a>` : 'Nothing chosen yet'; };
|
||||
function searchBox(placeholder) {
|
||||
const c = state.create;
|
||||
return `<div style="display:flex;gap:8px;margin-bottom:12px"><input id="kq" value="${esc(c.query || '')}" placeholder="${placeholder}" enterkeyhint="search"
|
||||
style="flex:1;min-width:0;padding:13px 16px;border-radius:24px;border:2px solid var(--accent);background:var(--surface);color:var(--ink);font:inherit">
|
||||
<button class="btn" data-ksearch="1" style="width:auto;flex:none">${c.busy ? '…' : 'Search'}</button></div>`;
|
||||
}
|
||||
function searchResults() {
|
||||
const c = state.create, r = c.results, d = c.draft; if (!r) return '';
|
||||
const label = s => `<div class="label">${s}</div>`;
|
||||
let h = '';
|
||||
if (r.collections.length) h += label('Collections') + r.collections.map(k => `<button class="ch" data-kcol="${esc(k.name)}"><div class="grow"><div class="what">${esc(k.name)}</div>
|
||||
<div class="then">${k.count.toLocaleString()} titles${k.complete ? '' : ' · still being read'}</div></div>${fold(false).replace('M6 9l6 6 6-6', 'M9 6l6 6-6 6')}</button>`).join('');
|
||||
if (r.networks.length) h += label('Networks and studios') + r.networks.map(k => `<button class="ch" data-knet="${esc(k.name)}"><div class="grow"><div class="what">${esc(k.name)}</div></div><span class="small">${k.count} titles</span></button>`).join('');
|
||||
if (r.genres.length) h += label('Genres') + `<div class="chips" style="flex-wrap:wrap">${r.genres.map(g => `<button data-kgenre="${esc(g)}">${esc(g)}</button>`).join('')}</div>`;
|
||||
if (r.titles.length) h += label('Titles') + r.titles.map(x => rowTitle(x, d.shows.some(y => y.id === x.id))).join('');
|
||||
if (!h) h = '<div class="empty">Nothing in your library matches that. Try a show, a collection like Crunchyroll, a network or a genre.</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
function create() {
|
||||
const c = state.create, d = c.draft;
|
||||
if (c.done) return `<div style="text-align:center;padding:40px 8px 0"><div style="font-size:44px;color:var(--mint)">✓</div>
|
||||
<div class="title" style="white-space:normal">${esc(c.done.name)} is ready on ${c.done.number}</div>
|
||||
<div class="small" style="margin:8px 0 22px">${esc(c.done.message || '')}</div>
|
||||
${c.watching ? `<div class="card small" style="margin-bottom:12px">${esc(c.watching)}</div>` : ''}
|
||||
<button class="btn gold" data-cwatch="next" style="margin-bottom:8px">Watch after this programme</button>
|
||||
<button class="btn" data-cwatch="now" style="margin-bottom:8px">Watch now</button>
|
||||
<button class="btn" data-cdone="1" style="background:none;color:var(--muted)">Done</button></div>`;
|
||||
const bar = `<div style="display:flex;gap:6px;margin:4px 0 4px">${STEPS.map((n, i) => `<div style="flex:1"><div style="height:4px;border-radius:2px;background:${i <= c.step ? 'var(--accent)' : 'var(--surface)'}"></div>
|
||||
<div class="small" style="font-size:10px;margin-top:4px;${i === c.step ? 'color:var(--ink);font-weight:700' : ''}">${n}</div></div>`).join('')}</div>`;
|
||||
const top = `<header><button data-cback="1" aria-label="Back" style="padding:4px 6px 4px 0">${I.chev('left')}</button><span class="title" style="font-size:20px;margin:0">${d.number ? 'Edit ' + esc(d.name) : 'Create a channel'}</span></header>` + bar;
|
||||
const note = c.note ? `<div class="card small" style="margin:10px 0;color:var(--accent)">${esc(c.note)}</div>` : '';
|
||||
let body = '', next = '';
|
||||
if (c.step === 0) {
|
||||
body = `<div class="title" style="margin-top:14px">Name it</div><div class="small">It goes in Yours${d.number ? ' as ' + d.number : ''}. Keep it short, like 90s Sci-Fi.</div>
|
||||
<input id="cname" maxlength="40" value="${esc(d.name)}" placeholder="Name, like 90s Sci-Fi" style="width:100%;margin-top:12px;padding:14px;border-radius:12px;border:2px solid var(--raised);background:var(--surface);color:var(--ink);font:inherit;font-size:18px">`;
|
||||
next = '<button class="btn gold" data-cnext="1">Next: shows</button>';
|
||||
} else if (c.step === 1) {
|
||||
const offered = c.offered.filter(s => !d.shows.some(x => x.id === s.id) && !d.skipped.includes(s.id));
|
||||
const row = (s, on) => `<button class="ch" data-ctoggle="${esc(s.id)}"><div class="grow"><div class="what">${esc(s.title)}</div>
|
||||
<div class="then">${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''}${s.reason && !on ? ' · ' + esc(s.reason) : ''}</div></div>
|
||||
<span style="width:26px;height:26px;border-radius:7px;flex:none;display:flex;align-items:center;justify-content:center;${on ? 'background:var(--accent);color:var(--on-accent)' : 'border:2px solid var(--accent)'}">${on ? '✓' : ''}</span></button>`;
|
||||
const b = c.browse, f = state.facets;
|
||||
const chip = (key, value, label) => `<button class="${b[key] === value ? 'on' : ''}" data-cb="${key}:${esc(value)}">${esc(label)}</button>`;
|
||||
const finder = c.mode === 'browse' ? (!f ? '<div class="small" style="margin:12px 0">Reading your library…</div>' : `
|
||||
<div class="label" style="margin-top:12px">Type</div><div class="chips">${chip('type', '', 'Both')}${chip('type', 'Series', 'Shows')}${chip('type', 'Movie', 'Films')}</div>
|
||||
<div class="label">Genre</div><div class="chips">${chip('genre', '', 'Any')}${f.genres.map(g => chip('genre', g, g)).join('')}</div>
|
||||
<div class="label">Network or studio</div><div class="chips">${chip('studio', '', 'Any')}${f.studios.map(g => chip('studio', g, g)).join('')}</div>
|
||||
<div class="label">Age</div><div class="chips">${chip('rating', '', 'Any')}${f.ratings.map(g => chip('rating', g, g)).join('')}</div>
|
||||
<button class="btn" data-cbrowse="1" style="margin:10px 0">${c.busy ? 'Looking…' : 'Show matching titles'}</button>`) : `
|
||||
<div class="row" style="gap:8px;margin:12px 0"><input id="cdesc" value="${esc(d.description)}" placeholder="90s sci-fi, or a title" style="flex:1;min-width:0;padding:12px;border-radius:12px;border:2px solid var(--raised);background:var(--surface);color:var(--ink);font:inherit">
|
||||
<button class="btn" data-csuggest="1" style="width:auto;flex:none">${c.busy ? 'Looking…' : 'Suggest'}</button></div>`;
|
||||
body = `<div class="title" style="margin-top:14px">Pick the shows</div><div class="small">${d.shows.length} chosen</div>
|
||||
<div class="chips" style="margin-top:10px"><button class="${c.mode !== 'browse' ? 'on' : ''}" data-cmode="describe">Describe it</button><button class="${c.mode === 'browse' ? 'on' : ''}" data-cmode="browse">Browse by genre, network, age</button></div>` + finder + note +
|
||||
d.shows.map(s => row(s, true)).join('') + offered.map(s => row(s, false)).join('') +
|
||||
(offered.length ? `<div class="small" style="margin:6px 0 4px">${c.mode === 'browse' ? 'Matching titles in your library, newest first.' : 'The Director suggests these from what you described and the shows you chose.'} Tap to add.</div>` : '');
|
||||
next = `<button class="btn gold" data-cnext="1" ${d.shows.length ? '' : 'disabled style="opacity:.5"'}>Next: style</button>`;
|
||||
} else if (c.step === 2) {
|
||||
body = `<div class="title" style="margin-top:14px">How it plays</div><div class="small">It shapes what's planned from then on, never what's already booked.</div><div style="margin-top:12px">` +
|
||||
STYLES.map(([k, t, l]) => `<button class="ch ${d.style === k ? 'on' : ''}" data-cstyle="${k}"><div class="grow"><div class="what">${t}</div><div class="then" style="white-space:normal">${l}</div></div></button>`).join('') + '</div>';
|
||||
// Shaped: the Director's layout, to change. Tap a show to move it to the next slot; the star marks a core show.
|
||||
if (d.style === 'Shaped') {
|
||||
const sh = c.shape;
|
||||
if (sh && !Object.keys(d.slots).length) body += `<div class="label">The Director's layout</div><div class="card small">Your picks are all one kind, so they play all day rather than being split into daytime, evening and late. ★ Core shows still come round more often.</div>
|
||||
<div style="margin-top:8px">${d.shows.map(x => `<div style="display:flex;align-items:center;gap:8px;margin-top:6px"><button data-ccore="${esc(x.id)}" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button><b>${esc(x.title)}</b></div>`).join('')}</div>`;
|
||||
else body += !sh ? '<div class="small" style="margin:12px 0">The Director is laying it out…</div>' : `<div class="label">The Director's layout</div>` +
|
||||
SLOTS.map(([slot, hours]) => { const mine = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot);
|
||||
return `<div class="rm"><h3>${slot}<span class="small">${hours}</span></h3>${mine.length ? mine.map(x => `<div style="display:flex;align-items:center;gap:8px;margin-top:8px">
|
||||
<button data-ccore="${esc(x.id)}" aria-label="Core" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button>
|
||||
<button class="grow" data-cslot="${esc(x.id)}" style="text-align:left"><div style="font-weight:700">${esc(x.title)}</div><div class="small">Tap to move to ${SLOTS[(SLOTS.findIndex(s => s[0] === slot) + 1) % 3][0]}</div></button></div>`).join('')
|
||||
: '<div class="small" style="margin-top:6px">Nothing here: these hours share all your shows.</div>'}</div>`; }).join('') +
|
||||
'<div class="small">★ Core shows come round more often. Everything can be changed later.</div>';
|
||||
}
|
||||
next = '<button class="btn gold" data-cnext="1">Next: hours</button>';
|
||||
} else if (c.step === 3) {
|
||||
body = `<div class="title" style="margin-top:14px">When it's on</div><div class="small">Outside its hours the channel shows a "back at" card.</div><div style="margin-top:12px">` +
|
||||
HOURS.map(([h, l], i) => `<button class="ch ${sameHours(h, d.hours) ? 'on' : ''}" data-chours="${i}"><div class="grow"><div class="what">${l}</div></div></button>`).join('') + '</div>';
|
||||
next = '<button class="btn gold" data-cnext="1">Next: review</button>';
|
||||
} else {
|
||||
body = `<div class="title" style="margin-top:14px">Review</div><div class="card" style="margin-top:12px"><div class="title">${esc(d.name)}</div>
|
||||
<div class="small" style="margin:4px 0 10px">Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}</div>
|
||||
${d.shows.slice(0, 8).map(s => `<div style="font-weight:600">${d.core.includes(s.id) ? '★ ' : ''}${esc(s.title)}${d.style === 'Shaped' ? ` <span class="small">· ${esc(d.slots[s.id] || 'Evening')}</span>` : ''}</div>`).join('')}${d.shows.length > 8 ? `<div class="small">and ${d.shows.length - 8} more</div>` : ''}</div>` +
|
||||
(c.week ? `<div class="card small" style="margin-top:12px;color:var(--accent)">${esc(c.week)}</div>` : c.shape === null ? '' : '') + clashCard() + note;
|
||||
next = `<button class="btn gold" data-cmake="1">${c.busy ? 'Saving…' : d.number ? 'Save changes' : 'Create and switch on'}</button>`;
|
||||
if (c.done) return doneView();
|
||||
if (c.view === 'start') {
|
||||
return cTop(d.number ? 'Edit ' + d.name : 'New channel') + `<div class="title" style="font-size:19px;margin-bottom:12px">How would you like to start?</div>
|
||||
<div class="card" style="background:var(--raised);border:2px solid var(--accent);border-radius:18px;padding:16px">
|
||||
<div style="display:flex;justify-content:space-between"><b style="font-size:17px">Let the Director build it</b><span class="small" style="color:var(--accent);font-weight:800">QUICK</span></div>
|
||||
<div class="small" style="margin:6px 0 10px">Say what you fancy. It finds the shows in your library, lays out the day, and you check it.</div>
|
||||
<input id="kdesc" value="${esc(d.description)}" placeholder="90s supernatural and sci-fi mysteries" style="width:100%;padding:13px;border-radius:12px;border:0;background:var(--deep);color:var(--ink);font:inherit">
|
||||
<div class="chips" style="margin-top:10px;flex-wrap:wrap"><span class="small" style="align-self:center">Try:</span>${(c.tries || []).map(t => `<button data-ktry="${esc(t)}">${esc(t)}</button>`).join('')}</div></div>
|
||||
<div class="card" style="border-radius:18px;padding:16px;margin-top:12px">
|
||||
<div style="display:flex;justify-content:space-between"><b style="font-size:17px">Build it yourself</b><span class="small" style="font-weight:800">CUSTOM</span></div>
|
||||
<div class="small" style="margin-top:6px">Pick every show: search titles, open a collection, or a network. The Director still helps with the day.</div></div>` + note +
|
||||
cBar(c.busy ? 'Looking…' : 'Build it for me', 'data-kquick="1"', 'Myself', 'data-kcustom="1"');
|
||||
}
|
||||
return top + body + `<div style="margin-top:16px">${next}</div>`;
|
||||
if (c.view === 'shows') {
|
||||
const head = c.quick ? `<div class="card" style="background:var(--raised);border-radius:14px"><b>“${esc(d.description)}”</b><div class="small" style="margin-top:4px">Untick any you don't want. Add your own with the search.</div></div>` : '';
|
||||
const picked = c.quick ? (c.results ? `<div class="label">The Director's picks</div>` : '') + d.shows.map(x => rowTitle(x, true)).join('') : '';
|
||||
return cTop(c.quick ? `The Director picked ${d.shows.length}` : 'Pick the shows') + head + searchBox(c.quick ? 'Add a show: search your library' : 'Search titles, collections, networks, genres') +
|
||||
note + searchResults() + picked + cBar('Next: shape the day', `data-knext="shape" ${d.shows.length ? '' : 'disabled style="flex:1;opacity:.5"'}`, null, null, chosenNote());
|
||||
}
|
||||
if (c.view === 'collection') {
|
||||
const k = c.col, rf = k.refine;
|
||||
const items = (k.items || []).filter(x => (!rf.type || x.type === rf.type) && (!rf.band || x.band === rf.band));
|
||||
const chosenHere = items.filter(x => d.shows.some(y => y.id === x.id)).length;
|
||||
const chip = (key, value, label) => `<button class="${rf[key] === value ? 'on' : ''}" data-kref="${key}:${esc(value)}">${label}</button>`;
|
||||
return cTop(k.name) + `<div class="card" style="background:var(--raised);border-radius:16px;display:flex;gap:10px;align-items:center"><div class="grow">
|
||||
<div class="small">Collection · ${(k.total || 0).toLocaleString()} titles</div>
|
||||
<div class="small" style="color:${k.complete ? 'var(--mint)' : 'var(--accent)'};font-weight:700">${k.complete ? 'Read in full from your library' : 'Still being read: more will appear'}</div>
|
||||
<div class="small">${(k.found || 0).toLocaleString()} in your library copy</div></div>
|
||||
<button class="btn gold" data-kaddall="1" style="width:auto;flex:none">Add ${items.length === (k.items || []).length ? 'all' : items.length}</button></div>
|
||||
<button class="ch" data-krefine="1" style="margin-top:10px"><div class="grow"><b>Refine</b> <span class="small">${[rf.type === 'Series' ? 'Shows' : rf.type === 'Movie' ? 'Films' : '', rf.band].filter(Boolean).join(' · ')}</span></div>${fold(k.refineOpen)}</button>
|
||||
${k.refineOpen ? `<div class="card" style="border-radius:14px"><div class="label" style="margin-top:0">Type</div><div class="chips">${chip('type', '', 'Both')}${chip('type', 'Series', 'Shows')}${chip('type', 'Movie', 'Films')}</div>
|
||||
<div class="label">Age</div><div class="chips" style="flex-wrap:wrap">${chip('band', '', 'Any')}${chip('band', 'Kids', 'Kids')}${chip('band', 'Family', 'Family')}${chip('band', 'Teen', 'Teen')}${chip('band', 'Adult', 'Grown-up')}${chip('band', 'Unknown', 'Unrated')}</div></div>` : ''}
|
||||
${note}${items.slice(0, k.shown || 60).map(x => rowTitle(x, d.shows.some(y => y.id === x.id))).join('')}
|
||||
${items.length > (k.shown || 60) ? `<button class="btn" data-kmore="1" style="margin:6px 0 10px">Show ${Math.min(60, items.length - (k.shown || 60))} more of ${items.length}</button>` : ''}` +
|
||||
cBar('Done with this collection', 'data-kcoldone="1"', null, null, `${chosenHere} of these chosen · ${chosenNote()}`);
|
||||
}
|
||||
if (c.view === 'shape') return cTop('Shape the day') + shapeView() + note + cBar('Next: when it’s on', 'data-knext="when"', null, null, d.style === 'Shaped' ? '★ comes round more often' : '');
|
||||
if (c.view === 'when') {
|
||||
return cTop('When it’s on') + `<div class="label" style="margin-top:0">Hours</div>` +
|
||||
HOURS.map(([h, l], i) => `<button class="ch ${sameHours(h, d.hours) ? 'on' : ''}" data-chours="${i}"><div class="grow"><div class="what">${l}</div></div></button>`).join('') + seasonPicker() + note +
|
||||
cBar('Next: review', 'data-knext="review"');
|
||||
}
|
||||
if (c.view === 'review') return reviewView() + note;
|
||||
return '';
|
||||
}
|
||||
|
||||
function shapeView() {
|
||||
const c = state.create, d = c.draft, sh = c.shape;
|
||||
if (d.style !== 'Shaped') return `<div class="card small">${esc(STYLES.find(s => s[0] === d.style)[1])}: ${esc(STYLES.find(s => s[0] === d.style)[2])}. Change the style on Review.</div>`;
|
||||
if (!sh) return '<div class="empty">The Director is laying out the day…</div>';
|
||||
let h = '';
|
||||
if (sh.suggestedCore?.length && !d.core.length && !c.coreSet) h += `<div class="card small">The Director would star ${sh.suggestedCore.map(id => esc(d.shows.find(x => x.id === id)?.title || '')).join(', ')} to come round more often.
|
||||
<div style="display:flex;gap:8px;margin-top:8px"><button class="btn gold" data-cstarok="1">Star them</button><button class="btn" data-cstarno="1">No thanks</button></div></div>`;
|
||||
if (!Object.keys(d.slots).length) return h + `<div class="card small">Your picks are all one kind, so they play all day rather than being split into daytime, evening and late.</div>` +
|
||||
d.shows.map(x => `<div class="ch" style="cursor:default"><button data-ccore="${esc(x.id)}" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button><div class="grow what">${esc(x.title)}</div></div>`).join('');
|
||||
h += `<div class="card small" style="background:var(--deep)">The Director balanced this for you. Open a part of the day to star a show or move it.${sh.notes?.length ? ' ' + esc(sh.notes.slice(0, 2).join(' ')) : ''}</div>`;
|
||||
const hoursOf = id => (sh.runHours || {})[id] || 0;
|
||||
for (const [slot, span] of SLOTS) {
|
||||
const mine = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot), open = c.open?.[slot];
|
||||
const hrs = Math.round(mine.reduce((a, x) => a + hoursOf(x.id), 0)), need = WEEK_HOURS[slot], frac = Math.min(1, hrs / (need * 3));
|
||||
h += `<div class="rm" style="padding:12px 14px"><button data-kfold="${slot}" style="width:100%;text-align:left;display:block">
|
||||
<div style="display:flex;align-items:center;gap:8px"><b style="font-size:16px">${slot}</b><span class="small">${span}</span><span style="margin-left:auto">${fold(open)}</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-top:8px"><div style="flex:1;height:6px;border-radius:3px;background:var(--deep)"><div style="width:${hrs ? frac * 100 : 0}%;height:100%;border-radius:3px;background:${hrs >= need || !hrs ? 'var(--mint)' : 'var(--accent)'}"></div></div>
|
||||
<span class="small">${mine.length} titles${hrs ? ' · ' + hrs + ' h' : ''}</span></div></button>
|
||||
${open ? (mine.length ? mine.map(x => `<div style="display:flex;align-items:center;gap:8px;margin-top:10px">
|
||||
<button data-ccore="${esc(x.id)}" aria-label="Core" style="font-size:20px;color:${d.core.includes(x.id) ? 'var(--accent)' : 'var(--muted)'}">${d.core.includes(x.id) ? '★' : '☆'}</button>
|
||||
<div class="grow"><div style="font-weight:700">${esc(x.title)}</div></div>
|
||||
<select data-kmove="${esc(x.id)}" style="padding:6px;border-radius:14px;background:var(--raised);color:var(--ink);border:0">${SLOTS.map(([s]) => `<option ${s === slot ? 'selected' : ''}>${s}</option>`).join('')}</select></div>`).join('')
|
||||
: '<div class="small" style="margin-top:8px">Nothing here: these hours share the daytime and evening shows.</div>') : ''}</div>`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function reviewView() {
|
||||
const c = state.create, d = c.draft, sh = c.shape;
|
||||
const style = STYLES.find(s => s[0] === d.style)[1].replace(' by the Director', '');
|
||||
const hours = HOURS.find(([h]) => sameHours(h, d.hours))?.[1]?.split(',')[0] || 'All day';
|
||||
const pillBtn = (label, attr) => `<button ${attr} style="background:var(--surface);border-radius:15px;padding:5px 12px;font-size:13px;font-weight:700">${label} ▾</button>`;
|
||||
let h = cTop('Review') + `<div class="card" style="background:var(--raised);border-radius:16px">
|
||||
<div style="display:flex;gap:8px;align-items:center"><input id="kname" value="${esc(d.name)}" maxlength="40" placeholder="Name it" style="flex:1;min-width:0;font-size:20px;font-weight:800;padding:6px 0;border:0;border-bottom:2px solid var(--accent);background:none;color:var(--ink)">
|
||||
<b style="color:var(--accent);font-size:20px">${d.number || state.channels?.next || ''}</b></div>
|
||||
${!d.name && c.suggestedName ? `<div class="small" style="margin-top:6px">The Director suggests <a href="#" data-kusename="1" style="color:var(--accent);font-weight:700">${esc(c.suggestedName)}</a></div>` : ''}
|
||||
<div class="chips" style="margin-top:10px;flex-wrap:wrap">${pillBtn(esc(style), 'data-kstyles="1"')}${pillBtn(esc(hours), 'data-kgo="when"')}${pillBtn(d.season ? seasonLabel(d.season) : 'All year', 'data-kgo="when"')}</div>
|
||||
${c.styleOpen ? `<div style="margin-top:10px">${STYLES.map(([k, t, l]) => `<button class="ch ${d.style === k ? 'on' : ''}" data-kstyle="${k}"><div class="grow"><div class="what">${t}</div><div class="then" style="white-space:normal">${l}</div></div></button>`).join('')}</div>` : ''}</div>`;
|
||||
// The week, in plain words.
|
||||
const total = sh?.hours;
|
||||
if (total) {
|
||||
const perDay = d.hours ? ((d.hours.to || 24) - d.hours.from + 24) % 24 || 24 : 24, weeks = total / perDay / 7;
|
||||
h += `<div class="card" style="border-radius:16px"><div style="display:flex;justify-content:space-between"><b>Your week</b><b style="color:${weeks >= 1 ? 'var(--mint)' : 'var(--accent)'}">${Math.round(total).toLocaleString()} hours</b></div>
|
||||
<div style="height:10px;border-radius:5px;background:var(--deep);margin:10px 0"><div style="width:${Math.min(100, weeks * 100)}%;height:100%;border-radius:5px;background:${weeks >= 1 ? 'var(--mint)' : 'var(--accent)'}"></div></div>
|
||||
<div class="small">${esc(c.week || '')}</div></div>`;
|
||||
} else if (c.week) h += `<div class="card small" style="color:var(--accent)">${esc(c.week)}</div>`;
|
||||
if (d.style === 'Shaped' && Object.keys(d.slots).length && sh?.runHours) {
|
||||
h += `<div class="card" style="border-radius:16px"><b>Balance</b><div style="display:flex;gap:10px;margin-top:10px">${SLOTS.map(([slot]) => {
|
||||
const hrs = d.shows.filter(x => (d.slots[x.id] || 'Evening') === slot).reduce((a, x) => a + (sh.runHours[x.id] || 0), 0), f = Math.min(1, hrs / WEEK_HOURS[slot]);
|
||||
return `<div style="flex:1"><div class="small">${slot}</div><div style="height:8px;border-radius:4px;background:var(--deep);margin-top:4px"><div style="width:${f * 100}%;height:100%;border-radius:4px;background:${f >= 1 ? 'var(--mint)' : 'var(--accent)'}"></div></div></div>`; }).join('')}</div></div>`;
|
||||
}
|
||||
h += clashCard();
|
||||
h += `<div class="card small" style="background:var(--deep)">Nothing changes on ${esc(room())} until you say. <b>Create channel</b> saves it and starts planning. <b>Create & watch next</b> switches to it after the programme on now.</div>`;
|
||||
return h + `<div class="kbar"><div class="kbarin"><button class="btn gold" data-kmake="next">${c.busy ? 'Saving…' : d.number ? 'Save & watch next on ' + esc(room()) : 'Create & watch next on ' + esc(room())}</button>
|
||||
<button class="btn" data-kmake="save" style="margin-top:8px">${d.number ? 'Save changes' : 'Create channel'}</button></div></div>`;
|
||||
}
|
||||
|
||||
function doneView() {
|
||||
const c = state.create, k = c.done;
|
||||
return `<div style="text-align:center;padding:40px 8px 0"><div style="font-size:44px;color:var(--mint)">✓</div>
|
||||
<div class="title" style="white-space:normal">${esc(k.name)} is ready on ${k.number}</div>
|
||||
<div class="small" style="margin:8px 0 22px">${esc(k.message || '')}</div>
|
||||
${c.watching ? `<div class="card small" style="margin-bottom:12px">${esc(c.watching)}</div>` : ''}
|
||||
${k.queued ? '' : `<button class="btn gold" data-cwatch="next" style="margin-bottom:8px">Watch after this programme</button><button class="btn" data-cwatch="now" style="margin-bottom:8px">Watch now</button>`}
|
||||
<button class="btn" data-cdone="1" style="background:none;color:var(--muted)">Done</button></div>`;
|
||||
}
|
||||
|
||||
// Who's watching (phase 3): Kids goes straight in; Adult asks for the PIN on the phone when one's set, as the TV does.
|
||||
async function switchTo(id, pin) {
|
||||
buzz();
|
||||
@@ -352,9 +453,73 @@ async function checkClashes() {
|
||||
try { c.clashes = (await api('/api/clashes', {shows: c.draft.shows.map(x => ({id: x.id, title: x.title})), number: c.draft.number})).clashes; } catch (e) { c.clashes = {}; }
|
||||
render();
|
||||
}
|
||||
function startCreate(draft) { draft.slots = draft.slots || {}; draft.core = draft.core || []; state.create = {step: 0, draft, offered: [], note: '', busy: false, mode: 'describe', browse: {type: '', genre: '', studio: '', rating: ''}, shape: null}; render(); }
|
||||
// The season: off unless you choose it. The Director may suggest one (Christmas titles: November and December).
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const seasonLabel = w => `${w.fromDay} ${MONTHS[w.fromMonth - 1]} to ${w.toDay} ${MONTHS[w.toMonth - 1]}`;
|
||||
function seasonPicker() {
|
||||
const c = state.create, d = c.draft, w = d.season, sug = c.shape?.suggestedSeason;
|
||||
const sel = (key, max, value) => `<select data-cseason="${key}" style="padding:8px;border-radius:8px;background:var(--surface);color:var(--ink);border:1px solid var(--raised)">${Array.from({length: max}, (_, i) => i + 1).map(n => `<option value="${n}" ${n === value ? 'selected' : ''}>${key.endsWith('Month') ? MONTHS[n - 1] : n}</option>`).join('')}</select>`;
|
||||
return `<div class="label">Season</div>
|
||||
<button class="ch ${w ? '' : 'on'}" data-cseasonoff="1"><div class="grow"><div class="what">All year</div></div></button>
|
||||
<button class="ch ${w ? 'on' : ''}" data-cseasonon="1"><div class="grow"><div class="what">Only in season</div><div class="then">It leaves the guide outside these dates, and books nothing past the end.</div></div></button>
|
||||
${w ? `<div class="row" style="gap:6px;flex-wrap:wrap;justify-content:flex-start;margin:6px 0">From ${sel('fromDay', 31, w.fromDay)} ${sel('fromMonth', 12, w.fromMonth)} to ${sel('toDay', 31, w.toDay)} ${sel('toMonth', 12, w.toMonth)}</div>` : ''}
|
||||
${sug && !w ? `<div class="card small" style="margin-top:8px">The Director suggests ${esc(sug.label)}, as most of these are Christmas titles.<button class="btn" data-cseasonsug="1" style="margin-top:8px">Use ${esc(sug.label)}</button></div>` : ''}`;
|
||||
}
|
||||
function startCreate(draft) {
|
||||
draft.slots = draft.slots || {}; draft.core = draft.core || [];
|
||||
state.create = {view: draft.number ? 'shows' : 'start', draft, results: null, query: '', busy: false, note: '', shape: null, open: {}, quick: false,
|
||||
tries: (state.collections || []).slice(0, 2).map(k => k.name).concat(['Cosy Christmas films']).slice(0, 3)};
|
||||
render(); scrollTo(0, 0);
|
||||
if (!state.collections) api('/api/collections').then(r => { state.collections = r.collections; if (state.create?.view === 'start') { const typed = $('#kdesc'); if (typed) state.create.draft.description = typed.value; state.create.tries = r.collections.slice(0, 2).map(k => k.name).concat(['Cosy Christmas films']); render(); } }).catch(() => {});
|
||||
}
|
||||
// Name the Director suggests: the collection's own name, or the description in a few words.
|
||||
const suggestName = text => text.replace(/\s+/g, ' ').trim().split(' ').slice(0, 3).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ').slice(0, 40);
|
||||
function goTo(view) { const c = state.create; c.view = view; c.note = ''; render(); scrollTo(0, 0);
|
||||
if (view === 'shape' || (view === 'review' && !c.shape) || view === 'when') shapeShows(); if (view === 'review') checkClashes(); }
|
||||
async function runSearch() {
|
||||
const c = state.create; c.query = ($('#kq')?.value || '').trim(); if (c.query.length < 2 || c.busy) return;
|
||||
c.busy = true; render();
|
||||
try { c.results = await api('/api/search?q=' + encodeURIComponent(c.query)); } catch (e) { c.note = e.message; }
|
||||
c.busy = false; render();
|
||||
}
|
||||
async function openCollection(name) {
|
||||
const c = state.create; c.busy = true; c.view = 'collection'; c.col = {name, refine: {type: '', band: ''}, items: [], shown: 60}; c.suggestedName = c.suggestedName || name; render(); scrollTo(0, 0);
|
||||
try { const r = await api('/api/browse?collection=' + encodeURIComponent(name)); Object.assign(c.col, {items: r.items, complete: r.complete, total: r.total, found: r.found}); }
|
||||
catch (e) { c.note = e.message; }
|
||||
c.busy = false; render();
|
||||
}
|
||||
// Quick: the Director picks. A collection named in the description is taken whole; otherwise its suggestions.
|
||||
async function quickBuild() {
|
||||
const c = state.create, d = c.draft; d.description = ($('#kdesc')?.value || '').trim(); if (!d.description || c.busy) return;
|
||||
c.busy = true; render();
|
||||
try {
|
||||
const col = (state.collections || []).find(k => k.name.toLowerCase() === d.description.toLowerCase());
|
||||
let picks;
|
||||
if (col) { const r = await api('/api/browse?collection=' + encodeURIComponent(col.name)); picks = r.items.slice(0, 200); c.suggestedName = col.name; }
|
||||
else { picks = await api('/api/suggest?d=' + encodeURIComponent(d.description) + '&chosen=&skipped='); c.suggestedName = suggestName(d.description); }
|
||||
if (!picks.length) { c.note = 'Nothing in your library fits that yet. Try other words, a collection or a network, or build it yourself.'; c.busy = false; render(); return; }
|
||||
d.shows = picks; c.quick = true; c.results = null; c.busy = false; goTo('shows');
|
||||
} catch (e) { c.note = e.message; c.busy = false; render(); }
|
||||
}
|
||||
function chosenSheet() {
|
||||
const d = state.create.draft;
|
||||
openSheet(`<div class="title">${d.shows.length} chosen</div><div style="max-height:55vh;overflow:auto;margin-top:8px">${d.shows.map(x => `<div style="display:flex;align-items:center;gap:8px;margin:6px 0"><div class="grow">${esc(x.title)}</div>
|
||||
<button class="round" style="width:auto;padding:0 12px;height:30px;font-size:12px" data-kunpick="${esc(x.id)}">Remove</button></div>`).join('')}</div>`);
|
||||
}
|
||||
// Smart setup: what the library has to browse by, and the Director's layout for the chosen shows.
|
||||
async function loadFacets() { if (state.facets) return; try { state.facets = await api('/api/facets'); } catch (e) { state.create.note = e.message; } render(); }
|
||||
async function loadFacets() {
|
||||
if (!state.facets) try { state.facets = await api('/api/facets'); } catch (e) { state.create.note = e.message; }
|
||||
if (!state.collections) try { state.collections = (await api('/api/collections')).collections; } catch (e) { state.collections = []; }
|
||||
render();
|
||||
}
|
||||
// From a collection: its actual members from the library, to add all at once or one by one.
|
||||
async function browseCollection(name) {
|
||||
const c = state.create; c.collection = name; c.busy = true; render();
|
||||
try { const r = await api('/api/browse?collection=' + encodeURIComponent(name)); c.offered = r.items; c.colInfo = {found: r.found, total: r.total, complete: r.complete};
|
||||
c.note = r.items.length ? '' : 'None of that collection is in the library copy yet.'; }
|
||||
catch (e) { c.note = e.message; c.colInfo = null; }
|
||||
c.busy = false; render();
|
||||
}
|
||||
async function browseShows() {
|
||||
const c = state.create; if (c.busy) return; c.busy = true; render();
|
||||
const q = Object.entries(c.browse).filter(([, v]) => v).map(([k, v]) => k + '=' + encodeURIComponent(v)).join('&');
|
||||
@@ -396,13 +561,16 @@ async function watchNew(when) {
|
||||
}
|
||||
c.trying = false; if (c.watching === 'Planning its first programmes…') c.watching = "It's still planning. Try again in a minute, or find it in the Guide."; render();
|
||||
}
|
||||
async function makeChannel() {
|
||||
const c = state.create, d = c.draft; if (c.busy) return; c.busy = true; render();
|
||||
async function makeChannel(ending) {
|
||||
const c = state.create, d = c.draft; if (c.busy) return;
|
||||
d.name = ($('#kname')?.value || d.name || '').trim(); if (!d.name) { toast('Give it a name first'); 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,
|
||||
slots: d.style === 'Shaped' ? d.slots : undefined, core: d.style === 'Shaped' ? d.core : undefined});
|
||||
// Made: offer to watch it now, or straight after the programme on the TV (Paul, 7 November 2026).
|
||||
c.done = {number: r.number, name: d.name, message: r.message, edit: !!d.number}; c.busy = false; render(); load('more'); }
|
||||
catch (e) { c.note = e.message; c.busy = false; render(); }
|
||||
slots: d.style === 'Shaped' ? d.slots : undefined, core: d.style === 'Shaped' ? d.core : undefined, season: d.season || null});
|
||||
c.done = {number: r.number, name: d.name, message: r.message, edit: !!d.number}; c.busy = false; render(); load('more');
|
||||
// Create & watch next: queued for after the programme on now, never interrupting it (Paul, 7 November 2026).
|
||||
if (ending === 'next') { await watchNew('next'); }
|
||||
} 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
|
||||
@@ -445,7 +613,7 @@ function offerHome() {
|
||||
}
|
||||
function render() {
|
||||
tabs();
|
||||
$('#app').className = tab === 'remote' ? 'fit' : '';
|
||||
$('#app').className = tab === 'remote' ? 'fit' : tab === 'more' && state.create ? 'kpad' : '';
|
||||
$('#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'); }
|
||||
@@ -459,7 +627,8 @@ async function load(what) {
|
||||
if (what === 'catchup') state.catchup = await api('/api/catchup');
|
||||
if (what === 'rooms') state.rooms = await api('/api/rooms');
|
||||
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); }
|
||||
state.looks = l.looks; state.channels = c; state.profiles = p; look(state.now?.look);
|
||||
if (p?.current?.kind !== 'Kids') api('/api/director').then(r => { state.directorCount = r.ready ? r.ideas.filter(i => i.decision !== 'approved').length + r.choices.length : 0; if (tab === 'more' && !state.create && !state.chair) render(); }).catch(() => {}); }
|
||||
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; }
|
||||
@@ -469,8 +638,20 @@ async function load(what) {
|
||||
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)); };
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (e.target.id === 'kq') { e.preventDefault(); runSearch(); }
|
||||
if (e.target.id === 'kdesc') { e.preventDefault(); quickBuild(); }
|
||||
});
|
||||
document.addEventListener('change', e => {
|
||||
const mv = e.target.dataset?.kmove;
|
||||
if (mv && state.create) { const c = state.create; c.draft.slots[mv] = e.target.value; c.moved = {...(c.moved || {}), [mv]: true}; render(); return; }
|
||||
const k = e.target.dataset?.cseason; if (!k || !state.create?.draft.season) return;
|
||||
state.create.draft.season[k] = +e.target.value; render();
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
const b = e.target.closest('button,[data-tab]'); if (!b) return;
|
||||
const b = e.target.closest('button,[data-tab],a[data-kseechosen],a[data-kusename]'); if (!b) return;
|
||||
if (b.tagName === 'A') e.preventDefault();
|
||||
const d = b.dataset;
|
||||
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});
|
||||
@@ -478,7 +659,7 @@ document.addEventListener('click', e => {
|
||||
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.look !== undefined) { closeSheet(); 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; }
|
||||
@@ -486,6 +667,44 @@ document.addEventListener('click', e => {
|
||||
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.kback) { const c = state.create, v = c.view;
|
||||
if (v === 'start' || (v === 'shows' && c.draft.number)) state.create = null;
|
||||
else if (v === 'collection') c.view = 'shows'; else if (v === 'shows') c.view = c.draft.number ? 'shows' : 'start';
|
||||
else c.view = {shape: 'shows', when: 'shape', review: 'when'}[v];
|
||||
render(); scrollTo(0, 0); return; }
|
||||
if (d.klook) { openSheet(`<div class="title">Look</div><div class="small" style="margin:6px 0 12px">Following the TV, each room's remote wears that room's look. Pick one to keep it on this phone instead.</div>
|
||||
<div class="chips" style="flex-wrap:wrap"><button class="${keptLook() ? '' : 'on'}" data-look="">Follow this TV${state.now?.look ? ' (' + esc(state.now.look.name) + ')' : ''}</button>
|
||||
${(state.looks || []).map(l => `<button class="${keptLook() === l.name ? 'on' : ''}" data-look="${esc(l.name)}">${esc(l.name)}</button>`).join('')}</div>`); return; }
|
||||
if (d.kabout) { openSheet(`<div class="title">Orbit Companion</div><div class="small" style="margin-top:8px">It 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 on the TV.</div>`); return; }
|
||||
if (d.kquick) { quickBuild(); return; }
|
||||
if (d.ktry) { $('#kdesc').value = d.ktry; quickBuild(); return; }
|
||||
if (d.kcustom) { const c = state.create; c.quick = false; c.view = 'shows'; render(); setTimeout(() => $('#kq')?.focus(), 50); return; }
|
||||
if (d.ksearch) { runSearch(); return; }
|
||||
if (d.ktoggle) { const dr = state.create.draft, id = d.ktoggle, c = state.create;
|
||||
if (dr.shows.some(x => x.id === id)) dr.shows = dr.shows.filter(x => x.id !== id);
|
||||
else { const x = [...(c.results?.titles || []), ...(c.col?.items || [])].find(x => x.id === id); if (x) dr.shows.push(x); }
|
||||
render(); return; }
|
||||
if (d.kcol) { openCollection(d.kcol); return; }
|
||||
if (d.knet) { const c = state.create; c.busy = true; render();
|
||||
api('/api/browse?studio=' + encodeURIComponent(d.knet)).then(items => { c.results = {collections: [], networks: [], genres: [], titles: items}; c.query = d.knet; }).catch(e => c.note = e.message).finally(() => { c.busy = false; render(); }); return; }
|
||||
if (d.kgenre) { const c = state.create; c.busy = true; render();
|
||||
api('/api/browse?genre=' + encodeURIComponent(d.kgenre)).then(items => { c.results = {collections: [], networks: [], genres: [], titles: items}; c.query = d.kgenre; }).catch(e => c.note = e.message).finally(() => { c.busy = false; render(); }); return; }
|
||||
if (d.kaddall) { const c = state.create, rf = c.col.refine;
|
||||
const items = c.col.items.filter(x => (!rf.type || x.type === rf.type) && (!rf.band || x.band === rf.band));
|
||||
items.forEach(x => { if (!c.draft.shows.some(y => y.id === x.id)) c.draft.shows.push(x); }); toast(`Added ${items.length}`); render(); return; }
|
||||
if (d.krefine) { state.create.col.refineOpen = !state.create.col.refineOpen; render(); return; }
|
||||
if (d.kref) { const cut = d.kref.indexOf(':'), k = d.kref.slice(0, cut), v = d.kref.slice(cut + 1); state.create.col.refine[k] = v; render(); return; }
|
||||
if (d.kmore) { state.create.col.shown = (state.create.col.shown || 60) + 60; render(); return; }
|
||||
if (d.kcoldone) { state.create.view = 'shows'; render(); scrollTo(0, 0); return; }
|
||||
if (d.knext) { goTo(d.knext); return; }
|
||||
if (d.kfold) { const c = state.create; c.open = {...c.open, [d.kfold]: !c.open?.[d.kfold]}; render(); return; }
|
||||
if (d.kgo) { state.create.draft.name = ($('#kname')?.value || state.create.draft.name || '').trim(); goTo(d.kgo); return; }
|
||||
if (d.kstyles) { const c = state.create; c.draft.name = ($('#kname')?.value || c.draft.name || '').trim(); c.styleOpen = !c.styleOpen; render(); return; }
|
||||
if (d.kstyle) { const c = state.create; c.draft.name = ($('#kname')?.value || c.draft.name || '').trim(); c.draft.style = d.kstyle; c.styleOpen = false; render(); if (d.kstyle === 'Shaped') shapeShows(); return; }
|
||||
if (d.kusename) { state.create.draft.name = state.create.suggestedName; render(); return; }
|
||||
if (d.kseechosen) { chosenSheet(); return; }
|
||||
if (d.kunpick) { const dr = state.create.draft; dr.shows = dr.shows.filter(x => x.id !== d.kunpick); chosenSheet(); render(); return; }
|
||||
if (d.kmake) { makeChannel(d.kmake); return; }
|
||||
if (d.create) { startCreate({name: '', description: '', style: 'Shaped', 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 => {
|
||||
@@ -501,15 +720,22 @@ document.addEventListener('click', e => {
|
||||
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); if (c.step === 2 && c.draft.style === 'Shaped') shapeShows(); if (c.step === 4) { checkClashes(); shapeShows(); } return; }
|
||||
c.step++; c.note = ''; render(); scrollTo(0, 0); if ((c.step === 2 && c.draft.style === 'Shaped') || (c.step === 3 && !c.shape)) shapeShows(); if (c.step === 4) { checkClashes(); shapeShows(); } 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(); if (d.cstyle === 'Shaped') shapeShows(); return; }
|
||||
if (d.ccol) { browseCollection(d.ccol); return; }
|
||||
if (d.caddall) { const c = state.create; c.offered.forEach(x => { if (!c.draft.shows.some(y => y.id === x.id)) c.draft.shows.push(x); }); toast(`Added ${c.offered.length}`); render(); return; }
|
||||
if (d.cstarok) { const c = state.create; c.draft.core = c.shape.suggestedCore.slice(); c.coreSet = true; render(); return; }
|
||||
if (d.cstarno) { const c = state.create; c.shape.suggestedCore = []; c.coreSet = true; render(); return; }
|
||||
if (d.cseasonoff) { state.create.draft.season = null; render(); return; }
|
||||
if (d.cseasonon) { state.create.draft.season = state.create.draft.season || state.create.shape?.suggestedSeason && {...state.create.shape.suggestedSeason} || {fromMonth: 11, fromDay: 1, toMonth: 12, toDay: 31}; delete state.create.draft.season.label; render(); return; }
|
||||
if (d.cseasonsug) { const sg = {...state.create.shape.suggestedSeason}; delete sg.label; state.create.draft.season = sg; render(); return; }
|
||||
if (d.cmode) { state.create.mode = d.cmode; state.create.offered = []; render(); if (d.cmode === 'browse') loadFacets(); return; }
|
||||
if (d.cb) { const cut = d.cb.indexOf(':'), k = d.cb.slice(0, cut), v = d.cb.slice(cut + 1); state.create.browse[k] = state.create.browse[k] === v ? '' : v; render(); return; }
|
||||
if (d.cb) { state.create.collection = null; state.create.colInfo = null; const cut = d.cb.indexOf(':'), k = d.cb.slice(0, cut), v = d.cb.slice(cut + 1); state.create.browse[k] = state.create.browse[k] === v ? '' : v; render(); return; }
|
||||
if (d.cbrowse) { browseShows(); return; }
|
||||
if (d.cslot) { const c = state.create, i = SLOTS.findIndex(s => s[0] === (c.draft.slots[d.cslot] || 'Evening'));
|
||||
c.draft.slots[d.cslot] = SLOTS[(i + 1) % 3][0]; c.moved = {...(c.moved || {}), [d.cslot]: true}; render(); return; }
|
||||
|
||||
@@ -187,6 +187,15 @@ internal class CatalogueStore(context: Context, private val file: File) : Catalo
|
||||
buildList { while (c.moveToNext()) add(ShapeRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4), c.getString(5).split('|').filter { it.isNotBlank() }.toSet())) }
|
||||
}
|
||||
}
|
||||
/** Networks and studios whose names contain [text], with how many titles each has: for Create's one search. Read only. */
|
||||
@Synchronized fun studiosMatching(text: String, limit: Int = 5): List<Pair<String, Int>> {
|
||||
val q = text.trim().take(60); if (q.length < 2) return emptyList()
|
||||
val counts = HashMap<String, Int>()
|
||||
readableDatabase.rawQuery("SELECT studios FROM items WHERE type IN ('Movie','Series') AND studios LIKE ?", arrayOf("%$q%")).use { c ->
|
||||
while (c.moveToNext()) c.getString(0).split('|').filter { it.contains(q, ignoreCase = true) }.forEach { counts.merge(it, 1, Int::plus) }
|
||||
}
|
||||
return counts.entries.sortedByDescending { it.value }.take(limit).map { it.key to it.value }
|
||||
}
|
||||
/** Films' running times by item, for Smart setup's hours estimate. Read only. */
|
||||
@Synchronized fun runTimes(ids: Collection<String>): Map<String, Long> = ids.distinct().chunked(400).flatMap { chunk ->
|
||||
readableDatabase.rawQuery("SELECT id, runtime_ms FROM items WHERE runtime_ms IS NOT NULL AND id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
|
||||
|
||||
@@ -560,15 +560,23 @@ internal const val CHRISTMAS_NUMBER = 310
|
||||
* Orbit Christmas is seasonal (Paul, 6 November 2026: "from today ... off from 1st January"): on from 1 November to
|
||||
* 31 December, and nowhere in the guide or Home the rest of the year.
|
||||
*/
|
||||
internal fun inSeason(number: Int, timeMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Boolean =
|
||||
number != CHRISTMAS_NUMBER || java.time.Instant.ofEpochMilli(timeMs).atZone(zone).monthValue >= 11
|
||||
internal fun inSeason(number: Int, timeMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Boolean {
|
||||
val date = java.time.Instant.ofEpochMilli(timeMs).atZone(zone).toLocalDate()
|
||||
if (number == CHRISTMAS_NUMBER) return date.monthValue >= 11
|
||||
// A channel of your own with a season you chose.
|
||||
return CustomChannels.of(number)?.season?.covers(date) ?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a seasonal channel may be booked: Orbit Christmas books nothing that starts from 1 January (it planned about a
|
||||
* week into January before, out of sight). Null for every other channel.
|
||||
*/
|
||||
internal fun seasonEndMs(number: Int, timeMs: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): Long? {
|
||||
if (number != CHRISTMAS_NUMBER) return null
|
||||
if (number != CHRISTMAS_NUMBER) {
|
||||
val season = CustomChannels.of(number)?.season ?: return null
|
||||
val date = java.time.Instant.ofEpochMilli(timeMs).atZone(zone).toLocalDate()
|
||||
return if (season.covers(date)) season.endAfter(date, zone) else timeMs
|
||||
}
|
||||
val day = java.time.Instant.ofEpochMilli(timeMs).atZone(zone)
|
||||
if (day.monthValue < 11) return timeMs
|
||||
return java.time.LocalDate.of(day.year + 1, 1, 1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
|
||||
@@ -60,6 +60,8 @@ internal data class ChannelProgramming(val number: Int, val name: String, val se
|
||||
* the channel's brief. Null keeps equal turns. Only Orbit Three has it (approved 4 November 2026).
|
||||
*/
|
||||
val tierWeights: Map<String, Double>? = null,
|
||||
/** Weighted channels only: how long a file rests before it airs again, where the pool allows ([SAME_FILE_MS] unless set). */
|
||||
val fileRestMs: Long = SAME_FILE_MS,
|
||||
/** A film channel's everyday blocks rest each film for a share of how long their own films take to play through ([adaptiveRestMs]). */
|
||||
val adaptiveFilmRest: Boolean = false,
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,9 @@ internal class ChannelScheduler(private val store: ScheduleStore,
|
||||
Log.i("OrbitSchedule", "evidence asked=${ids.distinct().size} missing=${missing.size} read=$read failedBatches=$failed")
|
||||
// Emby's collections, for verified franchise links: resumable from where the last read stopped, a bounded number per run.
|
||||
val collections = store.collectionsFile()
|
||||
// Read before collections were paged (every one stopped at 300): set aside once and read again in full.
|
||||
if (collections.exists() && collections.useLines { lines -> lines.firstOrNull()?.contains("\"complete\"") != true })
|
||||
collections.renameTo(java.io.File(collections.path + ".before-paged"))
|
||||
val done = if (collections.exists()) collections.useLines { it.count() } else 0
|
||||
var start = done; var sets = 0; var total = -1
|
||||
try {
|
||||
@@ -360,7 +363,7 @@ internal class ChannelScheduler(private val store: ScheduleStore,
|
||||
(if (number == PREMIERE_NUMBER && premieres.isNotEmpty()) approvedDebutSlots(premieres) else emptyList())
|
||||
val plan = topUpPlan(existing, base, pool?.series?.let { withoutFailed(it + premiereRotation(number, premieres, now).filter { p -> it.none { s -> s.id == p.id } }, store.loadFailed(userId)) }, now, programming.filmRestMs, programming.finishTogether, programming.dayParts,
|
||||
avoid = { show, startMs, endMs -> others(show, startMs, endMs) }, shuffle = programming.shuffleEpisodes, mixed = programming::mixes,
|
||||
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, stories = programming.storyGrouping, shorts = programming.shorts, marathon = programming.marathon,
|
||||
weightOf = programming.tierWeights?.let { programming::weightOf }, adaptiveRest = programming.adaptiveFilmRest, fileRestMs = programming.fileRestMs, stories = programming.storyGrouping, shorts = programming.shorts, marathon = programming.marathon,
|
||||
fixed = fixed, held = seasonalHeld().let { seasonal -> val premiere = premiereHeld(number); { f: PoolSeries, at: Long -> seasonal(f, at) || premiere(f, at) } },
|
||||
seasonEndMs = seasonEndMs(number, now))
|
||||
?: throw EmbyCheckException("${programming.name} has no episodes to schedule.")
|
||||
|
||||
@@ -52,7 +52,24 @@ internal class ChannelMaker(
|
||||
/** For each title, the other channels already showing it (Paul, 7 November 2026: "check conflicts with the rest of the channels"). */
|
||||
val carriedBy: (picks: Map<String, String>, number: Int?) -> Map<String, List<String>> = { _, _ -> emptyMap() },
|
||||
/** How long the picks run all told, in milliseconds, or null when the library can't say. */
|
||||
val runTime: suspend (List<ShapeRow>) -> Long? = { null })
|
||||
val runTime: suspend (List<ShapeRow>) -> Long? = { null },
|
||||
/** Your Emby collections, for Browse › From a collection: each one's name, members and whether all of them were read. */
|
||||
val collections: suspend () -> List<LibraryCollection> = { emptyList() },
|
||||
/** Titles by item from the catalogue alone, quickly, for listing a collection. */
|
||||
val rowsOf: suspend (List<String>) -> List<ShapeRow> = { emptyList() },
|
||||
/** Networks and studios by name, with their title counts. */
|
||||
val studios: suspend (String) -> List<Pair<String, Int>> = { emptyList() })
|
||||
|
||||
/** One of the library's collections as Orbit read it. [complete] is false when it stopped short of [total]. */
|
||||
internal class LibraryCollection(val name: String, val members: List<String>, val total: Int, val complete: Boolean)
|
||||
|
||||
/** The collections the Chair keeps a copy of, read from its file; nothing when it hasn't read them yet. */
|
||||
internal fun readCollections(file: java.io.File): List<LibraryCollection> = if (!file.exists()) emptyList() else
|
||||
file.readLines().mapNotNull { line -> runCatching { org.json.JSONObject(line) }.getOrNull() }.map { o ->
|
||||
val members = o.optJSONArray("members")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
|
||||
// Read before paging, every collection stopped at 300: treat one of exactly that size as possibly cut short.
|
||||
LibraryCollection(o.optString("name"), members, o.optInt("total", members.size), if (o.has("complete")) o.optBoolean("complete") else members.size < 300)
|
||||
}.filter { it.name.isNotBlank() && it.members.isNotEmpty() }.distinctBy { it.name }.sortedBy { it.name.lowercase() }
|
||||
|
||||
internal enum class CreateStep(val title: String) { Start("Create a channel"), Name("Name and number"), Shows("Shows and films"), Style("How it plays"), Hours("When it's on") }
|
||||
|
||||
@@ -157,7 +174,10 @@ internal fun CreateChannelFlow(maker: ChannelMaker, editing: CustomChannel?, onD
|
||||
OrbitButton(if (working) "Making it…" else if (editing != null) "Save changes" else "Create and switch on", Modifier.width(260.dp), enabled = !working) {
|
||||
working = true
|
||||
scope.launch {
|
||||
val channel = CustomChannel(number, name.trim(), style, hours, description.trim(), chosen.map { it.title })
|
||||
// Editing on the TV keeps what the phone set: the season, and a shaped layout while the shows and style stay.
|
||||
val kept = editing?.takeIf { it.style == style && it.shows.toSet() == chosen.map { c -> c.title }.toSet() }
|
||||
val channel = CustomChannel(number, name.trim(), style, hours, description.trim(), chosen.map { it.title },
|
||||
slots = kept?.slots.orEmpty(), core = kept?.core.orEmpty(), season = editing?.season)
|
||||
onDone(runCatching { maker.create(channel, chosen) }.getOrElse { "That channel couldn't be made. Try again." })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,24 +40,30 @@ internal data class CustomChannel(val number: Int, val name: String, val style:
|
||||
/** The shows' titles in the order they were added: Blocks' timetable follows it. The shows themselves are the accepted choices. */
|
||||
val shows: List<String> = emptyList(),
|
||||
/** Shaped only: each title's slot, and the core titles. */
|
||||
val slots: Map<String, Slot> = emptyMap(), val core: Set<String> = emptySet()) {
|
||||
val slots: Map<String, Slot> = emptyMap(), val core: Set<String> = emptySet(),
|
||||
/** On air only through this part of the year, if you chose one (Paul, 7 November 2026: an optional choice, with dates). */
|
||||
val season: SeasonWindow? = null) {
|
||||
init { require(number in YOURS_FIRST..YOURS_LAST && name.isNotBlank() && name.length <= 40) }
|
||||
fun toJson(): JSONObject = JSONObject().put("number", number).put("name", name).put("style", style.name).put("description", description)
|
||||
.put("hours", hours?.let { JSONObject().put("from", it.fromHour).put("to", it.toHour) } ?: JSONObject.NULL).put("shows", JSONArray(shows))
|
||||
.put("slots", JSONObject(slots.mapValues { it.value.name })).put("core", JSONArray(core.toList()))
|
||||
.put("slots", JSONObject(slots.mapValues { it.value.name })).put("core", JSONArray(core.toList())).put("season", season?.toJson() ?: JSONObject.NULL)
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = CustomChannel(o.getInt("number"), o.getString("name").take(40),
|
||||
runCatching { PlayStyle.valueOf(o.optString("style")) }.getOrDefault(PlayStyle.Standard),
|
||||
o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120),
|
||||
o.optJSONArray("shows")?.let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it).take(160) } }.orEmpty(),
|
||||
o.optJSONObject("slots")?.let { m -> m.keys().asSequence().take(200).mapNotNull { k -> runCatching { k.take(160) to Slot.valueOf(m.getString(k)) }.getOrNull() }.toMap() }.orEmpty(),
|
||||
o.optJSONArray("core")?.let { a -> (0 until minOf(a.length(), 20)).map { a.getString(it).take(160) }.toSet() }.orEmpty())
|
||||
o.optJSONArray("core")?.let { a -> (0 until minOf(a.length(), 20)).map { a.getString(it).take(160) }.toSet() }.orEmpty(),
|
||||
SeasonWindow.of(o.optJSONObject("season")))
|
||||
}
|
||||
}
|
||||
|
||||
/** Short episodes on a channel of your own: the same slots as ToonZ's, under a plain name. */
|
||||
internal val YoursShorts = ShortsRule { _, _ -> "Shorts" }
|
||||
|
||||
/** How long a shaped channel's films and episodes rest before airing again. */
|
||||
internal const val SHAPED_FILE_REST_MS = 6 * 24 * 60 * 60_000L
|
||||
|
||||
/** How many episodes a marathon runs before the next show takes over. */
|
||||
internal const val MARATHON_RUN = 4
|
||||
|
||||
@@ -105,6 +111,9 @@ internal fun programmingOf(channel: CustomChannel): ChannelProgramming = Channel
|
||||
storyGrouping = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Shaped,
|
||||
shorts = if (channel.style == PlayStyle.Standard || channel.style == PlayStyle.Shaped) YoursShorts else null,
|
||||
tierWeights = if (channel.style == PlayStyle.Shaped && channel.core.isNotEmpty()) shapedWeights(channel.core) else null,
|
||||
// A shaped channel's films and episodes rest most of a week before airing again, where it has enough to fill it (the
|
||||
// Crunchyroll test: with a day's rest, its films came round every three days).
|
||||
fileRestMs = if (channel.style == PlayStyle.Shaped) SHAPED_FILE_REST_MS else SAME_FILE_MS,
|
||||
marathon = if (channel.style == PlayStyle.Marathon) MARATHON_RUN else 0)
|
||||
|
||||
/**
|
||||
|
||||
@@ -543,12 +543,18 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
|
||||
browse = { genre, studio, ratings, type -> withContext(Dispatchers.IO) { catalogue.browseRows(genre, studio, ratings, type) } },
|
||||
facets = { withContext(Dispatchers.IO) { catalogue.facets() } },
|
||||
shapeOf = { ids -> withContext(Dispatchers.IO) { catalogue.shapeRows(ids) } },
|
||||
runTime = { rows -> withContext(Dispatchers.IO) {
|
||||
val films = rows.filter { it.type == "Movie" }.let { f -> catalogue.runTimes(f.map { it.id }).values.sum() }
|
||||
val series = rows.filter { it.type == "Series" }.take(40).map { active.seriesRunTime(it.id) }
|
||||
if (series.any { it == null }) null else films + series.sumOf { it ?: 0L }
|
||||
// With each title's running time where the library can say (films from the catalogue, up to 60 series from Emby),
|
||||
// so the Director balances the day by hours, not just by counts.
|
||||
shapeOf = { ids -> withContext(Dispatchers.IO) {
|
||||
val rows = catalogue.shapeRows(ids); val films = catalogue.runTimes(rows.filter { it.type == "Movie" }.map { it.id })
|
||||
var asked = 0
|
||||
rows.map { r -> val ms = if (r.type == "Movie") films[r.id] else if (asked++ < 60) active.seriesRunTime(r.id) else null
|
||||
ShapeRow(r.id, r.type, r.title, r.year, r.rating, r.genres, ms) }
|
||||
} },
|
||||
collections = { withContext(Dispatchers.IO) { readCollections(scheduleStore.collectionsFile()) } },
|
||||
rowsOf = { ids -> withContext(Dispatchers.IO) { catalogue.shapeRows(ids) } },
|
||||
studios = { text -> withContext(Dispatchers.IO) { catalogue.studiosMatching(text) } },
|
||||
runTime = { rows -> rows.takeIf { r -> r.isNotEmpty() && r.all { it.runMs != null } }?.sumOf { it.runMs ?: 0L } },
|
||||
carriedBy = { picks, number -> titlesCarriedBy(picks, scheduledChannels.values.filter { it.number != number }) },
|
||||
create = { asked, shows ->
|
||||
// Shaped on the TV, or from the phone without a layout: the Director lays it out from the catalogue.
|
||||
|
||||
@@ -46,6 +46,10 @@ internal object PlaybackUrls {
|
||||
internal const val PLAYBACK_INFO_TIMEOUT_MS = 45_000
|
||||
|
||||
/** A temporary session is owned by the sample screen, never by persistent storage. */
|
||||
/** Collections are read a page at a time, up to this many members each. */
|
||||
internal const val COLLECTION_PAGE = 500
|
||||
internal const val COLLECTION_MAX = 5000
|
||||
|
||||
internal class EmbySampleSession(private val transport: EmbyTransport, private var token: String?,
|
||||
private val userId: String, sample: EmbySample) {
|
||||
var sample: EmbySample = sample
|
||||
@@ -286,10 +290,20 @@ internal class EmbySampleSession(private val transport: EmbyTransport, private v
|
||||
val rows = body.optJSONArray("Items")
|
||||
val sets = (0 until (rows?.length() ?: 0)).mapNotNull { rows!!.optJSONObject(it) }.filter { EmbyClient.validId(it.optString("Id")) }
|
||||
return sets.map { set ->
|
||||
val members = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&ParentId=${set.optString("Id")}&Limit=300&Fields=ProviderIds&EnableImages=false&EnableUserData=false",
|
||||
token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)).let { r -> EmbyClient.expectSuccess(r.status, false); EmbyClient.parse(r.body).optJSONArray("Items") }
|
||||
// Every member, a page at a time (Paul, 7 November 2026: it stopped at 300, so big collections were cut short), up to
|
||||
// COLLECTION_MAX; "complete" says whether all of them were read.
|
||||
val members = ArrayList<String>(); var total = 0
|
||||
while (members.size < COLLECTION_MAX) {
|
||||
val page = transport.execute(EmbyRequest("GET", "/Items?UserId=$userId&ParentId=${set.optString("Id")}&StartIndex=${members.size}&Limit=$COLLECTION_PAGE&SortBy=SortName&SortOrder=Ascending&Fields=ProviderIds&EnableImages=false&EnableUserData=false",
|
||||
token = currentToken(), readTimeoutMs = WHOLE_SERIES_TIMEOUT_MS)).let { r -> EmbyClient.expectSuccess(r.status, false); EmbyClient.parse(r.body) }
|
||||
val items = page.optJSONArray("Items")
|
||||
total = page.optInt("TotalRecordCount", members.size + (items?.length() ?: 0))
|
||||
val ids = (0 until (items?.length() ?: 0)).mapNotNull { items!!.optJSONObject(it)?.optString("Id")?.takeIf(EmbyClient::validId) }
|
||||
members += ids
|
||||
if (ids.size < COLLECTION_PAGE || members.size >= total) break
|
||||
}
|
||||
JSONObject().put("id", set.optString("Id")).put("name", EmbyClient.clean(set.optString("Name"), 120))
|
||||
.put("members", org.json.JSONArray((0 until (members?.length() ?: 0)).mapNotNull { members!!.optJSONObject(it)?.optString("Id")?.takeIf(EmbyClient::validId) }))
|
||||
.put("members", org.json.JSONArray(members.take(COLLECTION_MAX))).put("total", total).put("complete", members.size >= total)
|
||||
} to body.optInt("TotalRecordCount", sets.size)
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,31 @@ internal object PhoneLink {
|
||||
val (genres, studios) = runBlocking { h.maker.facets() }
|
||||
json(out, 200, JSONObject().put("genres", JSONArray(genres)).put("studios", JSONArray(studios)).put("ratings", JSONArray(RatingGroups.keys.toList())))
|
||||
}
|
||||
"GET /api/browse" -> {
|
||||
// Create's one search (Paul, 7 November 2026: "search could include the collections"): titles, collections, networks
|
||||
// and genres together.
|
||||
"GET /api/search" -> {
|
||||
val q = r.query["q"].orEmpty().trim().take(80)
|
||||
if (q.length < 2) return json(out, 200, JSONObject().put("collections", JSONArray()).put("networks", JSONArray()).put("genres", JSONArray()).put("titles", JSONArray()))
|
||||
val cols = runBlocking { h.maker.collections() }.filter { it.name.contains(q, ignoreCase = true) }.sortedBy { !it.name.startsWith(q, ignoreCase = true) }.take(5)
|
||||
val nets = runBlocking { h.maker.studios(q) }
|
||||
val genres = runBlocking { h.maker.facets() }.first.filter { it.contains(q, ignoreCase = true) }.take(4)
|
||||
val titles = runBlocking { h.maker.search(q) }.take(20)
|
||||
json(out, 200, JSONObject()
|
||||
.put("collections", JSONArray(cols.map { c -> JSONObject().put("name", c.name).put("count", c.members.size).put("total", c.total).put("complete", c.complete) }))
|
||||
.put("networks", JSONArray(nets.map { (n, k) -> JSONObject().put("name", n).put("count", k) })).put("genres", JSONArray(genres))
|
||||
.put("titles", JSONArray(titles.map(::suggestionJson))))
|
||||
}
|
||||
"GET /api/collections" -> {
|
||||
val all = runBlocking { h.maker.collections() }
|
||||
json(out, 200, JSONObject().put("collections", JSONArray(all.map { c -> JSONObject().put("name", c.name).put("count", c.members.size).put("total", c.total).put("complete", c.complete) })))
|
||||
}
|
||||
"GET /api/browse" -> if (r.query["collection"] != null) {
|
||||
// From a collection: its actual members, as the library lists them, not a guess from its name.
|
||||
val c = runBlocking { h.maker.collections() }.firstOrNull { it.name == r.query["collection"] } ?: return json(out, 404, JSONObject().put("error", "That collection isn't in the library copy yet."))
|
||||
val rows = runBlocking { h.maker.rowsOf(c.members.take(1000)) }
|
||||
json(out, 200, JSONObject().put("complete", c.complete).put("total", c.total).put("found", rows.size).put("items", JSONArray(rows.map { row -> suggestionJson(Suggestion(row.id, row.title, row.year, row.type, "",
|
||||
listOfNotNull(row.genres.take(2).joinToString(", ").ifEmpty { null }, row.rating.ifEmpty { null }).joinToString(" · ").ifEmpty { "In ${c.name}" })).put("band", ratingBand(row.rating).name) })))
|
||||
} else {
|
||||
val rows = runBlocking { h.maker.browse(r.query["genre"]?.take(60), r.query["studio"]?.take(80), RatingGroups[r.query["rating"]].orEmpty(), r.query["type"]?.takeIf { it == "Movie" || it == "Series" }) }
|
||||
json(out, 200, JSONArray(rows.map { row -> suggestionJson(Suggestion(row.id, row.title, row.year, row.type, "",
|
||||
listOfNotNull(row.genres.take(2).joinToString(", ").ifEmpty { null }, row.rating.ifEmpty { null }).joinToString(" · ").ifEmpty { "In your library" })) }))
|
||||
@@ -326,8 +350,12 @@ internal object PhoneLink {
|
||||
val total = runCatching { runBlocking { h.maker.runTime(rows) } }.getOrNull()
|
||||
json(out, 200, JSONObject().put("slots", JSONObject(shape.slots.mapValues { it.value.name })).put("core", JSONArray(shape.core.toList()))
|
||||
.put("mixed", shape.slots.isEmpty() && rows.size >= 3)
|
||||
// What the Director would suggest but leaves to you: stars on a film channel, and a season.
|
||||
.put("suggestedCore", JSONArray(shape.suggestedCore.toList())).put("notes", JSONArray(shape.notes))
|
||||
.put("suggestedSeason", shape.suggestedSeason?.let { it.toJson().put("label", it.label) } ?: JSONObject.NULL)
|
||||
.put("week", total?.takeIf { it > 0 }?.let { weekLine(it, hoursPerDay) } ?: weekCheck(series, films) ?: JSONObject.NULL)
|
||||
.put("hours", total?.let { it / 3_600_000.0 } ?: JSONObject.NULL))
|
||||
.put("hours", total?.let { it / 3_600_000.0 } ?: JSONObject.NULL)
|
||||
.put("runHours", JSONObject(rows.filter { it.runMs != null }.associate { it.id to it.runMs!! / 3_600_000.0 })))
|
||||
}
|
||||
"POST /api/clashes" -> {
|
||||
val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject())
|
||||
@@ -348,7 +376,8 @@ internal object PhoneLink {
|
||||
if (shows.isEmpty()) return json(out, 400, JSONObject().put("error", "Add at least one show or film."))
|
||||
// Shaped: the phone's layout, by item, kept by title as the planner reads it.
|
||||
val titleOf = shows.associate { it.id to it.title }
|
||||
val laidOut = if (channel.style != PlayStyle.Shaped) channel else channel.copy(
|
||||
val seasoned = channel.copy(season = SeasonWindow.of(o.optJSONObject("season")))
|
||||
val laidOut = if (seasoned.style != PlayStyle.Shaped) seasoned else seasoned.copy(
|
||||
slots = o.optJSONObject("slots")?.let { m -> m.keys().asSequence().mapNotNull { id -> titleOf[id]?.let { t -> runCatching { t to Slot.valueOf(m.getString(id)) }.getOrNull() } }.toMap() }.orEmpty(),
|
||||
core = o.optJSONArray("core")?.let { a -> (0 until minOf(a.length(), 20)).mapNotNull { titleOf[a.getString(it)] }.toSet() }.orEmpty())
|
||||
val said = runBlocking { withContext(mainDispatcher) { h.maker.create(laidOut, shows) } }
|
||||
|
||||
@@ -11,37 +11,157 @@ internal enum class Slot(val label: String, val fromHour: Int, val toHour: Int)
|
||||
Daytime("Daytime", 6, 17), Evening("Evening", 17, 22), Late("Late", 22, 6)
|
||||
}
|
||||
|
||||
/** What the Director reads of a title to place it: its kind, age rating, genres and year. */
|
||||
internal class ShapeRow(val id: String, val type: String, val title: String, val year: Int?, val rating: String, val genres: Set<String>)
|
||||
/** What the Director reads of a title to place it: its kind, age rating, genres and year, and its running time all told when known. */
|
||||
internal class ShapeRow(val id: String, val type: String, val title: String, val year: Int?, val rating: String, val genres: Set<String>, val runMs: Long? = null)
|
||||
|
||||
/** A channel laid out: each title's slot, and the core titles. */
|
||||
internal class Shape(val slots: Map<String, Slot>, val core: Set<String>)
|
||||
/**
|
||||
* A channel laid out: each title's slot (none: it plays all day), the core titles, and what the Director would suggest but
|
||||
* leaves to you ([suggestedCore], [suggestedSeason]), with [notes] saying why it moved anything.
|
||||
*/
|
||||
internal class Shape(val slots: Map<String, Slot>, val core: Set<String>, val suggestedCore: Set<String> = emptySet(),
|
||||
val suggestedSeason: SeasonWindow? = null, val notes: List<String> = emptyList())
|
||||
|
||||
/** Who a title is for, from its age rating in whichever country's system it uses. Unknown is never taken as Kids. */
|
||||
internal enum class RatingBand { Kids, Family, Teen, Adult, Unknown }
|
||||
|
||||
private val BandOf = mapOf(
|
||||
// United States
|
||||
"TV-Y" to RatingBand.Kids, "TV-Y7" to RatingBand.Kids, "TV-Y7-FV" to RatingBand.Kids, "TV-G" to RatingBand.Kids, "G" to RatingBand.Kids,
|
||||
"TV-PG" to RatingBand.Family, "PG" to RatingBand.Family, "PG-13" to RatingBand.Teen, "TV-14" to RatingBand.Teen,
|
||||
"R" to RatingBand.Adult, "TV-MA" to RatingBand.Adult, "NC-17" to RatingBand.Adult, "X" to RatingBand.Adult,
|
||||
// United Kingdom
|
||||
"U" to RatingBand.Kids, "UC" to RatingBand.Kids, "12A" to RatingBand.Teen, "R18" to RatingBand.Adult,
|
||||
// Australia and New Zealand
|
||||
"M" to RatingBand.Teen, "MA15+" to RatingBand.Adult, "R18+" to RatingBand.Adult, "RP13" to RatingBand.Teen, "RP16" to RatingBand.Adult,
|
||||
// Brazil, Netherlands and others without a number
|
||||
"L" to RatingBand.Kids, "AL" to RatingBand.Kids, "ALL" to RatingBand.Kids, "E" to RatingBand.Kids)
|
||||
|
||||
/**
|
||||
* Normalises an age rating from any country: US and UK names, Germany's FSK 0 to 18, Australia's, Brazil's BR-L to BR-18,
|
||||
* and any rating that's an age (12, 16+, DE-16). NR, Not Rated, Approved or nothing at all is Unknown.
|
||||
*/
|
||||
internal fun ratingBand(raw: String): RatingBand {
|
||||
val r = raw.trim().uppercase().removePrefix("FSK").removePrefix("FSK-").trim()
|
||||
BandOf[r]?.let { return it }
|
||||
val bare = r.substringAfter('-', r).trim().removeSuffix("+")
|
||||
BandOf[bare]?.let { if (r.length > bare.length) return it }
|
||||
val age = Regex("(\\d{1,2})").find(r)?.groupValues?.get(1)?.toIntOrNull() ?: return RatingBand.Unknown
|
||||
return when { age <= 6 -> RatingBand.Kids; age <= 10 -> RatingBand.Family; age <= 14 -> RatingBand.Teen; else -> RatingBand.Adult }
|
||||
}
|
||||
|
||||
private val LateRatings = setOf("TV-MA", "R", "NC-17", "X", "18", "R18", "MA15+")
|
||||
private val DayRatings = setOf("TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "G", "U", "PG")
|
||||
private val DayGenres = setOf("Children", "Kids", "Family", "Game Show", "Reality", "Talk", "Home and Garden", "Food", "Travel")
|
||||
|
||||
/** Where the Director would put one title. */
|
||||
internal fun slotFor(row: ShapeRow): Slot = when {
|
||||
row.rating in LateRatings || "Horror" in row.genres -> Slot.Late
|
||||
row.type == "Movie" && row.genres.any { it in setOf("Thriller", "Crime", "War") } -> Slot.Late
|
||||
row.rating in DayRatings || row.genres.any { it in DayGenres } -> Slot.Daytime
|
||||
"Animation" in row.genres && "Anime" !in row.genres -> Slot.Daytime
|
||||
"Comedy" in row.genres && (row.year ?: 3000) < 2000 -> Slot.Daytime
|
||||
else -> Slot.Evening
|
||||
/** Genres nearly every pick shares: the channel's identity, so they say nothing about where one title belongs. */
|
||||
internal fun sharedGenres(rows: List<ShapeRow>): Set<String> =
|
||||
rows.flatMap { it.genres }.groupingBy { it }.eachCount().filter { rows.size >= 3 && it.value >= rows.size * .75 }.keys
|
||||
|
||||
/**
|
||||
* Where the Director would put one title, judged against the channel it's on (Paul, 7 November 2026: on an anime channel,
|
||||
* Animation says nothing; the rating and tone do). The age rating comes first; genres only where they set the title apart.
|
||||
*/
|
||||
internal fun slotFor(row: ShapeRow, shared: Set<String> = emptySet()): Slot {
|
||||
val g = row.genres - shared
|
||||
val band = ratingBand(row.rating)
|
||||
return when {
|
||||
band == RatingBand.Adult || "Horror" in g -> Slot.Late
|
||||
row.type == "Movie" && g.any { it in setOf("Thriller", "Crime", "War") } && band != RatingBand.Kids && band != RatingBand.Family -> Slot.Late
|
||||
band == RatingBand.Teen -> Slot.Evening
|
||||
band == RatingBand.Kids || band == RatingBand.Family || g.any { it in DayGenres } -> Slot.Daytime
|
||||
"Animation" in g && "Anime" !in row.genres -> Slot.Daytime
|
||||
"Comedy" in g && (row.year ?: 3000) < 2000 -> Slot.Daytime
|
||||
else -> Slot.Evening
|
||||
}
|
||||
}
|
||||
|
||||
/** Hours a week each slot fills, all day every day. */
|
||||
private val WeekHours = mapOf(Slot.Daytime to 11 * 7.0, Slot.Evening to 5 * 7.0, Slot.Late to 8 * 7.0)
|
||||
|
||||
/** Which titles may move into [to] to fill it: adult titles never leave Late, and only Kids and Family ones join the daytime. */
|
||||
private fun mayMove(row: ShapeRow, from: Slot, to: Slot): Boolean {
|
||||
val band = ratingBand(row.rating)
|
||||
return when (to) {
|
||||
Slot.Evening -> from == Slot.Daytime
|
||||
Slot.Late -> from == Slot.Evening || (from == Slot.Daytime && band != RatingBand.Kids)
|
||||
Slot.Daytime -> from == Slot.Evening && (band == RatingBand.Kids || band == RatingBand.Family)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole layout: every title's slot, and up to three core titles, the newest of the evening's (or of all, with none there).
|
||||
* The slots are a starting suggestion, not a rule (Paul, 7 November 2026): a channel that's nearly all one kind, an
|
||||
* all-horror channel or a kids' one, isn't split up, and everything plays all day.
|
||||
* The whole layout (Paul, 7 November 2026: a starting suggestion, balanced from the channel's own content):
|
||||
* - A channel nearly all one kind, an all-horror channel or a kids' one, plays all day instead of being split.
|
||||
* - Otherwise each title gets its slot ([slotFor]), then any slot that would run out within a week is topped up from
|
||||
* one with plenty to spare, by running time when it's known, so a small evening isn't repeated night after night.
|
||||
* - Core: up to three, the newest evening shows. On a film channel nothing is starred for you: the Director's choice is
|
||||
* offered as [Shape.suggestedCore] to accept or not.
|
||||
*/
|
||||
internal fun shapeChannel(rows: List<ShapeRow>): Shape {
|
||||
val suggested = rows.associate { it.id to slotFor(it) }
|
||||
val shared = sharedGenres(rows)
|
||||
val suggested = rows.associate { it.id to slotFor(it, shared) }
|
||||
val largest = suggested.values.groupingBy { it }.eachCount().maxOfOrNull { it.value } ?: 0
|
||||
val slots = if (rows.size >= 3 && largest >= rows.size * .75) emptyMap() else suggested
|
||||
val evening = rows.filter { slots[it.id] == Slot.Evening }.ifEmpty { rows }
|
||||
return Shape(slots, evening.sortedByDescending { it.year ?: 0 }.take(3).map { it.id }.toSet())
|
||||
val films = rows.count { it.type == "Movie" }
|
||||
val filmChannel = rows.isNotEmpty() && films >= rows.size * .75
|
||||
val season = suggestSeason(rows)
|
||||
if (rows.size >= 3 && largest >= rows.size * .75) {
|
||||
val pick = rows.sortedByDescending { it.year ?: 0 }.take(3).map { it.id }.toSet()
|
||||
return Shape(emptyMap(), if (filmChannel) emptySet() else pick, if (filmChannel) pick else emptySet(), season,
|
||||
listOf("Nearly all of these belong in one part of the day, so they play all day rather than being split."))
|
||||
}
|
||||
val slots = suggested.toMutableMap()
|
||||
val notes = mutableListOf<String>()
|
||||
val byId = rows.associateBy { it.id }
|
||||
// Running time per title: what's known, else a typical length for its kind.
|
||||
val known = rows.mapNotNull { it.runMs }
|
||||
fun hours(r: ShapeRow) = (r.runMs ?: if (r.type == "Movie") 6_300_000L else known.takeIf { it.isNotEmpty() }?.average()?.toLong() ?: 36_000_000L) / 3_600_000.0
|
||||
val total = rows.sumOf(::hours)
|
||||
val scale = minOf(1.0, total / 168.0)
|
||||
fun content(s: Slot) = slots.filterValues { it == s }.keys.sumOf { hours(byId.getValue(it)) }
|
||||
fun target(s: Slot) = WeekHours.getValue(s) * scale
|
||||
for (to in listOf(Slot.Evening, Slot.Late, Slot.Daytime)) {
|
||||
if (slots.values.none { it == to } && to != Slot.Evening) continue
|
||||
val preferred = listOf(RatingBand.Teen, RatingBand.Unknown, RatingBand.Family, RatingBand.Kids)
|
||||
for (from in Slot.entries.filter { it != to }) {
|
||||
val candidates = rows.filter { slots[it.id] == from && mayMove(it, from, to) }
|
||||
.sortedWith(compareBy<ShapeRow> { preferred.indexOf(ratingBand(it.rating)) }.thenByDescending { it.year ?: 0 })
|
||||
for (c in candidates) {
|
||||
if (content(to) >= target(to)) break
|
||||
if (content(from) - hours(c) < target(from) || slots.values.count { it == from } <= 1) continue
|
||||
slots[c.id] = to
|
||||
notes += "${c.title} moves to ${to.label} to fill it."
|
||||
}
|
||||
}
|
||||
}
|
||||
// Core shows are series where there are any: a film plays once a week whatever its star.
|
||||
val evening = rows.filter { slots[it.id] == Slot.Evening }.ifEmpty { rows }.let { e -> e.filter { it.type == "Series" }.ifEmpty { e } }
|
||||
val pick = evening.sortedByDescending { it.year ?: 0 }.take(3).map { it.id }.toSet()
|
||||
return Shape(slots, if (filmChannel) emptySet() else pick, if (filmChannel) pick else emptySet(), season, notes.take(12))
|
||||
}
|
||||
|
||||
/** A Christmas channel's season, offered by the Director when most of the picks are Christmas titles; the choice is yours. */
|
||||
internal fun suggestSeason(rows: List<ShapeRow>): SeasonWindow? {
|
||||
val festive = rows.count { r -> Regex("(?i)christmas|xmas|holiday|noel|santa|mistletoe|snow").containsMatchIn(r.title) || "Christmas" in r.genres }
|
||||
return if (rows.size >= 3 && festive >= rows.size * .5) SeasonWindow(11, 1, 12, 31) else null
|
||||
}
|
||||
|
||||
/** When a channel of your own is on air through the year: from one day to another, wrapping past New Year. */
|
||||
internal data class SeasonWindow(val fromMonth: Int, val fromDay: Int, val toMonth: Int, val toDay: Int) {
|
||||
init { require(fromMonth in 1..12 && toMonth in 1..12 && fromDay in 1..31 && toDay in 1..31) }
|
||||
private fun key(m: Int, d: Int) = m * 100 + d
|
||||
fun covers(date: java.time.LocalDate): Boolean {
|
||||
val k = key(date.monthValue, date.dayOfMonth); val from = key(fromMonth, fromDay); val to = key(toMonth, toDay)
|
||||
return if (from <= to) k in from..to else k >= from || k <= to
|
||||
}
|
||||
/** The first moment after the window ends, from [date] inside it. */
|
||||
fun endAfter(date: java.time.LocalDate, zone: java.time.ZoneId): Long {
|
||||
var end = java.time.LocalDate.of(date.year, toMonth, 1).let { it.withDayOfMonth(minOf(toDay, it.lengthOfMonth())) }
|
||||
if (end.isBefore(date)) end = end.plusYears(1)
|
||||
return end.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
}
|
||||
val label get() = "%d %s to %d %s".format(fromDay, month(fromMonth), toDay, month(toMonth))
|
||||
private fun month(m: Int) = java.time.Month.of(m).getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.UK)
|
||||
fun toJson(): org.json.JSONObject = org.json.JSONObject().put("fromMonth", fromMonth).put("fromDay", fromDay).put("toMonth", toMonth).put("toDay", toDay)
|
||||
companion object {
|
||||
fun of(o: org.json.JSONObject?): SeasonWindow? = o?.let { runCatching { SeasonWindow(it.getInt("fromMonth"), it.getInt("fromDay"), it.getInt("toMonth"), it.getInt("toDay")) }.getOrNull() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,5 +241,7 @@ internal fun yourBrief(number: Int, name: String, rows: List<ShapeRow>): Brief?
|
||||
val years = rows.mapNotNull { it.year }
|
||||
val never = Regex("(?!)")
|
||||
return Brief(number, name, rows.map { it.type }.toSet(), common.ifEmpty { counts.keys.take(4).toSet() }, must, never, never,
|
||||
(years.minOrNull() ?: 1900) - 5 to (years.maxOrNull() ?: 2030) + 5, kids = rows.all { it.rating in KidsOk })
|
||||
(years.minOrNull() ?: 1900) - 5 to (years.maxOrNull() ?: 2030) + 5,
|
||||
// Unknown ratings never make a channel count as children's (Paul, 7 November 2026).
|
||||
kids = rows.all { ratingBand(it.rating) == RatingBand.Kids || ratingBand(it.rating) == RatingBand.Family })
|
||||
}
|
||||
|
||||
@@ -258,10 +258,19 @@ class PhoneLinkTest {
|
||||
val rows = listOf(ShapeRow("a1", "Series", "Bluey", 2018, "TV-Y", setOf("Animation")), ShapeRow("a2", "Series", "Line of Duty", 2012, "TV-MA", setOf("Drama")))
|
||||
PhoneLink.handler = PhoneHandler(ChannelMaker({ _, _, _ -> emptyList() }, { emptyList() }, { c, s -> made = c to s; "${c.name} is ready" }, { emptyList() },
|
||||
browse = { genre, _, ratings, _ -> rows.filter { genre == null || genre in it.genres }.filter { ratings.isEmpty() || it.rating in ratings } },
|
||||
facets = { listOf("Drama", "Animation") to listOf("BBC One") }, shapeOf = { ids -> rows.filter { it.id in ids } }), { false }, { })
|
||||
facets = { listOf("Drama", "Animation") to listOf("BBC One") }, shapeOf = { ids -> rows.filter { it.id in ids } },
|
||||
collections = { listOf(LibraryCollection("Crunchyroll", listOf("a1"), 1567, false)) }, rowsOf = { ids -> rows.filter { it.id in ids } },
|
||||
studios = { q -> listOf("Crunchyroll" to 15).filter { it.first.contains(q, ignoreCase = true) } }), { false }, { })
|
||||
val cookie = joinedCookie()
|
||||
assertEquals("Drama", JSONObject(call("/api/facets", cookie = cookie).body).getJSONArray("genres").getString(0))
|
||||
assertEquals(1, JSONArray(call("/api/browse?rating=Kids", cookie = cookie).body).length())
|
||||
// One search finds the collection and the network by name (Paul: "it didn't find crunchyroll").
|
||||
val found = JSONObject(call("/api/search?q=crunch", cookie = cookie).body)
|
||||
assertEquals("Crunchyroll", found.getJSONArray("collections").getJSONObject(0).getString("name"))
|
||||
assertFalse(found.getJSONArray("collections").getJSONObject(0).getBoolean("complete"))
|
||||
assertEquals(15, found.getJSONArray("networks").getJSONObject(0).getInt("count"))
|
||||
val members = JSONObject(call("/api/browse?collection=Crunchyroll", cookie = cookie).body)
|
||||
assertEquals("Kids", members.getJSONArray("items").getJSONObject(0).getString("band")); assertFalse(members.getBoolean("complete"))
|
||||
val shape = JSONObject(call("/api/shape", "POST", """{"ids":["a1","a2"]}""", cookie = cookie).body)
|
||||
assertEquals("Daytime", shape.getJSONObject("slots").getString("a1")); assertEquals("Late", shape.getJSONObject("slots").getString("a2"))
|
||||
assertFalse(shape.isNull("week")) // two shows: it says so
|
||||
|
||||
@@ -77,7 +77,8 @@ class RoomsTest {
|
||||
assertEquals(1, join.collect().getOrThrow()!!.getInt("version"))
|
||||
PhoneLink.offerRoom(null)
|
||||
// With no offer and no phones, the server stops altogether.
|
||||
assertTrue(runCatching { call("POST", "/room/hello", "{}") }.isFailure)
|
||||
// The close can take a moment to land when the full suite runs (it failed now and then): give it up to a second.
|
||||
assertTrue((1..20).any { runCatching { call("POST", "/room/hello", "{}") }.isFailure.also { stopped -> if (!stopped) Thread.sleep(50) } })
|
||||
} finally { PhoneLink.stop(); PhoneLink.allowed = EmbyServer::isHomeAddress; PhoneLink.bindTo = null; PhoneLink.port = PhoneLink.PORT }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Smart setup v2 against Paul's two real collections (7 November 2026), kept as fixtures: Hallmark Christmas as Orbit
|
||||
* Mistletoe and Crunchyroll as Orbit Sakura, planned for a week by the real planner. Titles, ratings, genres and running
|
||||
* times only.
|
||||
*/
|
||||
class SmartSetupRegressionTest {
|
||||
private val zone = ZoneId.of("Europe/London")
|
||||
private val now = ZonedDateTime.of(2026, 11, 7, 9, 0, 0, 0, zone).toInstant().toEpochMilli()
|
||||
private val week = 7 * 24 * 3_600_000L
|
||||
private val fixture = JSONObject(javaClass.classLoader!!.getResource("fixtures/collections.json")!!.readText())
|
||||
|
||||
private class Run(val rows: List<ShapeRow>, val shape: Shape, val channel: CustomChannel, val plan: ChannelPlan)
|
||||
private fun run(collection: String, number: Int, name: String): Run {
|
||||
val a = fixture.getJSONArray(collection)
|
||||
val items = (0 until a.length()).map { a.getJSONObject(it) }
|
||||
val pool = items.map { o -> val e = o.getJSONArray("episodes")
|
||||
PoolSeries(o.getString("id"), o.getString("title"), (0 until e.length()).map { k -> val x = e.getJSONArray(k); PoolEpisode(x.getString(0), x.getString(1), EpisodeMark(x.getInt(2), x.getInt(3)), x.getString(4), x.getLong(5)) }) }
|
||||
val runOf = pool.associate { it.id to it.episodes.sumOf { e -> e.durationMs } }
|
||||
val rows = items.map { o -> ShapeRow(o.getString("id"), o.getString("type"), o.getString("title"), if (o.isNull("year")) null else o.getInt("year"), o.getString("rating"),
|
||||
o.getJSONArray("genres").let { g -> (0 until g.length()).map { g.getString(it) }.toSet() }, runOf[o.getString("id")]) }
|
||||
val shape = shapeChannel(rows); val titleOf = rows.associate { it.id to it.title }
|
||||
val channel = CustomChannel(number, name, PlayStyle.Shaped, shows = rows.map { it.title },
|
||||
slots = shape.slots.mapNotNull { (id, s) -> titleOf[id]?.let { it to s } }.toMap(), core = shape.core.mapNotNull { titleOf[it] }.toSet())
|
||||
val p = programmingOf(channel)
|
||||
val plan = topUpPlan(null, ScheduleCursor(), pool, now, p.filmRestMs, p.finishTogether, p.dayParts, zone, shuffle = p.shuffleEpisodes, mixed = p::mixes,
|
||||
weightOf = p.tierWeights?.let { p::weightOf }, adaptiveRest = p.adaptiveFilmRest, fileRestMs = p.fileRestMs, stories = p.storyGrouping, shorts = p.shorts, marathon = p.marathon)!!
|
||||
return Run(rows, shape, channel, plan)
|
||||
}
|
||||
private fun repeats(plan: ChannelPlan) = plan.entries.filter { it.startMs < now + week }.groupBy { it.media.item.id }.values.sumOf { it.size - 1 }
|
||||
|
||||
@Test fun mistletoeIsOneKindSoItPlaysAllDayWithNothingDecidedForYou() {
|
||||
val r = run("Hallmark Christmas", 701, "Orbit Mistletoe")
|
||||
assertTrue(r.shape.slots.isEmpty()); assertTrue(r.channel.dayParts().isEmpty())
|
||||
assertTrue(r.shape.core.isEmpty()); assertEquals(3, r.shape.suggestedCore.size) // film channel: stars only suggested
|
||||
assertEquals(SeasonWindow(11, 1, 12, 31), r.shape.suggestedSeason) // offered, not applied
|
||||
assertNull(r.channel.season)
|
||||
assertEquals(0, repeats(r.plan))
|
||||
}
|
||||
|
||||
@Test fun sakuraIsBalancedFromItsOwnContentAndRepeatsFarLess() {
|
||||
val r = run("Crunchyroll", 702, "Orbit Sakura")
|
||||
assertTrue("Animation" in sharedGenres(r.rows))
|
||||
val counts = r.shape.slots.values.groupingBy { it }.eachCount()
|
||||
// The original run starved the evening (10 of 81): it now carries a fair share.
|
||||
assertTrue(counts.getValue(Slot.Evening) >= r.rows.size / 4)
|
||||
// Adult-rated titles stay late; FSK 16 is understood.
|
||||
r.rows.filter { ratingBand(it.rating) == RatingBand.Adult }.forEach { assertEquals(it.title, Slot.Late, r.shape.slots[it.id]) }
|
||||
assertEquals(Slot.Late, r.shape.slots[r.rows.first { it.title == "Murder Princess" }.id])
|
||||
// Core is series, not films.
|
||||
assertTrue(r.shape.core.all { id -> r.rows.first { it.id == id }.type == "Series" })
|
||||
// Original: 34 repeats in the week, the first on Tuesday. Now far fewer, none before Friday.
|
||||
assertTrue(repeats(r.plan) <= 15)
|
||||
val firstRepeat = r.plan.entries.filter { it.startMs < now + week }.groupBy { it.media.item.id }.values.filter { it.size > 1 }.minOfOrNull { it[1].startMs }
|
||||
assertTrue(firstRepeat == null || firstRepeat - now >= 5 * 24 * 3_600_000L)
|
||||
// Every title gets airtime in the week.
|
||||
assertEquals(r.rows.size, r.plan.entries.filter { it.startMs < now + week }.map { it.seriesId }.toSet().size)
|
||||
}
|
||||
|
||||
@Test fun ratingsFromEveryCountryAndUnknownIsNeverKids() {
|
||||
assertEquals(RatingBand.Adult, ratingBand("FSK 16")); assertEquals(RatingBand.Adult, ratingBand("FSK 18")); assertEquals(RatingBand.Teen, ratingBand("FSK 12"))
|
||||
assertEquals(RatingBand.Kids, ratingBand("FSK 0")); assertEquals(RatingBand.Family, ratingBand("FSK 6").let { if (it == RatingBand.Kids) RatingBand.Family else it })
|
||||
assertEquals(RatingBand.Adult, ratingBand("DE-16")); assertEquals(RatingBand.Teen, ratingBand("BR-14")); assertEquals(RatingBand.Kids, ratingBand("BR-L"))
|
||||
assertEquals(RatingBand.Family, ratingBand("AU-PG")); assertEquals(RatingBand.Adult, ratingBand("MA15+")); assertEquals(RatingBand.Teen, ratingBand("M"))
|
||||
assertEquals(RatingBand.Teen, ratingBand("12A")); assertEquals(RatingBand.Adult, ratingBand("15")); assertEquals(RatingBand.Adult, ratingBand("18"))
|
||||
assertEquals(RatingBand.Teen, ratingBand("PG-13")); assertEquals(RatingBand.Adult, ratingBand("TV-MA")); assertEquals(RatingBand.Kids, ratingBand("TV-Y7"))
|
||||
listOf("", "NR", "Not Rated", "Unrated", "Approved").forEach { assertEquals(it, RatingBand.Unknown, ratingBand(it)) }
|
||||
// A channel with an unrated title isn't a children's channel.
|
||||
assertFalse(yourBrief(701, "X", listOf(ShapeRow("a", "Series", "A", 2000, "TV-Y", setOf("Animation")), ShapeRow("b", "Series", "B", 2000, "", setOf("Animation"))))!!.kids)
|
||||
}
|
||||
|
||||
@Test fun aSeasonIsYoursToSetAndTheChannelKeepsToIt() {
|
||||
val christmas = SeasonWindow(11, 1, 12, 31)
|
||||
assertTrue(christmas.covers(java.time.LocalDate.of(2026, 12, 25))); assertFalse(christmas.covers(java.time.LocalDate.of(2026, 7, 1)))
|
||||
val turn = SeasonWindow(12, 1, 1, 6) // across New Year
|
||||
assertTrue(turn.covers(java.time.LocalDate.of(2027, 1, 3))); assertFalse(turn.covers(java.time.LocalDate.of(2027, 1, 7)))
|
||||
assertEquals(java.time.LocalDate.of(2027, 1, 7).atStartOfDay(zone).toInstant().toEpochMilli(), turn.endAfter(java.time.LocalDate.of(2026, 12, 20), zone))
|
||||
assertEquals(turn, CustomChannel.fromJson(JSONObject(CustomChannel(701, "Mistletoe", season = turn).toJson().toString())).season)
|
||||
assertNull(CustomChannel.fromJson(JSONObject(CustomChannel(701, "Mistletoe").toJson().toString())).season)
|
||||
}
|
||||
|
||||
@Test fun collectionsSayWhenTheyAreIncomplete() {
|
||||
val file = java.io.File.createTempFile("collections", ".jsonl")
|
||||
file.writeText(JSONObject().put("name", "Big").put("members", org.json.JSONArray((1..300).map { "$it" })).toString() + "\n" +
|
||||
JSONObject().put("name", "Whole").put("members", org.json.JSONArray(listOf("1", "2"))).put("total", 2).put("complete", true).toString() + "\n" +
|
||||
JSONObject().put("name", "Paged").put("members", org.json.JSONArray((1..600).map { "$it" })).put("total", 900).put("complete", false).toString() + "\n")
|
||||
val read = readCollections(file).associateBy { it.name }
|
||||
assertFalse(read.getValue("Big").complete) // read before paging: may be cut short
|
||||
assertTrue(read.getValue("Whole").complete); assertFalse(read.getValue("Paged").complete); assertEquals(900, read.getValue("Paged").total)
|
||||
file.delete()
|
||||
}
|
||||
|
||||
private fun CustomChannel.dayParts() = programmingOf(this).dayParts
|
||||
}
|
||||
@@ -11,7 +11,9 @@ class SmartSetupTest {
|
||||
@Test fun lighterShowsInTheDaytimeTheMainOnesInTheEveningDarkerOnesLate() {
|
||||
assertEquals(Slot.Daytime, slotFor(row("Bluey", "Series", "TV-Y", "Animation")))
|
||||
assertEquals(Slot.Daytime, slotFor(row("Bake Off", "Series", "", "Reality")))
|
||||
assertEquals(Slot.Daytime, slotFor(row("Frasier", "Series", "TV-14", "Comedy", year = 1993)))
|
||||
// The rating comes first (v2): a TV-14 sitcom is evening viewing; an unrated older one is daytime.
|
||||
assertEquals(Slot.Evening, slotFor(row("Frasier", "Series", "TV-14", "Comedy", year = 1993)))
|
||||
assertEquals(Slot.Daytime, slotFor(row("Frasier", "Series", "", "Comedy", year = 1993)))
|
||||
assertEquals(Slot.Evening, slotFor(row("Doctor Who", "Series", "TV-14", "Drama", "Science Fiction")))
|
||||
assertEquals(Slot.Late, slotFor(row("Line of Duty", "Series", "TV-MA", "Drama", "Crime")))
|
||||
assertEquals(Slot.Late, slotFor(row("Alien", "Movie", "", "Horror")))
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user