diff --git a/design-system/design_handoff_settings/README.md b/design-system/design_handoff_settings/README.md
new file mode 100644
index 000000000..1e04ec187
--- /dev/null
+++ b/design-system/design_handoff_settings/README.md
@@ -0,0 +1,115 @@
+# Handoff: ChicoryTV — Settings
+
+## Overview
+The **Settings** screen replaces ErsatzTV's seven legacy Blazor settings pages (FFmpeg, HDHR,
+Playout, XMLTV, Scanner, Logging, UI) with **one screen**: a left sub-nav rail selects a settings
+group, the right pane shows that group as dense "system settings" rows, and a **floating save bar**
+appears whenever there are unsaved changes. It ships inside the ChicoryTV admin shell.
+
+## About the design files
+`Settings.jsx` here is a **design reference** (same file as `templates/chicorytv-admin/Settings.jsx`,
+running on the UI-kit's React primitives + CSS custom-property tokens) — recreate it in the target
+codebase (`web/` React SPA) using its established components and patterns, not by shipping this file.
+
+## Backend contract (agreed scope, issue #93)
+Each pane maps 1:1 to a thin REST wrapper over an existing MediatR handler pair:
+
+| Pane | Endpoint | Fields |
+|---|---|---|
+| General | `GET/PUT /api/settings/ui` | theme (Dark/Light), language (culture) |
+| Streaming | `GET/PUT /api/settings/ffmpeg` | ffmpeg/ffprobe paths, default profile id, preferred audio language, 4 bool flags, global watermark id, global fallback filler id, HLS idle timeout / work-ahead limit / initial segments, HLS-Direct output format, default MPEG-TS script |
+| Streaming → Custom resolutions | `GET /api/settings/resolutions` + `POST`/`DELETE` custom resolutions | width × height list, custom-only deletable |
+| Streaming → FFmpeg profiles | existing `GET /api/ffmpeg/profiles` | read-only list + Default badge; editing stays in legacy UI (callout) |
+| Playout | `GET/PUT /api/settings/playout` | days to build, skip missing items, scripted schedule timeout |
+| Guide (XMLTV) | `GET/PUT /api/settings/xmltv` | days to build, time zone (Local/UTC), block behavior (SplitTimeEvenly/UseActualTimes) |
+| Scanner | `GET/PUT /api/settings/scanner` | library refresh interval hours (0 = disabled → warning callout) |
+| Logging | `GET/PUT /api/settings/logging` | 6 Serilog min levels: Default, Scanning, Scheduling, Searching, Streaming, HTTP |
+| System → HDHR | `GET/PUT /api/settings/hdhr` | tuner count (writable), device UUID (read-only + copy) |
+| System → Media sources | existing `GET /api/media-sources` | read-only list + "Legacy UI" callout |
+| System → About | existing `GET /api/version`, `GET /api/health` | version, health summary + link to Dashboard |
+
+## Fidelity
+**High-fidelity.** Colors, type, spacing, radii and interactions are resolved; all values are token
+references (`var(--…)`) from the design system. Recreate with the codebase's real component library.
+
+## Layout
+Full-height flex row under the admin shell TopBar (title "Settings"):
+
+- **Sub-nav rail** — 224px, `flex: 0 0 auto`, hairline right border, `padding: 12px 8px`,
+ scrolls independently. One button per section: icon (16px, accent when active) + label
+ (`--text-sm` medium; primary when active, else secondary) + one-line hint (`--text-2xs`,
+ disabled color, ellipsized). Active = `--ctv-accent-soft` background, radius `--radius-sm`;
+ hover = `--ctv-surface-2`. Uses `.ctv-press`. Sections (id · label · lucide icon · hint):
+ 1. `general` · General · SlidersHorizontal · "Theme & language"
+ 2. `streaming` · Streaming · Clapperboard · "FFmpeg & transcoding"
+ 3. `playout` · Playout · ListVideo · "Build defaults"
+ 4. `xmltv` · Guide (XMLTV) · CalendarDays · "EPG output"
+ 5. `scanner` · Scanner · Radar · "Library refresh"
+ 6. `logging` · Logging · ScrollText · "Log levels"
+ 7. `system` · System · Server · "HDHR, sources, about"
+
+- **Pane** — fills the rest; scroll container padded `20px 24px 96px` (bottom room for the save
+ bar). Content is a `max-width: 760px` column, `gap: 16`, entering with `ctv-fade-in` 240ms.
+ Pane header: title (`--text-lg` semibold) + subtitle (`--text-sm`, secondary, 1.45 line-height).
+
+## Row primitive (the core pattern)
+Settings are flush rows inside `Card padded={false}`; each row:
+`display:flex; align-items:center; gap:20; padding:13px 16px;` hairline top border between rows.
+Left: label (`--text-sm` medium, primary) + optional help line (`--text-xs`, disabled, 1.45).
+Right: fixed-width control slot (`flex: 0 0 260px` default; 320px for path inputs, 180px for
+log-level selects, 340px for the UUID row), right-justified. Controls are `size="sm"`:
+`Select fullWidth`, `Switch`, `Input`.
+
+Specialized controls:
+- **Path input** — mono font, full width of slot, trailing validity icon: `CircleCheck` in
+ `--status-ok` when valid, `CircleAlert` in `--status-error` with a "File not found" tooltip when not.
+- **Number input** — `type="number"`, 120px, mono, trailing unit hint (`sec`, `days`, `hours`,
+ `tuners`, `sessions`, `segments`) in `--text-2xs` disabled color.
+
+## Floating save bar (screen-level dirty state)
+One draft state for the whole screen (all panes share it — switching sections keeps edits).
+When ≥1 field differs from saved values, a pill bar floats bottom-center of the pane
+(absolute, `padding-bottom: 18px`): `--surface-raised` bg, `--border-control` border,
+`--radius-pill`, `--shadow-pop`, `ctv-fade-in` 200ms, `.ctv-lift`. Contents:
+"N unsaved change(s)" (`--text-sm` medium) · 1px hairline divider · **Discard** (ghost, resets
+draft) · **Save changes** (primary, Check icon). After save, the bar swaps to a transient
+confirmation — `CircleCheck` in `--status-ok` + "Settings saved" — for ~1.8s, then disappears.
+
+## Pane details
+- **General**: one card — Theme select (Dark/Light), Language select. Subtitle notes these apply
+ to the *legacy* web UI (ChicoryTV theming is the shell's theme switcher, not a server setting).
+- **Streaming**: five cards —
+ 1. *FFmpeg*: both path inputs, default profile select, preferred audio language select, four
+ switches (use embedded subtitles, extract embedded subtitles, probe interlaced, save reports).
+ 2. *Global defaults*: watermark select, fallback filler select (both with "(none)" option).
+ 3. *HLS sessions*: idle timeout, work-ahead limit, initial segments (number inputs) + HLS-Direct
+ output format select (MPEG-TS/MP4/MKV) + default MPEG-TS script select.
+ 4. *Custom resolutions* (flush list): rows of `W × H` (mono) + `custom` Tag + ghost trash
+ IconButton; footer add-row on `--ctv-bg-sunken`: width/height number inputs + "Add resolution"
+ secondary button (disabled until both filled).
+ 5. *FFmpeg profiles* (flush list, read-only): Cpu icon (accent) + name + **Default** accent Badge on
+ the default profile; card action shows a count Badge; footer = Legacy-UI callout (see below).
+- **Playout**: one card — days to build, skip missing (switch), scripted timeout.
+- **Guide (XMLTV)**: one card — days to build, time zone select, block behavior select.
+- **Scanner**: one card — refresh interval hours. When value is 0, a warning callout appears under
+ the card: `--status-warn-soft` bg, TriangleAlert icon, "Automatic scanning is disabled…".
+- **Logging**: one card, six rows (Default/Scanning/Scheduling/Searching/Streaming/HTTP), each a
+ level select (Verbose…Fatal, 180px slot) with a per-category help line.
+- **System**: three cards —
+ 1. *HDHomeRun*: tuner count (writable number input), device UUID (read-only mono + Copy IconButton).
+ 2. *Media sources* (flush, read-only): kind icon (HardDrive for Local, Cast otherwise) + name +
+ mono host detail + StatusDot (ok/warn) + last-scan text; footer Legacy-UI callout.
+ 3. *About*: version (mono), health checks summary + "Open Dashboard" secondary button (ArrowRight).
+
+## Legacy-UI callout (reusable)
+For anything visible-but-not-editable here: a row with `--ctv-surface-2` bg, **dashed**
+`--border-control` border, `--radius-sm`; ExternalLink icon (disabled color) + explanation
+(`--text-xs`, secondary) + a neutral **Legacy UI** Badge pinned right.
+
+## Interaction notes
+- Section switching is instant (no route change needed in the prototype; the SPA should use its
+ routing so sections are linkable, e.g. `/app/settings/streaming`).
+- Draft edits survive section switches; Discard restores all panes at once.
+- Number inputs keep string state in the prototype; the SPA should validate (ints ≥ 0) before save.
+- Reduced motion: all animation comes from `.ctv-lift`/`.ctv-press`/`ctv-fade-in`, already gated by
+ `prefers-reduced-motion` in the kit.
diff --git a/design-system/design_handoff_settings/Settings.jsx b/design-system/design_handoff_settings/Settings.jsx
new file mode 100644
index 000000000..962908a1a
--- /dev/null
+++ b/design-system/design_handoff_settings/Settings.jsx
@@ -0,0 +1,416 @@
+// Settings screen — GROUND-UP: left sub-nav rail + focused form panes, one pane
+// per settings group (mirrors /api/settings/*). Dense "system settings" rows:
+// label + help text left, control right. A floating save bar appears when dirty.
+(function () {
+ const NS = window.ChicoryTVDesignSystem_eb3b61;
+ const { Card, Button, IconButton, Badge, Input, Select, Switch, Tooltip, StatusDot, Tag } = NS;
+ const Ico = window.Ico;
+ const D = window.CTV_DATA;
+ const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
+
+ const SECTIONS = [
+ { id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" },
+ { id: "streaming",label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" },
+ { id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" },
+ { id: "xmltv", label: "Guide (XMLTV)",icon: "CalendarDays", hint: "EPG output" },
+ { id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" },
+ { id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" },
+ { id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" },
+ ];
+
+ const PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "480p H.264"];
+ const LOG_LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"];
+ const DEFAULTS = {
+ theme: "Dark", language: "English (en-US)",
+ ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe",
+ defaultProfile: "1080p H.264", preferredAudioLanguage: "English (eng)",
+ useEmbeddedSubtitles: true, extractEmbeddedSubtitles: false,
+ probeInterlaced: true, saveReports: false,
+ globalWatermark: "(none)", globalFallbackFiller: "Retro Bumpers",
+ hlsIdleTimeout: "60", hlsWorkAhead: "1", hlsInitialSegments: "1",
+ hlsDirectFormat: "MPEG-TS", mpegTsScript: "(default template)",
+ playoutDays: "2", skipMissing: true, scriptedTimeout: "30",
+ xmltvDays: "2", xmltvTimeZone: "Local", xmltvBlockBehavior: "Split time evenly",
+ refreshHours: "6",
+ logDefault: "Information", logScanning: "Information", logScheduling: "Information",
+ logSearching: "Information", logStreaming: "Information", logHttp: "Warning",
+ tunerCount: "2",
+ };
+
+ /* ---------- shared row primitives ---------- */
+
+ function Pane({ title, subtitle, children }) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {children}
+
+ );
+ }
+
+ // One setting: label + help left, control right. Flush rows inside Card padded={false}.
+ function Row({ label, help, children, first, control = 260 }) {
+ return (
+
+
+
{label}
+ {help &&
{help}
}
+
+
{children}
+
+ );
+ }
+
+ function LegacyCallout({ children }) {
+ return (
+
+
+ {children}
+ Legacy UI
+
+ );
+ }
+
+ function PathInput({ value, onChange }) {
+ const ok = value.startsWith("/");
+ return (
+ onChange(e.target.value)}
+ style={mono}
+ trailing={ok
+ ?
+ : } />
+ );
+ }
+
+ function NumInput({ value, onChange, unit, w = 120 }) {
+ return (
+ onChange(e.target.value)}
+ style={{ width: w, ...mono }}
+ trailing={unit && {unit}} />
+ );
+ }
+
+ /* ---------- panes ---------- */
+
+ function GeneralPane({ v, set }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ function StreamingPane({ v, set }) {
+ const [resW, setResW] = React.useState("");
+ const [resH, setResH] = React.useState("");
+ const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]);
+ return (
+
+
+
+ set("ffmpegPath", x)} />
+
+
+ set("ffprobePath", x)} />
+
+
+
+
+
+
+ set("useEmbeddedSubtitles", x)} />
+
+
+ set("extractEmbeddedSubtitles", x)} />
+
+
+ set("probeInterlaced", x)} />
+
+
+ set("saveReports", x)} />
+
+
+
+
+
+
+
+
+
+
+
+
+ set("hlsIdleTimeout", x)} unit="sec" />
+
+
+ set("hlsWorkAhead", x)} unit="sessions" />
+
+
+ set("hlsInitialSegments", x)} unit="segments" />
+
+
+
+
+
+
+
+
+ {customRes.map((r, i) => (
+
+
+ {r.w} × {r.h}
+ custom
+ setCustomRes(customRes.filter((x) => x.id !== r.id))}>
+
+
+
+ ))}
+
+ setResW(e.target.value)} style={{ width: 96, ...mono }} />
+ ×
+ setResH(e.target.value)} style={{ width: 96, ...mono }} />
+
+ } disabled={!resW || !resH}
+ onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
+ Add resolution
+
+
+
+
+ {PROFILES.length} profiles}>
+ {PROFILES.map((p, i) => (
+
+
+ {p}
+ {p === v.defaultProfile && Default}
+ {i + 1} ch
+
+ ))}
+
+ Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.
+
+
+
+ );
+ }
+
+ function PlayoutPane({ v, set }) {
+ return (
+
+
+
+ set("playoutDays", x)} unit="days" />
+
+
+ set("skipMissing", x)} />
+
+
+ set("scriptedTimeout", x)} unit="sec" />
+
+
+
+ );
+ }
+
+ function XmltvPane({ v, set }) {
+ return (
+
+
+
+ set("xmltvDays", x)} unit="days" />
+
+
+
+
+
+
+
+ );
+ }
+
+ function ScannerPane({ v, set }) {
+ return (
+
+
+
+ set("refreshHours", x)} unit="hours" />
+
+
+ {v.refreshHours === "0" && (
+
+
+
+ Automatic scanning is disabled — libraries only update when scanned manually.
+
+
+ )}
+
+ );
+ }
+
+ function LoggingPane({ v, set }) {
+ const CATS = [
+ { key: "logDefault", label: "Default", help: "Everything without a more specific category." },
+ { key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." },
+ { key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." },
+ { key: "logSearching", label: "Searching", help: "Search index and queries." },
+ { key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." },
+ { key: "logHttp", label: "HTTP", help: "ASP.NET request logging." },
+ ];
+ return (
+
+
+ {CATS.map((c, i) => (
+
+
+ ))}
+
+
+ );
+ }
+
+ function SystemPane({ v, set }) {
+ const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
+ return (
+
+
+
+ set("tunerCount", x)} unit="tuners" />
+
+
+
+ {UUID}
+
+
+
+
+
+
+ {D.sources.map((s, i) => (
+
+
+
+ {s.name}
+ {s.detail}
+
+
+
{s.lastScan}
+
+ ))}
+
+ Connections, credentials and library sync are managed in the legacy UI.
+
+
+
+
+
+ v26.4.0
+
+
+ }>Open Dashboard
+
+
+
+ );
+ }
+
+ /* ---------- screen ---------- */
+
+ function Settings() {
+ const [section, setSection] = React.useState("general");
+ const [saved, setSaved] = React.useState(DEFAULTS);
+ const [draft, setDraft] = React.useState(DEFAULTS);
+ const [justSaved, setJustSaved] = React.useState(false);
+ const set = (k, val) => setDraft((d) => ({ ...d, [k]: val }));
+ const dirty = Object.keys(DEFAULTS).filter((k) => draft[k] !== saved[k]);
+
+ const PANES = {
+ general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
+ xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
+ };
+ const PaneCmp = PANES[section];
+
+ return (
+
+ {/* sub-nav rail */}
+
+ {SECTIONS.map((s) => {
+ const active = s.id === section;
+ return (
+
+ );
+ })}
+
+
+ {/* pane */}
+
+
+
+ {/* floating save bar */}
+ {(dirty.length > 0 || justSaved) && (
+
+
+ {justSaved ? (
+ <>
+
+ Settings saved
+ >
+ ) : (
+ <>
+
+ {dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
+
+
+
+ }
+ onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
+ Save changes
+
+ >
+ )}
+
+
+ )}
+
+
+ );
+ }
+
+ window.CTVSettings = Settings;
+})();
diff --git a/design-system/templates/chicorytv-admin/Settings.jsx b/design-system/templates/chicorytv-admin/Settings.jsx
new file mode 100644
index 000000000..962908a1a
--- /dev/null
+++ b/design-system/templates/chicorytv-admin/Settings.jsx
@@ -0,0 +1,416 @@
+// Settings screen — GROUND-UP: left sub-nav rail + focused form panes, one pane
+// per settings group (mirrors /api/settings/*). Dense "system settings" rows:
+// label + help text left, control right. A floating save bar appears when dirty.
+(function () {
+ const NS = window.ChicoryTVDesignSystem_eb3b61;
+ const { Card, Button, IconButton, Badge, Input, Select, Switch, Tooltip, StatusDot, Tag } = NS;
+ const Ico = window.Ico;
+ const D = window.CTV_DATA;
+ const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
+
+ const SECTIONS = [
+ { id: "general", label: "General", icon: "SlidersHorizontal", hint: "Theme & language" },
+ { id: "streaming",label: "Streaming", icon: "Clapperboard", hint: "FFmpeg & transcoding" },
+ { id: "playout", label: "Playout", icon: "ListVideo", hint: "Build defaults" },
+ { id: "xmltv", label: "Guide (XMLTV)",icon: "CalendarDays", hint: "EPG output" },
+ { id: "scanner", label: "Scanner", icon: "Radar", hint: "Library refresh" },
+ { id: "logging", label: "Logging", icon: "ScrollText", hint: "Log levels" },
+ { id: "system", label: "System", icon: "Server", hint: "HDHR, sources, about" },
+ ];
+
+ const PROFILES = ["1080p H.264", "1080p HEVC", "720p H.264", "480p H.264"];
+ const LOG_LEVELS = ["Verbose", "Debug", "Information", "Warning", "Error", "Fatal"];
+ const DEFAULTS = {
+ theme: "Dark", language: "English (en-US)",
+ ffmpegPath: "/usr/bin/ffmpeg", ffprobePath: "/usr/bin/ffprobe",
+ defaultProfile: "1080p H.264", preferredAudioLanguage: "English (eng)",
+ useEmbeddedSubtitles: true, extractEmbeddedSubtitles: false,
+ probeInterlaced: true, saveReports: false,
+ globalWatermark: "(none)", globalFallbackFiller: "Retro Bumpers",
+ hlsIdleTimeout: "60", hlsWorkAhead: "1", hlsInitialSegments: "1",
+ hlsDirectFormat: "MPEG-TS", mpegTsScript: "(default template)",
+ playoutDays: "2", skipMissing: true, scriptedTimeout: "30",
+ xmltvDays: "2", xmltvTimeZone: "Local", xmltvBlockBehavior: "Split time evenly",
+ refreshHours: "6",
+ logDefault: "Information", logScanning: "Information", logScheduling: "Information",
+ logSearching: "Information", logStreaming: "Information", logHttp: "Warning",
+ tunerCount: "2",
+ };
+
+ /* ---------- shared row primitives ---------- */
+
+ function Pane({ title, subtitle, children }) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {children}
+
+ );
+ }
+
+ // One setting: label + help left, control right. Flush rows inside Card padded={false}.
+ function Row({ label, help, children, first, control = 260 }) {
+ return (
+
+
+
{label}
+ {help &&
{help}
}
+
+
{children}
+
+ );
+ }
+
+ function LegacyCallout({ children }) {
+ return (
+
+
+ {children}
+ Legacy UI
+
+ );
+ }
+
+ function PathInput({ value, onChange }) {
+ const ok = value.startsWith("/");
+ return (
+ onChange(e.target.value)}
+ style={mono}
+ trailing={ok
+ ?
+ : } />
+ );
+ }
+
+ function NumInput({ value, onChange, unit, w = 120 }) {
+ return (
+ onChange(e.target.value)}
+ style={{ width: w, ...mono }}
+ trailing={unit && {unit}} />
+ );
+ }
+
+ /* ---------- panes ---------- */
+
+ function GeneralPane({ v, set }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ function StreamingPane({ v, set }) {
+ const [resW, setResW] = React.useState("");
+ const [resH, setResH] = React.useState("");
+ const [customRes, setCustomRes] = React.useState([{ id: 1, w: 1920, h: 820 }, { id: 2, w: 2560, h: 1080 }]);
+ return (
+
+
+
+ set("ffmpegPath", x)} />
+
+
+ set("ffprobePath", x)} />
+
+
+
+
+
+
+ set("useEmbeddedSubtitles", x)} />
+
+
+ set("extractEmbeddedSubtitles", x)} />
+
+
+ set("probeInterlaced", x)} />
+
+
+ set("saveReports", x)} />
+
+
+
+
+
+
+
+
+
+
+
+
+ set("hlsIdleTimeout", x)} unit="sec" />
+
+
+ set("hlsWorkAhead", x)} unit="sessions" />
+
+
+ set("hlsInitialSegments", x)} unit="segments" />
+
+
+
+
+
+
+
+
+ {customRes.map((r, i) => (
+
+
+ {r.w} × {r.h}
+ custom
+ setCustomRes(customRes.filter((x) => x.id !== r.id))}>
+
+
+
+ ))}
+
+ setResW(e.target.value)} style={{ width: 96, ...mono }} />
+ ×
+ setResH(e.target.value)} style={{ width: 96, ...mono }} />
+
+ } disabled={!resW || !resH}
+ onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
+ Add resolution
+
+
+
+
+ {PROFILES.length} profiles}>
+ {PROFILES.map((p, i) => (
+
+
+ {p}
+ {p === v.defaultProfile && Default}
+ {i + 1} ch
+
+ ))}
+
+ Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.
+
+
+
+ );
+ }
+
+ function PlayoutPane({ v, set }) {
+ return (
+
+
+
+ set("playoutDays", x)} unit="days" />
+
+
+ set("skipMissing", x)} />
+
+
+ set("scriptedTimeout", x)} unit="sec" />
+
+
+
+ );
+ }
+
+ function XmltvPane({ v, set }) {
+ return (
+
+
+
+ set("xmltvDays", x)} unit="days" />
+
+
+
+
+
+
+
+ );
+ }
+
+ function ScannerPane({ v, set }) {
+ return (
+
+
+
+ set("refreshHours", x)} unit="hours" />
+
+
+ {v.refreshHours === "0" && (
+
+
+
+ Automatic scanning is disabled — libraries only update when scanned manually.
+
+
+ )}
+
+ );
+ }
+
+ function LoggingPane({ v, set }) {
+ const CATS = [
+ { key: "logDefault", label: "Default", help: "Everything without a more specific category." },
+ { key: "logScanning", label: "Scanning", help: "Library scans and metadata refresh." },
+ { key: "logScheduling", label: "Scheduling", help: "Playout builds and schedule evaluation." },
+ { key: "logSearching", label: "Searching", help: "Search index and queries." },
+ { key: "logStreaming", label: "Streaming", help: "FFmpeg sessions and IPTV requests." },
+ { key: "logHttp", label: "HTTP", help: "ASP.NET request logging." },
+ ];
+ return (
+
+
+ {CATS.map((c, i) => (
+
+
+ ))}
+
+
+ );
+ }
+
+ function SystemPane({ v, set }) {
+ const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
+ return (
+
+
+
+ set("tunerCount", x)} unit="tuners" />
+
+
+
+ {UUID}
+
+
+
+
+
+
+ {D.sources.map((s, i) => (
+
+
+
+ {s.name}
+ {s.detail}
+
+
+
{s.lastScan}
+
+ ))}
+
+ Connections, credentials and library sync are managed in the legacy UI.
+
+
+
+
+
+ v26.4.0
+
+
+ }>Open Dashboard
+
+
+
+ );
+ }
+
+ /* ---------- screen ---------- */
+
+ function Settings() {
+ const [section, setSection] = React.useState("general");
+ const [saved, setSaved] = React.useState(DEFAULTS);
+ const [draft, setDraft] = React.useState(DEFAULTS);
+ const [justSaved, setJustSaved] = React.useState(false);
+ const set = (k, val) => setDraft((d) => ({ ...d, [k]: val }));
+ const dirty = Object.keys(DEFAULTS).filter((k) => draft[k] !== saved[k]);
+
+ const PANES = {
+ general: GeneralPane, streaming: StreamingPane, playout: PlayoutPane,
+ xmltv: XmltvPane, scanner: ScannerPane, logging: LoggingPane, system: SystemPane,
+ };
+ const PaneCmp = PANES[section];
+
+ return (
+
+ {/* sub-nav rail */}
+
+ {SECTIONS.map((s) => {
+ const active = s.id === section;
+ return (
+
+ );
+ })}
+
+
+ {/* pane */}
+
+
+
+ {/* floating save bar */}
+ {(dirty.length > 0 || justSaved) && (
+
+
+ {justSaved ? (
+ <>
+
+ Settings saved
+ >
+ ) : (
+ <>
+
+ {dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
+
+
+
+ }
+ onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
+ Save changes
+
+ >
+ )}
+
+
+ )}
+
+
+ );
+ }
+
+ window.CTVSettings = Settings;
+})();
diff --git a/design-system/templates/chicorytv-admin/app.html b/design-system/templates/chicorytv-admin/app.html
index da8d968e9..8898ae0f7 100644
--- a/design-system/templates/chicorytv-admin/app.html
+++ b/design-system/templates/chicorytv-admin/app.html
@@ -85,6 +85,7 @@
+