+
+
+
{isEdit ? 'Edit FFmpeg profile' : 'New FFmpeg profile'}
+
+ {saveError &&
{saveError}}
+ {validationError &&
{validationError}}
+
+
+
+
+
+ set({ name: event.target.value })}
+ size="sm"
+ value={draft.name ?? ''}
+ />
+
+
+ set({ threadCount })} value={draft.threadCount} />
+
+
+ set({ normalizeAudio })} />
+
+
+ set({ normalizeVideo })} />
+
+
+
+ {draft.normalizeVideo && (
+ <>
+
+
+
+
+ >
+ )}
+
+
+
+ {draft.normalizeVideo ? (
+ <>
+
+
+
+
+
+
+
+ set({ allowBFrames })} />
+
+
+
+
+ set({ videoBitrate })} unit="kBit/s" value={draft.videoBitrate} />
+
+
+ set({ videoBufferSize })} unit="kBit" value={draft.videoBufferSize} />
+
+
+
+ {draft.hardwareAcceleration === 'Vaapi' && (
+ <>
+
+
+
+ set({ vaapiDisplay: event.target.value })} size="sm" value={draft.vaapiDisplay ?? ''} />
+
+ >
+ )}
+ {(draft.hardwareAcceleration === 'Vaapi' || draft.hardwareAcceleration === 'Qsv') && (
+
+ set({ vaapiDevice: event.target.value })} size="sm" value={draft.vaapiDevice ?? ''} />
+
+ )}
+ {draft.hardwareAcceleration === 'Qsv' ? (
+
+ set({ qsvExtraHardwareFrames })}
+ value={draft.qsvExtraHardwareFrames ?? 0}
+ />
+
+ ) : (
+
+
+ )}
+
+ set({ normalizeFramerate })} />
+
+
+ set({ normalizeColors })} />
+
+
+ set({ deinterlaceVideo })} />
+
+ >
+ ) : (
+
+
+ Video will be copied as-is, including timestamps, which will cause issues with most clients.
+
+ )}
+
+
+
+ {draft.normalizeAudio ? (
+ <>
+
+
+
+ set({ audioBitrate })} unit="kBit/s" value={draft.audioBitrate} />
+
+
+ set({ audioBufferSize })} unit="kBit" value={draft.audioBufferSize} />
+
+
+ set({ audioChannels })} value={draft.audioChannels} />
+
+
+ set({ audioSampleRate })} unit="kHz" value={draft.audioSampleRate} />
+
+
+
+ {draft.normalizeLoudnessMode === 'LoudNorm' && (
+
+ set({ targetLoudness })}
+ unit="LUFS"
+ value={draft.targetLoudness ?? 0}
+ />
+
+ )}
+ >
+ ) : (
+
+
+ Audio will be copied as-is, including timestamps, which will cause issues with most clients.
+
+ )}
+
+
+ );
+}
diff --git a/web/src/screens/FillerPresetsScreen.tsx b/web/src/screens/FillerPresetsScreen.tsx
new file mode 100644
index 000000000..02b000051
--- /dev/null
+++ b/web/src/screens/FillerPresetsScreen.tsx
@@ -0,0 +1,738 @@
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { ArrowLeft, Check, Film, Plus, Trash2, TriangleAlert } from 'lucide-react';
+import { navigateToPath } from '../routing';
+import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
+import {
+ createFillerPreset,
+ deleteFillerPreset,
+ getFillerPreset,
+ getFillerPresets,
+ getLibraryBrowseItems,
+ messageFromFillerPresetError,
+ updateFillerPreset,
+ type CreateFillerPresetRequest,
+ type FillerPreset,
+ type LibraryBrowseItem,
+ type LibraryBrowseMediaType
+} from '../api';
+
+const BASE_PATH = '/app/filler-presets';
+
+type Draft = CreateFillerPresetRequest;
+type FillerKind = Draft['fillerKind'];
+type FillerMode = Draft['fillerMode'];
+type CollectionType = Draft['collectionType'];
+
+const KIND_OPTIONS = [
+ { label: 'Pre-Roll', value: 'PreRoll' },
+ { label: 'Mid-Roll', value: 'MidRoll' },
+ { label: 'Post-Roll', value: 'PostRoll' },
+ { label: 'Tail', value: 'Tail' },
+ { label: 'Fallback', value: 'Fallback' }
+];
+
+const MODE_OPTIONS = [
+ { label: 'Duration', value: 'Duration' },
+ { label: 'Count', value: 'Count' },
+ { label: 'Pad', value: 'Pad' },
+ { label: 'Random Count', value: 'RandomCount' }
+];
+
+const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
+
+// CollectionType -> the library-browse media type + which request id field it fills.
+const COLLECTION_TYPES: Array<{
+ browse: LibraryBrowseMediaType;
+ field: 'collectionId' | 'mediaItemId' | 'multiCollectionId' | 'playlistId' | 'smartCollectionId';
+ itemId: (item: LibraryBrowseItem) => null | number | undefined;
+ label: string;
+ playlistOnly?: boolean;
+ value: CollectionType;
+}> = [
+ { browse: 'Collection', field: 'collectionId', itemId: (item) => item.collectionId, label: 'Collection', value: 'Collection' },
+ { browse: 'TelevisionShow', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Television Show', value: 'TelevisionShow' },
+ { browse: 'TelevisionSeason', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Television Season', value: 'TelevisionSeason' },
+ { browse: 'Artist', field: 'mediaItemId', itemId: (item) => item.mediaItemId ?? item.id, label: 'Artist', value: 'Artist' },
+ { browse: 'MultiCollection', field: 'multiCollectionId', itemId: (item) => item.multiCollectionId, label: 'Multi Collection', value: 'MultiCollection' },
+ { browse: 'SmartCollection', field: 'smartCollectionId', itemId: (item) => item.smartCollectionId, label: 'Smart Collection', value: 'SmartCollection' },
+ { browse: 'Playlist', field: 'playlistId', itemId: (item) => item.playlistId, label: 'Playlist', playlistOnly: true, value: 'Playlist' }
+];
+
+const COLLECTION_ID_FIELDS = ['collectionId', 'mediaItemId', 'multiCollectionId', 'smartCollectionId', 'playlistId'] as const;
+
+/* ---------- TimeSpan (constant "c" format) helpers ---------- */
+
+function timeSpanToParts(value: null | string | undefined): { hours: number; minutes: number; seconds: number } {
+ if (!value) {
+ return { hours: 0, minutes: 0, seconds: 0 };
+ }
+
+ const body = value.replace(/^-/, '');
+ const segments = body.split(':');
+ let hoursSegment = segments[0] ?? '0';
+ let days = 0;
+
+ if (hoursSegment.includes('.')) {
+ const [d, h] = hoursSegment.split('.');
+ days = Number(d);
+ hoursSegment = h;
+ }
+
+ return {
+ hours: days * 24 + Number(hoursSegment),
+ minutes: Number(segments[1] ?? 0),
+ seconds: Math.trunc(Number(segments[2] ?? 0))
+ };
+}
+
+function partsToTimeSpan(hours: number, minutes: number, seconds: number): null | string {
+ if (!hours && !minutes && !seconds) {
+ return null;
+ }
+
+ const days = Math.floor(hours / 24);
+ const hh = hours % 24;
+ const mm = String(minutes).padStart(2, '0');
+ const ss = String(seconds).padStart(2, '0');
+ return days > 0 ? `${days}.${String(hh).padStart(2, '0')}:${mm}:${ss}` : `${hh}:${mm}:${ss}`;
+}
+
+/* ---------- validation (mirrors FillerPresetEditViewModelValidator) ---------- */
+
+function validate(draft: Draft): null | string {
+ if (!draft.name.trim()) {
+ return 'Name is required';
+ }
+
+ if (draft.fillerKind !== 'Fallback' && draft.fillerKind !== 'Tail' && draft.fillerMode === 'None') {
+ return 'Mode is required';
+ }
+
+ if (draft.fillerMode === 'Count' && (draft.count == null || draft.count < 0)) {
+ return 'Count is required';
+ }
+
+ if (draft.fillerMode === 'Duration' && draft.duration == null) {
+ return 'Duration is required';
+ }
+
+ if (draft.fillerMode === 'Pad' && draft.padToNearestMinute == null) {
+ return 'Pad-to-nearest-minute is required';
+ }
+
+ if (draft.fillerKind === 'Fallback' && draft.useChaptersAsMediaItems) {
+ return 'Fallback filler cannot use chapters as media items';
+ }
+
+ if (draft.collectionType === 'Collection' && draft.collectionId == null) {
+ return 'A collection is required';
+ }
+
+ if (draft.collectionType === 'MultiCollection' && draft.multiCollectionId == null) {
+ return 'A multi collection is required';
+ }
+
+ if (draft.collectionType === 'SmartCollection' && draft.smartCollectionId == null) {
+ return 'A smart collection is required';
+ }
+
+ if (
+ (draft.collectionType === 'Artist' ||
+ draft.collectionType === 'TelevisionShow' ||
+ draft.collectionType === 'TelevisionSeason') &&
+ draft.mediaItemId == null
+ ) {
+ return 'A media item is required';
+ }
+
+ if (draft.collectionType === 'Playlist' && draft.playlistId == null) {
+ return 'A playlist is required';
+ }
+
+ return null;
+}
+
+function defaultDraft(): Draft {
+ return {
+ allowWatermarks: false,
+ collectionId: null,
+ collectionType: 'Collection',
+ count: null,
+ duration: null,
+ expression: null,
+ fillerKind: 'PreRoll',
+ fillerMode: 'Count',
+ mediaItemId: null,
+ multiCollectionId: null,
+ name: '',
+ padToNearestMinute: null,
+ playlistId: null,
+ smartCollectionId: null,
+ useChaptersAsMediaItems: false
+ };
+}
+
+function draftFromPreset(preset: FillerPreset & Draft): Draft {
+ return {
+ allowWatermarks: preset.allowWatermarks,
+ collectionId: preset.collectionId,
+ collectionType: preset.collectionType,
+ count: preset.count,
+ duration: preset.duration,
+ expression: preset.expression,
+ fillerKind: preset.fillerKind,
+ fillerMode: preset.fillerMode,
+ mediaItemId: preset.mediaItemId,
+ multiCollectionId: preset.multiCollectionId,
+ name: preset.name,
+ padToNearestMinute: preset.padToNearestMinute,
+ playlistId: preset.playlistId,
+ smartCollectionId: preset.smartCollectionId,
+ useChaptersAsMediaItems: preset.useChaptersAsMediaItems
+ };
+}
+
+// Apply the kind-driven interlocks from FillerPresetEditViewModel's setters.
+function applyKind(draft: Draft, fillerKind: FillerKind): Draft {
+ const next: Draft = { ...draft, fillerKind };
+
+ if (fillerKind === 'Fallback' || fillerKind === 'Tail') {
+ next.fillerMode = 'None';
+ }
+
+ if (fillerKind !== 'MidRoll') {
+ next.expression = null;
+ }
+
+ if (fillerKind === 'Fallback') {
+ next.useChaptersAsMediaItems = false;
+
+ // Playlist is not offered for Fallback/Tail; fall back to Collection if it was selected.
+ if (next.collectionType === 'Playlist') {
+ next.collectionType = 'Collection';
+ }
+ }
+
+ if ((fillerKind === 'Tail') && next.collectionType === 'Playlist') {
+ next.collectionType = 'Collection';
+ }
+
+ return next;
+}
+
+function clearCollectionRefs(draft: Draft): Draft {
+ const next = { ...draft };
+ for (const field of COLLECTION_ID_FIELDS) {
+ next[field] = null;
+ }
+
+ return next;
+}
+
+/* ---------- form row helper ---------- */
+
+function Row({
+ children,
+ control = 320,
+ first = false,
+ help,
+ label
+}: {
+ children: ReactNode;
+ control?: number;
+ first?: boolean;
+ help?: string;
+ label: string;
+}) {
+ return (
+