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 ( + + + + set("language", e.target.value)} + options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} /> + + + + ); + } + + 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("preferredAudioLanguage", e.target.value)} + options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} /> + + + set("useEmbeddedSubtitles", x)} /> + + + set("extractEmbeddedSubtitles", x)} /> + + + set("probeInterlaced", x)} /> + + + set("saveReports", x)} /> + + + + + + set("globalFallbackFiller", e.target.value)} + options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} /> + + + + + + set("hlsIdleTimeout", x)} unit="sec" /> + + + set("hlsWorkAhead", x)} unit="sessions" /> + + + set("hlsInitialSegments", x)} unit="segments" /> + + + set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} /> + + + + + {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 }} /> + + +
+
+ + {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" /> + + + set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} /> + + + + ); + } + + 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) => ( + + 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 ( + + + + set("language", e.target.value)} + options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} /> + + + + ); + } + + 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("preferredAudioLanguage", e.target.value)} + options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} /> + + + set("useEmbeddedSubtitles", x)} /> + + + set("extractEmbeddedSubtitles", x)} /> + + + set("probeInterlaced", x)} /> + + + set("saveReports", x)} /> + + + + + + set("globalFallbackFiller", e.target.value)} + options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} /> + + + + + + set("hlsIdleTimeout", x)} unit="sec" /> + + + set("hlsWorkAhead", x)} unit="sessions" /> + + + set("hlsInitialSegments", x)} unit="segments" /> + + + set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} /> + + + + + {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 }} /> + + +
+
+ + {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" /> + + + set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} /> + + + + ); + } + + 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) => ( + +