Merge pull request 'feat(386): Auto-Tune per-channel DetailPanel slide-over (SPA)' (#432) from feat/386-autotune-detailpanel into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m19s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m0s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Failing after 13m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Failing after 13m15s

This commit was merged in pull request #432.
This commit is contained in:
2026-07-17 23:54:38 +00:00
11 changed files with 1518 additions and 212 deletions
+31
View File
@@ -92,6 +92,7 @@ in-file entries.
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
- [2026-07-17 — Auto-Tune per-channel overrides reuse the Channel Builder advanced-options DTO; weights + bug-colour logo split out to #425 (#385)](#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385)
- [2026-07-17 — Health-check remediation is server-declared `{Kind, Target}` on an additive DTO; the SPA acts on it (#164)](#2026-07-17--health-check-remediation-is-server-declared-kind-target-on-an-additive-dto-the-spa-acts-on-it-164)
- [2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)](#2026-07-18--auto-tune-detailpanel-spa-reusable-slideover--shared-advanced-options-model-decorative-panes-dropped-to-match-the-backend-386)
---
@@ -1646,3 +1647,33 @@ deliberately **not** bundled into a remediation-UX PR.
re-runs all 14 checks, 4 shelling out to ffmpeg, and the existing summary cache is write-only dead
code). Orthogonal to the UX; filed separately so a SPA-polled health panel gets a cache before it
polls.
## 2026-07-18 — Auto-Tune DetailPanel SPA: reusable `SlideOver` + shared advanced-options model; decorative panes dropped to match the backend (#386)
The SPA half of the Auto-Tune per-channel editor. It builds only what the shipped `/api/v1` surface
(#384 members read, #385 per-channel `templateId`/`logo`/`advanced`) can actually carry, so the panel
never presents a control with nowhere to send its value.
- **New reusable primitive `SlideOver`** (`web/src/components/overlay.tsx`), sharing one
`useOverlayBehavior` hook with `Dialog` (focus/scroll-lock/Escape/scrim). Right-edge panels are a
recurring need; this is the seam, not a screen-local drawer. See spa-conventions §11.
- **Advanced-options logic is shared, not re-implemented** — the `AdvancedPanel` internals in the 84 KB
`ChannelBuilder.tsx` were extracted to `web/src/builder/advancedOptions.tsx` (enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the INHERIT/omit `useAdvancedOverrides` hook). ChannelBuilder now
imports them with zero behavioral change (its test suite passes byte-for-byte); the DetailPanel writes
its own field JSX over the same hook. The #135 "inherit = omit, no None option" contract therefore has
a single implementation. Field *layout* stays per-screen (presentation, not logic) — the deliberate,
stated deviation from full component reuse.
- **Shipped panes:** identity (name/number + `uploadArtwork(_, 'logo')` dropzone), Playback (Shuffle →
`advanced.playbackOrder`, Always-playing → `advanced.playoutMode`, merged into `advanced` only when
diverged from the template), per-channel template picker, the Advanced disclosure, a lean read-only
Query&size (order-from-axis + est items + effective streaming mode), and a read-only Content-sources
member list via `GET /members`.
- **Dropped as backend-less decoration** (the prototype had them; the wire contract does not): the MiniEpg
"example schedule", the bug-initials/colour generator, and the "generated smart-collection query" text —
the proposal DTO carries no query string, so rendering one would be fabricated. **Deferred to #425** (its
own end-to-end slice): the per-source rotation-weight steppers and exclude/add-untagged corrections; the
Content-sources pane shows a "#425" hint so its read-only state reads as intentional.
- **Unsaved-changes guard is screen-scoped** (spa-conventions §8/§11): per-channel edits live in
`AutoTuneScreen` draft state until bulk-create, so closing the panel keeps them (an "Edited" row badge
makes that visible) and only screen navigation / full-page unload with uncommitted edits confirms.
+24
View File
@@ -403,3 +403,27 @@ string-keyed dispatcher, or generic screen-action framework participates in norm
or **semantically misplaced** (Dashboard is a status page; Libraries "Scan" is per-row). Coexisting with an
in-body create control is fine (Schedules has both) — the banner is a convenience, not the sole entry point.
See `docs/decisions.md` 2026-07-12 for the full rationale.
## 11. Slide-over panels + the shared advanced-options model
- **`SlideOver` (right-edge detail/edit panel)** lives in `web/src/components/overlay.tsx` alongside
`Dialog`. Use it for a per-item edit/detail surface layered over a screen (the Auto-Tune "Configure"
panel is the reference, #386); use `Dialog` for a centered confirm/short form. Both share one
private `useOverlayBehavior(open, onClose, panelRef)` hook (focus + body-scroll lock on the
closed→open transition, Escape-to-close via a latest-`onClose` ref, scrim-click dismiss). It
portals to `document.body` and takes `title`/`subtitle`/`children`/`footer`/`width`. Don't build a
bespoke edge panel — extend `SlideOver`.
- **Draft-until-commit slide-overs need the §8 guard at the SCREEN, not the panel.** When per-item
edits accumulate in the parent screen's state and are flushed by one later action (Auto-Tune's
bulk-create), closing the panel does **not** lose data (it's still in screen state) — so the
`window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
The 24-field `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the **INHERIT/omit** field adapters (`useAdvancedOverrides`) —
is one module consumed by both the Channel Builder and the Auto-Tune DetailPanel. The contract
(a select on `INHERIT` or a cleared text input **omits** the field so the create handler coalesces
it with the template value; there is no "None" — #135) must not be re-implemented per screen. Each
screen writes its own field JSX over the shared hook; `playbackOrder`/`playoutMode` are surfaced as
dedicated controls (Builder state / Auto-Tune's Shuffle + Always-playing toggles) and merged into
`advanced` at create, not carried in the override map.
+92 -1
View File
@@ -1,5 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createAutoTunedChannels, previewAutoTune } from './autoTune';
import { createAutoTunedChannels, getAutoTuneChannelMembers, previewAutoTune } from './autoTune';
const sampleMembers = {
totalCount: 3,
page: [
{ id: 1, mediaType: 'TelevisionShow', title: 'The Office', artwork: '/artwork/1' },
{ id: 2, mediaType: 'TelevisionShow', title: 'Parks and Rec', artwork: '/artwork/2' }
]
};
const sampleProposals = [
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
@@ -86,4 +94,87 @@ describe('createAutoTunedChannels', () => {
channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
});
});
it('sends per-channel overrides (templateId, logo, advanced) verbatim when present', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleResult), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await createAutoTunedChannels({
templateId: 3,
group: 'Auto-Tuned',
channels: [
{
axis: 'TvGenre',
value: 'Comedy',
name: 'Comedy',
number: '501',
templateId: 9,
logo: { path: 'logos/comedy.png', contentType: 'image/png' },
advanced: { shuffleScheduleItems: true, ffmpegProfileId: 4 }
}
]
});
const [, init] = fetchSpy.mock.calls[0];
expect(JSON.parse(String(init?.body)).channels[0]).toEqual({
axis: 'TvGenre',
value: 'Comedy',
name: 'Comedy',
number: '501',
templateId: 9,
logo: { path: 'logos/comedy.png', contentType: 'image/png' },
advanced: { shuffleScheduleItems: true, ffmpegProfileId: 4 }
});
});
});
describe('getAutoTuneChannelMembers', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('GETs /api/v1/channels/auto-tune/members with axis+value and optional paging', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleMembers), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await expect(
getAutoTuneChannelMembers({ axis: 'TvGenre', value: 'Comedy', pageNum: 1, pageSize: 50 })
).resolves.toMatchObject({ totalCount: 3 });
const [url, init] = fetchSpy.mock.calls[0];
expect((init?.method ?? 'GET').toUpperCase()).toBe('GET');
const parsed = new URL(String(url), 'http://localhost');
expect(parsed.pathname).toBe('/api/v1/channels/auto-tune/members');
expect(parsed.searchParams.get('axis')).toBe('TvGenre');
expect(parsed.searchParams.get('value')).toBe('Comedy');
expect(parsed.searchParams.get('pageNum')).toBe('1');
expect(parsed.searchParams.get('pageSize')).toBe('50');
});
it('omits paging params when not provided but always sends axis+value', async () => {
const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
new Response(JSON.stringify(sampleMembers), {
headers: { 'Content-Type': 'application/json' },
status: 200
})
);
await getAutoTuneChannelMembers({ axis: 'TvShow', value: 'The Office' });
const [url] = fetchSpy.mock.calls[0];
const parsed = new URL(String(url), 'http://localhost');
expect(parsed.searchParams.get('axis')).toBe('TvShow');
expect(parsed.searchParams.get('value')).toBe('The Office');
expect(parsed.searchParams.has('pageNum')).toBe(false);
expect(parsed.searchParams.has('pageSize')).toBe(false);
});
});
+28
View File
@@ -8,6 +8,15 @@ export type AutoTunedChannelRequest = components['schemas']['AutoTunedChannelReq
export type CreateAutoTunedChannelsRequest = components['schemas']['CreateAutoTunedChannelsRequest'];
export type AutoTuneChannelResult = components['schemas']['AutoTuneChannelResultModel'];
export type AutoTuneResult = components['schemas']['AutoTuneResultResponseModel'];
export type PagedAutoTuneMembers = components['schemas']['PagedLibraryBrowseItemsResponseModel'];
export type AutoTuneMember = components['schemas']['LibraryBrowseItemResponseModel'];
export interface GetAutoTuneMembersParams {
axis: AutoTuneAxis;
value: string;
pageNum?: number;
pageSize?: number;
}
export function previewAutoTune(body: PreviewAutoTuneChannelsRequest): Promise<AutoTuneProposal[]> {
return request<AutoTuneProposal[]>('/api/v1/channels/auto-tune/preview', {
@@ -23,6 +32,25 @@ export function createAutoTunedChannels(body: CreateAutoTunedChannelsRequest): P
});
}
// #384 read endpoint: the distinct members a proposed channel's smart collection
// resolves to (axis+value identify the proposal). Read-only source list for the
// DetailPanel; per-source rotation weights + corrections are #425, not here.
export function getAutoTuneChannelMembers(params: GetAutoTuneMembersParams): Promise<PagedAutoTuneMembers> {
const searchParams = new URLSearchParams();
searchParams.set('axis', params.axis);
searchParams.set('value', params.value);
if (params.pageNum != null) {
searchParams.set('pageNum', String(params.pageNum));
}
if (params.pageSize != null) {
searchParams.set('pageSize', String(params.pageSize));
}
return request<PagedAutoTuneMembers>(`/api/v1/channels/auto-tune/members?${searchParams.toString()}`);
}
export function messageFromAutoTuneError(error: unknown, fallback = 'Unable to auto-tune channels'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
+36 -163
View File
@@ -74,60 +74,39 @@ import {
Switch,
Tooltip
} from '../components';
import {
ADVANCED_KEYS,
FIXED_START_TIME_BEHAVIORS,
IDLE_BEHAVIORS,
MUSIC_VIDEO_CREDITS_MODES,
PLAYBACK_ORDERS,
SONG_VIDEO_MODES,
STREAM_SELECTOR_MODES,
STREAMING_MODE_LABELS,
STREAMING_MODES,
SUBTITLE_MODES,
TRANSCODE_MODES,
effectiveValue,
fillerName,
inheritOption,
templatePlaybackOrder,
useAdvancedOverrides,
type AdvancedKey,
type FixedStartTimeBehavior,
type IdleBehavior,
type MusicVideoCreditsMode,
type Overrides,
type PlaybackOrder,
type PlayoutMode,
type SongVideoMode,
type StreamSelectorMode,
type StreamingMode,
type SubtitleMode,
type TranscodeMode
} from './advancedOptions';
// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
const PLAYBACK_ORDERS = [
'None',
'Chronological',
'Random',
'Shuffle',
'ShuffleInOrder',
'MultiEpisodeShuffle',
'SeasonEpisode',
'RandomRotation',
'Marathon'
] as const;
type PlaybackOrder = (typeof PLAYBACK_ORDERS)[number];
const STREAMING_MODES = [
'TransportStream',
'HttpLiveStreamingDirect',
'HttpLiveStreamingSegmenter',
'TransportStreamHybrid'
] as const;
type StreamingMode = (typeof STREAMING_MODES)[number];
const SUBTITLE_MODES = ['None', 'Forced', 'Default', 'Any'] as const;
type SubtitleMode = (typeof SUBTITLE_MODES)[number];
const STREAM_SELECTOR_MODES = ['Default', 'Custom', 'Troubleshooting'] as const;
type StreamSelectorMode = (typeof STREAM_SELECTOR_MODES)[number];
const MUSIC_VIDEO_CREDITS_MODES = ['None', 'GenerateSubtitles'] as const;
type MusicVideoCreditsMode = (typeof MUSIC_VIDEO_CREDITS_MODES)[number];
const SONG_VIDEO_MODES = ['Default', 'WithProgress'] as const;
type SongVideoMode = (typeof SONG_VIDEO_MODES)[number];
const IDLE_BEHAVIORS = ['StopOnDisconnect', 'KeepRunning'] as const;
type IdleBehavior = (typeof IDLE_BEHAVIORS)[number];
const FIXED_START_TIME_BEHAVIORS = ['Strict', 'Flexible'] as const;
type FixedStartTimeBehavior = (typeof FIXED_START_TIME_BEHAVIORS)[number];
const TRANSCODE_MODES = ['OnDemand'] as const;
type TranscodeMode = (typeof TRANSCODE_MODES)[number];
type PlayoutMode = 'Continuous' | 'OnDemand';
// Streaming mode friendly labels for chips / selects.
const STREAMING_MODE_LABELS: Record<StreamingMode, string> = {
TransportStream: 'MPEG-TS',
HttpLiveStreamingDirect: 'HLS Direct',
HttpLiveStreamingSegmenter: 'HLS Segmenter',
TransportStreamHybrid: 'MPEG-TS Hybrid'
};
// Advanced-options model (enum unions, INHERIT/omit semantics, the override hook)
// is shared with the Auto-Tune DetailPanel — see ./advancedOptions.
// ---- Media-type presentation ----------------------------------------------
// TYPE_ICON / TYPE_LABEL / hueOf are shared with the media browse/search/trash screens
// (see ../media/mediaKinds) so the per-kind icon+label map has a single source of truth.
@@ -637,55 +616,6 @@ interface BuilderData {
mediaSources: MediaSource[];
}
// ---- Effective settings ----------------------------------------------------
// Templates carry no playbackOrder; the Shuffle toggle *is* the template's
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological).
function templatePlaybackOrder(template: ChannelTemplate): PlaybackOrder {
return template.shuffleScheduleItems ? 'Shuffle' : 'Chronological';
}
// Advanced field keys wired into request.advanced (playbackOrder + playoutMode
// live in dedicated state and are ALWAYS sent, so they are excluded here).
const ADVANCED_KEYS = [
'ffmpegProfileId',
'watermarkId',
'fallbackFillerId',
'preRollFillerId',
'midRollFillerId',
'postRollFillerId',
'streamSelectorMode',
'streamSelector',
'preferredAudioLanguageCode',
'preferredAudioTitle',
'streamingMode',
'preferredSubtitleLanguageCode',
'subtitleMode',
'musicVideoCreditsMode',
'songVideoMode',
'transcodeMode',
'idleBehavior',
'randomStartPoint',
'shuffleScheduleItems',
'fixedStartTimeBehavior'
] as const;
type AdvancedKey = (typeof ADVANCED_KEYS)[number];
type Overrides = Partial<Record<AdvancedKey, unknown>>;
// The current effective value for an advanced field = user override (if any)
// else the selected template's value.
function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
if (key in overrides) {
return overrides[key];
}
return (template as unknown as Record<string, unknown>)[key];
}
// 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 {
status: 'loading' | 'error' | 'success';
@@ -1839,59 +1769,10 @@ function AdvancedPanel({
const overrideCount = Object.keys(overrides).length;
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;
});
// 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 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}` }))
];
};
// 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));
// Shared INHERIT/omit field adapters (see ./advancedOptions): a select set to
// INHERIT or a text input cleared to '' removes the override (field omitted).
const { setOverride, idSelectOptions, selValue, onSelect, textValue, onText, textPlaceholder, boolValue } =
useAdvancedOverrides(template, overrides, setOverrides);
// Opening a panel scrolls the rail so the Advanced header sits near the top
// (ported from the prototype ChannelBuilder.jsx toggle()).
@@ -2208,11 +2089,3 @@ function ReadOnlyField({ label, value }: { label: string; value: string }) {
</div>
);
}
function fillerName(rows: Array<{ id: number; name: string | null }>, id: number | null): string {
if (id == null) {
return 'None';
}
const row = rows.find((candidate) => candidate.id === id);
return row?.name ?? `#${id}`;
}
+202
View File
@@ -0,0 +1,202 @@
// Shared "advanced channel options" model — the per-field override semantics over
// a channel template's baseline, used by BOTH the manual Channel Builder
// (ChannelBuilder.tsx) and the Auto-Tune per-channel DetailPanel (AutoTuneScreen.tsx).
//
// The tricky, bug-prone part is the INHERIT/omit contract (see #135): a field left
// on "Inherit from template" is OMITTED from the request entirely (not sent as null),
// because the create handler coalesces a missing value with the template's value and
// the API cannot express "clear to none". Both consumers MUST honour that, so it lives
// here once. Consumers render their own field JSX; only the model + adapters are shared.
import type { ChannelTemplate } from '../api';
// ---- Enum unions (hand-listed from generated v1.d.ts; keep in sync) --------
export const PLAYBACK_ORDERS = [
'None',
'Chronological',
'Random',
'Shuffle',
'ShuffleInOrder',
'MultiEpisodeShuffle',
'SeasonEpisode',
'RandomRotation',
'Marathon'
] as const;
export type PlaybackOrder = (typeof PLAYBACK_ORDERS)[number];
export const STREAMING_MODES = [
'TransportStream',
'HttpLiveStreamingDirect',
'HttpLiveStreamingSegmenter',
'TransportStreamHybrid'
] as const;
export type StreamingMode = (typeof STREAMING_MODES)[number];
export const SUBTITLE_MODES = ['None', 'Forced', 'Default', 'Any'] as const;
export type SubtitleMode = (typeof SUBTITLE_MODES)[number];
export const STREAM_SELECTOR_MODES = ['Default', 'Custom', 'Troubleshooting'] as const;
export type StreamSelectorMode = (typeof STREAM_SELECTOR_MODES)[number];
export const MUSIC_VIDEO_CREDITS_MODES = ['None', 'GenerateSubtitles'] as const;
export type MusicVideoCreditsMode = (typeof MUSIC_VIDEO_CREDITS_MODES)[number];
export const SONG_VIDEO_MODES = ['Default', 'WithProgress'] as const;
export type SongVideoMode = (typeof SONG_VIDEO_MODES)[number];
export const IDLE_BEHAVIORS = ['StopOnDisconnect', 'KeepRunning'] as const;
export type IdleBehavior = (typeof IDLE_BEHAVIORS)[number];
export const FIXED_START_TIME_BEHAVIORS = ['Strict', 'Flexible'] as const;
export type FixedStartTimeBehavior = (typeof FIXED_START_TIME_BEHAVIORS)[number];
export const TRANSCODE_MODES = ['OnDemand'] as const;
export type TranscodeMode = (typeof TRANSCODE_MODES)[number];
export type PlayoutMode = 'Continuous' | 'OnDemand';
// Streaming mode friendly labels for chips / selects.
export const STREAMING_MODE_LABELS: Record<StreamingMode, string> = {
TransportStream: 'MPEG-TS',
HttpLiveStreamingDirect: 'HLS Direct',
HttpLiveStreamingSegmenter: 'HLS Segmenter',
TransportStreamHybrid: 'MPEG-TS Hybrid'
};
// Advanced field keys wired into request.advanced. playbackOrder + playoutMode are
// deliberately EXCLUDED: the Channel Builder keeps them in dedicated state (always
// sent), and the Auto-Tune DetailPanel surfaces them as the two prominent Playback
// toggles — both write them separately rather than through this override map.
export const ADVANCED_KEYS = [
'ffmpegProfileId',
'watermarkId',
'fallbackFillerId',
'preRollFillerId',
'midRollFillerId',
'postRollFillerId',
'streamSelectorMode',
'streamSelector',
'preferredAudioLanguageCode',
'preferredAudioTitle',
'streamingMode',
'preferredSubtitleLanguageCode',
'subtitleMode',
'musicVideoCreditsMode',
'songVideoMode',
'transcodeMode',
'idleBehavior',
'randomStartPoint',
'shuffleScheduleItems',
'fixedStartTimeBehavior'
] as const;
export type AdvancedKey = (typeof ADVANCED_KEYS)[number];
export type Overrides = Partial<Record<AdvancedKey, unknown>>;
// The current effective value for an advanced field = user override (if any)
// else the selected template's value.
export function effectiveValue(key: AdvancedKey, template: ChannelTemplate, overrides: Overrides): unknown {
if (key in overrides) {
return overrides[key];
}
return (template as unknown as Record<string, unknown>)[key];
}
// 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.
export const INHERIT = '__inherit__';
// Templates carry no playbackOrder; the Shuffle toggle *is* the template's
// declared shuffle intent (template.shuffleScheduleItems -> Shuffle/Chronological).
export function templatePlaybackOrder(template: ChannelTemplate): PlaybackOrder {
return template.shuffleScheduleItems ? 'Shuffle' : 'Chronological';
}
export function fillerName(rows: Array<{ id: number; name: string | null }>, id: number | null): string {
if (id == null) {
return 'None';
}
const row = rows.find((candidate) => candidate.id === id);
return row?.name ?? `#${id}`;
}
export function inheritOption(inheritedLabel: string | null): { value: string; label: string } {
return {
value: INHERIT,
label: inheritedLabel ? `Inherit (${inheritedLabel})` : 'Inherit from template'
};
}
// Field adapters bound to one (template, overrides, setOverrides) triple. This is the
// shared INHERIT/omit logic: a select set back to INHERIT or a text input cleared to ''
// REMOVES the override (field omitted from the request) rather than sending null/''.
export function useAdvancedOverrides(
template: ChannelTemplate,
overrides: Overrides,
setOverrides: (updater: (prev: Overrides) => Overrides) => void
) {
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 templateValueOf = (key: AdvancedKey): unknown => (template as unknown as Record<string, unknown>)[key];
// 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 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}` }))
];
};
// 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));
return {
setOverride,
removeOverride,
templateValueOf,
idSelectOptions,
selValue,
onSelect,
textValue,
onText,
textPlaceholder,
boolValue
};
}
+83
View File
@@ -998,6 +998,89 @@
}
}
/* ---- Slide-over (right-edge detail/edit panel) ---- */
.ctv-slideover-scrim {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.55);
}
.ctv-slideover {
display: flex;
flex-direction: column;
width: 468px;
max-width: 100%;
height: 100vh;
border-left: 1px solid var(--border-hairline);
background: var(--surface-card);
box-shadow: var(--shadow-lg);
outline: 0;
animation: ctv-slideover-in var(--dur-slow) var(--ease-out);
}
.ctv-slideover-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex: 0 0 auto;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-hairline);
}
.ctv-slideover-heading {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.ctv-slideover-title {
color: var(--text-primary);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
.ctv-slideover-subtitle {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.ctv-slideover-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.5;
}
.ctv-slideover-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex: 0 0 auto;
padding: 12px 16px 16px;
border-top: 1px solid var(--border-hairline);
}
@keyframes ctv-slideover-in {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes ctv-spin {
to {
transform: rotate(360deg);
+78 -1
View File
@@ -1,6 +1,6 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConfirmDialog, Dialog } from './overlay';
import { ConfirmDialog, Dialog, SlideOver } from './overlay';
describe('Dialog', () => {
afterEach(() => {
@@ -78,6 +78,83 @@ describe('Dialog', () => {
});
});
describe('SlideOver', () => {
afterEach(() => {
cleanup();
document.body.style.overflow = '';
});
it('renders nothing when closed', () => {
render(
<SlideOver open={false} onClose={vi.fn()} title="Configure">
Panel content
</SlideOver>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders the title, subtitle and children when open', () => {
render(
<SlideOver open onClose={vi.fn()} title="Configure channel" subtitle="500 · Shuffled">
Panel content
</SlideOver>
);
expect(screen.getByRole('dialog', { name: 'Configure channel' })).toBeInTheDocument();
expect(screen.getByText('500 · Shuffled')).toBeInTheDocument();
expect(screen.getByText('Panel content')).toBeInTheDocument();
});
it('calls onClose when the scrim is clicked but not the panel', () => {
const onClose = vi.fn();
render(
<SlideOver open onClose={onClose} title="Configure">
Panel content
</SlideOver>
);
fireEvent.click(screen.getByText('Panel content'));
expect(onClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose on Escape and closes via the header button', () => {
const onClose = vi.fn();
render(
<SlideOver open onClose={onClose} title="Configure">
Panel content
</SlideOver>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it('locks and restores body scroll', () => {
document.body.style.overflow = 'scroll';
const { rerender } = render(
<SlideOver open onClose={vi.fn()} title="Configure">
Panel content
</SlideOver>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<SlideOver open={false} onClose={vi.fn()} title="Configure">
Panel content
</SlideOver>
);
expect(document.body.style.overflow).toBe('scroll');
});
});
describe('ConfirmDialog', () => {
afterEach(() => {
cleanup();
+83 -22
View File
@@ -3,30 +3,16 @@ import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button, IconButton } from './forms';
export interface DialogProps {
open: boolean;
onClose: () => void;
title: ReactNode;
children?: ReactNode;
footer?: ReactNode;
width?: number;
style?: CSSProperties;
}
export function Dialog({ open, onClose, title, children, footer, width, style }: DialogProps) {
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.
// Shared modal-overlay behavior for Dialog + SlideOver: focus the panel and lock
// body scroll on the closed->open transition, and close on Escape. Keyed on `open`
// alone — depending on onClose would steal focus from inputs on every keystroke
// (parents hand us a fresh onClose each render), so onClose is read via a latest-ref.
function useOverlayBehavior(open: boolean, onClose: () => void, panelRef: React.RefObject<HTMLDivElement | null>) {
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;
@@ -40,10 +26,8 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
}, [open, panelRef]);
// Escape-to-close listener; reads the latest onClose via ref so it too only
// depends on `open`.
useEffect(() => {
if (!open) {
return;
@@ -60,6 +44,23 @@ export function Dialog({ open, onClose, title, children, footer, width, style }:
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
}
export interface DialogProps {
open: boolean;
onClose: () => void;
title: ReactNode;
children?: ReactNode;
footer?: ReactNode;
width?: number;
style?: CSSProperties;
}
export function Dialog({ open, onClose, title, children, footer, width, style }: DialogProps) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
useOverlayBehavior(open, onClose, panelRef);
if (!open) {
return null;
@@ -146,3 +147,63 @@ export function ConfirmDialog({
</Dialog>
);
}
export interface SlideOverProps {
open: boolean;
onClose: () => void;
title: ReactNode;
subtitle?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
width?: number;
style?: CSSProperties;
}
// Right-edge slide-over panel. Same dismiss semantics as Dialog (Escape,
// scrim-click, focus + body-scroll lock), but anchored to the right and
// full-height — for a per-item detail/edit surface layered over a screen.
export function SlideOver({ open, onClose, title, subtitle, children, footer, width, style }: SlideOverProps) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
useOverlayBehavior(open, onClose, panelRef);
if (!open) {
return null;
}
const onOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return createPortal(
<div className="ctv-slideover-scrim" onClick={onOverlayClick}>
<div
className="ctv-slideover"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={panelRef}
style={{ ...(width ? { width } : null), ...style }}
>
<div className="ctv-slideover-header">
<div className="ctv-slideover-heading">
<span id={titleId} className="ctv-slideover-title">
{title}
</span>
{subtitle != null && <span className="ctv-slideover-subtitle">{subtitle}</span>}
</div>
<IconButton title="Close" onClick={onClose}>
<X aria-hidden="true" size={15} />
</IconButton>
</div>
<div className="ctv-slideover-body">{children}</div>
{footer && <div className="ctv-slideover-footer">{footer}</div>}
</div>
</div>,
document.body
);
}
+141 -2
View File
@@ -10,10 +10,46 @@ function jsonResponse(body: unknown, status = 200): Response {
}
const templates = [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Movie night' }
{
id: 1,
name: 'Standard',
shuffleScheduleItems: false,
playoutMode: 'Continuous',
streamingMode: 'TransportStream',
ffmpegProfileId: 7,
transcodeMode: 'OnDemand'
},
{ id: 2, name: 'Movie night', shuffleScheduleItems: true, playoutMode: 'OnDemand', streamingMode: 'TransportStream' }
];
const ffmpegProfiles = [{ id: 7, name: 'Default 1080p' }];
const fillerPresets = [{ id: 3, name: 'Bumpers' }];
const watermarks = [{ id: 4, name: 'Corner bug' }];
const members = {
totalCount: 2,
page: [
{ id: 11, mediaType: 'TelevisionShow', title: 'The Office', artwork: '/artwork/11' },
{ id: 12, mediaType: 'TelevisionShow', title: 'Parks and Rec', artwork: '' }
]
};
// The extra endpoints the Preview step's DetailPanel now pulls (pickers on mount, members on open).
function autoTuneAuxRoute(path: string, method: string): Response | null {
if (path === '/api/v1/ffmpeg/profiles') {
return jsonResponse(ffmpegProfiles);
}
if (path === '/api/v1/filler-presets') {
return jsonResponse(fillerPresets);
}
if (path === '/api/v1/watermarks') {
return jsonResponse(watermarks);
}
if (path.startsWith('/api/v1/channels/auto-tune/members') && method === 'GET') {
return jsonResponse(members);
}
return null;
}
const proposals = [
{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
{ axis: 'TvShow', value: 'Friends', name: 'Friends', number: '501', itemCount: 236, alreadyExists: true },
@@ -47,6 +83,10 @@ function mockAutoTuneApi(): ReturnType<typeof vi.spyOn> {
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
return Promise.resolve(jsonResponse(createResult));
}
const aux = autoTuneAuxRoute(path, method);
if (aux) {
return Promise.resolve(aux);
}
return Promise.resolve(jsonResponse(null, 404));
});
@@ -173,6 +213,10 @@ describe('AutoTuneScreen', () => {
if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
return Promise.resolve(jsonResponse({ results: [], createdCount: 0, skippedCount: 0, failedCount: 0 }));
}
const aux = autoTuneAuxRoute(path, method);
if (aux) {
return Promise.resolve(aux);
}
return Promise.resolve(jsonResponse(null, 404));
});
@@ -194,10 +238,105 @@ describe('AutoTuneScreen', () => {
// Exactly one remains selected → create posts only the still-selected genre proposal.
fireEvent.click(screen.getByRole('button', { name: /Create 1 channel/ }));
// Create is async now (per-channel logo upload + payload build), so wait for the result view.
await screen.findByText(/Added to group/);
const createCall = fetchSpy.mock.calls.find(
(call) => call[0] === '/api/v1/channels/auto-tune' && (call[1]?.method ?? '').toUpperCase() === 'POST'
);
const body = JSON.parse(String(createCall?.[1]?.body));
expect(body.channels).toEqual([{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501' }]);
});
async function openPreview() {
render(<AutoTuneScreen />);
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
await screen.findByText('TV Shows');
}
it('opens the DetailPanel from a proposal row and loads its content sources', async () => {
mockAutoTuneApi();
await openPreview();
// Each selectable row has a Configure button; the already-existing row does not.
const configureButtons = screen.getAllByRole('button', { name: 'Configure' });
expect(configureButtons).toHaveLength(2); // The Office + Comedy, not Friends (Exists)
fireEvent.click(configureButtons[0]);
// Panel opens as a dialog titled with the proposal name; members load from GET /members.
expect(await screen.findByRole('dialog', { name: 'The Office' })).toBeInTheDocument();
expect(await screen.findByText('Parks and Rec')).toBeInTheDocument();
expect(screen.getByText(/rotation weights/i)).toBeInTheDocument(); // #425 hint
});
it('threads per-channel edits (name, shuffle) into the create payload and flags the row Edited', async () => {
const fetchSpy = mockAutoTuneApi();
await openPreview();
fireEvent.click(screen.getAllByRole('button', { name: 'Configure' })[0]);
await screen.findByRole('dialog', { name: 'The Office' });
// Rename the channel.
const nameInput = screen.getByDisplayValue('The Office');
fireEvent.change(nameInput, { target: { value: 'The Office (US)' } });
// Flip Shuffle on (Standard template is chronological by default).
fireEvent.click(screen.getByRole('switch', { name: 'Shuffle' }));
// Close the panel; edits persist in the row (Edited badge shows).
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByText('Edited')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
const createCall = fetchSpy.mock.calls.find(
([url, init]: [RequestInfo | URL, RequestInit | undefined]) =>
url === '/api/v1/channels/auto-tune' && (init?.method ?? '').toUpperCase() === 'POST'
);
const body = JSON.parse(String(createCall?.[1]?.body));
const office = body.channels.find((c: { value: string }) => c.value === 'The Office');
expect(office.name).toBe('The Office (US)');
expect(office.advanced).toEqual({ playbackOrder: 'Shuffle' });
// The untouched Comedy proposal stays plain (no advanced/name override).
const comedy = body.channels.find((c: { value: string }) => c.value === 'Comedy');
expect(comedy).toEqual({ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502' });
});
it('does not flag a row Edited when the name is typed then cleared back to empty', async () => {
mockAutoTuneApi();
await openPreview();
fireEvent.click(screen.getAllByRole('button', { name: 'Configure' })[0]);
await screen.findByRole('dialog', { name: 'The Office' });
const nameInput = screen.getByDisplayValue('The Office');
fireEvent.change(nameInput, { target: { value: 'Renamed' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByText('Edited')).toBeInTheDocument();
// Reopen and clear the field — inheriting the default is not an edit.
fireEvent.click(screen.getAllByRole('button', { name: 'Configure' })[0]);
fireEvent.change(screen.getByDisplayValue('Renamed'), { target: { value: '' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByText('Edited')).not.toBeInTheDocument();
});
it('drops per-channel edits when the preview is re-run', async () => {
mockAutoTuneApi();
await openPreview();
fireEvent.click(screen.getAllByRole('button', { name: 'Configure' })[0]);
await screen.findByRole('dialog', { name: 'The Office' });
fireEvent.change(screen.getByDisplayValue('The Office'), { target: { value: 'The Office (US)' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByText('Edited')).toBeInTheDocument();
// Back to Configure, re-run Preview → a fresh batch starts with no carried-over overrides.
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
await screen.findByText('TV Shows');
expect(screen.queryByText('Edited')).not.toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'The Office' })).toBeInTheDocument();
});
});
+720 -23
View File
@@ -9,29 +9,63 @@ import {
Film,
FolderTree,
Hash,
Image as ImageIcon,
Info,
ListOrdered,
MinusCircle,
Pencil,
RotateCcw,
SearchX,
Settings2,
Shuffle,
Sparkles,
TriangleAlert,
Tv,
XCircle
} from 'lucide-react';
import { Badge, Button, Checkbox, Input, Select, Spinner, Stat, Tag, Tooltip } from '../components';
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 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';
@@ -67,10 +101,56 @@ const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNu
// 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[] };
| {
status: 'success';
templates: ChannelTemplate[];
ffmpegProfiles: FFmpegProfile[];
fillerPresets: FillerPreset[];
watermarks: Watermark[];
};
type PreviewState =
| { status: 'idle' }
@@ -102,6 +182,12 @@ export function AutoTuneScreen() {
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);
@@ -117,12 +203,20 @@ export function AutoTuneScreen() {
const loadTemplates = useCallback(() => {
const id = ++templatesSeqRef.current;
Promise.all([getChannelTemplates(), getDefaultChannelTemplate()])
.then(([templates, preferred]) => {
// 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 });
setTemplatesState({ status: 'success', templates, ffmpegProfiles, fillerPresets, watermarks });
const fallback = preferred ?? templates[0] ?? null;
if (fallback) {
setTemplateId(String(fallback.id));
@@ -158,6 +252,12 @@ export function AutoTuneScreen() {
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),
@@ -183,23 +283,74 @@ export function AutoTuneScreen() {
(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' });
createAutoTunedChannels({
templateId: Number(templateId),
group,
channels: selectedProposals.map((proposal) => ({
axis: proposal.axis as AutoTuneAxis,
value: proposal.value,
name: proposal.name,
number: proposal.number
}))
})
buildChannels(chosen)
.then((channels) => createAutoTunedChannels({ templateId: Number(templateId), group, channels }))
.then((result) => {
if (activeRef.current && id === createSeqRef.current) {
setCreateState({ status: 'success', result });
@@ -216,14 +367,57 @@ export function AutoTuneScreen() {
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
@@ -335,11 +529,30 @@ export function AutoTuneScreen() {
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>
);
}
@@ -584,11 +797,15 @@ 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') {
@@ -728,10 +945,13 @@ function PreviewStep({
</div>
<div>
{rows.map((row, i) => {
const on = selected.has(proposalKey(row));
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={proposalKey(row)}
key={key}
style={{
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
opacity: row.alreadyExists ? 0.55 : 1
@@ -741,11 +961,11 @@ function PreviewStep({
<Checkbox
checked={on && !row.alreadyExists}
disabled={row.alreadyExists}
label={row.name}
onChange={() => toggle(proposalKey(row))}
label={displayName}
onChange={() => toggle(key)}
/>
<span style={{ ...monoStyle, minWidth: 42, font: 'var(--text-sm) var(--font-mono)', color: 'var(--text-secondary)' }}>
{row.number}
{overrides[key]?.number?.trim() || row.number}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -757,16 +977,32 @@ function PreviewStep({
textOverflow: 'ellipsis'
}}
>
{row.name}
{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>}
{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>
);
@@ -865,6 +1101,467 @@ function CreateStep({ state, group, onRetry }: { state: CreateState; group: stri
);
}
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',