From 11d3ba38237f986060c39bdb28ba036512761286 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 16 Jul 2026 23:46:46 +0200 Subject: [PATCH 1/3] =?UTF-8?q?design(69):=20sync=20Auto-Tune=20prototype?= =?UTF-8?q?=20+=20handoff=20(incl.=20deferred=20DetailPanel=20=E2=86=92=20?= =?UTF-8?q?#383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterated Auto-Tune prototype pulled back from the Claude Design working copy (project 4eb64b16) into design-system/. The 3-step wizard (Configure → Preview → Create) is PR2; the per-channel DetailPanel it also contains is deferred to the #383 arc (#384/#385/#386) per design-first — new endpoints backlogged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../design_handoff_auto_tune/AutoTune.jsx | 900 ++++++++++++++++++ .../design_handoff_auto_tune/README.md | 167 ++++ .../templates/chicorytv-admin/AutoTune.jsx | 900 ++++++++++++++++++ .../templates/chicorytv-admin/Shell.jsx | 1 + .../templates/chicorytv-admin/app.html | 6 +- 5 files changed, 1972 insertions(+), 2 deletions(-) create mode 100644 design-system/design_handoff_auto_tune/AutoTune.jsx create mode 100644 design-system/design_handoff_auto_tune/README.md create mode 100644 design-system/templates/chicorytv-admin/AutoTune.jsx diff --git a/design-system/design_handoff_auto_tune/AutoTune.jsx b/design-system/design_handoff_auto_tune/AutoTune.jsx new file mode 100644 index 000000000..1e120c825 --- /dev/null +++ b/design-system/design_handoff_auto_tune/AutoTune.jsx @@ -0,0 +1,900 @@ +// Auto-Tune — generate a whole channel lineup from library metadata (#69). +// The "automatic-first" creation mode alongside the manual Channel Builder. +// One screen, three steps: Configure axes/defaults -> Preview proposed channels +// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary). +// Additive & non-destructive: never edits or deletes an existing channel; number +// or name collisions are skipped, never overwritten. Each generated channel is +// backed by a live SmartCollection query so it keeps tracking the library. +(function () { + const NS = window.ChicoryTVDesignSystem_eb3b61; + const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS; + const Ico = window.Ico; + const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" }; + const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }; + + // ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) ------------- + // nameOf() and order are display-only here; the real server owns query + name. + const AXES = [ + { id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v }, + { id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v }, + { id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" }, + ]; + const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a])); + + // ---- Mock library metadata (EF distinct+count in the real app) ------------ + const LIBRARY = { + TvShow: [ + { value: "The Office", count: 201 }, + { value: "Friends", count: 236 }, + { value: "Breaking Bad", count: 62 }, + { value: "Parks and Recreation", count: 125 }, + { value: "The Twilight Zone", count: 156 }, + { value: "Firefly", count: 3 }, // below default minItems -> filtered + ], + TvGenre: [ + { value: "Comedy", count: 640 }, + { value: "Drama", count: 512 }, + { value: "Sci-Fi", count: 208 }, + { value: "Crime", count: 174 }, + ], + MovieGenre: [ + { value: "Action", count: 42 }, + { value: "Sci-Fi", count: 28 }, + { value: "Horror", count: 35 }, + { value: "Comedy", count: 51 }, + { value: "Noir", count: 3 }, // below default minItems -> filtered + ], + }; + + // Coexistence demo: some names already exist (deduped, unchecked by default) + // and some numbers are already taken (allocation skips them). + const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]); + const TAKEN_NUMBERS = new Set(["500", "503"]); + + const TEMPLATES = ["Standard", "Movie night", "Music videos"]; + + // ---- Preview computation (advisory numbers, re-validated at create) ------- + function buildProposals(axisIds, minItems, startingNumber) { + const out = []; + let next = Math.max(1, startingNumber | 0); + const takenThisRun = new Set(TAKEN_NUMBERS); + const alloc = () => { + while (takenThisRun.has(String(next))) next++; + const n = String(next); + takenThisRun.add(n); + next++; + return n; + }; + // Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value. + AXES.forEach((ax) => { + if (!axisIds.has(ax.id)) return; + LIBRARY[ax.id] + .filter((row) => row.count >= minItems) + .slice() + .sort((a, b) => a.value.localeCompare(b.value)) + .forEach((row) => { + const name = ax.nameOf(row.value); + const exists = EXISTING_NAMES.has(name); + out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists }); + }); + }); + return out; + } + + // ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes + // explicit initials + color (ChannelLogo only derives them from name). -- + const BUG_PALETTE = [ + ["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"], + ["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"], + ]; + const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; }; + const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?"; + function Bug({ name, initials, ci, size = 32 }) { + const idx = ci != null ? ci : bugHash(name); + const [fg, bg] = BUG_PALETTE[idx]; + return ( + + {bugInitials(name, initials)} + + ); + } + + // ---- Example content sources (advisory sample of the smart-collection query) + const GENRE_SHOWS = { + Comedy: ["The Office", "Friends", "Parks and Recreation"], + Drama: ["Breaking Bad", "The Twilight Zone"], + "Sci-Fi": ["The Twilight Zone", "Firefly"], + Crime: ["Breaking Bad"], + }; + const MOVIE_TITLES = { + Action: ["Afterburn", "Steel Horizon", "Nightfall Run"], + "Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"], + Horror: ["Hollow", "The Vigil", "Saltmarsh"], + Comedy: ["Office Party", "Two Left Feet", "The Understudy"], + Noir: ["Rain on 5th", "The Long Con"], + }; + const isGenreAxis = (axis) => axis !== "TvShow"; + function baseSources(p) { + if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || []; + if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || []; + return []; // TvShow channels track a single show — no per-source editor + } + function effectiveSources(p, ov) { + const ex = ov.exclude || []; + return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])]; + } + // Advisory item-count estimate as sources are added/removed (per-source share). + function estItems(p, ov) { + const base = baseSources(p); + if (!base.length) return p.itemCount; + const share = p.itemCount / base.length; + return Math.max(0, Math.round(share * effectiveSources(p, ov).length)); + } + // Sources with their rotation weight (episodes played per rotation). + function sourceList(p, ov) { + return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 })); + } + + // Compact rotation-weight stepper (− N +). + function Weight({ w, onChange }) { + const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 }; + return ( +
+ + {w} + +
+ ); + } + + // ---- Example schedule (rundown → EPG blocks), advisory only --------------- + // Effective order for a channel: the axis default, unless overridden per channel. + function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; } + + const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 }; + function buildSchedule(p, ov) { + const mins = RUNTIME[p.axis] || 30; + const shuffled = isShuffled(p, ov); + let seq; + if (p.axis === "TvShow") { + const n = Math.min(8, Math.max(3, p.itemCount)); + let eps = Array.from({ length: n }, (_, i) => i + 1); + if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e); + seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value })); + } else { + const list = sourceList(p, ov).filter((s) => s.weight > 0); + if (!list.length) return []; + if (shuffled) { + // Weighted round-robin interleave — honors the per-source rotation ratio. + const total = list.reduce((a, s) => a + s.weight, 0); + const st = list.map((s) => ({ ...s, acc: 0 })); + const count = Math.min(9, Math.max(5, total * 2)); + seq = Array.from({ length: count }, () => { + let pick = null; + st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; }); + pick.acc -= total; + return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" }; + }); + } else { + // Sequential — each source plays its rotation count in turn. + seq = []; + list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); }); + seq = seq.slice(0, 9); + } + } + let t = 20 * 60; + return seq.map((b) => { + const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`; + t += mins; + return { ...b, start, mins }; + }); + } + + function MiniEpg({ blocks }) { + if (!blocks.length) return ( +
No content matches — nothing to schedule.
+ ); + const PPM = 2.0; + return ( +
+
+ {blocks.map((b, i) => ( +
+ {b.start} + {b.title} + {b.sub && {b.sub}} +
+ ))} +
+
+ ); + } + + function ConfigRow({ label, value, first }) { + return ( +
+ {label} + {value} +
+ ); + } + + // ---- Full channel settings (mirrors the manual Channel Builder) ----------- + const CH_TEMPLATES = [ + { id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] }, + { id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] }, + { id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] }, + ]; + const ADV_GROUPS = [ + { group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] }, + { group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] }, + { group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] }, + { group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] }, + ]; + + // Friendly toggle row (label + description + Switch), with an override tag. + function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) { + return ( + + ); + } + + // Channel Template picker (collapsed row + dropdown + spec chips). + function TemplatePicker({ value, onPick }) { + const [open, setOpen] = React.useState(false); + const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0]; + return ( +
+ + {open && ( +
+ {CH_TEMPLATES.map((x) => { + const on = x.id === value; + return ( + + ); + })} +
+ )} +
+ {t.sets.map((s) => ( + {s} + ))} +
+
+ ); + } + + // Advanced (~24-field) override disclosure: View defaults / Override. + function AdvancedSettings({ ov, patch, templateName }) { + const [mode, setMode] = React.useState("closed"); // closed | view | override + const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []); + const vals = { ...defaults, ...(ov.adv || {}) }; + const override = mode === "override"; + const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0); + const overridden = Object.keys(ov.adv || {}).length; + const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` }); + return ( +
+
+ + Advanced + + {overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName} +
+
+ + +
+ {mode !== "closed" && ( +
+
+ + {override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`} +
+ {ADV_GROUPS.map((g) => ( +
+
{g.group}
+
+ {g.fields.map(([k, v]) => override ? ( + patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} /> + ) : ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ))} +
+ )} +
+ ); + } + + // Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug). + function LogoDrop({ name, src, onSet }) { + const [over, setOver] = React.useState(false); + const fileRef = React.useRef(null); + const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); }; + return ( +
{ e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }} + onClick={() => fileRef.current && fileRef.current.click()} + style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}> + +
+
{src ? "Channel image set" : "Drop a channel image"}
+
Used as the guide logo and on-screen bug. Falls back to the initials below.
+
+ {src && { e.stopPropagation(); onSet(null); }}>} + read(e.target.files && e.target.files[0])} /> +
+ ); + } + + // ---- Channel detail slide-over: full per-channel settings ----------------- + function DetailPanel({ p, ov, patch, onClose }) { + const [addText, setAddText] = React.useState(""); + const name = ov.customName != null ? ov.customName : p.name; + const bug = ov.bug || {}; + const ax = AXIS[p.axis]; + const number = ov.number != null ? ov.number : p.number; + const shuffled = isShuffled(p, ov); + const always = ov.always != null ? ov.always : true; + const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0]; + const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always }); + const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } })); + const avatar = (size) => bug.src + ? + : ; + const genre = isGenreAxis(p.axis); + const base = baseSources(p); + const ex = ov.exclude || []; + const inc = ov.include || []; + const kept = base.filter((s) => !ex.includes(s)); + const items = estItems(p, ov); + const schedule = buildSchedule(p, ov); + const ratios = ov.ratios || {}; + const wOf = (s) => ratios[s] || 1; + const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } })); + const list = [...kept, ...inc]; + const multi = list.length > 1; + + const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] })); + const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) })); + const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) })); + const addInc = () => { + const v = addText.trim(); + if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; } + patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) })); + setAddText(""); + }; + + return ( +
+
+ +
+ ); + } + + // ========================================================================= + function AutoTune() { + const [step, setStep] = React.useState("configure"); // configure | preview | create + const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"])); + const [minItems, setMinItems] = React.useState("5"); + const [startingNumber, setStartingNumber] = React.useState("500"); + const [group, setGroup] = React.useState("Auto-Tuned"); + const [template, setTemplate] = React.useState("Standard"); + + const [proposals, setProposals] = React.useState([]); + const [selected, setSelected] = React.useState(() => new Set()); + const [results, setResults] = React.useState(null); + const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open + const [detail, setDetail] = React.useState(null); // proposal name open in the panel + const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include} + + const patchOv = (name) => (patch) => setOverrides((o) => { + const cur = o[name] || {}; + const delta = typeof patch === "function" ? patch(cur) : patch; + return { ...o, [name]: { ...cur, ...delta } }; + }); + const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; }; + + const toggleAxis = (id) => + setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + const runPreview = () => { + const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500); + setProposals(p); + // Default selection: everything that isn't an already-existing name. + setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name))); + setStep("preview"); + }; + + const runCreate = () => { + // Demo the three outcome states across the selected set. + const chosen = proposals.filter((p) => selected.has(p.name)); + const res = chosen.map((p, i) => { + const nm = dispName(p); + if (i === chosen.length - 1 && chosen.length > 2) + return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` }; + if (p.name === "Sci-Fi Movies") + return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" }; + return { name: nm, status: "Created", channelId: 80 + i, reason: "" }; + }); + setResults(res); + setStep("create"); + }; + + const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); }; + + return ( +
+ setStep("configure")} + onRestart={restart} + /> +
+ {step === "configure" && ( + + )} + {step === "preview" && ( + + )} + {step === "create" && } +
+ {detail != null && (() => { + const p = proposals.find((x) => x.name === detail); + if (!p) return null; + return setDetail(null)} />; + })()} +
+ ); + } + + // ---- Toolbar with step rail + contextual primary action ------------------- + function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) { + return ( +
+ +
+
Auto-Tune
+
Generate channels from your library
+
+
+ +
+ {step === "configure" && ( + + + + )} + {step === "preview" && ( + + + + + + + )} + {step === "create" && ( + + )} +
+ ); + } + + const STEPS = [ + { id: "configure", label: "Configure" }, + { id: "preview", label: "Preview" }, + { id: "create", label: "Create" }, + ]; + function StepRail({ step }) { + const idx = STEPS.findIndex((s) => s.id === step); + return ( +
+ {STEPS.map((s, i) => { + const done = i < idx, active = i === idx; + const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)"; + return ( + +
+ {done ? : i + 1} + {s.label} +
+ {i < STEPS.length - 1 && } +
+ ); + })} +
+ ); + } + + // ---- Step 1: Configure ---------------------------------------------------- + function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) { + return ( +
+

+ Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, + preview the proposed channels, then create the ones you want. Existing channels are never touched. +

+ +
+
Generate from
+
+ {AXES.map((ax) => { + const on = axisIds.has(ax.id); + return ( + + ); + })} +
+
+ +
+
Defaults
+
+ + setMinItems(e.target.value)} leadingIcon={} /> + + + setStartingNumber(e.target.value)} leadingIcon={} /> + + + setGroup(e.target.value)} leadingIcon={} /> + + + patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} /> + ) : ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ))} +
+ )} + + ); + } + + // Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug). + function LogoDrop({ name, src, onSet }) { + const [over, setOver] = React.useState(false); + const fileRef = React.useRef(null); + const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); }; + return ( +
{ e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} + onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }} + onClick={() => fileRef.current && fileRef.current.click()} + style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}> + +
+
{src ? "Channel image set" : "Drop a channel image"}
+
Used as the guide logo and on-screen bug. Falls back to the initials below.
+
+ {src && { e.stopPropagation(); onSet(null); }}>} + read(e.target.files && e.target.files[0])} /> +
+ ); + } + + // ---- Channel detail slide-over: full per-channel settings ----------------- + function DetailPanel({ p, ov, patch, onClose }) { + const [addText, setAddText] = React.useState(""); + const name = ov.customName != null ? ov.customName : p.name; + const bug = ov.bug || {}; + const ax = AXIS[p.axis]; + const number = ov.number != null ? ov.number : p.number; + const shuffled = isShuffled(p, ov); + const always = ov.always != null ? ov.always : true; + const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0]; + const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always }); + const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } })); + const avatar = (size) => bug.src + ? + : ; + const genre = isGenreAxis(p.axis); + const base = baseSources(p); + const ex = ov.exclude || []; + const inc = ov.include || []; + const kept = base.filter((s) => !ex.includes(s)); + const items = estItems(p, ov); + const schedule = buildSchedule(p, ov); + const ratios = ov.ratios || {}; + const wOf = (s) => ratios[s] || 1; + const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } })); + const list = [...kept, ...inc]; + const multi = list.length > 1; + + const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] })); + const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) })); + const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) })); + const addInc = () => { + const v = addText.trim(); + if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; } + patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) })); + setAddText(""); + }; + + return ( +
+
+ +
+ ); + } + + // ========================================================================= + function AutoTune() { + const [step, setStep] = React.useState("configure"); // configure | preview | create + const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"])); + const [minItems, setMinItems] = React.useState("5"); + const [startingNumber, setStartingNumber] = React.useState("500"); + const [group, setGroup] = React.useState("Auto-Tuned"); + const [template, setTemplate] = React.useState("Standard"); + + const [proposals, setProposals] = React.useState([]); + const [selected, setSelected] = React.useState(() => new Set()); + const [results, setResults] = React.useState(null); + const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open + const [detail, setDetail] = React.useState(null); // proposal name open in the panel + const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include} + + const patchOv = (name) => (patch) => setOverrides((o) => { + const cur = o[name] || {}; + const delta = typeof patch === "function" ? patch(cur) : patch; + return { ...o, [name]: { ...cur, ...delta } }; + }); + const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; }; + + const toggleAxis = (id) => + setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); + + const runPreview = () => { + const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500); + setProposals(p); + // Default selection: everything that isn't an already-existing name. + setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name))); + setStep("preview"); + }; + + const runCreate = () => { + // Demo the three outcome states across the selected set. + const chosen = proposals.filter((p) => selected.has(p.name)); + const res = chosen.map((p, i) => { + const nm = dispName(p); + if (i === chosen.length - 1 && chosen.length > 2) + return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` }; + if (p.name === "Sci-Fi Movies") + return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" }; + return { name: nm, status: "Created", channelId: 80 + i, reason: "" }; + }); + setResults(res); + setStep("create"); + }; + + const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); }; + + return ( +
+ setStep("configure")} + onRestart={restart} + /> +
+ {step === "configure" && ( + + )} + {step === "preview" && ( + + )} + {step === "create" && } +
+ {detail != null && (() => { + const p = proposals.find((x) => x.name === detail); + if (!p) return null; + return setDetail(null)} />; + })()} +
+ ); + } + + // ---- Toolbar with step rail + contextual primary action ------------------- + function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) { + return ( +
+ +
+
Auto-Tune
+
Generate channels from your library
+
+
+ +
+ {step === "configure" && ( + + + + )} + {step === "preview" && ( + + + + + + + )} + {step === "create" && ( + + )} +
+ ); + } + + const STEPS = [ + { id: "configure", label: "Configure" }, + { id: "preview", label: "Preview" }, + { id: "create", label: "Create" }, + ]; + function StepRail({ step }) { + const idx = STEPS.findIndex((s) => s.id === step); + return ( +
+ {STEPS.map((s, i) => { + const done = i < idx, active = i === idx; + const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)"; + return ( + +
+ {done ? : i + 1} + {s.label} +
+ {i < STEPS.length - 1 && } +
+ ); + })} +
+ ); + } + + // ---- Step 1: Configure ---------------------------------------------------- + function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) { + return ( +
+

+ Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, + preview the proposed channels, then create the ones you want. Existing channels are never touched. +

+ +
+
Generate from
+
+ {AXES.map((ax) => { + const on = axisIds.has(ax.id); + return ( + + ); + })} +
+
+ +
+
Defaults
+
+ + setMinItems(e.target.value)} leadingIcon={} /> + + + setStartingNumber(e.target.value)} leadingIcon={} /> + + + setGroup(e.target.value)} leadingIcon={} /> + + + setMinItems(event.target.value)} + leadingIcon={ + + setStartingNumber(event.target.value)} + leadingIcon={ + + setGroup(event.target.value)} + leadingIcon={ + + {templatesState.status === 'loading' ? ( +
+ + Loading templates… +
+ ) : templatesState.status === 'error' ? ( +
+
+ ) : ( + !proposal.alreadyExists); const existingCount = proposals.length - selectable.length; - const selectedCount = selectable.filter((proposal) => selected.has(proposal.name)).length; + const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length; const setAll = (rows: AutoTuneProposal[], on: boolean) => { setSelected((current) => { @@ -614,9 +635,9 @@ function PreviewStep({ rows.forEach((row) => { if (!row.alreadyExists) { if (on) { - next.add(row.name); + next.add(proposalKey(row)); } else { - next.delete(row.name); + next.delete(proposalKey(row)); } } }); @@ -624,13 +645,13 @@ function PreviewStep({ }); }; - const toggle = (name: string) => { + const toggle = (key: string) => { setSelected((current) => { const next = new Set(current); - if (next.has(name)) { - next.delete(name); + if (next.has(key)) { + next.delete(key); } else { - next.add(name); + next.add(key); } return next; }); @@ -663,8 +684,8 @@ function PreviewStep({ {groups.map(({ axis, rows }) => { const groupSelectable = rows.filter((row) => !row.alreadyExists); - const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(row.name)); - const someOn = groupSelectable.some((row) => selected.has(row.name)); + const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row))); + const someOn = groupSelectable.some((row) => selected.has(proposalKey(row))); const AxisIcon = axis.icon; const OrderIcon = axis.shuffled ? Shuffle : ListOrdered; return ( @@ -707,10 +728,10 @@ function PreviewStep({
{rows.map((row, i) => { - const on = selected.has(row.name); + const on = selected.has(proposalKey(row)); return (
toggle(row.name)} + onChange={() => toggle(proposalKey(row))} /> {row.number} -- 2.47.3