feat(69): Auto-Tune SPA wizard (Configure → Preview → Create) — PR2 #389

Merged
timothy merged 3 commits from feat/69-auto-tune-spa into main 2026-07-17 00:30:53 +02:00
15 changed files with 3220 additions and 4 deletions
@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- 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 (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- 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 (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- 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 (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// 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 (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// 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 (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// 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 (
<div onDragOver={(e) => { 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)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- 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
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
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 (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
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 (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{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 (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
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.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();
@@ -0,0 +1,167 @@
# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
## Overview
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
lineup on every run. Ours adds a preview/select step and is non-destructive.
The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
handoff is **PR2: the SPA screen** that drives them.
> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
> #383 arc, not PR2.
## About the design files
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
its mock data and mock outcome logic are illustrative only — the real screen calls the API.
## Fidelity
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
are exact.
---
## The screen — a 3-step wizard
One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
primary action change per step.
**Toolbar (all steps):**
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
`color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
connectors. Each step is a 20px round chip + label. The **active** step: chip
`background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
**completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
`--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
- Right: the contextual primary action (below).
### Step 1 — Configure
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
the non-destructive promise.
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
(one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
- Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
`var(--text-on-accent)`.
- Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
- Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
**order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
(`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
- **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
- **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
- **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
- **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
axis is selected.
### Step 2 — Preview
Centered column, `max-width: 920px`.
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
only touch selectable — non-existing — rows).
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
`var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
(`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
`min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
re-create a channel that already exists by that name).
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
### Step 3 — Create (results)
Centered column, `max-width: 860px`.
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
`CheckCircle2` / `MinusCircle` / `XCircle`).
- A **"Added to group {group}"** line (`FolderTree` icon).
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
(`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
**reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
status **Badge** (`positive` / `neutral` / `danger`).
**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
---
## API mapping (the real screen)
Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
query; the server regenerates it (query authorship is server-side only).
| UI element | Endpoint / field |
|---|---|
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
| — Minimum items | request `minItems: number` |
| — Starting channel number | request `startingNumber: number` |
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
| — `{itemCount} items` badge | `itemCount` |
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
| — Channel group field | request `group: string` |
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
Notes:
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
**re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
- **Default selection = every proposal whose `alreadyExists` is false.**
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
server's ordering rather than re-sorting client-side.
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
template) so the field is never empty; the batch requires a `templateId`.
## Real-SPA implementation notes (target = `web/`)
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
*create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
button for this screen.
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
`createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
re-exported from `web/src/api/index.ts`.
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
for the template `Select`.
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
`activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
`Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
`docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
(if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: size, height: size, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: bg, border: "1px solid var(--border-hairline)" }}>
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.36)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{bugInitials(name, initials)}</span>
</span>
);
}
// ---- 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 (
<div title="Episodes played per rotation" style={{ display: "inline-flex", alignItems: "center", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)" }}>
<button type="button" style={btn} onClick={() => onChange(w - 1)}><Ico n="Minus" s={13} /></button>
<span style={{ ...mono, minWidth: 22, textAlign: "center", font: "var(--weight-medium) var(--text-xs) var(--font-mono)", color: "var(--text-primary)" }}>{w}</span>
<button type="button" style={btn} onClick={() => onChange(w + 1)}><Ico n="Plus" s={13} /></button>
</div>
);
}
// ---- 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 (
<div style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "6px 2px" }}>No content matches — nothing to schedule.</div>
);
const PPM = 2.0;
return (
<div style={{ overflowX: "auto", paddingBottom: 2 }}>
<div style={{ display: "flex", gap: 4, minWidth: "min-content" }}>
{blocks.map((b, i) => (
<div key={i} style={{ width: b.mins * PPM, minWidth: 68, flex: "0 0 auto", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", padding: "7px 9px", display: "flex", flexDirection: "column", gap: 3, overflow: "hidden" }}>
<span style={{ ...mono, font: "var(--text-2xs) var(--font-mono)", color: "var(--text-disabled)" }}>{b.start}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.title}</span>
{b.sub && <span style={{ font: "var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{b.sub}</span>}
</div>
))}
</div>
</div>
);
}
function ConfigRow({ label, value, first }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 12px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<span style={{ flex: "0 0 132px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden" }}>{value}</span>
</div>
);
}
// ---- 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 (
<label onClick={() => onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
{icon && <span style={{ display: "inline-flex", marginTop: 1, color: iconColor || "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={icon} s={16} /></span>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</span>
{overrideOf != null && checked !== overrideOf && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{desc}</div>
</div>
<Switch checked={checked} onChange={onChange} />
</label>
);
}
// 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 (
<div>
<button type="button" onClick={() => setOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.id}</span>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</div>
</div>
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={15} style={{ color: "var(--text-disabled)" }} />
</button>
{open && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{CH_TEMPLATES.map((x) => {
const on = x.id === value;
return (
<button key={x.id} type="button" className="ctv-press" onClick={() => { onPick(x); setOpen(false); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} style={{ color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{x.id}</span>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{x.desc}</div>
</div>
</button>
);
})}
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{t.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
);
}
// 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 (
<section style={{ display: "flex", flexDirection: "column", gap: 10, borderTop: "1px solid var(--border-hairline)", paddingTop: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<Ico n="SlidersHorizontal" s={16} style={{ color: "var(--text-secondary)" }} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}><Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}</button>
<button type="button" onClick={() => setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}><Ico n="Eye" s={14} />View defaults</button>
</div>
{mode !== "closed" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14, marginTop: 2 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} style={{ color: override ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto", marginTop: 1 }} />
{override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV_GROUPS.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => override ? (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
) : (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</section>
);
}
// 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 (
<div onDragOver={(e) => { 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)"}` }}>
<ChannelLogo name={name || "New Channel"} src={src} size={40} radius="var(--radius-xs)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src ? "Channel image set" : "Drop a channel image"}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as the guide logo and on-screen bug. Falls back to the initials below.</div>
</div>
{src && <IconButton size="sm" title="Remove image" onClick={(e) => { e.stopPropagation(); onSet(null); }}><Ico n="X" s={14} /></IconButton>}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
);
}
// ---- 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
? <ChannelLogo name={name || "Channel"} src={bug.src} size={size} radius="var(--radius-sm)" />
: <Bug name={name} initials={bug.initials} ci={bug.ci} size={size} />;
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 (
<div style={{ position: "absolute", inset: 0, zIndex: 20, display: "flex", justifyContent: "flex-end" }}>
<div onClick={onClose} style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.5)" }} />
<aside style={{ position: "relative", width: 468, maxWidth: "94%", height: "100%", background: "var(--surface-card)", borderLeft: "1px solid var(--border-hairline)", boxShadow: "var(--shadow-lg)", display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
{avatar(34)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name || "Untitled channel"}</div>
<div style={{ marginTop: 2, display: "inline-flex", alignItems: "center", gap: 6, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<span style={mono}>{number}</span><span>·</span><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={11} />{shuffled ? "Shuffled" : "In order"}
</div>
</div>
<IconButton size="sm" title="Close" onClick={onClose}><Ico n="X" s={16} /></IconButton>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "18px 16px 28px", display: "flex", flexDirection: "column", gap: 22 }}>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Channel identity</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 120px", gap: 10 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Channel name</span>
<Input size="sm" value={name} onChange={(e) => patch({ customName: e.target.value })} leadingIcon={<Ico n="Tv" s={14} />} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Number</span>
<Input size="sm" value={number} onChange={(e) => patch({ number: e.target.value })} leadingIcon={<Ico n="Hash" s={14} />} />
</label>
</div>
<LogoDrop name={name} src={bug.src} onSet={setLogo} />
<div style={{ display: "flex", alignItems: "flex-start", gap: 14 }}>
{avatar(48)}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 9 }}>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Bug initials</span>
<Input size="sm" value={bug.initials != null ? bug.initials : bugInitials(name)} maxLength={3} onChange={(e) => patch((o) => ({ bug: { ...(o.bug || {}), initials: e.target.value } }))} />
</label>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
{BUG_PALETTE.map((pair, i) => {
const on = (bug.ci != null ? bug.ci : bugHash(name)) === i;
return (
<button key={i} type="button" title="Bug color" onClick={() => patch((o) => ({ bug: { ...(o.bug || {}), ci: i } }))}
style={{ width: 22, height: 22, padding: 0, cursor: "pointer", borderRadius: "50%", background: pair[1], border: `2px solid ${on ? "var(--text-primary)" : "transparent"}` }}>
<span style={{ display: "block", width: 8, height: 8, margin: "0 auto", borderRadius: "50%", background: pair[0] }} />
</button>
);
})}
</div>
</div>
</div>
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>Initials + color are the fallback bug shown until a channel image is added.</span>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={eyebrow}>Playback</div>
<ToggleRow icon={shuffled ? "Shuffle" : "ListOrdered"} title="Shuffle" desc={shuffled ? "Plays in a random / interleaved order." : "Plays in sequence."} checked={shuffled} onChange={(v) => patch({ shuffle: v })} overrideOf={tpl.shuffle} />
<ToggleRow icon="Radio" iconColor="var(--ctv-live)" live title="Always playing" desc="Like live TV — advances on schedule even when nobody is watching." checked={always} onChange={(v) => patch({ always: v })} overrideOf={tpl.always} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Channel Template</div>
<TemplatePicker value={tpl.id} onPick={pickTemplate} />
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={eyebrow}>Query &amp; size</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--ctv-bg-sunken)" }}>
<ConfigRow first label="Order" value={<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}><Ico n={shuffled ? "Shuffle" : "ListOrdered"} s={12} />{shuffled ? "Shuffled" : "In order"}</span>} />
<ConfigRow label="Streaming mode" value={tpl.sets[1]} />
<ConfigRow label="Est. items" value={<span style={mono}>{items.toLocaleString()}</span>} />
<ConfigRow label="Smart collection" value={<span style={{ ...mono, color: "var(--text-secondary)" }}>{p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}</span>} />
</div>
</section>
{genre && (
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Content sources</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.</span>
</div>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{list.length === 0 && (
<div style={{ padding: "12px 12px", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>No sources — add one below.</div>
)}
{[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
<div key={row.s} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderTop: idx ? "1px solid var(--border-hairline)" : "none", background: row.added ? "var(--ctv-accent-soft)" : "transparent" }}>
<Ico n={row.added ? "Plus" : "Check"} s={13} style={{ color: row.added ? "var(--ctv-accent)" : "var(--text-disabled)", flex: "0 0 auto" }} />
<span style={{ flex: 1, minWidth: 0, font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{row.s}</span>
{multi && <Weight w={wOf(row.s)} onChange={(w) => setW(row.s, w)} />}
<IconButton size="sm" title={row.added ? "Remove" : "Exclude"} onClick={() => (row.added ? removeInc(row.s) : excludeSrc(row.s))}><Ico n="X" s={14} /></IconButton>
</div>
))}
</div>
{multi && (
<span style={{ font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
</span>
)}
<div style={{ display: "flex", gap: 8 }}>
<div style={{ flex: 1 }}>
<Input size="sm" value={addText} placeholder="Add a show or movie…" onChange={(e) => setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={<Ico n="Plus" s={14} />} />
</div>
<Button size="sm" variant="secondary" onClick={addInc} disabled={!addText.trim()}>Add</Button>
</div>
{ex.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>Excluded:</span>
{ex.map((s) => (
<button key={s} type="button" onClick={() => restoreSrc(s)} title="Add back"
style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<span style={{ textDecoration: "line-through" }}>{s}</span><Ico n="RotateCcw" s={10} />
</button>
))}
</div>
)}
</section>
)}
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
<div style={eyebrow}>Example schedule</div>
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>A preview of tonight from 20:00 — the built playout may differ.</span>
</div>
<MiniEpg blocks={schedule} />
</section>
<AdvancedSettings ov={ov} patch={patch} templateName={tpl.id} />
</div>
</aside>
</div>
);
}
// =========================================================================
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", position: "relative" }}>
<Toolbar
step={step}
axisCount={axisIds.size}
selectedCount={selected.size}
onPreview={runPreview}
onCreate={runCreate}
onBack={() => setStep("configure")}
onRestart={restart}
/>
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{step === "configure" && (
<Configure
axisIds={axisIds} toggleAxis={toggleAxis}
minItems={minItems} setMinItems={setMinItems}
startingNumber={startingNumber} setStartingNumber={setStartingNumber}
group={group} setGroup={setGroup}
template={template} setTemplate={setTemplate}
/>
)}
{step === "preview" && (
<Preview proposals={proposals} selected={selected} setSelected={setSelected}
expanded={expanded} setExpanded={setExpanded} overrides={overrides}
openDetail={setDetail} dispName={dispName} />
)}
{step === "create" && <Results results={results} group={group} />}
</main>
{detail != null && (() => {
const p = proposals.find((x) => x.name === detail);
if (!p) return null;
return <DetailPanel p={p} ov={overrides[detail] || {}} patch={patchOv(detail)} onClose={() => setDetail(null)} />;
})()}
</div>
);
}
// ---- Toolbar with step rail + contextual primary action -------------------
function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Sparkles" s={19} /></span>
<div style={{ flex: "0 0 auto", minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Auto-Tune</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Generate channels from your library</div>
</div>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<StepRail step={step} />
</div>
{step === "configure" && (
<Tooltip placement="bottom" label={axisCount ? "Enumerate the library and preview proposed channels" : "Pick at least one metadata axis"}>
<Button variant="primary" disabled={!axisCount} startIcon={<Ico n="Eye" s={15} />} onClick={onPreview}>Preview channels</Button>
</Tooltip>
)}
{step === "preview" && (
<React.Fragment>
<Button variant="ghost" startIcon={<Ico n="ArrowLeft" s={15} />} onClick={onBack}>Back</Button>
<Tooltip placement="bottom" label={selectedCount ? "Create the selected channels" : "Select at least one channel"}>
<Button variant="primary" disabled={!selectedCount} startIcon={<Ico n="Check" s={15} />} onClick={onCreate}>
{selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
</Button>
</Tooltip>
</React.Fragment>
)}
{step === "create" && (
<Button variant="primary" startIcon={<Ico n="RotateCcw" s={15} />} onClick={onRestart}>Start over</Button>
)}
</div>
);
}
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 (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{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 (
<React.Fragment key={s.id}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 7 }}>
<span style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", width: 20, height: 20, borderRadius: "50%",
font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)",
background: active ? "var(--ctv-accent)" : done ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: active ? "var(--text-on-accent)" : done ? "var(--ctv-accent)" : "var(--text-disabled)",
}}>{done ? <Ico n="Check" s={12} /> : i + 1}</span>
<span style={{ font: `${active ? "var(--weight-semibold)" : "var(--weight-medium)"} var(--text-xs)/1 var(--font-sans)`, color }}>{s.label}</span>
</div>
{i < STEPS.length - 1 && <span style={{ width: 26, height: 1, background: "var(--border-control)", margin: "0 4px" }} />}
</React.Fragment>
);
})}
</div>
);
}
// ---- Step 1: Configure ----------------------------------------------------
function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 24 }}>
<p style={{ margin: 0, font: "var(--text-sm)/1.55 var(--font-sans)", color: "var(--text-secondary)", maxWidth: 620 }}>
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.
</p>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Generate from</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
{AXES.map((ax) => {
const on = axisIds.has(ax.id);
return (
<button key={ax.id} type="button" onClick={() => toggleAxis(ax.id)}
className="ctv-press"
style={{
textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
}}>
<span style={{ position: "absolute", top: 12, right: 12, color: on ? "var(--ctv-accent)" : "var(--text-disabled)" }}>
<Ico n={on ? "CheckCircle2" : "Circle"} s={17} />
</span>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-sm)", background: on ? "var(--ctv-accent)" : "var(--ctv-surface-2)", color: on ? "var(--text-on-accent)" : "var(--text-secondary)" }}><Ico n={ax.icon} s={17} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{ax.tagline}</div>
</div>
<div style={{ marginTop: "auto", display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div style={eyebrow}>Defaults</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0,1fr))", gap: 14, alignItems: "start" }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input size="sm" type="number" value={minItems} onChange={(e) => setMinItems(e.target.value)} leadingIcon={<Ico n="Hash" s={14} />} />
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input size="sm" type="number" value={startingNumber} onChange={(e) => setStartingNumber(e.target.value)} leadingIcon={<Ico n="Tv" s={14} />} />
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input size="sm" value={group} onChange={(e) => setGroup(e.target.value)} leadingIcon={<Ico n="FolderTree" s={14} />} />
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
<Select size="sm" value={template} onChange={(e) => setTemplate(e.target.value)} options={TEMPLATES} />
</Field>
</div>
</section>
</div>
);
}
function Field({ label, hint, children }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>
{children}
<span style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-disabled)" }}>{hint}</span>
</label>
);
}
// ---- Step 2: Preview ------------------------------------------------------
function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
const selectable = proposals.filter((p) => !p.alreadyExists);
const existingCount = proposals.length - selectable.length;
if (!proposals.length) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="SearchX" s={26} />
<div style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>No channels matched — try lowering the minimum items.</div>
</div>
);
}
const setAll = (rows, on) => setSelected((s) => {
const n = new Set(s);
rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
return n;
});
return (
<div style={{ maxWidth: 920, margin: "0 auto", padding: "20px 24px 40px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={{ ...mono, color: "var(--text-primary)", fontWeight: 600 }}>{selected.size}</span> of {selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<Ico n="Info" s={11} />} tone="neutral">{existingCount} already exist — deselected</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>Select all</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>Clear</Button>
</div>
{groups.map(({ ax, rows }) => {
const groupSel = rows.filter((r) => !r.alreadyExists);
const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
const someOn = groupSel.some((r) => selected.has(r.name));
return (
<section key={ax.id} style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderBottom: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Checkbox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setAll(rows, !allOn)} />
<Ico n={ax.icon} s={15} />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{ax.title}</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrow, display: "inline-flex", alignItems: "center", gap: 5 }}>
<Ico n={ax.order === "Shuffled" ? "Shuffle" : "ListOrdered"} s={11} />{ax.order}
</span>
</div>
<div>
{rows.map((r, i) => {
const on = selected.has(r.name);
const ov = overrides[r.name] || {};
const name = dispName(r);
const isExp = expanded.has(r.name);
return (
<div key={r.name} style={{ borderTop: i ? "1px solid var(--border-hairline)" : "none", opacity: r.alreadyExists ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "9px 14px" }}>
<Checkbox checked={on} disabled={r.alreadyExists} onChange={() => toggle(r.name)} />
<span style={{ ...mono, minWidth: 42, font: "var(--text-sm) var(--font-mono)", color: "var(--text-secondary)" }}>{r.number}</span>
<Bug name={name} initials={ov.bug && ov.bug.initials} ci={ov.bug && ov.bug.ci} size={28} />
<button type="button" onClick={() => !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{name}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>from “{r.value}”</div>
</button>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "2px 7px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)", flex: "0 0 auto" }}>
<Ico n={isShuffled(r, ov) ? "Shuffle" : "ListOrdered"} s={11} />{isShuffled(r, ov) ? "Shuffled" : "In order"}
</span>
<Badge tone="neutral"><span style={mono}>{r.itemCount}</span>&nbsp;items</Badge>
{r.alreadyExists ? (
<Tag icon={<Ico n="CircleSlash" s={11} />} tone="neutral">Exists</Tag>
) : (
<React.Fragment>
<Button size="sm" variant="ghost" startIcon={<Ico n="SlidersHorizontal" s={14} />} onClick={() => openDetail(r.name)}>Configure</Button>
<IconButton size="sm" active={isExp} title={isExp ? "Hide schedule" : "Show schedule"} onClick={() => toggleExp(r.name)}>
<Ico n="ChevronDown" s={16} style={{ transform: isExp ? "rotate(180deg)" : "none", transition: "transform var(--dur-fast) var(--ease-standard)" }} />
</IconButton>
</React.Fragment>
)}
</div>
{isExp && !r.alreadyExists && (
<div style={{ padding: "0 14px 14px 58px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={eyebrow}>Example schedule</span>
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="ExternalLink" s={13} />} onClick={() => openDetail(r.name)}>Open channel</Button>
</div>
<MiniEpg blocks={buildSchedule(r, ov)} />
</div>
)}
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
// ---- Step 3: Results ------------------------------------------------------
const RESULT_META = {
Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
};
function Results({ results, group }) {
const count = (s) => results.filter((r) => r.status === s).length;
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: "26px 24px 40px", display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0,1fr))", gap: 12 }}>
<Stat label="Created" value={count("Created")} icon={<Ico n="CheckCircle2" s={16} />} />
<Stat label="Skipped" value={count("Skipped")} icon={<Ico n="MinusCircle" s={16} />} />
<Stat label="Failed" value={count("Failed")} icon={<Ico n="XCircle" s={16} />} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="FolderTree" s={13} /> Added to group <span style={{ font: "var(--weight-semibold) var(--text-xs) var(--font-sans)", color: "var(--text-primary)" }}>{group}</span>
</div>
<section style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
{results.map((r, i) => {
const m = RESULT_META[r.status];
return (
<div key={r.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 14px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ color: m.color, display: "inline-flex" }}><Ico n={m.icon} s={17} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{r.name}</div>
{r.reason && <div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-disabled)" }}>{r.reason}</div>}
</div>
{r.channelId != null && <span style={{ ...mono, font: "var(--text-xs) var(--font-mono)", color: "var(--text-disabled)" }}>#{r.channelId}</span>}
<Badge tone={m.tone}>{r.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
window.CTVAutoTune = AutoTune;
})();
@@ -8,6 +8,7 @@
{ id: "dashboard", label: "Dashboard", icon: "LayoutDashboard" },
{ id: "channels", label: "Channels", icon: "Tv" },
{ id: "builder", label: "New Channel", icon: "Plus" },
{ id: "autoTune", label: "Auto-Tune", icon: "Sparkles" },
{ id: "guide", label: "Guide", icon: "LayoutGrid" },
{ id: "schedules", label: "Schedules", icon: "CalendarClock" },
{ id: "playouts", label: "Playouts", icon: "ListVideo", badge: 3 },
@@ -81,6 +81,7 @@
<script type="text/babel" src="Dashboard.jsx" data-presets="react"></script>
<script type="text/babel" src="Channels.jsx" data-presets="react"></script>
<script type="text/babel" src="ChannelBuilder.jsx" data-presets="react"></script>
<script type="text/babel" src="AutoTune.jsx" data-presets="react"></script>
<script type="text/babel" src="ScheduleEditor.jsx" data-presets="react"></script>
<script type="text/babel" src="Epg.jsx" data-presets="react"></script>
<script type="text/babel" src="Playouts.jsx" data-presets="react"></script>
@@ -90,7 +91,7 @@
const { Sidebar, TopBar } = window.CTVShell;
const TITLES = {
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", guide: "Guide", schedules: "Schedules",
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", autoTune: "Auto-Tune", guide: "Guide", schedules: "Schedules",
playouts: "Playouts", collections: "Collections", libraries: "Libraries", settings: "Settings",
};
@@ -110,6 +111,7 @@ function App() {
dashboard: window.CTVDashboard,
channels: window.CTVChannels,
builder: window.CTVChannelBuilder,
autoTune: window.CTVAutoTune,
guide: window.CTVEpg,
schedules: window.CTVScheduleEditor,
playouts: window.CTVPlayouts,
@@ -132,7 +134,7 @@ function App() {
function mount() {
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVAutoTune || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
return setTimeout(mount, 40);
}
+9
View File
@@ -185,6 +185,15 @@ section is now empty (mappings retained at the top of this section for the recor
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** |
| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
## Section 2b — SPA-NATIVE (no Blazor ancestor)
Screens the SPA introduces that never existed in the Blazor UI, so they have no parity row and no
redirect (nothing to redirect *from*). Recorded here so the parity tracker is complete.
| SPA route | Screen | Notes |
|---|---|---|
| `/app/auto-tune` | `AutoTuneScreen` | **Net-new (#69).** Auto-Tune wizard: bulk-generate channels from library metadata (axes: TV Shows / TV Genres / Movie Genres) → preview & select → bulk-create, over the PR1 `POST /api/v1/channels/auto-tune/preview` + `POST /api/v1/channels/auto-tune` endpoints. Additive/non-destructive; no Blazor equivalent. Per-channel DetailPanel deferred to #383. |
## Section 3 — BLAZOR-ONLY (blocking issues)
### Playlist variant management — API gap #153 RESOLVED
+2 -2
View File
@@ -75,7 +75,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` |
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69) |
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
@@ -83,7 +83,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
## Where things are edited (SPA routes)
Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/guide`,
Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/auto-tune` (#69), `/app/guide`,
`/app/schedules`, `/app/blocks`, `/app/templates`, `/app/decos`, `/app/deco-templates`,
`/app/playouts` (+ sub-paths `/app/playouts/{id}/alternate-schedules`,
`/app/playouts/{id}/templates`).
+13
View File
@@ -99,6 +99,19 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
});
it('routes to the Auto-Tune wizard from its sidebar nav entry (#69)', async () => {
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Auto-Tune' }));
expect(window.location.pathname).toBe('/app/auto-tune');
// The wizard mounts on its Configure step with an in-body Preview action (no TopBar "+").
expect(await screen.findByRole('button', { name: /TV Shows/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeInTheDocument();
// Loads channel templates on mount (its own endpoint dependency).
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
});
it('registers the multi-collection and rerun-collection nav entries', async () => {
render(<App />);
+89
View File
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createAutoTunedChannels, previewAutoTune } from './autoTune';
const sampleProposals = [
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: true }
];
const sampleResult = {
results: [{ name: 'The Office', status: 'Created', channelId: 80, reason: null }],
createdCount: 1,
skippedCount: 0,
failedCount: 0
};
describe('previewAutoTune', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/v1/channels/auto-tune/preview with the axes/minItems/startingNumber body', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleProposals), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
previewAutoTune({ axes: ['TvShow', 'TvGenre'], minItems: 5, startingNumber: 500 })
).resolves.toHaveLength(2);
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/channels/auto-tune/preview');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({
axes: ['TvShow', 'TvGenre'],
minItems: 5,
startingNumber: 500
});
});
it('rejects with the ApiError status on failure', async () => {
vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
headers: { 'Content-Type': 'application/json' },
status: 500
})
);
await expect(
previewAutoTune({ axes: ['TvShow'], minItems: 1, startingNumber: 1 })
).rejects.toMatchObject({ status: 500 });
});
});
describe('createAutoTunedChannels', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('POSTs /api/v1/channels/auto-tune with templateId, group and echoed channels', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
createAutoTunedChannels({
templateId: 3,
group: 'Auto-Tuned',
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
})
).resolves.toMatchObject({ createdCount: 1 });
const [url, init] = fetchSpy.mock.calls[0];
expect(url).toBe('/api/v1/channels/auto-tune');
expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({
templateId: 3,
group: 'Auto-Tuned',
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type AutoTuneAxis = components['schemas']['AutoTuneAxis'];
export type AutoTuneProposal = components['schemas']['AutoTuneProposalResponseModel'];
export type PreviewAutoTuneChannelsRequest = components['schemas']['PreviewAutoTuneChannelsRequest'];
export type AutoTunedChannelRequest = components['schemas']['AutoTunedChannelRequest'];
export type CreateAutoTunedChannelsRequest = components['schemas']['CreateAutoTunedChannelsRequest'];
export type AutoTuneChannelResult = components['schemas']['AutoTuneChannelResultModel'];
export type AutoTuneResult = components['schemas']['AutoTuneResultResponseModel'];
export function previewAutoTune(body: PreviewAutoTuneChannelsRequest): Promise<AutoTuneProposal[]> {
return request<AutoTuneProposal[]>('/api/v1/channels/auto-tune/preview', {
body,
method: 'POST'
});
}
export function createAutoTunedChannels(body: CreateAutoTunedChannelsRequest): Promise<AutoTuneResult> {
return request<AutoTuneResult>('/api/v1/channels/auto-tune', {
body,
method: 'POST'
});
}
export function messageFromAutoTuneError(error: unknown, fallback = 'Unable to auto-tune channels'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './artwork';
export * from './auth';
export * from './autoTune';
export * from './blocks';
export * from './channels';
export * from './channelTemplates';
+3
View File
@@ -8,6 +8,7 @@ import {
parseMediaSubRoute
} from '../routing';
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
import { AutoTuneScreen } from '../screens/AutoTuneScreen';
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
import { BlocksScreen } from '../screens/BlocksScreen';
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
@@ -165,6 +166,8 @@ export function ScreenContent({
return <ChannelsScreen />;
case 'builder':
return <ChannelBuilderScreen />;
case 'autoTune':
return <AutoTuneScreen />;
case 'editChannel':
return <ChannelEditScreen key={window.location.pathname} />;
case 'guide':
+16
View File
@@ -23,6 +23,7 @@ import {
Search,
Settings,
SlidersHorizontal,
Sparkles,
Stamp,
Stethoscope,
Trash2,
@@ -33,6 +34,7 @@ export type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
| 'autoTune'
| 'editChannel'
| 'guide'
| 'schedules'
@@ -113,6 +115,19 @@ export const routes: ScreenRoute[] = [
primaryAction: '',
placeholder: 'Channel builder workspace'
},
{
// Net-new SPA screen with no Blazor ancestor (#69). The 3-step wizard drives its own in-body
// buttons, so it registers no TopBar primaryAction (spa-conventions §10).
id: 'autoTune',
path: '/app/auto-tune',
label: 'Auto-Tune',
title: 'Auto-Tune',
kicker: 'Builder',
description: 'Generate a whole channel lineup from your library metadata, preview it, then bulk-create.',
icon: <Sparkles aria-hidden="true" size={16} />,
primaryAction: '',
placeholder: 'Auto-tune workspace'
},
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
@@ -458,6 +473,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
'dashboard',
'channels',
'builder',
'autoTune',
'guide',
'schedules',
'blocks',
+203
View File
@@ -0,0 +1,203 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AutoTuneScreen } from './AutoTuneScreen';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status
});
}
const templates = [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Movie night' }
];
const proposals = [
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
{ axis: 'TvShow', value: 'Friends', name: 'Friends', number: '501', itemCount: 236, alreadyExists: true },
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502', itemCount: 640, alreadyExists: false }
];
const createResult = {
results: [
{ name: 'The Office', status: 'Created', channelId: 80, reason: null },
{ name: 'Comedy', status: 'Skipped', channelId: null, reason: 'number 502 already taken' }
],
createdCount: 1,
skippedCount: 1,
failedCount: 0
};
function mockAutoTuneApi(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (path === '/api/v1/channel-templates') {
return Promise.resolve(jsonResponse(templates));
}
if (path === '/api/v1/channel-templates/default') {
return Promise.resolve(jsonResponse(templates[0]));
}
if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
return Promise.resolve(jsonResponse(proposals));
}
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
return Promise.resolve(jsonResponse(createResult));
}
return Promise.resolve(jsonResponse(null, 404));
});
}
describe('AutoTuneScreen', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
cleanup();
});
it('gates the Preview action on at least one selected axis', async () => {
mockAutoTuneApi();
render(<AutoTuneScreen />);
// All three axes start selected, so Preview is enabled.
const previewButton = screen.getByRole('button', { name: /Preview channels/ });
expect(previewButton).toBeEnabled();
// Deselect every axis → Preview disabled.
fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
fireEvent.click(screen.getByRole('button', { name: /TV Genres/ }));
fireEvent.click(screen.getByRole('button', { name: /Movie Genres/ }));
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeDisabled();
// Re-select one → enabled again.
fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
expect(screen.getByRole('button', { name: /Preview channels/ })).toBeEnabled();
});
it('renders preview groups and deselects already-existing proposals', async () => {
mockAutoTuneApi();
render(<AutoTuneScreen />);
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
// Group headers appear in axis order.
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
expect(screen.getByText('TV Genres')).toBeInTheDocument();
// Non-existing proposals are selected by default.
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('checkbox', { name: 'Comedy' })).toHaveAttribute('aria-checked', 'true');
// The already-existing proposal is disabled + unchecked.
const existing = screen.getByRole('checkbox', { name: 'Friends' });
expect(existing).toHaveAttribute('aria-checked', 'false');
expect(existing).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByText('Exists')).toBeInTheDocument();
});
it('supports select-all / clear over the selectable proposals', async () => {
mockAutoTuneApi();
render(<AutoTuneScreen />);
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
await screen.findByText('TV Shows');
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'false');
expect(screen.getByRole('button', { name: /Create channels/ })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('button', { name: /Create 2 channels/ })).toBeEnabled();
});
it('posts the selected proposals and renders the result tiles', async () => {
const fetchSpy = mockAutoTuneApi();
render(<AutoTuneScreen />);
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
await screen.findByText('TV Shows');
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
// Result view renders on success (reason + group line are unique to it).
expect(await screen.findByText('number 502 already taken')).toBeInTheDocument();
expect(screen.getByText(/Added to group/)).toBeInTheDocument();
expect(screen.getByText('#80')).toBeInTheDocument();
// "Created" / "Skipped" appear both as Stat tile labels and per-row status badges.
expect(screen.getAllByText('Created').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('Skipped').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Failed')).toBeInTheDocument();
// The create request echoes only the selected (non-existing) proposals.
const createCall = fetchSpy.mock.calls.find(
([url, init]: [RequestInfo | URL, RequestInit | undefined]) =>
url === '/api/v1/channels/auto-tune' && (init?.method ?? '').toUpperCase() === 'POST'
);
expect(createCall).toBeTruthy();
const body = JSON.parse(String(createCall?.[1]?.body));
expect(body.templateId).toBe(1);
expect(body.group).toBe('Auto-Tuned');
expect(body.channels).toEqual([
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' },
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502' }
]);
});
it('keys selection by axis+value so same-named proposals stay independent', async () => {
// A TV show literally titled "Comedy" and the "Comedy" TV genre — identical names, different
// axes. Keying selection on the name would couple them (toggling one flips both).
const collision = [
{ axis: 'TvShow', value: 'Comedy', name: 'Comedy', number: '500', itemCount: 30, alreadyExists: false },
{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: false }
];
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
const method = (init?.method ?? 'GET').toUpperCase();
if (path === '/api/v1/channel-templates') {
return Promise.resolve(jsonResponse(templates));
}
if (path === '/api/v1/channel-templates/default') {
return Promise.resolve(jsonResponse(templates[0]));
}
if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
return Promise.resolve(jsonResponse(collision));
}
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
return Promise.resolve(jsonResponse({ results: [], createdCount: 0, skippedCount: 0, failedCount: 0 }));
}
return Promise.resolve(jsonResponse(null, 404));
});
render(<AutoTuneScreen />);
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
await screen.findByText('TV Shows');
// Two distinct row checkboxes both named "Comedy" (one per axis), both selected by default.
const boxes = screen.getAllByRole('checkbox', { name: 'Comedy' });
expect(boxes).toHaveLength(2);
expect(boxes[0]).toHaveAttribute('aria-checked', 'true');
expect(boxes[1]).toHaveAttribute('aria-checked', 'true');
// Deselecting the TV-show "Comedy" must NOT deselect the genre "Comedy".
fireEvent.click(boxes[0]);
const after = screen.getAllByRole('checkbox', { name: 'Comedy' });
expect(after[0]).toHaveAttribute('aria-checked', 'false');
expect(after[1]).toHaveAttribute('aria-checked', 'true');
// Exactly one remains selected → create posts only the still-selected genre proposal.
fireEvent.click(screen.getByRole('button', { name: /Create 1 channel/ }));
const createCall = fetchSpy.mock.calls.find(
(call) => call[0] === '/api/v1/channels/auto-tune' && (call[1]?.method ?? '').toUpperCase() === 'POST'
);
const body = JSON.parse(String(createCall?.[1]?.body));
expect(body.channels).toEqual([{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501' }]);
});
});
+876
View File
@@ -0,0 +1,876 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
ArrowLeft,
Check,
CheckCircle2,
Circle,
Clapperboard,
Eye,
Film,
FolderTree,
Hash,
ListOrdered,
MinusCircle,
RotateCcw,
SearchX,
Shuffle,
Sparkles,
TriangleAlert,
Tv,
XCircle
} from 'lucide-react';
import { Badge, Button, Checkbox, Input, Select, Spinner, Stat, Tag, Tooltip } from '../components';
import {
createAutoTunedChannels,
getChannelTemplates,
getDefaultChannelTemplate,
messageFromAutoTuneError,
messageFromChannelTemplateError,
previewAutoTune,
type AutoTuneAxis,
type AutoTuneProposal,
type AutoTuneResult,
type ChannelTemplate
} from '../api';
type WizardStep = 'configure' | 'preview' | 'create';
interface AxisMeta {
id: AutoTuneAxis;
title: string;
tagline: string;
icon: typeof Tv;
order: string;
shuffled: boolean;
}
// Display catalogue only — the server owns the query and channel name. Order here mirrors the
// server grouping (TvShow → TvGenre → MovieGenre).
const AXES: AxisMeta[] = [
{ id: 'TvShow', title: 'TV Shows', tagline: 'One 24/7 channel per show', icon: Tv, order: 'Episode order', shuffled: false },
{ id: 'TvGenre', title: 'TV Genres', tagline: 'A channel per episode genre', icon: Clapperboard, order: 'Shuffled', shuffled: true },
{ id: 'MovieGenre', title: 'Movie Genres', tagline: 'A movie channel per genre', icon: Film, order: 'Shuffled', shuffled: true }
];
const eyebrowStyle: CSSProperties = {
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--text-disabled)'
};
const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };
// Selection identity — axis+value uniquely identifies a proposal. Keying on the display
// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the
// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single
// space separates it unambiguously from the metadata value.
const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;
type TemplatesState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; templates: ChannelTemplate[] };
type PreviewState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; proposals: AutoTuneProposal[] };
type CreateState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; result: AutoTuneResult };
const RESULT_META: Record<string, { icon: typeof CheckCircle2; tone: 'ok' | 'neutral' | 'error'; color: string }> = {
Created: { icon: CheckCircle2, tone: 'ok', color: 'var(--status-ok)' },
Skipped: { icon: MinusCircle, tone: 'neutral', color: 'var(--text-secondary)' },
Failed: { icon: XCircle, tone: 'error', color: 'var(--status-error)' }
};
export function AutoTuneScreen() {
const [step, setStep] = useState<WizardStep>('configure');
const [axisIds, setAxisIds] = useState<Set<AutoTuneAxis>>(() => new Set<AutoTuneAxis>(['TvShow', 'TvGenre', 'MovieGenre']));
const [minItems, setMinItems] = useState('5');
const [startingNumber, setStartingNumber] = useState('500');
const [group, setGroup] = useState('Auto-Tuned');
const [templateId, setTemplateId] = useState('');
const [templatesState, setTemplatesState] = useState<TemplatesState>({ status: 'loading' });
const [previewState, setPreviewState] = useState<PreviewState>({ status: 'idle' });
const [createState, setCreateState] = useState<CreateState>({ status: 'idle' });
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const activeRef = useRef(true);
const templatesSeqRef = useRef(0);
const previewSeqRef = useRef(0);
const createSeqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadTemplates = useCallback(() => {
const id = ++templatesSeqRef.current;
Promise.all([getChannelTemplates(), getDefaultChannelTemplate()])
.then(([templates, preferred]) => {
if (!activeRef.current || id !== templatesSeqRef.current) {
return;
}
setTemplatesState({ status: 'success', templates });
const fallback = preferred ?? templates[0] ?? null;
if (fallback) {
setTemplateId(String(fallback.id));
}
})
.catch((error: unknown) => {
if (activeRef.current && id === templatesSeqRef.current) {
setTemplatesState({ status: 'error', message: messageFromChannelTemplateError(error) });
}
});
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
const toggleAxis = (id: AutoTuneAxis) => {
setAxisIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const runPreview = () => {
if (axisIds.size === 0) {
return;
}
const id = ++previewSeqRef.current;
setStep('preview');
setPreviewState({ status: 'loading' });
previewAutoTune({
axes: AXES.filter((axis) => axisIds.has(axis.id)).map((axis) => axis.id),
minItems: Math.max(1, Number.parseInt(minItems, 10) || 1),
startingNumber: Number.parseInt(startingNumber, 10) || 1
})
.then((proposals) => {
if (!activeRef.current || id !== previewSeqRef.current) {
return;
}
setPreviewState({ status: 'success', proposals });
// Default selection: every proposal that doesn't already exist.
setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey)));
})
.catch((error: unknown) => {
if (activeRef.current && id === previewSeqRef.current) {
setPreviewState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to preview channels') });
}
});
};
const proposals = previewState.status === 'success' ? previewState.proposals : [];
const selectedProposals = proposals.filter(
(proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal))
);
const runCreate = () => {
if (selectedProposals.length === 0 || templateId === '') {
return;
}
const id = ++createSeqRef.current;
setStep('create');
setCreateState({ status: 'loading' });
createAutoTunedChannels({
templateId: Number(templateId),
group,
channels: selectedProposals.map((proposal) => ({
axis: proposal.axis as AutoTuneAxis,
value: proposal.value,
name: proposal.name,
number: proposal.number
}))
})
.then((result) => {
if (activeRef.current && id === createSeqRef.current) {
setCreateState({ status: 'success', result });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === createSeqRef.current) {
setCreateState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to create channels') });
}
});
};
const restart = () => {
setPreviewState({ status: 'idle' });
setCreateState({ status: 'idle' });
setSelected(new Set());
setStep('configure');
};
const templateOptions =
templatesState.status === 'success'
? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
: [];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '12px 20px',
borderBottom: '1px solid var(--border-hairline)',
flex: '0 0 auto'
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 34,
height: 34,
borderRadius: 'var(--radius-sm)',
background: 'var(--ctv-accent-soft)',
color: 'var(--ctv-accent)',
flex: '0 0 auto'
}}
>
<Sparkles aria-hidden="true" size={19} />
</span>
<div style={{ flex: '0 0 auto', minWidth: 0 }}>
<h2 style={{ margin: 0, font: 'var(--weight-semibold) var(--text-md)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
Auto-Tune
</h2>
<div style={{ marginTop: 3, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
Generate channels from your library
</div>
</div>
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<StepRail step={step} />
</div>
{step === 'configure' && (
<Tooltip
placement="bottom"
label={axisIds.size ? 'Enumerate the library and preview proposed channels' : 'Pick at least one metadata axis'}
>
<Button
variant="primary"
disabled={axisIds.size === 0}
startIcon={<Eye aria-hidden="true" size={15} />}
onClick={runPreview}
>
Preview channels
</Button>
</Tooltip>
)}
{step === 'preview' && (
<>
<Button variant="ghost" startIcon={<ArrowLeft aria-hidden="true" size={15} />} onClick={() => setStep('configure')}>
Back
</Button>
<Tooltip
placement="bottom"
label={
selectedProposals.length === 0
? 'Select at least one channel'
: templateId === ''
? 'Choose a channel template on the Configure step first'
: 'Create the selected channels'
}
>
<Button
variant="primary"
disabled={selectedProposals.length === 0 || templateId === ''}
startIcon={<Check aria-hidden="true" size={15} />}
onClick={runCreate}
>
{selectedProposals.length
? `Create ${selectedProposals.length} channel${selectedProposals.length === 1 ? '' : 's'}`
: 'Create channels'}
</Button>
</Tooltip>
</>
)}
{step === 'create' && (
<Button variant="primary" startIcon={<RotateCcw aria-hidden="true" size={15} />} onClick={restart}>
Start over
</Button>
)}
</div>
<main style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{step === 'configure' && (
<ConfigureStep
axisIds={axisIds}
toggleAxis={toggleAxis}
minItems={minItems}
setMinItems={setMinItems}
startingNumber={startingNumber}
setStartingNumber={setStartingNumber}
group={group}
setGroup={setGroup}
templateId={templateId}
setTemplateId={setTemplateId}
templatesState={templatesState}
templateOptions={templateOptions}
retryTemplates={loadTemplates}
/>
)}
{step === 'preview' && (
<PreviewStep
state={previewState}
selected={selected}
setSelected={setSelected}
onRetry={runPreview}
/>
)}
{step === 'create' && <CreateStep state={createState} group={group} onRetry={runCreate} />}
</main>
</div>
);
}
const STEPS: { id: WizardStep; label: string }[] = [
{ id: 'configure', label: 'Configure' },
{ id: 'preview', label: 'Preview' },
{ id: 'create', label: 'Create' }
];
function StepRail({ step }: { step: WizardStep }) {
const index = STEPS.findIndex((entry) => entry.id === step);
return (
<ol style={{ display: 'flex', alignItems: 'center', gap: 4, margin: 0, padding: 0, listStyle: 'none' }}>
{STEPS.map((entry, i) => {
const done = i < index;
const active = i === index;
const color = active ? 'var(--ctv-accent)' : done ? 'var(--text-secondary)' : 'var(--text-disabled)';
return (
<li key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span
aria-hidden="true"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 20,
height: 20,
borderRadius: '50%',
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)',
background: active ? 'var(--ctv-accent)' : done ? 'var(--ctv-accent-soft)' : 'var(--ctv-surface-2)',
color: active ? 'var(--text-on-accent)' : done ? 'var(--ctv-accent)' : 'var(--text-disabled)'
}}
>
{done ? <Check aria-hidden="true" size={12} /> : i + 1}
</span>
<span
aria-current={active ? 'step' : undefined}
style={{
font: `${active ? 'var(--weight-semibold)' : 'var(--weight-medium)'} var(--text-xs)/1 var(--font-sans)`,
color
}}
>
{entry.label}
</span>
</span>
{i < STEPS.length - 1 && (
<span aria-hidden="true" style={{ width: 26, height: 1, background: 'var(--border-control)', margin: '0 4px' }} />
)}
</li>
);
})}
</ol>
);
}
function Field({ label, hint, children }: { label: string; hint: string; children: ReactNode }) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>{label}</span>
{children}
<span style={{ font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</span>
</label>
);
}
function ConfigureStep({
axisIds,
toggleAxis,
minItems,
setMinItems,
startingNumber,
setStartingNumber,
group,
setGroup,
templateId,
setTemplateId,
templatesState,
templateOptions,
retryTemplates
}: {
axisIds: Set<AutoTuneAxis>;
toggleAxis: (id: AutoTuneAxis) => void;
minItems: string;
setMinItems: (value: string) => void;
startingNumber: string;
setStartingNumber: (value: string) => void;
group: string;
setGroup: (value: string) => void;
templateId: string;
setTemplateId: (value: string) => void;
templatesState: TemplatesState;
templateOptions: { value: string; label: string }[];
retryTemplates: () => void;
}) {
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 24 }}>
<p style={{ margin: 0, font: 'var(--text-sm)/1.55 var(--font-sans)', color: 'var(--text-secondary)', maxWidth: 620 }}>
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.
</p>
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={eyebrowStyle}>Generate from</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
{AXES.map((axis) => {
const on = axisIds.has(axis.id);
const AxisIcon = axis.icon;
const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
return (
<button
key={axis.id}
type="button"
aria-pressed={on}
onClick={() => toggleAxis(axis.id)}
className="ctv-press"
style={{
textAlign: 'left',
cursor: 'pointer',
padding: '16px 16px 15px',
borderRadius: 'var(--radius-md)',
border: `1px solid ${on ? 'var(--ctv-accent)' : 'var(--border-control)'}`,
background: on ? 'var(--ctv-accent-soft)' : 'var(--surface-card)',
boxShadow: on ? 'var(--shadow-sm)' : 'none',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 9
}}
>
<span style={{ position: 'absolute', top: 12, right: 12, color: on ? 'var(--ctv-accent)' : 'var(--text-disabled)' }}>
{on ? <CheckCircle2 aria-hidden="true" size={17} /> : <Circle aria-hidden="true" size={17} />}
</span>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 30,
height: 30,
borderRadius: 'var(--radius-sm)',
background: on ? 'var(--ctv-accent)' : 'var(--ctv-surface-2)',
color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)'
}}
>
<AxisIcon aria-hidden="true" size={17} />
</span>
<div>
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
{axis.title}
</div>
<div style={{ marginTop: 3, font: 'var(--text-xs)/1.35 var(--font-sans)', color: 'var(--text-secondary)' }}>
{axis.tagline}
</div>
</div>
<div
style={{
marginTop: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: 5,
font: 'var(--text-2xs)/1 var(--font-sans)',
color: 'var(--text-disabled)'
}}
>
<OrderIcon aria-hidden="true" size={11} />
{axis.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={eyebrowStyle}>Defaults</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 14, alignItems: 'start' }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input
size="sm"
type="number"
value={minItems}
onChange={(event) => setMinItems(event.target.value)}
leadingIcon={<Hash aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input
size="sm"
type="number"
value={startingNumber}
onChange={(event) => setStartingNumber(event.target.value)}
leadingIcon={<Tv aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input
size="sm"
value={group}
onChange={(event) => setGroup(event.target.value)}
leadingIcon={<FolderTree aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
{templatesState.status === 'loading' ? (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '4px 2px' }} role="status">
<Spinner size={14} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading templates…</span>
</div>
) : templatesState.status === 'error' ? (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--status-error)' }}>{templatesState.message}</span>
<Button size="sm" variant="secondary" onClick={retryTemplates}>
Retry
</Button>
</div>
) : templateOptions.length === 0 ? (
<div
role="status"
style={{ font: 'var(--text-xs)/1.4 var(--font-sans)', color: 'var(--text-secondary)', padding: '4px 2px' }}
>
No channel templates yet — create one under Templates first.
</div>
) : (
<Select
size="sm"
value={templateId}
onChange={(event) => setTemplateId(event.target.value)}
options={templateOptions}
/>
)}
</Field>
</div>
</section>
</div>
);
}
function PreviewStep({
state,
selected,
setSelected,
onRetry
}: {
state: PreviewState;
selected: Set<string>;
setSelected: (updater: (current: Set<string>) => Set<string>) => void;
onRetry: () => void;
}) {
if (state.status === 'loading' || state.status === 'idle') {
return (
<div style={centeredMessageStyle} role="status">
<Spinner size={22} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Enumerating your library…</div>
</div>
);
}
if (state.status === 'error') {
return (
<div style={centeredMessageStyle} role="alert">
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
<Button size="sm" variant="secondary" onClick={onRetry}>
Retry
</Button>
</div>
);
}
const { proposals } = state;
if (proposals.length === 0) {
return (
<div style={centeredMessageStyle}>
<SearchX aria-hidden="true" size={26} style={{ color: 'var(--text-disabled)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
No channels matched — try lowering the minimum items.
</div>
</div>
);
}
const selectable = proposals.filter((proposal) => !proposal.alreadyExists);
const existingCount = proposals.length - selectable.length;
const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length;
const setAll = (rows: AutoTuneProposal[], on: boolean) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((row) => {
if (!row.alreadyExists) {
if (on) {
next.add(proposalKey(row));
} else {
next.delete(proposalKey(row));
}
}
});
return next;
});
};
const toggle = (key: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const groups = AXES.map((axis) => ({ axis, rows: proposals.filter((proposal) => proposal.axis === axis.id) })).filter(
(entry) => entry.rows.length > 0
);
return (
<div style={{ maxWidth: 920, margin: '0 auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
<span style={{ ...monoStyle, color: 'var(--text-primary)', fontWeight: 600 }}>{selectedCount}</span> of{' '}
{selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<CheckCircle2 aria-hidden="true" size={11} />} tone="neutral">
{existingCount} already exist — deselected
</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>
Clear
</Button>
</div>
{groups.map(({ axis, rows }) => {
const groupSelectable = rows.filter((row) => !row.alreadyExists);
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 (
<section
key={axis.id}
style={{
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--surface-card)'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderBottom: '1px solid var(--border-hairline)',
background: 'var(--ctv-bg-sunken)'
}}
>
<Checkbox
checked={allOn}
indeterminate={someOn && !allOn}
disabled={groupSelectable.length === 0}
label={`Select all ${axis.title}`}
onChange={() => setAll(rows, !allOn)}
/>
<AxisIcon aria-hidden="true" size={15} />
<span style={{ font: 'var(--weight-semibold) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
{axis.title}
</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrowStyle, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<OrderIcon aria-hidden="true" size={11} />
{axis.order}
</span>
</div>
<div>
{rows.map((row, i) => {
const on = selected.has(proposalKey(row));
return (
<div
key={proposalKey(row)}
style={{
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
opacity: row.alreadyExists ? 0.55 : 1
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px' }}>
<Checkbox
checked={on && !row.alreadyExists}
disabled={row.alreadyExists}
label={row.name}
onChange={() => toggle(proposalKey(row))}
/>
<span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
{row.number}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)',
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{row.name}
</div>
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
from “{row.value}”
</div>
</div>
<Badge tone="neutral">
<span style={monoStyle}>{row.itemCount}</span>&nbsp;items
</Badge>
{row.alreadyExists && <Tag tone="neutral">Exists</Tag>}
</div>
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
function CreateStep({ state, group, onRetry }: { state: CreateState; group: string; onRetry: () => void }) {
if (state.status === 'loading' || state.status === 'idle') {
return (
<div style={centeredMessageStyle} role="status">
<Spinner size={22} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Creating channels…</div>
</div>
);
}
if (state.status === 'error') {
return (
<div style={centeredMessageStyle} role="alert">
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
<Button size="sm" variant="secondary" onClick={onRetry}>
Retry
</Button>
</div>
);
}
const { result } = state;
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
<Stat label="Created" value={result.createdCount} icon={<CheckCircle2 aria-hidden="true" size={16} />} />
<Stat label="Skipped" value={result.skippedCount} icon={<MinusCircle aria-hidden="true" size={16} />} />
<Stat label="Failed" value={result.failedCount} icon={<XCircle aria-hidden="true" size={16} />} />
</div>
<div
style={{ display: 'flex', alignItems: 'center', gap: 7, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}
>
<FolderTree aria-hidden="true" size={13} /> Added to group{' '}
<span style={{ font: 'var(--weight-semibold) var(--text-xs) var(--font-sans)', color: 'var(--text-primary)' }}>{group}</span>
</div>
<section
style={{
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--surface-card)'
}}
>
{result.results.map((entry, i) => {
const meta = RESULT_META[entry.status] ?? RESULT_META.Skipped;
const StatusIcon = meta.icon;
return (
<div
key={`${entry.name}-${i}`}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '11px 14px',
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
}}
>
<span style={{ color: meta.color, display: 'inline-flex' }}>
<StatusIcon aria-hidden="true" size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
{entry.name}
</div>
{entry.reason && (
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
{entry.reason}
</div>
)}
</div>
{entry.channelId != null && (
<span style={{ ...monoStyle, font: 'var(--text-xs) var(--font-mono)', color: 'var(--text-disabled)' }}>
#{entry.channelId}
</span>
)}
<Badge tone={meta.tone}>{entry.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
const centeredMessageStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 12,
padding: '40px 24px'
};