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
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> 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 & 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',
|
||||
|
||||
Reference in New Issue
Block a user