Files
ersatztv/design-system/design_handoff_settings/Settings.jsx
T
timothyandClaude Fable 5 5f1d3e4d96 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>
2026-07-07 09:22:50 +02:00

417 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
})();