fix(web): Channel Builder review fixes (refs #89)
- Dialog: focus only on open transition (was stealing focus from its own inputs every parent re-render); Escape listener via latest-ref - Advanced overrides: honest 'Inherit from template' semantics - the handler coalesces null with the template value so 'None' could never clear anything (clear-to-none follow-up: #135); save-as-template now composes from the same effective-value helper - toggle rows: label->div (label activation forwarding double-fired in real browsers); clear stale 422 row highlight on lineup mutations - port .ctv-lift/.ctv-press motion utilities + apply per prototype; Advanced expand scroll-into-view; Info glyph on advanced info line - fixture fidelity: real 'Validation failed' title, verbatim handler detail message, omit null keys, collectionKind on rerun fixture - cache successful artwork upload across create retries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-18
@@ -2203,7 +2203,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
...builderDefaults(),
|
||||
browseItems: [
|
||||
browseItem(),
|
||||
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', rerunCollectionId: 9, mediaItemId: undefined })
|
||||
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
|
||||
]
|
||||
});
|
||||
|
||||
@@ -2276,7 +2276,9 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
mockDashboardApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
|
||||
fromLineupFailure: { detail: 'lineup[1] references a missing media item.', status: 422, title: 'Invalid lineup' }
|
||||
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
|
||||
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
|
||||
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
@@ -2287,7 +2289,7 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
expect(await screen.findByText('lineup[1] references a missing media item.')).toBeInTheDocument();
|
||||
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
|
||||
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
|
||||
@@ -2392,6 +2394,13 @@ function mediaSource(overrides: Record<string, unknown> = {}): Record<string, un
|
||||
}
|
||||
|
||||
// ---- Channel Builder (#89) fixtures ----------------------------------------
|
||||
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
|
||||
// members are omitted from the wire entirely. Fixtures mirror that by
|
||||
// stripping null/undefined keys after overrides are applied.
|
||||
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
|
||||
}
|
||||
|
||||
// ChannelResponseModel
|
||||
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -2410,46 +2419,38 @@ function channelSummary(overrides: Record<string, unknown> = {}): Record<string,
|
||||
};
|
||||
}
|
||||
|
||||
// ChannelTemplateResponseModel
|
||||
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
|
||||
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
|
||||
// the Standard template and therefore absent from the serialized payload.
|
||||
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
return omitNullKeys({
|
||||
description: 'General-purpose 1080p H.264.',
|
||||
fFmpegProfileId: 100,
|
||||
fallbackFillerId: null,
|
||||
fixedStartTimeBehavior: 'Strict',
|
||||
id: 10,
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isDefault: true,
|
||||
isSystem: true,
|
||||
midRollFillerId: null,
|
||||
musicVideoCreditsMode: 'None',
|
||||
musicVideoCreditsTemplate: null,
|
||||
name: 'Standard',
|
||||
playoutMode: 'Continuous',
|
||||
playoutSource: 'Generated',
|
||||
postRollFillerId: null,
|
||||
preRollFillerId: null,
|
||||
preferredAudioLanguageCode: null,
|
||||
preferredAudioTitle: null,
|
||||
preferredSubtitleLanguageCode: null,
|
||||
randomStartPoint: false,
|
||||
shuffleScheduleItems: false,
|
||||
songVideoMode: 'Default',
|
||||
streamSelector: null,
|
||||
streamSelectorMode: 'Default',
|
||||
streamingMode: 'HttpLiveStreamingSegmenter',
|
||||
subtitleMode: 'None',
|
||||
transcodeMode: 'OnDemand',
|
||||
watermarkId: null,
|
||||
...overrides
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
|
||||
// typed id is populated and nullable metadata (duration/itemCount) is absent
|
||||
// unless the caller supplies it.
|
||||
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
return omitNullKeys({
|
||||
artwork: '',
|
||||
collectionType: 'TelevisionShow',
|
||||
id: 1,
|
||||
@@ -2459,7 +2460,7 @@ function browseItem(overrides: Record<string, unknown> = {}): Record<string, unk
|
||||
mediaType: 'TelevisionShow',
|
||||
title: 'Looney Tunes',
|
||||
...overrides
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
|
||||
|
||||
+229
-113
@@ -19,6 +19,7 @@ import {
|
||||
FolderTree,
|
||||
GripVertical,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Layers,
|
||||
Library,
|
||||
LayoutGrid,
|
||||
@@ -289,7 +290,7 @@ function Segmented<T extends string>({
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
title={option.title ?? option.label}
|
||||
className={`ctv-builder-seg-btn${on ? ' ctv-builder-seg-btn-on' : ''}`}
|
||||
className={`ctv-builder-seg-btn ctv-press${on ? ' ctv-builder-seg-btn-on' : ''}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.icon}
|
||||
@@ -368,7 +369,7 @@ function LibCard({
|
||||
onDragEnd={onDragEnd}
|
||||
onDoubleClick={onAdd}
|
||||
title="Double-click or drag to add"
|
||||
className="ctv-builder-libcard-compact"
|
||||
className="ctv-builder-libcard-compact ctv-press"
|
||||
style={
|
||||
added
|
||||
? {
|
||||
@@ -409,7 +410,7 @@ function LibCard({
|
||||
onDragEnd={onDragEnd}
|
||||
onDoubleClick={onAdd}
|
||||
title="Double-click or drag to add"
|
||||
className="ctv-builder-libcard"
|
||||
className="ctv-builder-libcard ctv-lift"
|
||||
>
|
||||
<div
|
||||
className="ctv-builder-libcard-poster"
|
||||
@@ -566,11 +567,10 @@ function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides:
|
||||
return (template as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
const NONE_OPTION = { value: '', label: 'None' };
|
||||
|
||||
function toIdOverride(raw: string): number | null {
|
||||
return raw ? Number(raw) : null;
|
||||
}
|
||||
// Sentinel select value meaning "inherit from the template" — the field is
|
||||
// omitted from `advanced` entirely (not sent as null). The API cannot express
|
||||
// clear-to-none, so there is no "None" choice; see #135.
|
||||
const INHERIT = '__inherit__';
|
||||
|
||||
// ---- Main screen -----------------------------------------------------------
|
||||
interface BuilderDataState {
|
||||
@@ -727,6 +727,9 @@ function ChannelBuilder({
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [errorRowIndex, setErrorRowIndex] = useState<number | null>(null);
|
||||
const creatingRef = useRef(false);
|
||||
// Cache a successful artwork upload keyed by the File object, so retrying a
|
||||
// failed create with the same image doesn't orphan a fresh upload each attempt.
|
||||
const uploadedLogoRef = useRef<{ file: File; logo: CreateChannelFromLineupRequest['logo'] } | null>(null);
|
||||
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
@@ -751,16 +754,26 @@ function ChannelBuilder({
|
||||
setTplOpen(false);
|
||||
};
|
||||
|
||||
// Any lineup mutation invalidates a server row-index highlight (it pointed at
|
||||
// the pre-mutation array), so clear the stale create error whenever it changes.
|
||||
const clearLineupError = () => {
|
||||
setCreateError(null);
|
||||
setErrorRowIndex(null);
|
||||
};
|
||||
|
||||
const addItem = (item: LibraryBrowseItem) => {
|
||||
clearLineupError();
|
||||
setLineup((current) => (current.some((existing) => lineupKey(existing) === lineupKey(item)) ? current : [...current, item]));
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
clearLineupError();
|
||||
setLineup((current) => current.filter((_, position) => position !== index));
|
||||
};
|
||||
|
||||
// drop onto lineup container (append) or a row (insert at index)
|
||||
const dropAt = (targetIndex: number | null) => {
|
||||
clearLineupError();
|
||||
setLineup((current) => {
|
||||
if (dragRow != null) {
|
||||
const next = current.slice();
|
||||
@@ -871,10 +884,18 @@ function ChannelBuilder({
|
||||
try {
|
||||
let logo: CreateChannelFromLineupRequest['logo'] = { path: '', contentType: '' };
|
||||
if (logoFile) {
|
||||
const uploaded = await uploadArtwork(logoFile, 'logo');
|
||||
logo = { path: uploaded.path, contentType: uploaded.contentType };
|
||||
if (uploadedLogoRef.current?.file === logoFile) {
|
||||
logo = uploadedLogoRef.current.logo;
|
||||
} else {
|
||||
const uploaded = await uploadArtwork(logoFile, 'logo');
|
||||
logo = { path: uploaded.path, contentType: uploaded.contentType };
|
||||
uploadedLogoRef.current = { file: logoFile, logo };
|
||||
}
|
||||
}
|
||||
|
||||
// advanced null/omitted = inherit template value (handler coalesces with ??);
|
||||
// clear-to-none is not expressible — see #135. Overrides only ever holds
|
||||
// real values (inherited fields are absent), so we copy present keys as-is.
|
||||
const advanced: CreateChannelFromLineupRequest['advanced'] = { playbackOrder, playoutMode };
|
||||
for (const key of ADVANCED_KEYS) {
|
||||
if (key in overrides) {
|
||||
@@ -944,37 +965,39 @@ function ChannelBuilder({
|
||||
setSaveBusy(true);
|
||||
setSaveError(null);
|
||||
|
||||
const resolved = <T,>(key: AdvancedKey, fallback: T): T =>
|
||||
(key in overrides ? (overrides[key] as T) : ((template as unknown as Record<string, unknown>)[key] as T)) ?? fallback;
|
||||
// Compose from the same effective-value helper the panel renders with, so
|
||||
// what's saved equals what's shown. Overrides never hold null/'' (inherited
|
||||
// fields are absent), so effectiveValue is exactly the displayed value.
|
||||
const eff = <T,>(key: AdvancedKey): T => effectiveValue(key, template, overrides) as T;
|
||||
|
||||
try {
|
||||
const requestBody: CreateChannelTemplateRequest = {
|
||||
name: saveName.trim(),
|
||||
description: saveDesc.trim(),
|
||||
fFmpegProfileId: resolved('fFmpegProfileId', template.fFmpegProfileId),
|
||||
watermarkId: resolved('watermarkId', template.watermarkId),
|
||||
fallbackFillerId: resolved('fallbackFillerId', template.fallbackFillerId),
|
||||
preRollFillerId: resolved('preRollFillerId', template.preRollFillerId),
|
||||
midRollFillerId: resolved('midRollFillerId', template.midRollFillerId),
|
||||
postRollFillerId: resolved('postRollFillerId', template.postRollFillerId),
|
||||
streamSelectorMode: resolved('streamSelectorMode', template.streamSelectorMode),
|
||||
streamSelector: resolved('streamSelector', template.streamSelector),
|
||||
preferredAudioLanguageCode: resolved('preferredAudioLanguageCode', template.preferredAudioLanguageCode),
|
||||
preferredAudioTitle: resolved('preferredAudioTitle', template.preferredAudioTitle),
|
||||
fFmpegProfileId: eff('fFmpegProfileId'),
|
||||
watermarkId: eff('watermarkId'),
|
||||
fallbackFillerId: eff('fallbackFillerId'),
|
||||
preRollFillerId: eff('preRollFillerId'),
|
||||
midRollFillerId: eff('midRollFillerId'),
|
||||
postRollFillerId: eff('postRollFillerId'),
|
||||
streamSelectorMode: eff('streamSelectorMode'),
|
||||
streamSelector: eff('streamSelector'),
|
||||
preferredAudioLanguageCode: eff('preferredAudioLanguageCode'),
|
||||
preferredAudioTitle: eff('preferredAudioTitle'),
|
||||
playoutSource: template.playoutSource,
|
||||
// toggles are the source of truth for these two:
|
||||
playoutMode,
|
||||
streamingMode: resolved('streamingMode', template.streamingMode),
|
||||
preferredSubtitleLanguageCode: resolved('preferredSubtitleLanguageCode', template.preferredSubtitleLanguageCode),
|
||||
subtitleMode: resolved('subtitleMode', template.subtitleMode),
|
||||
musicVideoCreditsMode: resolved('musicVideoCreditsMode', template.musicVideoCreditsMode),
|
||||
streamingMode: eff('streamingMode'),
|
||||
preferredSubtitleLanguageCode: eff('preferredSubtitleLanguageCode'),
|
||||
subtitleMode: eff('subtitleMode'),
|
||||
musicVideoCreditsMode: eff('musicVideoCreditsMode'),
|
||||
musicVideoCreditsTemplate: template.musicVideoCreditsTemplate,
|
||||
songVideoMode: resolved('songVideoMode', template.songVideoMode),
|
||||
transcodeMode: resolved('transcodeMode', template.transcodeMode),
|
||||
idleBehavior: resolved('idleBehavior', template.idleBehavior),
|
||||
songVideoMode: eff('songVideoMode'),
|
||||
transcodeMode: eff('transcodeMode'),
|
||||
idleBehavior: eff('idleBehavior'),
|
||||
shuffleScheduleItems: shuffleOn,
|
||||
randomStartPoint: resolved('randomStartPoint', template.randomStartPoint),
|
||||
fixedStartTimeBehavior: resolved('fixedStartTimeBehavior', template.fixedStartTimeBehavior)
|
||||
randomStartPoint: eff('randomStartPoint'),
|
||||
fixedStartTimeBehavior: eff('fixedStartTimeBehavior')
|
||||
};
|
||||
|
||||
const created = await createChannelTemplate(requestBody);
|
||||
@@ -1082,7 +1105,7 @@ function ChannelBuilder({
|
||||
<div className="ctv-builder-chiprow">
|
||||
<button
|
||||
type="button"
|
||||
className={`ctv-builder-chip${libraryId == null ? ' ctv-builder-chip-on' : ''}`}
|
||||
className={`ctv-builder-chip ctv-press${libraryId == null ? ' ctv-builder-chip-on' : ''}`}
|
||||
onClick={() => setLibraryId(null)}
|
||||
>
|
||||
All
|
||||
@@ -1091,7 +1114,7 @@ function ChannelBuilder({
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
className={`ctv-builder-chip${libraryId === chip.id ? ' ctv-builder-chip-on' : ''}`}
|
||||
className={`ctv-builder-chip ctv-press${libraryId === chip.id ? ' ctv-builder-chip-on' : ''}`}
|
||||
onClick={() => setLibraryId(chip.id)}
|
||||
>
|
||||
{chip.name}
|
||||
@@ -1200,7 +1223,7 @@ function ChannelBuilder({
|
||||
return (
|
||||
<div
|
||||
key={`${lineupKey(item)}-${index}`}
|
||||
className={`ctv-builder-lineup-row${rowError || serverError ? ' ctv-builder-lineup-row-error' : ''}`}
|
||||
className={`ctv-builder-lineup-row ctv-lift${rowError || serverError ? ' ctv-builder-lineup-row-error' : ''}`}
|
||||
style={{
|
||||
opacity: dragRow === index ? 0.35 : 1,
|
||||
boxShadow: overRow === index && dragRow !== index ? '0 -2px 0 var(--action-primary)' : 'none'
|
||||
@@ -1392,7 +1415,10 @@ function ChannelBuilder({
|
||||
|
||||
{/* friendly toggles */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label
|
||||
{/* div, not label: the Switch is a labelable <button>, so a wrapping
|
||||
label would forward its activation AND bubble the click, firing
|
||||
the toggle twice. The row's onClick is the single toggle source. */}
|
||||
<div
|
||||
className="ctv-builder-toggle-row"
|
||||
onClick={() => setPlaybackOrder(shuffleOn ? 'Chronological' : 'Shuffle')}
|
||||
>
|
||||
@@ -1409,9 +1435,9 @@ function ChannelBuilder({
|
||||
</div>
|
||||
{/* presentational - the row's onClick is the single toggle source */}
|
||||
<Switch checked={shuffleOn} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="ctv-builder-hairline" />
|
||||
<label
|
||||
<div
|
||||
className={`ctv-builder-toggle-row ctv-builder-toggle-live${alwaysOn ? ' ctv-builder-toggle-live-on' : ''}`}
|
||||
onClick={() => setPlayoutMode(alwaysOn ? 'OnDemand' : 'Continuous')}
|
||||
>
|
||||
@@ -1437,7 +1463,7 @@ function ChannelBuilder({
|
||||
</div>
|
||||
{/* presentational - the row's onClick is the single toggle source */}
|
||||
<Switch checked={alwaysOn} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* channel template */}
|
||||
@@ -1452,7 +1478,7 @@ function ChannelBuilder({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="ctv-builder-tpl-button" onClick={() => setTplOpen((open) => !open)}>
|
||||
<button type="button" className="ctv-builder-tpl-button ctv-press" onClick={() => setTplOpen((open) => !open)}>
|
||||
<span className="ctv-builder-tpl-tile">
|
||||
<SlidersHorizontal size={15} aria-hidden="true" />
|
||||
</span>
|
||||
@@ -1476,7 +1502,7 @@ function ChannelBuilder({
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`ctv-builder-tpl-option${on ? ' ctv-builder-tpl-option-on' : ''}`}
|
||||
className={`ctv-builder-tpl-option ctv-press${on ? ' ctv-builder-tpl-option-on' : ''}`}
|
||||
onClick={() => applyTemplate(option)}
|
||||
>
|
||||
{on ? (
|
||||
@@ -1536,6 +1562,7 @@ function ChannelBuilder({
|
||||
setMode={setAdvMode}
|
||||
maxHeight={advMaxHeight}
|
||||
fieldsRef={advFieldsRef}
|
||||
scrollerRef={railScrollRef}
|
||||
ffmpegProfiles={data.ffmpegProfiles}
|
||||
fillerPresets={data.fillerPresets}
|
||||
watermarks={data.watermarks}
|
||||
@@ -1552,6 +1579,7 @@ function ChannelBuilder({
|
||||
confirmLabel="Clear lineup"
|
||||
tone="danger"
|
||||
onConfirm={() => {
|
||||
clearLineupError();
|
||||
setLineup([]);
|
||||
setConfirmClear(false);
|
||||
}}
|
||||
@@ -1652,6 +1680,7 @@ function AdvancedPanel({
|
||||
setMode,
|
||||
maxHeight,
|
||||
fieldsRef,
|
||||
scrollerRef,
|
||||
ffmpegProfiles,
|
||||
fillerPresets,
|
||||
watermarks
|
||||
@@ -1671,6 +1700,7 @@ function AdvancedPanel({
|
||||
setMode: (next: 'closed' | 'view' | 'override') => void;
|
||||
maxHeight: number;
|
||||
fieldsRef: React.RefObject<HTMLDivElement | null>;
|
||||
scrollerRef: React.RefObject<HTMLDivElement | null>;
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
fillerPresets: FillerPreset[];
|
||||
watermarks: Watermark[];
|
||||
@@ -1680,28 +1710,77 @@ function AdvancedPanel({
|
||||
const fieldCount = ADVANCED_KEYS.length + 2; // + playbackOrder + playoutMode (toggled)
|
||||
|
||||
const setOverride = (key: AdvancedKey, value: unknown) => setOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
const removeOverride = (key: AdvancedKey) =>
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
|
||||
const idSelectOptions = (rows: Array<{ id: number; name: string | null }>) => [
|
||||
NONE_OPTION,
|
||||
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
|
||||
];
|
||||
// Override selects: the first option is always "Inherit from template" (the
|
||||
// INHERIT sentinel -> field omitted from the request). There is deliberately
|
||||
// NO "None" option for filler/watermark ids: the create handler coalesces
|
||||
// null with the template value, so clear-to-none is not expressible (#135).
|
||||
const templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
|
||||
const inheritOption = (inheritedLabel: string | null) => ({
|
||||
value: INHERIT,
|
||||
label: inheritedLabel ? `Inherit (${inheritedLabel})` : 'Inherit from template'
|
||||
});
|
||||
|
||||
const fillerOptions = idSelectOptions(fillerPresets);
|
||||
const watermarkOptions = idSelectOptions(watermarks);
|
||||
const ffmpegOptions = ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }));
|
||||
|
||||
const idValue = (key: AdvancedKey): string => {
|
||||
const value = effectiveValue(key, template, overrides);
|
||||
return value == null ? '' : String(value);
|
||||
const idSelectOptions = (key: AdvancedKey, rows: Array<{ id: number; name: string | null }>) => {
|
||||
const inherited = templateValueOf(key) as number | null;
|
||||
return [
|
||||
inheritOption(inherited == null ? null : fillerName(rows, inherited)),
|
||||
...rows.map((row) => ({ value: String(row.id), label: row.name ?? `#${row.id}` }))
|
||||
];
|
||||
};
|
||||
const strValue = (key: AdvancedKey): string => {
|
||||
const value = effectiveValue(key, template, overrides);
|
||||
return value == null ? '' : String(value);
|
||||
|
||||
// Selected value for an override select: the override when present, else INHERIT.
|
||||
const selValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key]) : INHERIT);
|
||||
const onSelect = (key: AdvancedKey, map: (raw: string) => unknown) => (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === INHERIT) {
|
||||
removeOverride(key);
|
||||
} else {
|
||||
setOverride(key, map(raw));
|
||||
}
|
||||
};
|
||||
|
||||
// Text inputs: empty string = inherit (field omitted); never send null or ''.
|
||||
const textValue = (key: AdvancedKey): string => (key in overrides ? String(overrides[key] ?? '') : '');
|
||||
const onText = (key: AdvancedKey) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === '') {
|
||||
removeOverride(key);
|
||||
} else {
|
||||
setOverride(key, raw);
|
||||
}
|
||||
};
|
||||
const textPlaceholder = (key: AdvancedKey, fallback?: string): string | undefined => {
|
||||
const inherited = templateValueOf(key);
|
||||
return inherited ? `Inherit: ${String(inherited)}` : fallback;
|
||||
};
|
||||
|
||||
const boolValue = (key: AdvancedKey): boolean => Boolean(effectiveValue(key, template, overrides));
|
||||
|
||||
// Opening a panel scrolls the rail so the Advanced header sits near the top
|
||||
// (ported from the prototype ChannelBuilder.jsx toggle()).
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const toggleMode = (next: 'view' | 'override') => {
|
||||
const nextMode = mode === next ? 'closed' : next;
|
||||
setMode(nextMode);
|
||||
if (nextMode !== 'closed' && scrollerRef.current && rootRef.current) {
|
||||
const scroller = scrollerRef.current;
|
||||
const root = rootRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
const target = scroller.scrollTop + root.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 20;
|
||||
scroller.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-builder-adv">
|
||||
<div className="ctv-builder-adv" ref={rootRef}>
|
||||
<div className="ctv-builder-adv-head">
|
||||
<SlidersHorizontal size={16} color="var(--text-secondary)" aria-hidden="true" />
|
||||
<span className="ctv-builder-adv-head-title">Advanced</span>
|
||||
@@ -1713,16 +1792,16 @@ function AdvancedPanel({
|
||||
<div className="ctv-builder-adv-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ctv-builder-adv-tab${override ? ' ctv-builder-adv-tab-on' : ''}`}
|
||||
onClick={() => setMode(mode === 'override' ? 'closed' : 'override')}
|
||||
className={`ctv-builder-adv-tab ctv-press${override ? ' ctv-builder-adv-tab-on' : ''}`}
|
||||
onClick={() => toggleMode('override')}
|
||||
>
|
||||
<SquarePen size={14} aria-hidden="true" />
|
||||
{override ? 'Overriding' : 'Override settings'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ctv-builder-adv-tab${mode === 'view' ? ' ctv-builder-adv-tab-on' : ''}`}
|
||||
onClick={() => setMode(mode === 'view' ? 'closed' : 'view')}
|
||||
className={`ctv-builder-adv-tab ctv-press${mode === 'view' ? ' ctv-builder-adv-tab-on' : ''}`}
|
||||
onClick={() => toggleMode('view')}
|
||||
>
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
View defaults
|
||||
@@ -1731,6 +1810,11 @@ function AdvancedPanel({
|
||||
<div className="ctv-builder-adv-panel" style={{ maxHeight }}>
|
||||
<div className="ctv-builder-adv-fields" ref={fieldsRef}>
|
||||
<div className="ctv-builder-adv-info">
|
||||
<Info
|
||||
size={13}
|
||||
color={override ? 'var(--action-primary)' : 'var(--text-disabled)'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{override
|
||||
? 'Editing these overrides the template for this channel only.'
|
||||
: `Read-only — inherited from the ${template.name} template. Turn on Override to edit.`}
|
||||
@@ -1742,23 +1826,35 @@ function AdvancedPanel({
|
||||
<Select
|
||||
size="sm"
|
||||
label="FFmpeg profile"
|
||||
value={idValue('fFmpegProfileId')}
|
||||
options={ffmpegOptions}
|
||||
onChange={(event) => setOverride('fFmpegProfileId', Number(event.target.value))}
|
||||
value={selValue('fFmpegProfileId')}
|
||||
options={[
|
||||
inheritOption(
|
||||
ffmpegProfiles.find((p) => p.id === template.fFmpegProfileId)?.name ??
|
||||
`#${template.fFmpegProfileId}`
|
||||
),
|
||||
...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` }))
|
||||
]}
|
||||
onChange={onSelect('fFmpegProfileId', Number)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Streaming mode"
|
||||
value={strValue('streamingMode')}
|
||||
options={STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))}
|
||||
onChange={(event) => setOverride('streamingMode', event.target.value as StreamingMode)}
|
||||
value={selValue('streamingMode')}
|
||||
options={[
|
||||
inheritOption(STREAMING_MODE_LABELS[template.streamingMode as StreamingMode] ?? template.streamingMode),
|
||||
...STREAMING_MODES.map((value) => ({ value, label: STREAMING_MODE_LABELS[value] }))
|
||||
]}
|
||||
onChange={onSelect('streamingMode', (raw) => raw as StreamingMode)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Transcode mode"
|
||||
value={strValue('transcodeMode')}
|
||||
options={[...TRANSCODE_MODES]}
|
||||
onChange={(event) => setOverride('transcodeMode', event.target.value as TranscodeMode)}
|
||||
value={selValue('transcodeMode')}
|
||||
options={[
|
||||
inheritOption(template.transcodeMode),
|
||||
...TRANSCODE_MODES.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('transcodeMode', (raw) => raw as TranscodeMode)}
|
||||
/>
|
||||
</AdvGroup>
|
||||
|
||||
@@ -1766,37 +1862,37 @@ function AdvancedPanel({
|
||||
<Select
|
||||
size="sm"
|
||||
label="Fallback filler"
|
||||
value={idValue('fallbackFillerId')}
|
||||
options={fillerOptions}
|
||||
onChange={(event) => setOverride('fallbackFillerId', toIdOverride(event.target.value))}
|
||||
value={selValue('fallbackFillerId')}
|
||||
options={idSelectOptions('fallbackFillerId', fillerPresets)}
|
||||
onChange={onSelect('fallbackFillerId', Number)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Pre-roll filler"
|
||||
value={idValue('preRollFillerId')}
|
||||
options={fillerOptions}
|
||||
onChange={(event) => setOverride('preRollFillerId', toIdOverride(event.target.value))}
|
||||
value={selValue('preRollFillerId')}
|
||||
options={idSelectOptions('preRollFillerId', fillerPresets)}
|
||||
onChange={onSelect('preRollFillerId', Number)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Mid-roll filler"
|
||||
value={idValue('midRollFillerId')}
|
||||
options={fillerOptions}
|
||||
onChange={(event) => setOverride('midRollFillerId', toIdOverride(event.target.value))}
|
||||
value={selValue('midRollFillerId')}
|
||||
options={idSelectOptions('midRollFillerId', fillerPresets)}
|
||||
onChange={onSelect('midRollFillerId', Number)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Post-roll filler"
|
||||
value={idValue('postRollFillerId')}
|
||||
options={fillerOptions}
|
||||
onChange={(event) => setOverride('postRollFillerId', toIdOverride(event.target.value))}
|
||||
value={selValue('postRollFillerId')}
|
||||
options={idSelectOptions('postRollFillerId', fillerPresets)}
|
||||
onChange={onSelect('postRollFillerId', Number)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Watermark"
|
||||
value={idValue('watermarkId')}
|
||||
options={watermarkOptions}
|
||||
onChange={(event) => setOverride('watermarkId', toIdOverride(event.target.value))}
|
||||
value={selValue('watermarkId')}
|
||||
options={idSelectOptions('watermarkId', watermarks)}
|
||||
onChange={onSelect('watermarkId', Number)}
|
||||
/>
|
||||
</AdvGroup>
|
||||
|
||||
@@ -1811,42 +1907,50 @@ function AdvancedPanel({
|
||||
<Input
|
||||
size="sm"
|
||||
label="Preferred audio language"
|
||||
value={strValue('preferredAudioLanguageCode')}
|
||||
onChange={(event) => setOverride('preferredAudioLanguageCode', event.target.value || null)}
|
||||
placeholder="e.g. eng"
|
||||
value={textValue('preferredAudioLanguageCode')}
|
||||
onChange={onText('preferredAudioLanguageCode')}
|
||||
placeholder={textPlaceholder('preferredAudioLanguageCode', 'e.g. eng')}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label="Preferred subtitle language"
|
||||
value={strValue('preferredSubtitleLanguageCode')}
|
||||
onChange={(event) => setOverride('preferredSubtitleLanguageCode', event.target.value || null)}
|
||||
placeholder="e.g. eng"
|
||||
value={textValue('preferredSubtitleLanguageCode')}
|
||||
onChange={onText('preferredSubtitleLanguageCode')}
|
||||
placeholder={textPlaceholder('preferredSubtitleLanguageCode', 'e.g. eng')}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label="Preferred audio title"
|
||||
value={strValue('preferredAudioTitle')}
|
||||
onChange={(event) => setOverride('preferredAudioTitle', event.target.value || null)}
|
||||
value={textValue('preferredAudioTitle')}
|
||||
onChange={onText('preferredAudioTitle')}
|
||||
placeholder={textPlaceholder('preferredAudioTitle')}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Subtitle mode"
|
||||
value={strValue('subtitleMode')}
|
||||
options={[...SUBTITLE_MODES]}
|
||||
onChange={(event) => setOverride('subtitleMode', event.target.value as SubtitleMode)}
|
||||
value={selValue('subtitleMode')}
|
||||
options={[
|
||||
inheritOption(template.subtitleMode),
|
||||
...SUBTITLE_MODES.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('subtitleMode', (raw) => raw as SubtitleMode)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Stream selector mode"
|
||||
value={strValue('streamSelectorMode')}
|
||||
options={[...STREAM_SELECTOR_MODES]}
|
||||
onChange={(event) => setOverride('streamSelectorMode', event.target.value as StreamSelectorMode)}
|
||||
value={selValue('streamSelectorMode')}
|
||||
options={[
|
||||
inheritOption(template.streamSelectorMode),
|
||||
...STREAM_SELECTOR_MODES.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('streamSelectorMode', (raw) => raw as StreamSelectorMode)}
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label="Stream selector"
|
||||
value={strValue('streamSelector')}
|
||||
onChange={(event) => setOverride('streamSelector', event.target.value || null)}
|
||||
value={textValue('streamSelector')}
|
||||
onChange={onText('streamSelector')}
|
||||
placeholder={textPlaceholder('streamSelector')}
|
||||
/>
|
||||
</AdvGroup>
|
||||
|
||||
@@ -1861,30 +1965,42 @@ function AdvancedPanel({
|
||||
<Select
|
||||
size="sm"
|
||||
label="Music video credits"
|
||||
value={strValue('musicVideoCreditsMode')}
|
||||
options={[...MUSIC_VIDEO_CREDITS_MODES]}
|
||||
onChange={(event) => setOverride('musicVideoCreditsMode', event.target.value as MusicVideoCreditsMode)}
|
||||
value={selValue('musicVideoCreditsMode')}
|
||||
options={[
|
||||
inheritOption(template.musicVideoCreditsMode),
|
||||
...MUSIC_VIDEO_CREDITS_MODES.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('musicVideoCreditsMode', (raw) => raw as MusicVideoCreditsMode)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Song video mode"
|
||||
value={strValue('songVideoMode')}
|
||||
options={[...SONG_VIDEO_MODES]}
|
||||
onChange={(event) => setOverride('songVideoMode', event.target.value as SongVideoMode)}
|
||||
value={selValue('songVideoMode')}
|
||||
options={[
|
||||
inheritOption(template.songVideoMode),
|
||||
...SONG_VIDEO_MODES.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('songVideoMode', (raw) => raw as SongVideoMode)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Idle behavior"
|
||||
value={strValue('idleBehavior')}
|
||||
options={[...IDLE_BEHAVIORS]}
|
||||
onChange={(event) => setOverride('idleBehavior', event.target.value as IdleBehavior)}
|
||||
value={selValue('idleBehavior')}
|
||||
options={[
|
||||
inheritOption(template.idleBehavior),
|
||||
...IDLE_BEHAVIORS.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('idleBehavior', (raw) => raw as IdleBehavior)}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label="Fixed start time"
|
||||
value={strValue('fixedStartTimeBehavior')}
|
||||
options={[...FIXED_START_TIME_BEHAVIORS]}
|
||||
onChange={(event) => setOverride('fixedStartTimeBehavior', event.target.value as FixedStartTimeBehavior)}
|
||||
value={selValue('fixedStartTimeBehavior')}
|
||||
options={[
|
||||
inheritOption(template.fixedStartTimeBehavior),
|
||||
...FIXED_START_TIME_BEHAVIORS.map((value) => ({ value, label: value }))
|
||||
]}
|
||||
onChange={onSelect('fixedStartTimeBehavior', (raw) => raw as FixedStartTimeBehavior)}
|
||||
/>
|
||||
<div className="ctv-builder-adv-switchrow">
|
||||
<span>Random start point</span>
|
||||
@@ -1920,8 +2036,8 @@ function AdvancedPanel({
|
||||
</AdvGroup>
|
||||
<AdvGroup label="Playback">
|
||||
<ReadOnlyField label="Playback order" value={playbackOrder} />
|
||||
<ReadOnlyField label="Preferred audio language" value={template.preferredAudioLanguageCode ?? 'None'} />
|
||||
<ReadOnlyField label="Preferred subtitle language" value={template.preferredSubtitleLanguageCode ?? 'None'} />
|
||||
<ReadOnlyField label="Preferred audio language" value={template.preferredAudioLanguageCode ?? 'Not set'} />
|
||||
<ReadOnlyField label="Preferred subtitle language" value={template.preferredSubtitleLanguageCode ?? 'Not set'} />
|
||||
<ReadOnlyField label="Subtitle mode" value={template.subtitleMode} />
|
||||
<ReadOnlyField label="Stream selector mode" value={template.streamSelectorMode} />
|
||||
</AdvGroup>
|
||||
|
||||
@@ -17,6 +17,16 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
|
||||
const titleId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Latest-ref for onClose so the Escape listener never has to re-subscribe when
|
||||
// the parent re-renders with a fresh onClose identity.
|
||||
const onCloseRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
});
|
||||
|
||||
// Focus the panel + lock body scroll only on the closed->open transition.
|
||||
// (Keyed on `open` alone — depending on onClose here would steal focus from
|
||||
// inputs on every keystroke, since parents hand us a new onClose each render.)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
@@ -27,18 +37,29 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Escape-to-close listener; reads the latest onClose via ref so it too only
|
||||
// depends on `open`.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
|
||||
+37
-6
@@ -2380,14 +2380,10 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* hover lift comes from the shared .ctv-lift utility */
|
||||
.ctv-builder-libcard {
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
transition: transform var(--dur-fast) var(--ease-standard, ease);
|
||||
}
|
||||
|
||||
.ctv-builder-libcard:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.ctv-builder-libcard-poster {
|
||||
@@ -2550,7 +2546,7 @@
|
||||
.ctv-builder-auto-chip {
|
||||
font: 9px / 1 var(--font-mono);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--ctv-accent-soft);
|
||||
color: var(--ctv-accent);
|
||||
}
|
||||
@@ -2932,15 +2928,50 @@
|
||||
border: 1px solid var(--border-hairline);
|
||||
}
|
||||
|
||||
/* Shared motion utilities — ported from the prototype (chicorytv-admin/app.html).
|
||||
Subtle, iOS/macOS-style: quick, ease-out, small. */
|
||||
.ctv-lift {
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ctv-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.ctv-press {
|
||||
transition:
|
||||
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 220ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ctv-press:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ctv-lift,
|
||||
.ctv-press,
|
||||
.ctv-builder-seg-thumb,
|
||||
.ctv-builder-seg-btn,
|
||||
.ctv-builder-adv-panel,
|
||||
.ctv-builder-adv-tab,
|
||||
.ctv-builder-lineup-row,
|
||||
.ctv-builder-libcard,
|
||||
.ctv-builder-toggle-live,
|
||||
.ctv-builder-added-check {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.ctv-lift:hover,
|
||||
.ctv-press:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
|
||||
Reference in New Issue
Block a user