design: Settings screen prototype + handoff bundle (#93)

templates/chicorytv-admin/Settings.jsx wired into app.html;
design_handoff_settings/ carries the implementation spec + API mapping.
Synced to the Claude Design project via DesignSync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 09:22:50 +02:00
co-authored by Claude Fable 5
parent 57905bc0f4
commit 5f1d3e4d96
4 changed files with 950 additions and 1 deletions
@@ -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.
@@ -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 (
<div style={{ maxWidth: 760, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
{subtitle && <div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
</div>
{children}
</div>
);
}
// One setting: label + help left, control right. Flush rows inside Card padded={false}.
function Row({ label, help, children, first, control = 260 }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
</div>
);
}
function LegacyCallout({ children }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
<Ico n="ExternalLink" s={14} color="var(--text-disabled)" />
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{children}</span>
<Badge tone="neutral">Legacy UI</Badge>
</div>
);
}
function PathInput({ value, onChange }) {
const ok = value.startsWith("/");
return (
<Input size="sm" fullWidth value={value} onChange={(e) => onChange(e.target.value)}
style={mono}
trailing={ok
? <Ico n="CircleCheck" s={14} color="var(--status-ok)" />
: <Tooltip label="File not found"><Ico n="CircleAlert" s={14} color="var(--status-error)" /></Tooltip>} />
);
}
function NumInput({ value, onChange, unit, w = 120 }) {
return (
<Input size="sm" type="number" value={value} onChange={(e) => onChange(e.target.value)}
style={{ width: w, ...mono }}
trailing={unit && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>} />
);
}
/* ---------- panes ---------- */
function GeneralPane({ v, set }) {
return (
<Pane title="General" subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.">
<Card padded={false}>
<Row first label="Theme" help="Legacy UI color scheme.">
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
</Row>
<Row label="Language" help="Display language for the legacy UI.">
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
</Row>
</Card>
</Pane>
);
}
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 (
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
<Card title="FFmpeg" padded={false}>
<Row first label="FFmpeg path" help="Binary used for all transcoding." control={320}>
<PathInput value={v.ffmpegPath} onChange={(x) => set("ffmpegPath", x)} />
</Row>
<Row label="FFprobe path" help="Binary used to inspect media files." control={320}>
<PathInput value={v.ffprobePath} onChange={(x) => set("ffprobePath", x)} />
</Row>
<Row label="Default profile" help="Transcoding profile for new channels.">
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
</Row>
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
</Row>
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
</Row>
<Row label="Extract embedded subtitles" help="Copy text subtitles out ahead of playback (slower scans).">
<Switch size="sm" checked={v.extractEmbeddedSubtitles} onChange={(x) => set("extractEmbeddedSubtitles", x)} />
</Row>
<Row label="Probe for interlaced frames" help="Deeper scan to auto-apply deinterlacing.">
<Switch size="sm" checked={v.probeInterlaced} onChange={(x) => set("probeInterlaced", x)} />
</Row>
<Row label="Save troubleshooting reports" help="Write a report per transcode session to the config folder.">
<Switch size="sm" checked={v.saveReports} onChange={(x) => set("saveReports", x)} />
</Row>
</Card>
<Card title="Global defaults" padded={false}>
<Row first label="Watermark" help="Applied to every channel without its own watermark.">
<Select size="sm" fullWidth value={v.globalWatermark} onChange={(e) => set("globalWatermark", e.target.value)}
options={["(none)", "Station bug — corner", "Station bug — large"]} />
</Row>
<Row label="Fallback filler" help="Plays when a playout has nothing scheduled.">
<Select size="sm" fullWidth value={v.globalFallbackFiller} onChange={(e) => set("globalFallbackFiller", e.target.value)}
options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} />
</Row>
</Card>
<Card title="HLS sessions" padded={false}>
<Row first label="Idle timeout" help="Stop a segmenter after no client has asked for segments.">
<NumInput value={v.hlsIdleTimeout} onChange={(x) => set("hlsIdleTimeout", x)} unit="sec" />
</Row>
<Row label="Work-ahead limit" help="Max segmenters transcoding ahead of live at once.">
<NumInput value={v.hlsWorkAhead} onChange={(x) => set("hlsWorkAhead", x)} unit="sessions" />
</Row>
<Row label="Initial segments" help="Segments ready before playback starts.">
<NumInput value={v.hlsInitialSegments} onChange={(x) => set("hlsInitialSegments", x)} unit="segments" />
</Row>
<Row label="HLS Direct output format" help="Container for HLS Direct channels.">
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
</Row>
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
</Row>
</Card>
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
{customRes.map((r, i) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
<Tag>custom</Tag>
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
<Ico n="Trash2" s={14} />
</IconButton>
</div>
))}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ color: "var(--text-disabled)" }}>×</span>
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
Add resolution
</Button>
</div>
</Card>
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
{PROFILES.map((p, i) => (
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
</div>
</Card>
</Pane>
);
}
function PlayoutPane({ v, set }) {
return (
<Pane title="Playout" subtitle="Defaults for how far ahead playouts are built and how gaps are handled.">
<Card padded={false}>
<Row first label="Days to build" help="How many days of programming each playout keeps ahead.">
<NumInput value={v.playoutDays} onChange={(x) => set("playoutDays", x)} unit="days" />
</Row>
<Row label="Skip missing items" help="Skip items whose files are missing instead of failing the build.">
<Switch size="sm" checked={v.skipMissing} onChange={(x) => set("skipMissing", x)} />
</Row>
<Row label="Scripted schedule timeout" help="Max run time for external scripted-schedule processes.">
<NumInput value={v.scriptedTimeout} onChange={(x) => set("scriptedTimeout", x)} unit="sec" />
</Row>
</Card>
</Pane>
);
}
function XmltvPane({ v, set }) {
return (
<Pane title="Guide (XMLTV)" subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients.">
<Card padded={false}>
<Row first label="Days to build" help="Guide horizon exported per channel.">
<NumInput value={v.xmltvDays} onChange={(x) => set("xmltvDays", x)} unit="days" />
</Row>
<Row label="Time zone" help="Timestamps written into the XMLTV file.">
<Select size="sm" fullWidth value={v.xmltvTimeZone} onChange={(e) => set("xmltvTimeZone", e.target.value)} options={["Local", "UTC"]} />
</Row>
<Row label="Block behavior" help="How block-schedule playouts are represented in the guide.">
<Select size="sm" fullWidth value={v.xmltvBlockBehavior} onChange={(e) => set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} />
</Row>
</Card>
</Pane>
);
}
function ScannerPane({ v, set }) {
return (
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
<Card padded={false}>
<Row first label="Library refresh interval" help="Hours between automatic local-library scans. 0 disables automatic scanning.">
<NumInput value={v.refreshHours} onChange={(x) => set("refreshHours", x)} unit="hours" />
</Row>
</Card>
{v.refreshHours === "0" && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Automatic scanning is disabled — libraries only update when scanned manually.
</span>
</div>
)}
</Pane>
);
}
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 (
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
<Card padded={false}>
{CATS.map((c, i) => (
<Row key={c.key} first={i === 0} label={c.label} help={c.help} control={180}>
<Select size="sm" fullWidth value={v[c.key]} onChange={(e) => set(c.key, e.target.value)} options={LOG_LEVELS} />
</Row>
))}
</Card>
</Pane>
);
}
function SystemPane({ v, set }) {
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
return (
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
<Card title="HDHomeRun" padded={false}>
<Row first label="Tuner count" help="Concurrent streams advertised to clients that speak HDHR.">
<NumInput value={v.tunerCount} onChange={(x) => set("tunerCount", x)} unit="tuners" />
</Row>
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
</div>
</Row>
</Card>
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
{D.sources.map((s, i) => (
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
</div>
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
</div>
</Card>
<Card title="About" padded={false}>
<Row first label="Version" control={220}>
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
</Row>
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
</Row>
</Card>
</Pane>
);
}
/* ---------- 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 (
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
{/* sub-nav rail */}
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
{SECTIONS.map((s) => {
const active = s.id === section;
return (
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: `var(--weight-medium) var(--text-sm)/1 var(--font-sans)`, color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
</div>
</button>
);
})}
</div>
{/* pane */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
<PaneCmp key={section} v={draft} set={set} />
</div>
{/* floating save bar */}
{(dirty.length > 0 || justSaved) && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
{justSaved ? (
<>
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
</>
) : (
<>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
</span>
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
window.CTVSettings = Settings;
})();
@@ -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 (
<div style={{ maxWidth: 760, display: "flex", flexDirection: "column", gap: 16, animation: "ctv-fade-in 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-lg)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>
{subtitle && <div style={{ marginTop: 5, font: "var(--text-sm)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
</div>
{children}
</div>
);
}
// One setting: label + help left, control right. Flush rows inside Card padded={false}.
function Row({ label, help, children, first, control = 260 }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 20, padding: "13px 16px", borderTop: first ? "none" : "1px solid var(--border-hairline)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
{help && <div style={{ marginTop: 3, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-disabled)" }}>{help}</div>}
</div>
<div style={{ flex: `0 0 ${control}px`, display: "flex", justifyContent: "flex-end" }}>{children}</div>
</div>
);
}
function LegacyCallout({ children }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px dashed var(--border-control)" }}>
<Ico n="ExternalLink" s={14} color="var(--text-disabled)" />
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{children}</span>
<Badge tone="neutral">Legacy UI</Badge>
</div>
);
}
function PathInput({ value, onChange }) {
const ok = value.startsWith("/");
return (
<Input size="sm" fullWidth value={value} onChange={(e) => onChange(e.target.value)}
style={mono}
trailing={ok
? <Ico n="CircleCheck" s={14} color="var(--status-ok)" />
: <Tooltip label="File not found"><Ico n="CircleAlert" s={14} color="var(--status-error)" /></Tooltip>} />
);
}
function NumInput({ value, onChange, unit, w = 120 }) {
return (
<Input size="sm" type="number" value={value} onChange={(e) => onChange(e.target.value)}
style={{ width: w, ...mono }}
trailing={unit && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>} />
);
}
/* ---------- panes ---------- */
function GeneralPane({ v, set }) {
return (
<Pane title="General" subtitle="Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.">
<Card padded={false}>
<Row first label="Theme" help="Legacy UI color scheme.">
<Select size="sm" fullWidth value={v.theme} onChange={(e) => set("theme", e.target.value)} options={["Dark", "Light"]} />
</Row>
<Row label="Language" help="Display language for the legacy UI.">
<Select size="sm" fullWidth value={v.language} onChange={(e) => set("language", e.target.value)}
options={["English (en-US)", "English (en-GB)", "Deutsch (de)", "Español (es)", "Français (fr)", "Nederlands (nl)"]} />
</Row>
</Card>
</Pane>
);
}
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 (
<Pane title="Streaming" subtitle="FFmpeg engine, transcoding defaults and HLS session tuning.">
<Card title="FFmpeg" padded={false}>
<Row first label="FFmpeg path" help="Binary used for all transcoding." control={320}>
<PathInput value={v.ffmpegPath} onChange={(x) => set("ffmpegPath", x)} />
</Row>
<Row label="FFprobe path" help="Binary used to inspect media files." control={320}>
<PathInput value={v.ffprobePath} onChange={(x) => set("ffprobePath", x)} />
</Row>
<Row label="Default profile" help="Transcoding profile for new channels.">
<Select size="sm" fullWidth value={v.defaultProfile} onChange={(e) => set("defaultProfile", e.target.value)} options={PROFILES} />
</Row>
<Row label="Preferred audio language" help="Track picked when media has multiple audio languages.">
<Select size="sm" fullWidth value={v.preferredAudioLanguage} onChange={(e) => set("preferredAudioLanguage", e.target.value)}
options={["English (eng)", "Japanese (jpn)", "Spanish (spa)", "French (fra)", "German (deu)"]} />
</Row>
<Row label="Use embedded subtitles" help="Burn in / pass through subtitles found in media files.">
<Switch size="sm" checked={v.useEmbeddedSubtitles} onChange={(x) => set("useEmbeddedSubtitles", x)} />
</Row>
<Row label="Extract embedded subtitles" help="Copy text subtitles out ahead of playback (slower scans).">
<Switch size="sm" checked={v.extractEmbeddedSubtitles} onChange={(x) => set("extractEmbeddedSubtitles", x)} />
</Row>
<Row label="Probe for interlaced frames" help="Deeper scan to auto-apply deinterlacing.">
<Switch size="sm" checked={v.probeInterlaced} onChange={(x) => set("probeInterlaced", x)} />
</Row>
<Row label="Save troubleshooting reports" help="Write a report per transcode session to the config folder.">
<Switch size="sm" checked={v.saveReports} onChange={(x) => set("saveReports", x)} />
</Row>
</Card>
<Card title="Global defaults" padded={false}>
<Row first label="Watermark" help="Applied to every channel without its own watermark.">
<Select size="sm" fullWidth value={v.globalWatermark} onChange={(e) => set("globalWatermark", e.target.value)}
options={["(none)", "Station bug — corner", "Station bug — large"]} />
</Row>
<Row label="Fallback filler" help="Plays when a playout has nothing scheduled.">
<Select size="sm" fullWidth value={v.globalFallbackFiller} onChange={(e) => set("globalFallbackFiller", e.target.value)}
options={["(none)", "Retro Bumpers", "Ad Break Pool", "Test Pattern"]} />
</Row>
</Card>
<Card title="HLS sessions" padded={false}>
<Row first label="Idle timeout" help="Stop a segmenter after no client has asked for segments.">
<NumInput value={v.hlsIdleTimeout} onChange={(x) => set("hlsIdleTimeout", x)} unit="sec" />
</Row>
<Row label="Work-ahead limit" help="Max segmenters transcoding ahead of live at once.">
<NumInput value={v.hlsWorkAhead} onChange={(x) => set("hlsWorkAhead", x)} unit="sessions" />
</Row>
<Row label="Initial segments" help="Segments ready before playback starts.">
<NumInput value={v.hlsInitialSegments} onChange={(x) => set("hlsInitialSegments", x)} unit="segments" />
</Row>
<Row label="HLS Direct output format" help="Container for HLS Direct channels.">
<Select size="sm" fullWidth value={v.hlsDirectFormat} onChange={(e) => set("hlsDirectFormat", e.target.value)} options={["MPEG-TS", "MP4", "MKV"]} />
</Row>
<Row label="Default MPEG-TS script" help="Channels in MPEG-TS mode use this script unless overridden.">
<Select size="sm" fullWidth value={v.mpegTsScript} onChange={(e) => set("mpegTsScript", e.target.value)} options={["(default template)", "custom-remux.sh"]} />
</Row>
</Card>
<Card title="Custom resolutions" subtitle="Extra target resolutions selectable in FFmpeg profiles" padded={false}>
{customRes.map((r, i) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Proportions" s={15} color="var(--text-disabled)" />
<span style={{ flex: 1, ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>{r.w} × {r.h}</span>
<Tag>custom</Tag>
<IconButton size="sm" variant="ghost" title="Delete resolution" onClick={() => setCustomRes(customRes.filter((x) => x.id !== r.id))}>
<Ico n="Trash2" s={14} />
</IconButton>
</div>
))}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px", borderTop: customRes.length ? "1px solid var(--border-hairline)" : "none", background: "var(--ctv-bg-sunken)" }}>
<Input size="sm" type="number" placeholder="width" value={resW} onChange={(e) => setResW(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ color: "var(--text-disabled)" }}>×</span>
<Input size="sm" type="number" placeholder="height" value={resH} onChange={(e) => setResH(e.target.value)} style={{ width: 96, ...mono }} />
<span style={{ flex: 1 }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Plus" s={13} />} disabled={!resW || !resH}
onClick={() => { setCustomRes([...customRes, { id: Date.now(), w: +resW, h: +resH }]); setResW(""); setResH(""); }}>
Add resolution
</Button>
</div>
</Card>
<Card title="FFmpeg profiles" subtitle="Fine-grained transcoding presets" padded={false}
actions={<Badge tone="neutral">{PROFILES.length} profiles</Badge>}>
{PROFILES.map((p, i) => (
<div key={p} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n="Cpu" s={15} color="var(--ctv-accent)" />
<span style={{ flex: 1, font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{p}</span>
{p === v.defaultProfile && <Badge tone="accent">Default</Badge>}
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{i + 1} ch</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Profile editing (hardware acceleration, bitrates, tonemapping) stays in the legacy UI for now.</LegacyCallout>
</div>
</Card>
</Pane>
);
}
function PlayoutPane({ v, set }) {
return (
<Pane title="Playout" subtitle="Defaults for how far ahead playouts are built and how gaps are handled.">
<Card padded={false}>
<Row first label="Days to build" help="How many days of programming each playout keeps ahead.">
<NumInput value={v.playoutDays} onChange={(x) => set("playoutDays", x)} unit="days" />
</Row>
<Row label="Skip missing items" help="Skip items whose files are missing instead of failing the build.">
<Switch size="sm" checked={v.skipMissing} onChange={(x) => set("skipMissing", x)} />
</Row>
<Row label="Scripted schedule timeout" help="Max run time for external scripted-schedule processes.">
<NumInput value={v.scriptedTimeout} onChange={(x) => set("scriptedTimeout", x)} unit="sec" />
</Row>
</Card>
</Pane>
);
}
function XmltvPane({ v, set }) {
return (
<Pane title="Guide (XMLTV)" subtitle="Shape of the XMLTV guide data served to Jellyfin and other clients.">
<Card padded={false}>
<Row first label="Days to build" help="Guide horizon exported per channel.">
<NumInput value={v.xmltvDays} onChange={(x) => set("xmltvDays", x)} unit="days" />
</Row>
<Row label="Time zone" help="Timestamps written into the XMLTV file.">
<Select size="sm" fullWidth value={v.xmltvTimeZone} onChange={(e) => set("xmltvTimeZone", e.target.value)} options={["Local", "UTC"]} />
</Row>
<Row label="Block behavior" help="How block-schedule playouts are represented in the guide.">
<Select size="sm" fullWidth value={v.xmltvBlockBehavior} onChange={(e) => set("xmltvBlockBehavior", e.target.value)} options={["Split time evenly", "Use actual times"]} />
</Row>
</Card>
</Pane>
);
}
function ScannerPane({ v, set }) {
return (
<Pane title="Scanner" subtitle="Background scanning of local libraries.">
<Card padded={false}>
<Row first label="Library refresh interval" help="Hours between automatic local-library scans. 0 disables automatic scanning.">
<NumInput value={v.refreshHours} onChange={(x) => set("refreshHours", x)} unit="hours" />
</Row>
</Card>
{v.refreshHours === "0" && (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", borderRadius: "var(--radius-sm)", background: "var(--status-warn-soft)", border: "1px solid transparent" }}>
<Ico n="TriangleAlert" s={14} color="var(--status-warn)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
Automatic scanning is disabled — libraries only update when scanned manually.
</span>
</div>
)}
</Pane>
);
}
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 (
<Pane title="Logging" subtitle="Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.">
<Card padded={false}>
{CATS.map((c, i) => (
<Row key={c.key} first={i === 0} label={c.label} help={c.help} control={180}>
<Select size="sm" fullWidth value={v[c.key]} onChange={(e) => set(c.key, e.target.value)} options={LOG_LEVELS} />
</Row>
))}
</Card>
</Pane>
);
}
function SystemPane({ v, set }) {
const UUID = "6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41";
return (
<Pane title="System" subtitle="HDHomeRun emulation, connected media sources and server info.">
<Card title="HDHomeRun" padded={false}>
<Row first label="Tuner count" help="Concurrent streams advertised to clients that speak HDHR.">
<NumInput value={v.tunerCount} onChange={(x) => set("tunerCount", x)} unit="tuners" />
</Row>
<Row label="Device UUID" help="Identity presented to HDHR clients." control={340}>
<div style={{ display: "flex", alignItems: "center", gap: 6, width: "100%", justifyContent: "flex-end" }}>
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis" }}>{UUID}</span>
<IconButton size="sm" variant="ghost" title="Copy UUID"><Ico n="Copy" s={13} /></IconButton>
</div>
</Row>
</Card>
<Card title="Media sources" subtitle="Read-only — add or edit connections in the legacy UI" padded={false}>
{D.sources.map((s, i) => (
<div key={s.name} style={{ display: "flex", alignItems: "center", gap: 12, padding: "11px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<Ico n={s.kind === "Local" ? "HardDrive" : "Cast"} s={15} color="var(--text-secondary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{s.name}</span>
<span style={{ marginLeft: 8, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{s.detail}</span>
</div>
<StatusDot status={s.status === "connected" ? "ok" : "warn"} size={7} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", width: 62, textAlign: "right" }}>{s.lastScan}</span>
</div>
))}
<div style={{ padding: "10px 16px", borderTop: "1px solid var(--border-hairline)" }}>
<LegacyCallout>Connections, credentials and library sync are managed in the legacy UI.</LegacyCallout>
</div>
</Card>
<Card title="About" padded={false}>
<Row first label="Version" control={220}>
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: "var(--text-primary)" }}>v26.4.0</span>
</Row>
<Row label="Health checks" help="14 checks — 2 warnings." control={220}>
<Button size="sm" variant="secondary" endIcon={<Ico n="ArrowRight" s={13} />}>Open Dashboard</Button>
</Row>
</Card>
</Pane>
);
}
/* ---------- 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 (
<div style={{ display: "flex", height: "100%", minHeight: 0 }}>
{/* sub-nav rail */}
<div style={{ width: 224, flex: "0 0 auto", borderRight: "1px solid var(--border-hairline)", overflow: "auto", padding: "12px 8px" }}>
{SECTIONS.map((s) => {
const active = s.id === section;
return (
<button key={s.id} type="button" onClick={() => setSection(s.id)} className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "9px 10px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<Ico n={s.icon} s={16} color={active ? "var(--ctv-accent)" : "var(--text-secondary)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: `var(--weight-medium) var(--text-sm)/1 var(--font-sans)`, color: active ? "var(--text-primary)" : "var(--text-secondary)" }}>{s.label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.hint}</div>
</div>
</button>
);
})}
</div>
{/* pane */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, position: "relative", display: "flex", flexDirection: "column" }}>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "20px 24px 96px" }}>
<PaneCmp key={section} v={draft} set={set} />
</div>
{/* floating save bar */}
{(dirty.length > 0 || justSaved) && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", justifyContent: "center", padding: "0 24px 18px", pointerEvents: "none" }}>
<div className="ctv-lift" style={{ pointerEvents: "auto", display: "flex", alignItems: "center", gap: 14, padding: "10px 12px 10px 16px", borderRadius: "var(--radius-pill)", background: "var(--surface-raised)", border: "1px solid var(--border-control)", boxShadow: "var(--shadow-pop)", animation: "ctv-fade-in 200ms cubic-bezier(0.16,1,0.3,1)" }}>
{justSaved ? (
<>
<Ico n="CircleCheck" s={15} color="var(--status-ok)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Settings saved</span>
</>
) : (
<>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>
{dirty.length} unsaved {dirty.length === 1 ? "change" : "changes"}
</span>
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="ghost" onClick={() => setDraft(saved)}>Discard</Button>
<Button size="sm" variant="primary" startIcon={<Ico n="Check" s={14} />}
onClick={() => { setSaved(draft); setJustSaved(true); setTimeout(() => setJustSaved(false), 1800); }}>
Save changes
</Button>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
window.CTVSettings = Settings;
})();
@@ -85,6 +85,7 @@
<script type="text/babel" src="Epg.jsx" data-presets="react"></script>
<script type="text/babel" src="Playouts.jsx" data-presets="react"></script>
<script type="text/babel" src="ScheduleLibrary.jsx" data-presets="react"></script>
<script type="text/babel" src="Settings.jsx" data-presets="react"></script>
<script type="text/babel" data-presets="react">
const { Sidebar, TopBar } = window.CTVShell;
@@ -113,6 +114,7 @@ function App() {
schedules: window.CTVScheduleEditor,
playouts: window.CTVPlayouts,
libraries: window.CTVLibraries,
settings: window.CTVSettings,
};
const Screen = screens[view];
return (
@@ -131,7 +133,7 @@ function App() {
function mount() {
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVPlayouts || !window.CTVLibraries) {
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
return setTimeout(mount, 40);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);