Files
ersatztv/web/src/screens/AutoTuneScreen.tsx
T
timothy 56fffacfd4
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m16s
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 5s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 17m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(386): address cold-review findings on the DetailPanel
Cold adversarial review (no blockers, 3 real Mediums):
- Query&size "Order" row ignored the Shuffle toggle (dead ?? fallback showing
  the static axis order); now reflects shuffleOn, consistent with the subtitle.
- Clearing Name/Number to '' flagged the row Edited + armed the unsaved-changes
  guard, but the payload reverted to the proposal default — the two "edited"
  derivations disagreed. overrideEdited now ignores an empty (inherited) value.
- runPreview did not clear per-channel overrides, so edits (incl. pinned numbers)
  silently carried into a re-generated batch → collision risk. Fresh preview now
  resets overrides/detailKey/logo cache.
Tests: empty-name-not-Edited + re-preview-clears-overrides.

Live-E2E (local instance, seeded TV library): preview → Configure → toggle
Shuffle → Create verified end-to-end; the created channel's schedule shows
PlaybackOrder=Shuffle (overridden) vs SeasonEpisode (axis default), confirming
the per-channel override flows UI → SPA → backend → playout.

Refs #386
2026-07-18 01:31:40 +02:00

1574 lines
63 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import {
ArrowLeft,
Check,
CheckCircle2,
Circle,
Clapperboard,
Eye,
Film,
FolderTree,
Hash,
Image as ImageIcon,
Info,
ListOrdered,
MinusCircle,
Pencil,
RotateCcw,
SearchX,
Settings2,
Shuffle,
Sparkles,
TriangleAlert,
Tv,
XCircle
} from 'lucide-react';
import { Badge, Button, ChannelLogo, Checkbox, Input, Select, SlideOver, Spinner, Stat, Switch, Tag, Tooltip } from '../components';
import {
createAutoTunedChannels,
getAutoTuneChannelMembers,
getChannelTemplates,
getDefaultChannelTemplate,
getFFmpegProfiles,
getFillerPresets,
getWatermarks,
messageFromAutoTuneError,
messageFromChannelTemplateError,
uploadArtwork,
previewAutoTune,
type AutoTuneAxis,
type AutoTunedChannelRequest,
type AutoTuneMember,
type AutoTuneProposal,
type AutoTuneResult,
type ChannelTemplate,
type FFmpegProfile,
type FillerPreset,
type Watermark
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import {
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
SONG_VIDEO_MODES,
STREAM_SELECTOR_MODES,
STREAMING_MODE_LABELS,
STREAMING_MODES,
SUBTITLE_MODES,
TRANSCODE_MODES,
effectiveValue,
inheritOption,
templatePlaybackOrder,
useAdvancedOverrides,
type Overrides,
type PlaybackOrder,
type PlayoutMode,
type StreamingMode
} from '../builder/advancedOptions';
type WizardStep = 'configure' | 'preview' | 'create';
interface AxisMeta {
id: AutoTuneAxis;
title: string;
tagline: string;
icon: typeof Tv;
order: string;
shuffled: boolean;
}
// Display catalogue only — the server owns the query and channel name. Order here mirrors the
// server grouping (TvShow → TvGenre → MovieGenre).
const AXES: AxisMeta[] = [
{ id: 'TvShow', title: 'TV Shows', tagline: 'One 24/7 channel per show', icon: Tv, order: 'Episode order', shuffled: false },
{ id: 'TvGenre', title: 'TV Genres', tagline: 'A channel per episode genre', icon: Clapperboard, order: 'Shuffled', shuffled: true },
{ id: 'MovieGenre', title: 'Movie Genres', tagline: 'A movie channel per genre', icon: Film, order: 'Shuffled', shuffled: true }
];
const eyebrowStyle: CSSProperties = {
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--text-disabled)'
};
const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };
// Selection identity — axis+value uniquely identifies a proposal. Keying on the display
// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the
// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single
// space separates it unambiguously from the metadata value.
const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;
// A per-channel edit accumulated in the Preview step's DetailPanel, keyed by proposalKey.
// Everything is optional/inherited: an unset field falls back to the proposal default or the
// (per-channel-or-batch) template. playbackOrder + playoutMode mirror the Channel Builder's
// dedicated-state model (see builder/advancedOptions ADVANCED_KEYS comment) — the Shuffle and
// Always-playing toggles — and are merged into `advanced` only when the user diverges from the
// template default. Per-source rotation weights + query corrections are #425, not modelled here.
interface ChannelOverride {
name?: string;
number?: string;
templateId?: string; // '' / undefined = inherit the batch template
logoFile?: File;
logoPreview?: string;
playbackOrder?: PlaybackOrder;
playoutMode?: PlayoutMode;
advanced: Overrides;
}
const emptyOverride = (): ChannelOverride => ({ advanced: {} });
// True when the user actually diverged this proposal from its defaults (drives the "Edited"
// row badge + the unsaved-changes guard). A name/number equal to the proposal default does
// not count as edited.
function overrideEdited(override: ChannelOverride | undefined, proposal: AutoTuneProposal): boolean {
if (!override) {
return false;
}
return Boolean(
// A cleared field (trimmed to '') is NOT an edit — it inherits the proposal default, the same
// value buildChannels submits (`override.name?.trim() || proposal.name`). Only a non-empty value
// that differs counts, so the "Edited" badge + dirty guard match what actually gets created.
(override.name != null && override.name.trim() !== '' && override.name.trim() !== proposal.name) ||
(override.number != null && override.number.trim() !== '' && override.number.trim() !== proposal.number) ||
override.templateId ||
override.logoFile ||
override.playbackOrder != null ||
override.playoutMode != null ||
Object.keys(override.advanced).length > 0
);
}
type TemplatesState =
| { status: 'loading' }
| { status: 'error'; message: string }
| {
status: 'success';
templates: ChannelTemplate[];
ffmpegProfiles: FFmpegProfile[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
};
type PreviewState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; proposals: AutoTuneProposal[] };
type CreateState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; result: AutoTuneResult };
const RESULT_META: Record<string, { icon: typeof CheckCircle2; tone: 'ok' | 'neutral' | 'error'; color: string }> = {
Created: { icon: CheckCircle2, tone: 'ok', color: 'var(--status-ok)' },
Skipped: { icon: MinusCircle, tone: 'neutral', color: 'var(--text-secondary)' },
Failed: { icon: XCircle, tone: 'error', color: 'var(--status-error)' }
};
export function AutoTuneScreen() {
const [step, setStep] = useState<WizardStep>('configure');
const [axisIds, setAxisIds] = useState<Set<AutoTuneAxis>>(() => new Set<AutoTuneAxis>(['TvShow', 'TvGenre', 'MovieGenre']));
const [minItems, setMinItems] = useState('5');
const [startingNumber, setStartingNumber] = useState('500');
const [group, setGroup] = useState('Auto-Tuned');
const [templateId, setTemplateId] = useState('');
const [templatesState, setTemplatesState] = useState<TemplatesState>({ status: 'loading' });
const [previewState, setPreviewState] = useState<PreviewState>({ status: 'idle' });
const [createState, setCreateState] = useState<CreateState>({ status: 'idle' });
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [overrides, setOverrides] = useState<Record<string, ChannelOverride>>({});
const [detailKey, setDetailKey] = useState<string | null>(null);
// Cache each per-channel logo upload by File so a create retry doesn't orphan a fresh
// artwork every attempt (mirrors ChannelBuilder's uploadedLogoRef).
const uploadedLogosRef = useRef<Map<File, { path: string; contentType: string }>>(new Map());
const activeRef = useRef(true);
const templatesSeqRef = useRef(0);
const previewSeqRef = useRef(0);
const createSeqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadTemplates = useCallback(() => {
const id = ++templatesSeqRef.current;
// Pickers (ffmpeg profiles / filler presets / watermarks) back the DetailPanel's Advanced
// pane; load them alongside templates so a Configure panel opens without a second spinner.
Promise.all([
getChannelTemplates(),
getDefaultChannelTemplate(),
getFFmpegProfiles(),
getFillerPresets(),
getWatermarks()
])
.then(([templates, preferred, ffmpegProfiles, fillerPresets, watermarks]) => {
if (!activeRef.current || id !== templatesSeqRef.current) {
return;
}
setTemplatesState({ status: 'success', templates, ffmpegProfiles, fillerPresets, watermarks });
const fallback = preferred ?? templates[0] ?? null;
if (fallback) {
setTemplateId(String(fallback.id));
}
})
.catch((error: unknown) => {
if (activeRef.current && id === templatesSeqRef.current) {
setTemplatesState({ status: 'error', message: messageFromChannelTemplateError(error) });
}
});
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
const toggleAxis = (id: AutoTuneAxis) => {
setAxisIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const runPreview = () => {
if (axisIds.size === 0) {
return;
}
const id = ++previewSeqRef.current;
setStep('preview');
setPreviewState({ status: 'loading' });
// A fresh preview is a clean slate: drop any per-channel edits from a prior batch. They are
// keyed by axis+value and would silently re-attach to the re-enumerated proposals — including a
// pinned channel number that no longer fits the server's recomputed numbering (collision risk).
setOverrides({});
setDetailKey(null);
uploadedLogosRef.current = new Map();
previewAutoTune({
axes: AXES.filter((axis) => axisIds.has(axis.id)).map((axis) => axis.id),
minItems: Math.max(1, Number.parseInt(minItems, 10) || 1),
startingNumber: Number.parseInt(startingNumber, 10) || 1
})
.then((proposals) => {
if (!activeRef.current || id !== previewSeqRef.current) {
return;
}
setPreviewState({ status: 'success', proposals });
// Default selection: every proposal that doesn't already exist.
setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey)));
})
.catch((error: unknown) => {
if (activeRef.current && id === previewSeqRef.current) {
setPreviewState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to preview channels') });
}
});
};
const proposals = previewState.status === 'success' ? previewState.proposals : [];
const selectedProposals = proposals.filter(
(proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal))
);
const patchOverride = useCallback(
(key: string, delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => {
setOverrides((prev) => {
const current = prev[key] ?? emptyOverride();
const resolved = typeof delta === 'function' ? delta(current) : delta;
return { ...prev, [key]: { ...current, ...resolved } };
});
},
[]
);
const patchAdvanced = useCallback((key: string, updater: (prev: Overrides) => Overrides) => {
setOverrides((prev) => {
const current = prev[key] ?? emptyOverride();
return { ...prev, [key]: { ...current, advanced: updater(current.advanced) } };
});
}, []);
// Build the per-channel create payload, uploading any per-channel logo first and folding the
// Shuffle/Always-playing toggles back into `advanced` (only when diverged from the template).
const buildChannels = async (chosen: AutoTuneProposal[]): Promise<AutoTunedChannelRequest[]> =>
Promise.all(
chosen.map(async (proposal) => {
const override = overrides[proposalKey(proposal)];
const channel: AutoTunedChannelRequest = {
axis: proposal.axis as AutoTuneAxis,
value: proposal.value,
name: override?.name?.trim() || proposal.name,
number: override?.number?.trim() || proposal.number
};
if (override?.templateId) {
channel.templateId = Number(override.templateId);
}
if (override?.logoFile) {
let logo = uploadedLogosRef.current.get(override.logoFile);
if (!logo) {
const uploaded = await uploadArtwork(override.logoFile, 'logo');
logo = { path: uploaded.path, contentType: uploaded.contentType };
uploadedLogosRef.current.set(override.logoFile, logo);
}
channel.logo = logo;
}
const advanced: Overrides & { playbackOrder?: PlaybackOrder; playoutMode?: PlayoutMode } = {
...(override?.advanced ?? {})
};
if (override?.playbackOrder != null) {
advanced.playbackOrder = override.playbackOrder;
}
if (override?.playoutMode != null) {
advanced.playoutMode = override.playoutMode;
}
if (Object.keys(advanced).length > 0) {
channel.advanced = advanced as AutoTunedChannelRequest['advanced'];
}
return channel;
})
);
const runCreate = () => {
if (selectedProposals.length === 0 || templateId === '') {
return;
}
const id = ++createSeqRef.current;
const chosen = selectedProposals;
setStep('create');
setCreateState({ status: 'loading' });
buildChannels(chosen)
.then((channels) => createAutoTunedChannels({ templateId: Number(templateId), group, channels }))
.then((result) => {
if (activeRef.current && id === createSeqRef.current) {
setCreateState({ status: 'success', result });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === createSeqRef.current) {
setCreateState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to create channels') });
}
});
};
const restart = () => {
setPreviewState({ status: 'idle' });
setCreateState({ status: 'idle' });
setSelected(new Set());
setOverrides({});
setDetailKey(null);
setStep('configure');
};
// Unsaved-changes guard (spa-conventions §8): per-channel edits live only in this screen's
// draft state until bulk-create, so leaving the screen (or a full-page unload) with edits that
// haven't been created yet must confirm first. Closing the DetailPanel keeps edits in state, so
// only navigation/unload is guarded, not panel close.
const hasUncreatedEdits =
createState.status !== 'success' && proposals.some((proposal) => overrideEdited(overrides[proposalKey(proposal)], proposal));
const dirtyRef = useRef(false);
useEffect(() => {
dirtyRef.current = hasUncreatedEdits;
}, [hasUncreatedEdits]);
useEffect(
() =>
registerNavigationGuard(
() => !dirtyRef.current || window.confirm('Discard your per-channel edits? They have not been created yet.')
),
[]
);
useEffect(() => {
if (!hasUncreatedEdits) {
return;
}
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [hasUncreatedEdits]);
const templateOptions =
templatesState.status === 'success'
? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
: [];
// Resolve the proposal + effective template backing the open DetailPanel. The per-channel
// template override wins over the batch template; fall back to the batch/first template.
const detailProposal = detailKey ? (proposals.find((proposal) => proposalKey(proposal) === detailKey) ?? null) : null;
const detailOverride = detailKey ? (overrides[detailKey] ?? emptyOverride()) : emptyOverride();
const detailTemplate =
templatesState.status === 'success'
? (templatesState.templates.find((template) => String(template.id) === (detailOverride.templateId || templateId)) ??
templatesState.templates.find((template) => String(template.id) === templateId) ??
templatesState.templates[0] ??
null)
: null;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '12px 20px',
borderBottom: '1px solid var(--border-hairline)',
flex: '0 0 auto'
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 34,
height: 34,
borderRadius: 'var(--radius-sm)',
background: 'var(--ctv-accent-soft)',
color: 'var(--ctv-accent)',
flex: '0 0 auto'
}}
>
<Sparkles aria-hidden="true" size={19} />
</span>
<div style={{ flex: '0 0 auto', minWidth: 0 }}>
<h2 style={{ margin: 0, font: 'var(--weight-semibold) var(--text-md)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
Auto-Tune
</h2>
<div style={{ marginTop: 3, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
Generate channels from your library
</div>
</div>
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<StepRail step={step} />
</div>
{step === 'configure' && (
<Tooltip
placement="bottom"
label={axisIds.size ? 'Enumerate the library and preview proposed channels' : 'Pick at least one metadata axis'}
>
<Button
variant="primary"
disabled={axisIds.size === 0}
startIcon={<Eye aria-hidden="true" size={15} />}
onClick={runPreview}
>
Preview channels
</Button>
</Tooltip>
)}
{step === 'preview' && (
<>
<Button variant="ghost" startIcon={<ArrowLeft aria-hidden="true" size={15} />} onClick={() => setStep('configure')}>
Back
</Button>
<Tooltip
placement="bottom"
label={
selectedProposals.length === 0
? 'Select at least one channel'
: templateId === ''
? 'Choose a channel template on the Configure step first'
: 'Create the selected channels'
}
>
<Button
variant="primary"
disabled={selectedProposals.length === 0 || templateId === ''}
startIcon={<Check aria-hidden="true" size={15} />}
onClick={runCreate}
>
{selectedProposals.length
? `Create ${selectedProposals.length} channel${selectedProposals.length === 1 ? '' : 's'}`
: 'Create channels'}
</Button>
</Tooltip>
</>
)}
{step === 'create' && (
<Button variant="primary" startIcon={<RotateCcw aria-hidden="true" size={15} />} onClick={restart}>
Start over
</Button>
)}
</div>
<main style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{step === 'configure' && (
<ConfigureStep
axisIds={axisIds}
toggleAxis={toggleAxis}
minItems={minItems}
setMinItems={setMinItems}
startingNumber={startingNumber}
setStartingNumber={setStartingNumber}
group={group}
setGroup={setGroup}
templateId={templateId}
setTemplateId={setTemplateId}
templatesState={templatesState}
templateOptions={templateOptions}
retryTemplates={loadTemplates}
/>
)}
{step === 'preview' && (
<PreviewStep
state={previewState}
selected={selected}
setSelected={setSelected}
overrides={overrides}
onConfigure={setDetailKey}
onRetry={runPreview}
/>
)}
{step === 'create' && <CreateStep state={createState} group={group} onRetry={runCreate} />}
</main>
{detailProposal && detailTemplate && templatesState.status === 'success' && (
<DetailPanel
key={detailKey ?? undefined}
proposal={detailProposal}
override={detailOverride}
templates={templatesState.templates}
template={detailTemplate}
batchTemplateId={templateId}
ffmpegProfiles={templatesState.ffmpegProfiles}
fillerPresets={templatesState.fillerPresets}
watermarks={templatesState.watermarks}
onPatch={(delta) => detailKey && patchOverride(detailKey, delta)}
onPatchAdvanced={(updater) => detailKey && patchAdvanced(detailKey, updater)}
onClose={() => setDetailKey(null)}
/>
)}
</div>
);
}
const STEPS: { id: WizardStep; label: string }[] = [
{ id: 'configure', label: 'Configure' },
{ id: 'preview', label: 'Preview' },
{ id: 'create', label: 'Create' }
];
function StepRail({ step }: { step: WizardStep }) {
const index = STEPS.findIndex((entry) => entry.id === step);
return (
<ol style={{ display: 'flex', alignItems: 'center', gap: 4, margin: 0, padding: 0, listStyle: 'none' }}>
{STEPS.map((entry, i) => {
const done = i < index;
const active = i === index;
const color = active ? 'var(--ctv-accent)' : done ? 'var(--text-secondary)' : 'var(--text-disabled)';
return (
<li key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
<span
aria-hidden="true"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 20,
height: 20,
borderRadius: '50%',
font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)',
background: active ? 'var(--ctv-accent)' : done ? 'var(--ctv-accent-soft)' : 'var(--ctv-surface-2)',
color: active ? 'var(--text-on-accent)' : done ? 'var(--ctv-accent)' : 'var(--text-disabled)'
}}
>
{done ? <Check aria-hidden="true" size={12} /> : i + 1}
</span>
<span
aria-current={active ? 'step' : undefined}
style={{
font: `${active ? 'var(--weight-semibold)' : 'var(--weight-medium)'} var(--text-xs)/1 var(--font-sans)`,
color
}}
>
{entry.label}
</span>
</span>
{i < STEPS.length - 1 && (
<span aria-hidden="true" style={{ width: 26, height: 1, background: 'var(--border-control)', margin: '0 4px' }} />
)}
</li>
);
})}
</ol>
);
}
function Field({ label, hint, children }: { label: string; hint: string; children: ReactNode }) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>{label}</span>
{children}
<span style={{ font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</span>
</label>
);
}
function ConfigureStep({
axisIds,
toggleAxis,
minItems,
setMinItems,
startingNumber,
setStartingNumber,
group,
setGroup,
templateId,
setTemplateId,
templatesState,
templateOptions,
retryTemplates
}: {
axisIds: Set<AutoTuneAxis>;
toggleAxis: (id: AutoTuneAxis) => void;
minItems: string;
setMinItems: (value: string) => void;
startingNumber: string;
setStartingNumber: (value: string) => void;
group: string;
setGroup: (value: string) => void;
templateId: string;
setTemplateId: (value: string) => void;
templatesState: TemplatesState;
templateOptions: { value: string; label: string }[];
retryTemplates: () => void;
}) {
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 24 }}>
<p style={{ margin: 0, font: 'var(--text-sm)/1.55 var(--font-sans)', color: 'var(--text-secondary)', maxWidth: 620 }}>
Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, preview the proposed
channels, then create the ones you want. Existing channels are never touched.
</p>
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={eyebrowStyle}>Generate from</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
{AXES.map((axis) => {
const on = axisIds.has(axis.id);
const AxisIcon = axis.icon;
const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
return (
<button
key={axis.id}
type="button"
aria-pressed={on}
onClick={() => toggleAxis(axis.id)}
className="ctv-press"
style={{
textAlign: 'left',
cursor: 'pointer',
padding: '16px 16px 15px',
borderRadius: 'var(--radius-md)',
border: `1px solid ${on ? 'var(--ctv-accent)' : 'var(--border-control)'}`,
background: on ? 'var(--ctv-accent-soft)' : 'var(--surface-card)',
boxShadow: on ? 'var(--shadow-sm)' : 'none',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: 9
}}
>
<span style={{ position: 'absolute', top: 12, right: 12, color: on ? 'var(--ctv-accent)' : 'var(--text-disabled)' }}>
{on ? <CheckCircle2 aria-hidden="true" size={17} /> : <Circle aria-hidden="true" size={17} />}
</span>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 30,
height: 30,
borderRadius: 'var(--radius-sm)',
background: on ? 'var(--ctv-accent)' : 'var(--ctv-surface-2)',
color: on ? 'var(--text-on-accent)' : 'var(--text-secondary)'
}}
>
<AxisIcon aria-hidden="true" size={17} />
</span>
<div>
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
{axis.title}
</div>
<div style={{ marginTop: 3, font: 'var(--text-xs)/1.35 var(--font-sans)', color: 'var(--text-secondary)' }}>
{axis.tagline}
</div>
</div>
<div
style={{
marginTop: 'auto',
display: 'inline-flex',
alignItems: 'center',
gap: 5,
font: 'var(--text-2xs)/1 var(--font-sans)',
color: 'var(--text-disabled)'
}}
>
<OrderIcon aria-hidden="true" size={11} />
{axis.order}
</div>
</button>
);
})}
</div>
</section>
<section style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={eyebrowStyle}>Defaults</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 14, alignItems: 'start' }}>
<Field label="Minimum items" hint="Skip channels with fewer matching items than this.">
<Input
size="sm"
type="number"
value={minItems}
onChange={(event) => setMinItems(event.target.value)}
leadingIcon={<Hash aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Starting channel number" hint="Numbers count up from here, skipping any already taken.">
<Input
size="sm"
type="number"
value={startingNumber}
onChange={(event) => setStartingNumber(event.target.value)}
leadingIcon={<Tv aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Channel group" hint="Every generated channel lands in this group.">
<Input
size="sm"
value={group}
onChange={(event) => setGroup(event.target.value)}
leadingIcon={<FolderTree aria-hidden="true" size={14} />}
/>
</Field>
<Field label="Channel template" hint="Streaming, playout & filler defaults for the batch.">
{templatesState.status === 'loading' ? (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '4px 2px' }} role="status">
<Spinner size={14} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading templates</span>
</div>
) : templatesState.status === 'error' ? (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--status-error)' }}>{templatesState.message}</span>
<Button size="sm" variant="secondary" onClick={retryTemplates}>
Retry
</Button>
</div>
) : templateOptions.length === 0 ? (
<div
role="status"
style={{ font: 'var(--text-xs)/1.4 var(--font-sans)', color: 'var(--text-secondary)', padding: '4px 2px' }}
>
No channel templates yet create one under Templates first.
</div>
) : (
<Select
size="sm"
value={templateId}
onChange={(event) => setTemplateId(event.target.value)}
options={templateOptions}
/>
)}
</Field>
</div>
</section>
</div>
);
}
function PreviewStep({
state,
selected,
setSelected,
overrides,
onConfigure,
onRetry
}: {
state: PreviewState;
selected: Set<string>;
setSelected: (updater: (current: Set<string>) => Set<string>) => void;
overrides: Record<string, ChannelOverride>;
onConfigure: (key: string) => void;
onRetry: () => void;
}) {
if (state.status === 'loading' || state.status === 'idle') {
return (
<div style={centeredMessageStyle} role="status">
<Spinner size={22} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Enumerating your library</div>
</div>
);
}
if (state.status === 'error') {
return (
<div style={centeredMessageStyle} role="alert">
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
<Button size="sm" variant="secondary" onClick={onRetry}>
Retry
</Button>
</div>
);
}
const { proposals } = state;
if (proposals.length === 0) {
return (
<div style={centeredMessageStyle}>
<SearchX aria-hidden="true" size={26} style={{ color: 'var(--text-disabled)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
No channels matched try lowering the minimum items.
</div>
</div>
);
}
const selectable = proposals.filter((proposal) => !proposal.alreadyExists);
const existingCount = proposals.length - selectable.length;
const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length;
const setAll = (rows: AutoTuneProposal[], on: boolean) => {
setSelected((current) => {
const next = new Set(current);
rows.forEach((row) => {
if (!row.alreadyExists) {
if (on) {
next.add(proposalKey(row));
} else {
next.delete(proposalKey(row));
}
}
});
return next;
});
};
const toggle = (key: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const groups = AXES.map((axis) => ({ axis, rows: proposals.filter((proposal) => proposal.axis === axis.id) })).filter(
(entry) => entry.rows.length > 0
);
return (
<div style={{ maxWidth: 920, margin: '0 auto', padding: '20px 24px 40px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>
<span style={{ ...monoStyle, color: 'var(--text-primary)', fontWeight: 600 }}>{selectedCount}</span> of{' '}
{selectable.length} selected
</span>
{existingCount > 0 && (
<Tag icon={<CheckCircle2 aria-hidden="true" size={11} />} tone="neutral">
{existingCount} already exist deselected
</Tag>
)}
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, true)}>
Select all
</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(selectable, false)}>
Clear
</Button>
</div>
{groups.map(({ axis, rows }) => {
const groupSelectable = rows.filter((row) => !row.alreadyExists);
const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row)));
const someOn = groupSelectable.some((row) => selected.has(proposalKey(row)));
const AxisIcon = axis.icon;
const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
return (
<section
key={axis.id}
style={{
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--surface-card)'
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderBottom: '1px solid var(--border-hairline)',
background: 'var(--ctv-bg-sunken)'
}}
>
<Checkbox
checked={allOn}
indeterminate={someOn && !allOn}
disabled={groupSelectable.length === 0}
label={`Select all ${axis.title}`}
onChange={() => setAll(rows, !allOn)}
/>
<AxisIcon aria-hidden="true" size={15} />
<span style={{ font: 'var(--weight-semibold) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
{axis.title}
</span>
<Badge tone="neutral">{rows.length}</Badge>
<span style={{ flex: 1 }} />
<span style={{ ...eyebrowStyle, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<OrderIcon aria-hidden="true" size={11} />
{axis.order}
</span>
</div>
<div>
{rows.map((row, i) => {
const key = proposalKey(row);
const on = selected.has(key);
const displayName = overrides[key]?.name?.trim() || row.name;
const edited = overrideEdited(overrides[key], row);
return (
<div
key={key}
style={{
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
opacity: row.alreadyExists ? 0.55 : 1
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 14px' }}>
<Checkbox
checked={on && !row.alreadyExists}
disabled={row.alreadyExists}
label={displayName}
onChange={() => toggle(key)}
/>
<span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
{overrides[key]?.number?.trim() || row.number}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)',
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{displayName}
</div>
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
from {row.value}
</div>
</div>
{edited && (
<Tag icon={<Pencil aria-hidden="true" size={11} />} tone="accent">
Edited
</Tag>
)}
<Badge tone="neutral">
<span style={monoStyle}>{row.itemCount}</span>&nbsp;items
</Badge>
{row.alreadyExists ? (
<Tag tone="neutral">Exists</Tag>
) : (
<Button
size="sm"
variant="ghost"
startIcon={<Settings2 aria-hidden="true" size={14} />}
onClick={() => onConfigure(key)}
>
Configure
</Button>
)}
</div>
</div>
);
})}
</div>
</section>
);
})}
</div>
);
}
function CreateStep({ state, group, onRetry }: { state: CreateState; group: string; onRetry: () => void }) {
if (state.status === 'loading' || state.status === 'idle') {
return (
<div style={centeredMessageStyle} role="status">
<Spinner size={22} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Creating channels</div>
</div>
);
}
if (state.status === 'error') {
return (
<div style={centeredMessageStyle} role="alert">
<TriangleAlert aria-hidden="true" size={24} style={{ color: 'var(--status-error)' }} />
<div style={{ font: 'var(--text-sm)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>{state.message}</div>
<Button size="sm" variant="secondary" onClick={onRetry}>
Retry
</Button>
</div>
);
}
const { result } = state;
return (
<div style={{ maxWidth: 860, margin: '0 auto', padding: '26px 24px 40px', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 12 }}>
<Stat label="Created" value={result.createdCount} icon={<CheckCircle2 aria-hidden="true" size={16} />} />
<Stat label="Skipped" value={result.skippedCount} icon={<MinusCircle aria-hidden="true" size={16} />} />
<Stat label="Failed" value={result.failedCount} icon={<XCircle aria-hidden="true" size={16} />} />
</div>
<div
style={{ display: 'flex', alignItems: 'center', gap: 7, font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}
>
<FolderTree aria-hidden="true" size={13} /> Added to group{' '}
<span style={{ font: 'var(--weight-semibold) var(--text-xs) var(--font-sans)', color: 'var(--text-primary)' }}>{group}</span>
</div>
<section
style={{
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
background: 'var(--surface-card)'
}}
>
{result.results.map((entry, i) => {
const meta = RESULT_META[entry.status] ?? RESULT_META.Skipped;
const StatusIcon = meta.icon;
return (
<div
key={`${entry.name}-${i}`}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '11px 14px',
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
}}
>
<span style={{ color: meta.color, display: 'inline-flex' }}>
<StatusIcon aria-hidden="true" size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: 'var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)', color: 'var(--text-primary)' }}>
{entry.name}
</div>
{entry.reason && (
<div style={{ marginTop: 2, font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
{entry.reason}
</div>
)}
</div>
{entry.channelId != null && (
<span style={{ ...monoStyle, font: 'var(--text-xs) var(--font-mono)', color: 'var(--text-disabled)' }}>
#{entry.channelId}
</span>
)}
<Badge tone={meta.tone}>{entry.status}</Badge>
</div>
);
})}
</section>
</div>
);
}
type MembersState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; totalCount: number; members: AutoTuneMember[] };
// How many distinct members to preview in the Content sources pane. The list is advisory
// (it shows what the smart collection resolves to) so a single page is enough — no infinite scroll.
const MEMBER_PREVIEW_SIZE = 50;
function DetailSection({ label, children, hint }: { label: string; hint?: ReactNode; children: ReactNode }) {
return (
<section style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 22 }}>
<div style={eyebrowStyle}>{label}</div>
{children}
{hint != null && <div style={{ font: 'var(--text-2xs)/1.4 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>}
</section>
);
}
function ToggleRow({
label,
hint,
checked,
overridesTemplate,
onChange
}: {
label: string;
hint: string;
checked: boolean;
overridesTemplate: boolean;
onChange: (next: boolean) => void;
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch checked={checked} onChange={onChange} label={label} />
{overridesTemplate && <Tag tone="accent">overrides template</Tag>}
</div>
<div style={{ marginTop: 1, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>{hint}</div>
</div>
);
}
function ReadRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-secondary)' }}>{label}</span>
<span style={{ font: 'var(--weight-medium) var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-primary)', textAlign: 'right' }}>
{value}
</span>
</div>
);
}
function DetailPanel({
proposal,
override,
templates,
template,
batchTemplateId,
ffmpegProfiles,
fillerPresets,
watermarks,
onPatch,
onPatchAdvanced,
onClose
}: {
proposal: AutoTuneProposal;
override: ChannelOverride;
templates: ChannelTemplate[];
template: ChannelTemplate;
batchTemplateId: string;
ffmpegProfiles: FFmpegProfile[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
onPatch: (delta: Partial<ChannelOverride> | ((current: ChannelOverride) => Partial<ChannelOverride>)) => void;
onPatchAdvanced: (updater: (prev: Overrides) => Overrides) => void;
onClose: () => void;
}) {
const [membersState, setMembersState] = useState<MembersState>({ status: 'loading' });
const [advancedOpen, setAdvancedOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Load the distinct members this proposal's smart collection resolves to (#384). The parent
// keys the panel by proposalKey, so it remounts per proposal and `membersState` starts at
// 'loading' each time — the effect never sets state synchronously (spa-conventions §3), only in
// the fetch callbacks.
useEffect(() => {
getAutoTuneChannelMembers({ axis: proposal.axis as AutoTuneAxis, value: proposal.value, pageNum: 0, pageSize: MEMBER_PREVIEW_SIZE })
.then((page) => {
if (activeRef.current) {
setMembersState({ status: 'success', totalCount: page.totalCount, members: page.page ?? [] });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setMembersState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to load content sources') });
}
});
}, [proposal.axis, proposal.value]);
const readImage = (file: File | null | undefined) => {
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => onPatch({ logoFile: file, logoPreview: typeof reader.result === 'string' ? reader.result : undefined });
reader.readAsDataURL(file);
};
const displayName = override.name?.trim() || proposal.name;
const displayNumber = override.number?.trim() || proposal.number;
const templateShuffle = templatePlaybackOrder(template);
const shuffleOn = (override.playbackOrder ?? templateShuffle) === 'Shuffle';
const shuffleOverrides = override.playbackOrder != null && override.playbackOrder !== templateShuffle;
const templatePlayout = ((template as unknown as Record<string, unknown>).playoutMode as PlayoutMode) ?? 'Continuous';
const alwaysOn = (override.playoutMode ?? templatePlayout) === 'Continuous';
const alwaysOverrides = override.playoutMode != null && override.playoutMode !== templatePlayout;
const effectiveStreaming = effectiveValue('streamingMode', template, override.advanced) as StreamingMode | undefined;
const streamingLabel = effectiveStreaming ? (STREAMING_MODE_LABELS[effectiveStreaming] ?? effectiveStreaming) : '—';
const batchTemplateName = templates.find((candidate) => String(candidate.id) === batchTemplateId)?.name ?? 'batch template';
const overrideCount = Object.keys(override.advanced).length;
return (
<SlideOver
open
onClose={onClose}
title={displayName}
subtitle={
<span style={monoStyle}>
{displayNumber} · {shuffleOn ? 'Shuffled' : 'In order'}
</span>
}
>
<DetailSection label="Channel identity">
<Field label="Name" hint="Shown in the guide and channel list.">
<Input
size="sm"
value={displayName}
onChange={(event) => onPatch({ name: event.target.value })}
/>
</Field>
<Field label="Channel number" hint="Must be unique; the server skips numbers already taken.">
<Input
size="sm"
value={displayNumber}
onChange={(event) => onPatch({ number: event.target.value })}
leadingIcon={<Hash aria-hidden="true" size={14} />}
/>
</Field>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
fileInputRef.current?.click();
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 10,
borderRadius: 'var(--radius-md)',
border: '1px dashed var(--border-control)',
cursor: 'pointer'
}}
>
<ChannelLogo name={displayName} src={override.logoPreview ?? null} size={44} radius="var(--radius-sm)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: 'var(--weight-medium) var(--text-xs)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
{override.logoPreview ? 'Channel image set' : 'Drop or choose a channel image'}
</div>
<div style={{ marginTop: 3, font: 'var(--text-2xs)/1.35 var(--font-sans)', color: 'var(--text-disabled)' }}>
Used as the guide logo and on-screen bug. Optional.
</div>
</div>
<ImageIcon aria-hidden="true" size={16} style={{ color: 'var(--text-disabled)' }} />
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(event) => readImage(event.target.files && event.target.files[0])}
/>
</div>
</DetailSection>
<DetailSection label="Playback">
<ToggleRow
label="Shuffle"
hint="Play this channel's items in a shuffled order instead of chronologically."
checked={shuffleOn}
overridesTemplate={shuffleOverrides}
onChange={(next) => onPatch({ playbackOrder: next ? 'Shuffle' : 'Chronological' })}
/>
<ToggleRow
label="Always playing"
hint="Keep a continuous playout running instead of starting on demand."
checked={alwaysOn}
overridesTemplate={alwaysOverrides}
onChange={(next) => onPatch({ playoutMode: next ? 'Continuous' : 'OnDemand' })}
/>
</DetailSection>
<DetailSection label="Channel template" hint={`Leave on Inherit to use the batch template (${batchTemplateName}).`}>
<Select
size="sm"
value={override.templateId ?? ''}
onChange={(event) => onPatch({ templateId: event.target.value })}
options={[{ value: '', label: `Inherit (${batchTemplateName})` }, ...templates.map((candidate) => ({ value: String(candidate.id), label: candidate.name }))]}
/>
</DetailSection>
<DetailSection label="Advanced">
<button
type="button"
className="ctv-press"
onClick={() => setAdvancedOpen((open) => !open)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '9px 11px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-control)',
background: 'var(--surface-card)',
cursor: 'pointer',
textAlign: 'left'
}}
>
<Settings2 aria-hidden="true" size={15} style={{ color: 'var(--text-secondary)' }} />
<span style={{ flex: 1, font: 'var(--weight-medium) var(--text-sm)/1 var(--font-sans)', color: 'var(--text-primary)' }}>
{advancedOpen ? 'Hide advanced settings' : 'Override advanced settings'}
</span>
<span style={{ font: 'var(--text-2xs)/1 var(--font-mono)', color: 'var(--text-disabled)' }}>
{overrideCount > 0 ? `${overrideCount} overridden · ` : ''}
{template.name}
</span>
</button>
{advancedOpen && (
<AdvancedFields
template={template}
overrides={override.advanced}
setOverrides={onPatchAdvanced}
ffmpegProfiles={ffmpegProfiles}
fillerPresets={fillerPresets}
watermarks={watermarks}
/>
)}
</DetailSection>
<DetailSection label="Query & size">
<ReadRow label="Order" value={shuffleOn ? 'Shuffled' : 'In order'} />
<ReadRow label="Estimated items" value={<span style={monoStyle}>{proposal.itemCount}</span>} />
<ReadRow label="Streaming mode" value={streamingLabel} />
</DetailSection>
<DetailSection
label="Content sources"
hint={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Info aria-hidden="true" size={12} /> Per-source rotation weights &amp; query corrections are coming (#425).
</span>
}
>
{membersState.status === 'loading' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="status">
<Spinner size={14} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading content sources</span>
</div>
)}
{membersState.status === 'error' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
<span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>{membersState.message}</span>
</div>
)}
{membersState.status === 'success' && (
<>
<div style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
{membersState.totalCount} {membersState.totalCount === 1 ? 'source' : 'sources'}
{membersState.totalCount > membersState.members.length ? ` · showing first ${membersState.members.length}` : ''}
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
maxHeight: 220,
overflowY: 'auto'
}}
>
{membersState.members.length === 0 ? (
<div style={{ padding: '12px 14px', font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
No members resolved.
</div>
) : (
membersState.members.map((member, i) => (
<div
key={member.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
}}
>
{member.artwork ? (
<img
src={member.artwork}
alt=""
loading="lazy"
style={{ width: 28, height: 42, objectFit: 'cover', borderRadius: 3, flex: '0 0 auto' }}
/>
) : (
<div style={{ width: 28, height: 42, borderRadius: 3, background: 'var(--ctv-surface-2)', flex: '0 0 auto' }} />
)}
<span
style={{
flex: 1,
minWidth: 0,
font: 'var(--text-xs)/1.3 var(--font-sans)',
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{member.title}
</span>
</div>
))
)}
</div>
</>
)}
</DetailSection>
</SlideOver>
);
}
// The per-channel Advanced override grid. Reuses the shared INHERIT/omit adapters
// (builder/advancedOptions) so the "leave on Inherit = omit from request" contract (#135) is
// identical to the Channel Builder. Playback order + playout mode are NOT here — they are the
// Playback pane's Shuffle / Always-playing toggles; group/categories are Channel-Builder-only.
function AdvancedFields({
template,
overrides,
setOverrides,
ffmpegProfiles,
fillerPresets,
watermarks
}: {
template: ChannelTemplate;
overrides: Overrides;
setOverrides: (updater: (prev: Overrides) => Overrides) => void;
ffmpegProfiles: FFmpegProfile[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
}) {
const { setOverride, idSelectOptions, selValue, onSelect, textValue, onText, textPlaceholder, boolValue } =
useAdvancedOverrides(template, overrides, setOverrides);
const t = template as unknown as Record<string, unknown>;
const str = (value: unknown): string | null => (value == null ? null : String(value));
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 2 }}>
<AdvGroup label="Streaming">
<Select
size="sm"
label="FFmpeg profile"
value={selValue('ffmpegProfileId')}
options={[
inheritOption(ffmpegProfiles.find((p) => p.id === t.ffmpegProfileId)?.name ?? `#${t.ffmpegProfileId}`),
...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
]}
onChange={onSelect('ffmpegProfileId', Number)}
/>
<Select
size="sm"
label="Streaming mode"
value={selValue('streamingMode')}
options={[
inheritOption(STREAMING_MODE_LABELS[t.streamingMode as StreamingMode] ?? str(t.streamingMode)),
...STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))
]}
onChange={onSelect('streamingMode', (raw) => raw)}
/>
<Select
size="sm"
label="Transcode mode"
value={selValue('transcodeMode')}
options={[inheritOption(str(t.transcodeMode)), ...TRANSCODE_MODES.map((value) => ({ value, label: value }))]}
onChange={onSelect('transcodeMode', (raw) => raw)}
/>
</AdvGroup>
<AdvGroup label="Filler">
<Select size="sm" label="Fallback filler" value={selValue('fallbackFillerId')} options={idSelectOptions('fallbackFillerId', fillerPresets)} onChange={onSelect('fallbackFillerId', Number)} />
<Select size="sm" label="Pre-roll filler" value={selValue('preRollFillerId')} options={idSelectOptions('preRollFillerId', fillerPresets)} onChange={onSelect('preRollFillerId', Number)} />
<Select size="sm" label="Mid-roll filler" value={selValue('midRollFillerId')} options={idSelectOptions('midRollFillerId', fillerPresets)} onChange={onSelect('midRollFillerId', Number)} />
<Select size="sm" label="Post-roll filler" value={selValue('postRollFillerId')} options={idSelectOptions('postRollFillerId', fillerPresets)} onChange={onSelect('postRollFillerId', Number)} />
<Select size="sm" label="Watermark" value={selValue('watermarkId')} options={idSelectOptions('watermarkId', watermarks)} onChange={onSelect('watermarkId', Number)} />
</AdvGroup>
<AdvGroup label="Playback">
<Input size="sm" label="Preferred audio language" value={textValue('preferredAudioLanguageCode')} onChange={onText('preferredAudioLanguageCode')} placeholder={textPlaceholder('preferredAudioLanguageCode', 'e.g. eng')} />
<Input size="sm" label="Preferred subtitle language" value={textValue('preferredSubtitleLanguageCode')} onChange={onText('preferredSubtitleLanguageCode')} placeholder={textPlaceholder('preferredSubtitleLanguageCode', 'e.g. eng')} />
<Input size="sm" label="Preferred audio title" value={textValue('preferredAudioTitle')} onChange={onText('preferredAudioTitle')} placeholder={textPlaceholder('preferredAudioTitle')} />
<Select size="sm" label="Subtitle mode" value={selValue('subtitleMode')} options={[inheritOption(str(t.subtitleMode)), ...SUBTITLE_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('subtitleMode', (raw) => raw)} />
<Select size="sm" label="Stream selector mode" value={selValue('streamSelectorMode')} options={[inheritOption(str(t.streamSelectorMode)), ...STREAM_SELECTOR_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('streamSelectorMode', (raw) => raw)} />
<Input size="sm" label="Stream selector" value={textValue('streamSelector')} onChange={onText('streamSelector')} placeholder={textPlaceholder('streamSelector')} />
</AdvGroup>
<AdvGroup label="Behavior">
<Select size="sm" label="Music video credits" value={selValue('musicVideoCreditsMode')} options={[inheritOption(str(t.musicVideoCreditsMode)), ...MUSIC_VIDEO_CREDITS_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('musicVideoCreditsMode', (raw) => raw)} />
<Select size="sm" label="Song video mode" value={selValue('songVideoMode')} options={[inheritOption(str(t.songVideoMode)), ...SONG_VIDEO_MODES.map((value) => ({ value, label: value }))]} onChange={onSelect('songVideoMode', (raw) => raw)} />
<Select size="sm" label="Idle behavior" value={selValue('idleBehavior')} options={[inheritOption(str(t.idleBehavior)), ...IDLE_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('idleBehavior', (raw) => raw)} />
<Select size="sm" label="Fixed start time" value={selValue('fixedStartTimeBehavior')} options={[inheritOption(str(t.fixedStartTimeBehavior)), ...FIXED_START_TIME_BEHAVIORS.map((value) => ({ value, label: value }))]} onChange={onSelect('fixedStartTimeBehavior', (raw) => raw)} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Random start point</span>
<Switch checked={boolValue('randomStartPoint')} onChange={(next) => setOverride('randomStartPoint', next)} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Shuffle schedule items</span>
<Switch checked={boolValue('shuffleScheduleItems')} onChange={(next) => setOverride('shuffleScheduleItems', next)} />
</div>
</AdvGroup>
</div>
);
}
function AdvGroup({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ ...eyebrowStyle, color: 'var(--text-secondary)' }}>{label}</div>
{children}
</div>
);
}
const centeredMessageStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: 12,
padding: '40px 24px'
};