Files
silo-server/web/src/lib/settingsManifest.ts
T
Silo Server Migration 04a7e8c82d Add online marker support for recap and preview segments
- Wire introdb marker fetching into playback and Jellyfin compat
- Persist and expose recap/preview markers alongside intro and credits
- Add new playback/profile settings for recap and preview behavior
2026-05-23 23:48:25 -04:00

280 lines
8.4 KiB
TypeScript

export type SettingScope = "user" | "device";
export type SettingControl = "switch" | "select" | "slider" | "json";
export interface SettingOption {
value: string;
label: string;
}
export interface SettingDefinition {
key: string;
scope: SettingScope;
label: string;
description: string;
control: SettingControl;
defaultValue?: string;
options?: SettingOption[];
min?: number;
max?: number;
step?: number;
unit?: string;
summary?: (value: string | null | undefined) => string;
}
export const LANGUAGE_OPTIONS: SettingOption[] = [
{ value: "", label: "No preference" },
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "it", label: "Italian" },
{ value: "pt", label: "Portuguese" },
{ value: "ja", label: "Japanese" },
{ value: "ko", label: "Korean" },
{ value: "zh", label: "Chinese" },
{ value: "ru", label: "Russian" },
{ value: "ar", label: "Arabic" },
{ value: "hi", label: "Hindi" },
];
const definitions: SettingDefinition[] = [
{
key: "playback.preferred_quality",
scope: "device",
label: "Preferred quality",
description: "Pick the quality Silo should prefer on this device for the current profile.",
control: "select",
defaultValue: "auto",
options: [
{ value: "auto", label: "Auto" },
{ value: "original", label: "Original quality" },
{ value: "2160p", label: "2160p / 4K" },
{ value: "1080p", label: "1080p" },
{ value: "720p", label: "720p" },
{ value: "480p", label: "480p" },
],
},
{
key: "playback.audio_language",
scope: "device",
label: "Preferred audio language",
description: "Choose which spoken language Silo should prefer first on this device.",
control: "select",
options: LANGUAGE_OPTIONS,
summary: (value) =>
LANGUAGE_OPTIONS.find((option) => option.value === (value ?? ""))?.label ?? (value || "None"),
},
{
key: "playback.auto_skip_intro",
scope: "device",
label: "Auto-skip intros",
description: "Jump past intros automatically on this device when Silo can detect them.",
control: "switch",
defaultValue: "false",
},
{
key: "playback.auto_skip_credits",
scope: "device",
label: "Auto-skip credits",
description: "Move through end credits automatically on this device when a skip is available.",
control: "switch",
defaultValue: "false",
},
{
key: "playback.auto_skip_recap",
scope: "device",
label: "Auto-skip recaps",
description: "Skip 'previously on…' recaps automatically when Silo can detect them.",
control: "switch",
defaultValue: "false",
},
{
key: "playback.auto_play_next_preview",
scope: "device",
label: "Start next episode at preview",
description:
"Begin playing the next episode when the current one reaches its next-episode preview teaser, rather than waiting for the end credits.",
control: "switch",
defaultValue: "false",
},
{
key: "playback.auto_play_next",
scope: "device",
label: "Auto-play next episode",
description: "Start the next episode automatically on this device after the current one ends.",
control: "switch",
defaultValue: "true",
},
{
key: "playback.next_up_prompt_seconds",
scope: "device",
label: "Next Up prompt",
description: "Choose when the Next Up screen should appear near the end of playback.",
control: "select",
defaultValue: "30",
options: [
{ value: "0", label: "At end" },
{ value: "10", label: "10 seconds before end" },
{ value: "30", label: "30 seconds before end" },
{ value: "60", label: "1 minute before end" },
{ value: "120", label: "2 minutes before end" },
],
},
{
key: "subtitle_appearance",
scope: "device",
label: "Subtitle appearance",
description: "Customize subtitles on this device.",
control: "json",
summary: (value) => (value ? "Custom subtitle appearance" : "Using fallback"),
},
{
key: "player.hdr_enabled",
scope: "device",
label: "HDR enabled",
description: "Allow HDR playback on this device when the player and display support it.",
control: "switch",
defaultValue: "true",
},
{
key: "player.dv_profile7_hdr10_fallback",
scope: "device",
label: "Profile 7 HDR10 fallback",
description: "Play Dolby Vision Profile 7 as the HDR10 base layer on this device.",
control: "switch",
defaultValue: "false",
},
{
key: "player.playback_speed",
scope: "device",
label: "Playback speed",
description: "Choose the default playback speed for this device.",
control: "select",
defaultValue: "1",
options: [
{ value: "0.25", label: "0.25x" },
{ value: "0.5", label: "0.5x" },
{ value: "0.75", label: "0.75x" },
{ value: "1", label: "1x (Normal)" },
{ value: "1.25", label: "1.25x" },
{ value: "1.5", label: "1.5x" },
{ value: "1.75", label: "1.75x" },
{ value: "2", label: "2x" },
{ value: "2.5", label: "2.5x" },
{ value: "3", label: "3x" },
],
summary: (value) => `${value ?? "1"}x`,
},
{
key: "player.audio_sync_ms",
scope: "device",
label: "Audio sync",
description: "Offset audio timing on this device.",
control: "slider",
defaultValue: "0",
min: -5000,
max: 5000,
step: 50,
unit: "ms",
summary: (value) => `${value ?? "0"} ms`,
},
{
key: "player.subtitle_sync_ms",
scope: "device",
label: "Subtitle sync",
description: "Offset subtitle timing on this device.",
control: "slider",
defaultValue: "0",
min: -10000,
max: 10000,
step: 50,
unit: "ms",
summary: (value) => `${value ?? "0"} ms`,
},
{
key: "player.video_gravity",
scope: "device",
label: "Video fit",
description: "Control how video should fill the screen on this device.",
control: "select",
defaultValue: "fit",
options: [
{ value: "fit", label: "Fit" },
{ value: "fill", label: "Fill" },
{ value: "stretch", label: "Stretch" },
],
},
{
key: "player.orientation_mode",
scope: "device",
label: "Orientation mode",
description: "Choose whether playback stays locked or rotates freely on this device.",
control: "select",
defaultValue: "landscapeLocked",
options: [
{ value: "landscapeLocked", label: "Landscape locked" },
{ value: "rotateFreely", label: "Rotate freely" },
],
},
];
export const settingsManifest = definitions.reduce<Record<string, SettingDefinition>>(
(acc, definition) => {
acc[definition.key] = definition;
return acc;
},
{},
);
export const USER_SETTING_KEYS = definitions
.filter((definition) => definition.scope === "user")
.map((definition) => definition.key);
export const DEVICE_SETTING_KEYS = definitions
.filter((definition) => definition.scope === "device" && definition.key !== "subtitle_appearance")
.map((definition) => definition.key);
/**
* Every device-scoped setting in canonical manifest order, including the
* subtitle_appearance JSON setting. The admin "show all overrides" view
* iterates this list so admins can set an override on any setting,
* regardless of whether one already exists.
*/
export const ALL_DEVICE_SETTING_KEYS: string[] = definitions
.filter((definition) => definition.scope === "device")
.map((definition) => definition.key);
export function listDeviceSettingDefinitions(): SettingDefinition[] {
return definitions.filter((definition) => definition.scope === "device");
}
export function getSettingDefinition(key: string): SettingDefinition | null {
return settingsManifest[key] ?? null;
}
export function formatSettingValue(key: string, value: string | null | undefined): string {
const definition = getSettingDefinition(key);
if (!definition) {
return value ?? "Unset";
}
if (definition.summary) {
return definition.summary(value);
}
if (definition.control === "switch") {
return value === "true" ? "Enabled" : "Disabled";
}
if (definition.options) {
return (
definition.options.find((option) => option.value === (value ?? definition.defaultValue))
?.label ??
value ??
definition.defaultValue ??
"Unset"
);
}
if (definition.unit) {
return `${value ?? definition.defaultValue ?? "0"} ${definition.unit}`;
}
return value ?? definition.defaultValue ?? "Unset";
}