diff --git a/docs/decisions.md b/docs/decisions.md index 7f68a484d..08f5282dd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md index 0e306666b..b93d35f09 100644 --- a/docs/spa-conventions.md +++ b/docs/spa-conventions.md @@ -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. diff --git a/web/src/api/autoTune.test.ts b/web/src/api/autoTune.test.ts index 2d23c1ac1..273bec70e 100644 --- a/web/src/api/autoTune.test.ts +++ b/web/src/api/autoTune.test.ts @@ -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); + }); }); diff --git a/web/src/api/autoTune.ts b/web/src/api/autoTune.ts index cd4c93d38..8daa9db57 100644 --- a/web/src/api/autoTune.ts +++ b/web/src/api/autoTune.ts @@ -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 { return request('/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 { + 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(`/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; diff --git a/web/src/builder/ChannelBuilder.tsx b/web/src/builder/ChannelBuilder.tsx index bc427ab67..fb402b5f6 100644 --- a/web/src/builder/ChannelBuilder.tsx +++ b/web/src/builder/ChannelBuilder.tsx @@ -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 = { - 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>; - -// 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)[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)[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) => { - 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) => { - 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 }) { ); } - -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}`; -} diff --git a/web/src/builder/advancedOptions.tsx b/web/src/builder/advancedOptions.tsx new file mode 100644 index 000000000..94ef34e9f --- /dev/null +++ b/web/src/builder/advancedOptions.tsx @@ -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 = { + 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>; + +// 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)[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)[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) => { + 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) => { + 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 + }; +} diff --git a/web/src/components/components.css b/web/src/components/components.css index 6db4b7183..bd9ab3a60 100644 --- a/web/src/components/components.css +++ b/web/src/components/components.css @@ -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); diff --git a/web/src/components/overlay.test.tsx b/web/src/components/overlay.test.tsx index 928d978e9..d7f64b3b7 100644 --- a/web/src/components/overlay.test.tsx +++ b/web/src/components/overlay.test.tsx @@ -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( + + Panel content + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the title, subtitle and children when open', () => { + render( + + Panel content + + ); + + 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( + + Panel content + + ); + + 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( + + Panel content + + ); + + 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( + + Panel content + + ); + expect(document.body.style.overflow).toBe('hidden'); + + rerender( + + Panel content + + ); + expect(document.body.style.overflow).toBe('scroll'); + }); +}); + describe('ConfirmDialog', () => { afterEach(() => { cleanup(); diff --git a/web/src/components/overlay.tsx b/web/src/components/overlay.tsx index e837e7d94..af4857dd7 100644 --- a/web/src/components/overlay.tsx +++ b/web/src/components/overlay.tsx @@ -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(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) { 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(null); + + useOverlayBehavior(open, onClose, panelRef); if (!open) { return null; @@ -146,3 +147,63 @@ export function ConfirmDialog({ ); } + +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(null); + + useOverlayBehavior(open, onClose, panelRef); + + if (!open) { + return null; + } + + const onOverlayClick = (event: MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + return createPortal( +
+
+
+
+ + {title} + + {subtitle != null && {subtitle}} +
+ + +
+
{children}
+ {footer &&
{footer}
} +
+
, + document.body + ); +} diff --git a/web/src/screens/AutoTuneScreen.test.tsx b/web/src/screens/AutoTuneScreen.test.tsx index 2d718e0f9..5fd6c3631 100644 --- a/web/src/screens/AutoTuneScreen.test.tsx +++ b/web/src/screens/AutoTuneScreen.test.tsx @@ -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 { 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(); + 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(); + }); }); diff --git a/web/src/screens/AutoTuneScreen.tsx b/web/src/screens/AutoTuneScreen.tsx index 0dc7f9c61..59800a8a6 100644 --- a/web/src/screens/AutoTuneScreen.tsx +++ b/web/src/screens/AutoTuneScreen.tsx @@ -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({ status: 'idle' }); const [createState, setCreateState] = useState({ status: 'idle' }); const [selected, setSelected] = useState>(() => new Set()); + const [overrides, setOverrides] = useState>({}); + const [detailKey, setDetailKey] = useState(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>(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 | ((current: ChannelOverride) => Partial)) => { + 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 => + 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 (
)} {step === 'create' && } + + {detailProposal && detailTemplate && templatesState.status === 'success' && ( + detailKey && patchOverride(detailKey, delta)} + onPatchAdvanced={(updater) => detailKey && patchAdvanced(detailKey, updater)} + onClose={() => setDetailKey(null)} + /> + )}
); } @@ -584,11 +797,15 @@ function PreviewStep({ state, selected, setSelected, + overrides, + onConfigure, onRetry }: { state: PreviewState; selected: Set; setSelected: (updater: (current: Set) => Set) => void; + overrides: Record; + onConfigure: (key: string) => void; onRetry: () => void; }) { if (state.status === 'loading' || state.status === 'idle') { @@ -728,10 +945,13 @@ function PreviewStep({
{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 (
toggle(proposalKey(row))} + label={displayName} + onChange={() => toggle(key)} /> - {row.number} + {overrides[key]?.number?.trim() || row.number}
- {row.name} + {displayName}
from “{row.value}”
+ {edited && ( + + )} {row.itemCount} items - {row.alreadyExists && Exists} + {row.alreadyExists ? ( + Exists + ) : ( + + )}
); @@ -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 ( +
+
{label}
+ {children} + {hint != null &&
{hint}
} +
+ ); +} + +function ToggleRow({ + label, + hint, + checked, + overridesTemplate, + onChange +}: { + label: string; + hint: string; + checked: boolean; + overridesTemplate: boolean; + onChange: (next: boolean) => void; +}) { + return ( +
+
+ + {overridesTemplate && overrides template} +
+
{hint}
+
+ ); +} + +function ReadRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +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 | ((current: ChannelOverride) => Partial)) => void; + onPatchAdvanced: (updater: (prev: Overrides) => Overrides) => void; + onClose: () => void; +}) { + const [membersState, setMembersState] = useState({ status: 'loading' }); + const [advancedOpen, setAdvancedOpen] = useState(false); + const fileInputRef = useRef(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).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 ( + + {displayNumber} · {shuffleOn ? 'Shuffled' : 'In order'} + + } + > + + + onPatch({ name: event.target.value })} + /> + + + onPatch({ number: event.target.value })} + leadingIcon={ +
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' + }} + > + +
+
+ {override.logoPreview ? 'Channel image set' : 'Drop or choose a channel image'} +
+
+ Used as the guide logo and on-screen bug. Optional. +
+
+
+
+ + + onPatch({ playbackOrder: next ? 'Shuffle' : 'Chronological' })} + /> + onPatch({ playoutMode: next ? 'Continuous' : 'OnDemand' })} + /> + + + + p.id === t.ffmpegProfileId)?.name ?? `#${t.ffmpegProfileId}`), + ...ffmpegProfiles.map((profile) => ({ value: String(profile.id), label: profile.name ?? `#${profile.id}` })) + ]} + onChange={onSelect('ffmpegProfileId', Number)} + /> + ({ value, label: value }))]} + onChange={onSelect('transcodeMode', (raw) => raw)} + /> + + + + + + + + + ({ value, label: value }))]} onChange={onSelect('streamSelectorMode', (raw) => raw)} /> + + + + + ({ value, label: value }))]} onChange={onSelect('songVideoMode', (raw) => raw)} /> + ({ value, label: value }))]} onChange={onSelect('fixedStartTimeBehavior', (raw) => raw)} /> +
+ Random start point + setOverride('randomStartPoint', next)} /> +
+
+ Shuffle schedule items + setOverride('shuffleScheduleItems', next)} /> +
+
+ + ); +} + +function AdvGroup({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + const centeredMessageStyle: CSSProperties = { display: 'flex', flexDirection: 'column',