Smart setup for Create a channel (from release 0.108.82)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -250,10 +250,11 @@ function more() {
|
||||
}
|
||||
// Create a channel (phase 3, from design/companion v2): five steps, Name, Shows, Style, Hours and Review, in the TV's look.
|
||||
// It saves through the same route as before, so the TV makes it exactly as Settings › Create your own does.
|
||||
const STYLES = [['Standard','Standard TV',"As Orbit's own channels: shows take turns, episodes in order"],['Shuffled','Shuffled','Episodes in a mixed order, every one before any repeats'],
|
||||
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'],
|
||||
['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 SLOTS = [['Daytime','06:00 to 17:00'],['Evening','17:00 to 22:00'],['Late','22:00 to 06:00']];
|
||||
const sameHours = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
function create() {
|
||||
const c = state.create, d = c.draft;
|
||||
@@ -271,15 +272,35 @@ function create() {
|
||||
const row = (s, on) => `<button class="ch" data-ctoggle="${esc(s.id)}"><div class="grow"><div class="what">${esc(s.title)}</div>
|
||||
<div class="then">${s.type === 'Movie' ? 'Film' : 'Series'}${s.year ? ' · ' + s.year : ''}${s.reason && !on ? ' · ' + esc(s.reason) : ''}</div></div>
|
||||
<span style="width:26px;height:26px;border-radius:7px;flex:none;display:flex;align-items:center;justify-content:center;${on ? 'background:var(--accent);color:var(--on-accent)' : 'border:2px solid var(--accent)'}">${on ? '✓' : ''}</span></button>`;
|
||||
body = `<div class="title" style="margin-top:14px">Pick the shows</div><div class="small">${esc(d.description || 'Describe it, or search for a title')} · ${d.shows.length} chosen</div>
|
||||
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>` + note +
|
||||
<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">The Director suggests these from what you described and the shows you chose. Tap to add.</div>' : '');
|
||||
(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;
|
||||
body += !sh ? '<div class="small" style="margin:12px 0">The Director is laying it out…</div>' : `<div class="label">The Director's layout</div>` + (sh.week ? `<div class="card small" style="margin-bottom:10px;color:var(--accent)">${esc(sh.week)}</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">` +
|
||||
@@ -288,7 +309,7 @@ function create() {
|
||||
} else {
|
||||
body = `<div class="title" style="margin-top:14px">Review</div><div class="card" style="margin-top:12px"><div class="title">${esc(d.name)}</div>
|
||||
<div class="small" style="margin:4px 0 10px">Yours${d.number ? ' · ' + d.number : ''} · ${STYLES.find(s => s[0] === d.style)[1]} · ${HOURS.find(([h]) => sameHours(h, d.hours))?.[1] || 'Set hours'}</div>
|
||||
${d.shows.slice(0, 8).map(s => `<div style="font-weight:600">${esc(s.title)}</div>`).join('')}${d.shows.length > 8 ? `<div class="small">and ${d.shows.length - 8} more</div>` : ''}</div>` + note;
|
||||
${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>` + note;
|
||||
next = `<button class="btn gold" data-cmake="1">${c.busy ? 'Saving…' : d.number ? 'Save changes' : 'Create and switch on'}</button>`;
|
||||
}
|
||||
return top + body + `<div style="margin-top:16px">${next}</div>`;
|
||||
@@ -304,7 +325,24 @@ async function switchTo(id, pin) {
|
||||
else { toast(e.message); }
|
||||
}
|
||||
}
|
||||
function startCreate(draft) { state.create = {step: 0, draft, offered: [], note: '', busy: false}; 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(); }
|
||||
// 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 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('&');
|
||||
try { c.offered = await api('/api/browse?' + q); c.note = c.offered.length ? '' : 'Nothing in your library matches all of those. Try fewer.'; } catch (e) { c.note = e.message; }
|
||||
c.busy = false; render();
|
||||
}
|
||||
async function shapeShows() {
|
||||
const c = state.create, d = c.draft;
|
||||
try { const s = await api('/api/shape', {ids: d.shows.map(x => x.id)}); c.shape = s;
|
||||
// The Director's layout fills in what you haven't set yourself.
|
||||
d.shows.forEach(x => { if (!c.moved?.[x.id]) d.slots[x.id] = s.slots[x.id] || 'Evening'; });
|
||||
if (!c.coreSet) d.core = s.core.slice(); }
|
||||
catch (e) { c.note = e.message; }
|
||||
render();
|
||||
}
|
||||
async function suggestShows() {
|
||||
const c = state.create, d = c.draft; d.description = ($('#cdesc')?.value || '').trim(); if (!d.description || c.busy) return;
|
||||
c.busy = true; render();
|
||||
@@ -315,7 +353,8 @@ async function suggestShows() {
|
||||
}
|
||||
async function makeChannel() {
|
||||
const c = state.create, d = c.draft; if (c.busy) return; c.busy = true; render();
|
||||
try { const r = await api('/api/channels', {number: d.number, name: d.name, description: d.description, style: d.style, hours: d.hours, shows: d.shows});
|
||||
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});
|
||||
state.create = null; toast(r.message || 'Saved'); load('more'); }
|
||||
catch (e) { c.note = e.message; c.busy = false; render(); }
|
||||
}
|
||||
@@ -401,21 +440,35 @@ 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.create) { startCreate({name: '', description: '', style: 'Standard', hours: null, shows: [], skipped: []}); 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 => startCreate({number: ch.number, name: ch.name, description: ch.description || '', style: ch.style, hours: ch.hours || null, shows, skipped: []}))
|
||||
api('/api/shows?number=' + ch.number).then(shows => {
|
||||
// A shaped channel keeps its layout by title; the phone works by item.
|
||||
const byTitle = Object.fromEntries(shows.map(x => [x.title, x.id])), slots = {}, core = [];
|
||||
Object.entries(ch.slots || {}).forEach(([t, slot]) => { if (byTitle[t]) slots[byTitle[t]] = slot; });
|
||||
(ch.core || []).forEach(t => { if (byTitle[t]) core.push(byTitle[t]); });
|
||||
startCreate({number: ch.number, name: ch.name, description: ch.description || '', style: ch.style, hours: ch.hours || null, shows, skipped: [], slots, core});
|
||||
if (Object.keys(slots).length) { state.create.coreSet = true; state.create.moved = Object.fromEntries(Object.keys(slots).map(k => [k, true])); }
|
||||
})
|
||||
.catch(e => toast(e.message)); return; }
|
||||
if (d.cback) { const c = state.create; if (c.step === 0) state.create = null; else { if ($('#cdesc')) c.draft.description = $('#cdesc').value.trim(); c.step--; c.note = ''; } render(); return; }
|
||||
if (d.cnext) { const c = state.create;
|
||||
if (c.step === 0) { c.draft.name = ($('#cname')?.value || '').trim(); if (!c.draft.name) { toast('Give it a name first'); return; } }
|
||||
if (c.step === 1 && !c.draft.shows.length) return;
|
||||
c.step++; c.note = ''; render(); scrollTo(0, 0); return; }
|
||||
c.step++; c.note = ''; render(); scrollTo(0, 0); if (c.step === 2 && c.draft.style === 'Shaped') 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(); return; }
|
||||
if (d.cstyle) { state.create.draft.style = d.cstyle; render(); if (d.cstyle === 'Shaped') shapeShows(); 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.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; }
|
||||
if (d.ccore) { const c = state.create, id = d.ccore; c.coreSet = true;
|
||||
c.draft.core = c.draft.core.includes(id) ? c.draft.core.filter(x => x !== id) : [...c.draft.core, id].slice(-5); render(); return; }
|
||||
if (d.chours !== undefined) { state.create.draft.hours = HOURS[+d.chours][0]; render(); return; }
|
||||
if (d.cmake) { makeChannel(); return; }
|
||||
if (d.homedone) { try { localStorage.setItem('orbit.home', '1'); } catch {} closeSheet(); return; }
|
||||
|
||||
@@ -173,6 +173,38 @@ internal class CatalogueStore(context: Context, private val file: File) : Catalo
|
||||
buildList { while (c.moveToNext()) add(ChairRow(c.getString(0), c.getString(1), c.getString(2), if (c.isNull(3)) null else c.getInt(3), c.getString(4).split('|').filter { it.isNotBlank() }.toSet())) }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Browse for Create a channel (Paul, 7 November 2026, after other apps' channel matching): titles by genre, network or
|
||||
* studio, and age rating, newest first. Read only.
|
||||
*/
|
||||
@Synchronized fun browseRows(genre: String?, studio: String?, ratings: Set<String>, type: String?, limit: Int = 60): List<ShapeRow> {
|
||||
val where = mutableListOf("type IN ('Movie','Series')"); val args = mutableListOf<String>()
|
||||
if (type != null) { where += "type = ?"; args += type }
|
||||
if (!genre.isNullOrBlank()) { where += "('|' || genres || '|') LIKE ?"; args += "%|${genre.take(60)}|%" }
|
||||
if (!studio.isNullOrBlank()) { where += "('|' || studios || '|') LIKE ?"; args += "%|${studio.take(80)}|%" }
|
||||
if (ratings.isNotEmpty()) { where += "rating IN (${ratings.joinToString(",") { "?" }})"; args += ratings }
|
||||
return readableDatabase.rawQuery("SELECT id, type, title, year, rating, genres FROM items WHERE ${where.joinToString(" AND ")} ORDER BY year DESC, title LIMIT $limit", args.toTypedArray()).use { c ->
|
||||
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())) }
|
||||
}
|
||||
}
|
||||
/** What Smart setup reads of chosen titles: kind, rating, genres and year. Read only. */
|
||||
@Synchronized fun shapeRows(ids: Collection<String>): List<ShapeRow> = ids.distinct().chunked(400).flatMap { chunk ->
|
||||
readableDatabase.rawQuery("SELECT id, type, title, year, rating, genres FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
|
||||
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())) }
|
||||
}
|
||||
}
|
||||
@Volatile private var facetCache: Pair<List<String>, List<String>>? = null
|
||||
/** The library's most common genres and networks or studios, for browsing; worked out once. Read only. */
|
||||
@Synchronized fun facets(): Pair<List<String>, List<String>> = facetCache ?: run {
|
||||
val genres = HashMap<String, Int>(); val studios = HashMap<String, Int>()
|
||||
readableDatabase.rawQuery("SELECT genres, studios FROM items WHERE type IN ('Movie','Series')", null).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
c.getString(0).split('|').filter { it.isNotBlank() }.forEach { genres.merge(it, 1, Int::plus) }
|
||||
c.getString(1).split('|').filter { it.isNotBlank() }.forEach { studios.merge(it, 1, Int::plus) }
|
||||
}
|
||||
}
|
||||
(genres.entries.sortedByDescending { it.value }.take(24).map { it.key } to studios.entries.sortedByDescending { it.value }.take(40).map { it.key }).also { facetCache = it }
|
||||
}
|
||||
/** The same rows for chosen titles, by item. Read only. */
|
||||
@Synchronized fun chairRowsById(ids: Collection<String>): List<ChairRow> = ids.distinct().chunked(400).flatMap { chunk ->
|
||||
readableDatabase.rawQuery("SELECT id, type, title, year, genres FROM items WHERE id IN (${chunk.joinToString(",") { "?" }})", chunk.toTypedArray()).use { c ->
|
||||
|
||||
@@ -44,7 +44,11 @@ internal class ChannelMaker(
|
||||
/** Saves the channel and its shows and switches it on; returns what to tell you. */
|
||||
val create: suspend (CustomChannel, List<Suggestion>) -> String,
|
||||
/** A channel's shows as they stand, for editing. */
|
||||
val showsOf: suspend (Int) -> List<Suggestion> = { emptyList() })
|
||||
val showsOf: suspend (Int) -> List<Suggestion> = { emptyList() },
|
||||
/** Smart setup: browse the library, its genres and networks, and how the Director would lay chosen titles out. */
|
||||
val browse: suspend (genre: String?, studio: String?, ratings: Set<String>, type: String?) -> List<ShapeRow> = { _, _, _, _ -> emptyList() },
|
||||
val facets: suspend () -> Pair<List<String>, List<String>> = { emptyList<String>() to emptyList() },
|
||||
val shapeOf: suspend (List<String>) -> List<ShapeRow> = { emptyList() })
|
||||
|
||||
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") }
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ internal enum class PlayStyle(val title: String, val line: String) {
|
||||
Shuffled("Shuffled", "Episodes in a mixed order, every one before any repeats"),
|
||||
/** A timetable: each show at its own hours, every day. */
|
||||
Blocks("Blocks", "A timetable: each show at its own times, every day"),
|
||||
Marathon("Marathon", "One show at a time, several episodes back to back")
|
||||
Marathon("Marathon", "One show at a time, several episodes back to back"),
|
||||
/** Smart setup ([shapeChannel]): daytime, evening and late slots as the Director laid them out, with core shows more often. */
|
||||
Shaped("Shaped by the Director", "Daytime, evening and late, like Orbit's own, with your core shows more often")
|
||||
}
|
||||
|
||||
/** When a channel is on: all day, or from one hour to another (wrapping past midnight). */
|
||||
@@ -36,15 +38,20 @@ internal data class OnHours(val fromHour: Int, val toHour: Int) {
|
||||
|
||||
internal data class CustomChannel(val number: Int, val name: String, val style: PlayStyle = PlayStyle.Standard, val hours: OnHours? = null, val description: String = "",
|
||||
/** 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()) {
|
||||
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()) {
|
||||
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()))
|
||||
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.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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +99,12 @@ internal object CustomChannels {
|
||||
*/
|
||||
internal fun programmingOf(channel: CustomChannel): ChannelProgramming = ChannelProgramming(
|
||||
channel.number, channel.name, emptyList(), seriesCount = 60,
|
||||
finishTogether = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Blocks,
|
||||
finishTogether = channel.style == PlayStyle.Standard || channel.style == PlayStyle.Blocks || channel.style == PlayStyle.Shaped,
|
||||
exactTitles = true, shuffleEpisodes = channel.style == PlayStyle.Shuffled,
|
||||
dayParts = if (channel.style == PlayStyle.Blocks) timetableParts(channel.shows) else emptyList(),
|
||||
storyGrouping = channel.style == PlayStyle.Standard,
|
||||
shorts = if (channel.style == PlayStyle.Standard) YoursShorts else null,
|
||||
dayParts = when (channel.style) { PlayStyle.Blocks -> timetableParts(channel.shows); PlayStyle.Shaped -> shapedParts(channel.slots); else -> emptyList() },
|
||||
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,
|
||||
marathon = if (channel.style == PlayStyle.Marathon) MARATHON_RUN else 0)
|
||||
|
||||
/**
|
||||
|
||||
@@ -541,7 +541,15 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
|
||||
chairSuggestions(description, rows, catalogue.chairRowsById(chosen.map { it.id }), skipped) } },
|
||||
search = { text -> withContext(Dispatchers.IO) { catalogue.chairRows(titleLike = text, limit = 30).map { Suggestion(it.id, it.title, it.year, it.type, "", "In your library") } } },
|
||||
showsOf = { number -> withContext(Dispatchers.IO) { scheduleStore.loadChoices(active.accountId, number).accepted } },
|
||||
create = { channel, shows ->
|
||||
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) } },
|
||||
create = { asked, shows ->
|
||||
// Shaped on the TV, or from the phone without a layout: the Director lays it out from the catalogue.
|
||||
val channel = if (asked.style == PlayStyle.Shaped && asked.slots.isEmpty()) withContext(Dispatchers.IO) {
|
||||
val rows = catalogue.shapeRows(shows.map { it.id }); val shape = shapeChannel(rows); val titleOf = rows.associate { it.id to it.title }
|
||||
asked.copy(slots = shape.slots.mapNotNull { (id, slot) -> titleOf[id]?.let { it to slot } }.toMap(), core = shape.core.mapNotNull { titleOf[it] }.toSet())
|
||||
} else asked
|
||||
withContext(Dispatchers.IO) {
|
||||
CustomChannels.upsert(workspace.scheduleDir, channel)
|
||||
val before = scheduleStore.loadChoices(active.accountId, channel.number)
|
||||
|
||||
@@ -305,6 +305,25 @@ internal object PhoneLink {
|
||||
val d = r.query["d"].orEmpty().take(120)
|
||||
json(out, 200, JSONArray(runBlocking { h.maker.suggest(d, chosen, skipped).ifEmpty { h.maker.search(d) } }.map(::suggestionJson)))
|
||||
}
|
||||
// Smart setup (Paul, 7 November 2026): browse the library and let the Director lay the channel out.
|
||||
"GET /api/facets" -> {
|
||||
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" -> {
|
||||
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" })) }))
|
||||
}
|
||||
"POST /api/shape" -> {
|
||||
val ids = runCatching { JSONObject(r.body).getJSONArray("ids").let { a -> (0 until minOf(a.length(), 200)).map { a.getString(it) }.filter(EmbyClient::validId) } }.getOrNull()
|
||||
?: return json(out, 400, JSONObject())
|
||||
val rows = runBlocking { h.maker.shapeOf(ids) }
|
||||
val shape = shapeChannel(rows)
|
||||
val series = rows.count { it.type == "Series" }; val films = rows.count { it.type == "Movie" }
|
||||
json(out, 200, JSONObject().put("slots", JSONObject(shape.slots.mapValues { it.value.name })).put("core", JSONArray(shape.core.toList()))
|
||||
.put("week", weekCheck(series, films) ?: JSONObject.NULL))
|
||||
}
|
||||
"POST /api/channels" -> {
|
||||
if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult to make channels."))
|
||||
val o = runCatching { JSONObject(r.body) }.getOrNull() ?: return json(out, 400, JSONObject().put("error", "Unreadable"))
|
||||
@@ -315,7 +334,12 @@ internal object PhoneLink {
|
||||
o.optJSONObject("hours")?.let { OnHours(it.getInt("from").coerceIn(0, 23), it.getInt("to").coerceIn(0, 24)) }, o.optString("description").take(120), shows.map { it.title }) }.getOrNull()
|
||||
?: return json(out, 400, JSONObject().put("error", "A channel needs a name."))
|
||||
if (shows.isEmpty()) return json(out, 400, JSONObject().put("error", "Add at least one show or film."))
|
||||
val said = runBlocking { withContext(mainDispatcher) { h.maker.create(channel, shows) } }
|
||||
// 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(
|
||||
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) } }
|
||||
json(out, 200, JSONObject().put("message", said).put("number", number))
|
||||
}
|
||||
"POST /api/watch" -> {
|
||||
@@ -429,3 +453,10 @@ internal object PhoneLink {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Age ratings by the groups the phone browses with. */
|
||||
internal val RatingGroups = mapOf(
|
||||
"Kids" to setOf("TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "G", "U"),
|
||||
"Family" to setOf("PG", "TV-PG"),
|
||||
"Teen" to setOf("PG-13", "TV-14", "12", "12A", "15"),
|
||||
"Grown-up" to setOf("R", "TV-MA", "NC-17", "18", "R18"))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
/**
|
||||
* Smart setup for Create a channel (Paul, 7 November 2026: "can the chair use our logic of day time, evening slots, core
|
||||
* shows"). The Director lays a new channel out the way Orbit's own are: lighter shows in the daytime, the main ones in the
|
||||
* evening, grown-up and darker ones late, with a few core shows that come round more often. It's a suggestion: the
|
||||
* viewer can move any show, or keep it simple. Built from the catalogue's genres, networks and age ratings, which Emby
|
||||
* and Jellyfin both carry, so it works the same on either.
|
||||
*/
|
||||
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>)
|
||||
|
||||
/** A channel laid out: each title's slot, and the core titles. */
|
||||
internal class Shape(val slots: Map<String, Slot>, val core: Set<String>)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
internal fun shapeChannel(rows: List<ShapeRow>): Shape {
|
||||
val slots = rows.associate { it.id to slotFor(it) }
|
||||
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())
|
||||
}
|
||||
|
||||
/** How the week looks: a gentle warning when the picks are few enough that episodes would repeat within days. */
|
||||
internal fun weekCheck(series: Int, films: Int): String? = when {
|
||||
series + films == 0 -> null
|
||||
series == 0 && films < 21 -> "$films films is under three a day, so they'd repeat within the week. Add a few more, or keep it as it is."
|
||||
series in 1..4 && films < 10 -> "With $series ${if (series == 1) "show" else "shows"}, the same few come round every day. A few more would make the week feel fuller."
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** A layout as the planner reads it: each slot's titles for its hours, and the core titles weighted up. */
|
||||
internal fun shapedParts(slots: Map<String, Slot>): List<DayPart> = Slot.entries.mapNotNull { s ->
|
||||
slots.filterValues { it == s }.keys.toList().takeIf { it.isNotEmpty() }?.let { DayPart(s.fromHour, s.toHour, it) }
|
||||
}
|
||||
internal fun shapedWeights(core: Set<String>): Map<String, Double> = core.associate { seriesKey(it) to TIER_CORE }
|
||||
@@ -253,4 +253,23 @@ class PhoneLinkTest {
|
||||
assertEquals(403, call("/api/profile", "POST", """{"id":"nobody"}""", cookie = cookie).status)
|
||||
} finally { PhoneLink.screen = null; Profiles.current = before }
|
||||
}
|
||||
|
||||
@Test fun smartSetupShapesFromTheCatalogueAndSavesTheLayoutByTitle() {
|
||||
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 }, { })
|
||||
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())
|
||||
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
|
||||
val body = """{"name":"Mixed","style":"Shaped","shows":[{"id":"a1","title":"Bluey","year":2018,"type":"Series"},{"id":"a2","title":"Line of Duty","year":2012,"type":"Series"}],
|
||||
"slots":{"a1":"Evening","a2":"Late"},"core":["a2"]}"""
|
||||
assertEquals(200, call("/api/channels", "POST", body, cookie = cookie).status)
|
||||
val channel = made!!.first
|
||||
assertEquals(PlayStyle.Shaped, channel.style)
|
||||
assertEquals(mapOf("Bluey" to Slot.Evening, "Line of Duty" to Slot.Late), channel.slots); assertEquals(setOf("Line of Duty"), channel.core)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/** Smart setup (Paul, 7 November 2026): the Director lays a new channel out like Orbit's own, and the planner follows it. */
|
||||
class SmartSetupTest {
|
||||
private fun row(id: String, type: String, rating: String, vararg genres: String, year: Int? = 2015) = ShapeRow(id, type, id, year, rating, genres.toSet())
|
||||
|
||||
@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)))
|
||||
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")))
|
||||
assertEquals(Slot.Evening, slotFor(row("Cowboy Bebop", "Series", "TV-14", "Animation", "Anime")))
|
||||
val shape = shapeChannel(listOf(row("Old", "Series", "TV-14", "Drama", year = 1990), row("New", "Series", "TV-14", "Drama", year = 2022), row("Kids", "Series", "TV-Y", "Animation")))
|
||||
assertEquals(setOf("New", "Old"), shape.core) // the evening's, newest first
|
||||
}
|
||||
|
||||
@Test fun theWeekCheckSpeaksUpOnlyWhenItWouldRepeat() {
|
||||
assertNotNull(weekCheck(2, 0)); assertNotNull(weekCheck(0, 8))
|
||||
assertNull(weekCheck(6, 0)); assertNull(weekCheck(0, 30)); assertNull(weekCheck(0, 0))
|
||||
}
|
||||
|
||||
@Test fun aShapedChannelPlansItsSlotsAndCoreAndKeepsThemWhenSaved() {
|
||||
val channel = CustomChannel(701, "Mixed Bag", PlayStyle.Shaped, shows = listOf("Bluey", "Doctor Who", "Line of Duty"),
|
||||
slots = mapOf("Bluey" to Slot.Daytime, "Doctor Who" to Slot.Evening, "Line of Duty" to Slot.Late), core = setOf("Doctor Who"))
|
||||
val back = CustomChannel.fromJson(JSONObject(channel.toJson().toString()))
|
||||
assertEquals(channel.slots, back.slots); assertEquals(channel.core, back.core)
|
||||
val p = programmingOf(back)
|
||||
assertEquals(listOf(6 to 17, 17 to 22, 22 to 6), p.dayParts.map { it.fromHour to it.toHour })
|
||||
assertEquals(listOf("Line of Duty"), p.dayParts.last().titles)
|
||||
assertEquals(TIER_CORE, p.tierWeights!![seriesKey("Doctor Who")])
|
||||
// Other styles are as they were.
|
||||
assertNull(programmingOf(channel.copy(style = PlayStyle.Standard)).tierWeights)
|
||||
assertTrue(programmingOf(channel.copy(style = PlayStyle.Standard)).dayParts.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user