diff --git a/cmd/settingsgen/main.go b/cmd/settingsgen/main.go
index a42af872..d1cc5c59 100644
--- a/cmd/settingsgen/main.go
+++ b/cmd/settingsgen/main.go
@@ -213,6 +213,10 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
out.WriteString(" category: string;\n")
out.WriteString(" control?: string;\n")
out.WriteString(" unit?: string;\n")
+ out.WriteString(" /** Advisory: platforms this setting is expected to matter on. Absent\n")
+ out.WriteString(" * means everywhere. A client hides settings for platforms it is not,\n")
+ out.WriteString(" * rather than disabling them without explanation. */\n")
+ out.WriteString(" platforms?: readonly string[];\n")
out.WriteString(" suggestedOptions?: SettingOptionSetId;\n")
out.WriteString(" unsetLabel?: string;\n")
out.WriteString(" values?: readonly { value: unknown; label: string; introducedIn: number }[];\n")
@@ -254,6 +258,9 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
if def.Unit != "" {
fmt.Fprintf(&out, " unit: %q,\n", def.Unit)
}
+ if len(def.Platforms) > 0 {
+ fmt.Fprintf(&out, " platforms: [%s],\n", quotedStrings(def.Platforms))
+ }
if def.SuggestedOptions != "" {
fmt.Fprintf(&out, " suggestedOptions: %q,\n", def.SuggestedOptions)
}
@@ -533,6 +540,14 @@ func sortedOptionSetNames(contract *settingscontract.Manifest) []string {
return names
}
+func quotedStrings(values []string) string {
+ parts := make([]string, 0, len(values))
+ for _, value := range values {
+ parts = append(parts, fmt.Sprintf("%q", value))
+ }
+ return strings.Join(parts, ", ")
+}
+
func quotedScopes(def *settingscontract.Definition) string {
parts := make([]string, 0, len(def.AllowedScopes))
for _, entry := range def.AllowedScopes {
diff --git a/cmd/settingsgen/main_test.go b/cmd/settingsgen/main_test.go
index 0ea7d9e2..76d3ff5a 100644
--- a/cmd/settingsgen/main_test.go
+++ b/cmd/settingsgen/main_test.go
@@ -26,6 +26,10 @@ func TestPresentationMetadataIsGeneratedForEveryClientLanguage(t *testing.T) {
`catalog_metadata_languages`,
`suggestedOptions: "playback_subtitle_languages"`,
`unsetLabel: "None"`,
+ // Advisory platform tags reach the web UI so it can hide
+ // settings that do not apply to the device being edited.
+ `platforms: ["web"]`,
+ `platforms: ["ios", "android"]`,
},
},
{
diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json
index d2559359..57743d3b 100644
--- a/contracts/settings/v1/manifest.json
+++ b/contracts/settings/v1/manifest.json
@@ -428,6 +428,7 @@
"resolution_order": ["profile_device", "default"],
"value_schema": { "type": "boolean" },
"default_value": true,
+ "platforms": ["ios", "tvos", "macos", "android", "android_tv"],
"category": "player",
"label": "Dolby Vision",
"description": "Allow Dolby Vision output on this device.",
@@ -441,6 +442,7 @@
"resolution_order": ["profile_device", "default"],
"value_schema": { "type": "boolean" },
"default_value": false,
+ "platforms": ["ios", "tvos", "macos", "android", "android_tv"],
"category": "player",
"label": "Dolby Vision Profile 7 fallback",
"description": "Play Profile 7 sources as HDR10 when this device cannot decode them natively.",
@@ -498,6 +500,7 @@
"resolution_order": ["profile_device", "default"],
"value_schema": { "type": "integer", "minimum": -5000, "maximum": 5000 },
"default_value": 0,
+ "platforms": ["ios", "tvos", "macos", "android", "android_tv"],
"unit": "milliseconds",
"category": "player",
"label": "Audio sync offset",
diff --git a/web/src/components/settings/DeviceSettingGroups.test.tsx b/web/src/components/settings/DeviceSettingGroups.test.tsx
index 0e019b60..5b6f983b 100644
--- a/web/src/components/settings/DeviceSettingGroups.test.tsx
+++ b/web/src/components/settings/DeviceSettingGroups.test.tsx
@@ -187,6 +187,64 @@ describe("DeviceSettingGroups", () => {
expect(screen.getByText("3.5 Mbps")).toBeInTheDocument();
});
+ // The sliders shipped controlled with only onValueCommit, so the thumb
+ // never moved and no keyboard or pointer gesture could change the value.
+ // Keyboard steps are what jsdom can exercise; they commit through the same
+ // path a pointer release does.
+ it("commits a slider change", async () => {
+ const { onChange } = renderGroups({
+ "playback.next_up_prompt_seconds": effective({
+ key: "playback.next_up_prompt_seconds",
+ value: 30,
+ source: "default",
+ }),
+ });
+
+ const slider = screen.getByRole("slider", { name: "Next up prompt" });
+ slider.focus();
+ await userEvent.keyboard("{ArrowRight}");
+
+ expect(onChange).toHaveBeenCalledWith("playback.next_up_prompt_seconds", 31);
+ });
+
+ it("hides settings that cannot apply to the device being edited", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText("Screen orientation")).not.toBeInTheDocument();
+ expect(screen.queryByText("Audio sync offset")).not.toBeInTheDocument();
+ expect(screen.getByText("Subtitles", { selector: "h3" })).toBeInTheDocument();
+ });
+
+ it("keeps an inapplicable setting visible while this device stores a value", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Audio sync offset")).toBeInTheDocument();
+ expect(screen.queryByText("Screen orientation")).not.toBeInTheDocument();
+ });
+
it("states who set a locked value rather than disabling it silently", () => {
renderGroups({
"playback.preferred_quality": effective({
diff --git a/web/src/components/settings/DeviceSettingGroups.tsx b/web/src/components/settings/DeviceSettingGroups.tsx
index 03496ea8..cb1a410d 100644
--- a/web/src/components/settings/DeviceSettingGroups.tsx
+++ b/web/src/components/settings/DeviceSettingGroups.tsx
@@ -1,7 +1,6 @@
import { Lock, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -11,6 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { LanguageSelect } from "@/components/settings/LanguageSelect";
+import { SettingSlider } from "@/components/settings/SettingSlider";
import { SettingsGroup } from "@/components/settings/SettingsGroup";
import { groupDeviceSettings } from "@/lib/deviceSettingGroups";
import type { EffectiveSetting } from "@/hooks/queries/settingValues";
@@ -30,6 +30,12 @@ export interface DeviceSettingGroupsProps {
* a name when the household parent is acting for someone else.
*/
ownerLabel: string;
+ /**
+ * The target device's self-reported platform string. When given, settings
+ * the manifest marks as not applying to that platform are hidden — unless
+ * the device already stores a value, which must stay clearable.
+ */
+ devicePlatform?: string;
disabled?: boolean;
onChange: (key: SettingKey, value: unknown) => void;
onReset: (key: SettingKey) => void;
@@ -40,14 +46,23 @@ export interface DeviceSettingGroupsProps {
export function DeviceSettingGroups({
settings,
ownerLabel,
+ devicePlatform,
disabled = false,
onChange,
onReset,
onOpenPanel,
}: DeviceSettingGroupsProps) {
+ const storedHere = new Set(
+ (Object.keys(settings) as SettingKey[]).filter(
+ (key) => settings[key]?.scope === "profile_device",
+ ),
+ );
return (
- {groupDeviceSettings().map((group) => (
+ {groupDeviceSettings(undefined, {
+ devicePlatform,
+ keysWithStoredValues: storedHere,
+ }).map((group) => (
{group.keys.map((key) => (
- onChange(settingKey, values[0] ?? numeric)}
- />
-
- {numeric}
- {definition.unit ? ` ${definition.unit}` : ""}
-
-
+ onChange(settingKey, next)}
+ />
);
}
diff --git a/web/src/components/settings/RegistrySettingControl.tsx b/web/src/components/settings/RegistrySettingControl.tsx
index 9fe11821..0db70c48 100644
--- a/web/src/components/settings/RegistrySettingControl.tsx
+++ b/web/src/components/settings/RegistrySettingControl.tsx
@@ -1,4 +1,3 @@
-import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -7,6 +6,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { SettingSlider } from "@/components/settings/SettingSlider";
import { controlKindFor, optionsFor, type SettingDisplay } from "@/lib/settingsDisplay";
const EMPTY_SELECT_VALUE = "__empty__";
@@ -58,20 +58,17 @@ export function RegistrySettingControl({
const parsed = Number(value);
const numericValue = Number.isFinite(parsed) && value !== "" ? parsed : fallback;
return (
-
- onChange(String(values[0] ?? numericValue))}
- />
-
- {numericValue}
- {definition.unit ? ` ${definition.unit}` : ""}
-
-
+ onChange(String(next))}
+ />
);
}
diff --git a/web/src/components/settings/SettingSlider.tsx b/web/src/components/settings/SettingSlider.tsx
new file mode 100644
index 00000000..37f9ddd5
--- /dev/null
+++ b/web/src/components/settings/SettingSlider.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+
+import { Slider } from "@/components/ui/slider";
+
+interface SettingSliderProps {
+ /** The persisted value. The thumb returns here if a save is rejected. */
+ value: number;
+ min?: number;
+ max?: number;
+ step?: number;
+ unit?: string;
+ disabled?: boolean;
+ "aria-label"?: string;
+ /** Fired once per gesture, when the thumb is released or a key is pressed. */
+ onCommit: (value: number) => void;
+ className?: string;
+}
+
+/**
+ * A slider bound to a saved setting.
+ *
+ * Radix's Slider is controlled here, so the thumb only moves if something
+ * re-renders it with a new value. Persisting on every pointer move would flood
+ * the API, but rendering only the saved value makes the control inert — the
+ * thumb snaps back under the pointer and onValueCommit never sees a change,
+ * which is exactly the bug this component exists to prevent. While a drag is
+ * in flight the draft value drives the thumb and the readout; on release the
+ * draft is committed and cleared, and the saved value takes over again.
+ */
+export function SettingSlider({
+ value,
+ min,
+ max,
+ step,
+ unit,
+ disabled = false,
+ "aria-label": ariaLabel,
+ onCommit,
+ className,
+}: SettingSliderProps) {
+ const [draft, setDraft] = useState(null);
+ const shown = draft ?? value;
+
+ return (
+
+ setDraft(values[0] ?? shown)}
+ onValueCommit={(values) => {
+ setDraft(null);
+ const next = values[0];
+ if (next !== undefined && next !== value) onCommit(next);
+ }}
+ />
+
+ {shown}
+ {unit ? ` ${unit}` : ""}
+
+
+ );
+}
diff --git a/web/src/lib/deviceSettingGroups.test.ts b/web/src/lib/deviceSettingGroups.test.ts
index e5184c14..e917e8e8 100644
--- a/web/src/lib/deviceSettingGroups.test.ts
+++ b/web/src/lib/deviceSettingGroups.test.ts
@@ -4,6 +4,8 @@ import {
groupDeviceSettings,
groupForDeviceSetting,
hiddenDeviceSettingKeys,
+ manifestPlatformFor,
+ settingAppliesToPlatform,
} from "@/lib/deviceSettingGroups";
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
@@ -48,4 +50,48 @@ describe("deviceSettingGroups", () => {
const single = groupDeviceSettings(["player.hdr_enabled"]);
expect(single.map((group) => group.id)).toEqual(["picture"]);
});
+
+ // Browser platform strings all end in "Web" ("iOS Web"), so a phone browser
+ // must classify as web, not as the phone's native app.
+ it("maps self-reported platform strings onto the manifest's identifiers", () => {
+ expect(manifestPlatformFor("macOS Web")).toBe("web");
+ expect(manifestPlatformFor("iOS Web")).toBe("web");
+ expect(manifestPlatformFor("iOS")).toBe("ios");
+ expect(manifestPlatformFor("tvOS")).toBe("tvos");
+ expect(manifestPlatformFor("android")).toBe("android");
+ expect(manifestPlatformFor("android-tv")).toBe("android_tv");
+ expect(manifestPlatformFor("Roku")).toBeNull();
+ expect(manifestPlatformFor(undefined)).toBeNull();
+ });
+
+ it("hides settings the manifest marks as not applying to the device", () => {
+ // Screen orientation is ios/android only; audio sync is native-only.
+ expect(settingAppliesToPlatform("player.orientation_mode", "web")).toBe(false);
+ expect(settingAppliesToPlatform("player.orientation_mode", "ios")).toBe(true);
+ expect(settingAppliesToPlatform("player.audio_sync_ms", "web")).toBe(false);
+ expect(settingAppliesToPlatform("player.audio_sync_ms", "tvos")).toBe(true);
+ // An untagged setting is expected everywhere.
+ expect(settingAppliesToPlatform("playback.subtitle_mode", "web")).toBe(true);
+ // An unrecognized platform hides nothing.
+ expect(settingAppliesToPlatform("player.orientation_mode", null)).toBe(true);
+
+ const keys = groupDeviceSettings(undefined, { devicePlatform: "macOS Web" }).flatMap(
+ (group) => group.keys,
+ );
+ expect(keys).not.toContain("player.orientation_mode");
+ expect(keys).not.toContain("player.match_frame_rate");
+ expect(keys).not.toContain("player.audio_sync_ms");
+ expect(keys).toContain("playback.subtitle_mode");
+ });
+
+ // A stale override on a now-inapplicable setting must stay visible, or it
+ // can never be cleared from this screen.
+ it("keeps a platform-hidden setting visible while the device stores a value", () => {
+ const keys = groupDeviceSettings(undefined, {
+ devicePlatform: "macOS Web",
+ keysWithStoredValues: new Set(["player.audio_sync_ms"]),
+ }).flatMap((group) => group.keys);
+ expect(keys).toContain("player.audio_sync_ms");
+ expect(keys).not.toContain("player.orientation_mode");
+ });
});
diff --git a/web/src/lib/deviceSettingGroups.ts b/web/src/lib/deviceSettingGroups.ts
index 44b5dd20..8b2c5464 100644
--- a/web/src/lib/deviceSettingGroups.ts
+++ b/web/src/lib/deviceSettingGroups.ts
@@ -78,6 +78,51 @@ const HIDDEN_KEYS = new Set([
"ui.remember_library_page_state",
]);
+/**
+ * The manifest's platform identifiers, as `platforms` uses them.
+ *
+ * Devices self-report a free-form platform string ("iOS Web", "tvOS",
+ * "android-tv"), so editing another device means mapping that string onto
+ * these before the advisory `platforms` tags can be applied.
+ */
+export type ManifestPlatform = "web" | "ios" | "tvos" | "macos" | "android" | "android_tv";
+
+/**
+ * Maps a device's self-reported platform string to a manifest platform, or
+ * null when the string is unrecognized. Order matters: every browser platform
+ * string ends in "Web" ("iOS Web", "Android Web"), so the web check runs
+ * before the OS checks — an iPhone browser is a web device, not an iOS app.
+ */
+export function manifestPlatformFor(devicePlatform: string | undefined): ManifestPlatform | null {
+ if (!devicePlatform) return null;
+ const p = devicePlatform.toLowerCase();
+ if (p.includes("web") || p.includes("browser")) return "web";
+ if (p.includes("android-tv") || p.includes("android_tv") || p.includes("android tv")) {
+ return "android_tv";
+ }
+ if (p.includes("tvos") || p.includes("apple tv")) return "tvos";
+ if (p.includes("ios") || p.includes("iphone") || p.includes("ipad")) return "ios";
+ if (p.includes("macos") || p.includes("mac os")) return "macos";
+ if (p.includes("android")) return "android";
+ return null;
+}
+
+/**
+ * Whether a setting is expected to do anything on the given platform.
+ *
+ * The manifest's `platforms` field is advisory UI metadata: absent means
+ * "expected everywhere". An unrecognized device platform shows everything —
+ * offering a control that may be inert beats hiding one that is not.
+ */
+export function settingAppliesToPlatform(
+ key: SettingKey,
+ platform: ManifestPlatform | null,
+): boolean {
+ const platforms = SETTING_DEFINITIONS[key]?.platforms;
+ if (!platforms?.length || !platform) return true;
+ return platforms.includes(platform);
+}
+
export function groupForDeviceSetting(key: SettingKey): DeviceSettingGroupId | null {
if (HIDDEN_KEYS.has(key)) return null;
const explicit = EXPLICIT_GROUPS[key];
@@ -95,12 +140,29 @@ export function groupForDeviceSetting(key: SettingKey): DeviceSettingGroupId | n
}
}
-/** The groups to render, in reading order, with empty groups omitted. */
+/**
+ * The groups to render, in reading order, with empty groups omitted.
+ *
+ * `devicePlatform` is the target device's self-reported platform string; when
+ * given, settings whose `platforms` tag excludes that device are dropped — a
+ * browser gets no "Screen orientation", a phone no TV-only toggles. A key the
+ * device stores a value for stays visible regardless, so a stale override can
+ * always be seen and cleared.
+ */
export function groupDeviceSettings(
keys: readonly SettingKey[] = ALL_DEVICE_SETTING_KEYS,
+ options?: { devicePlatform?: string; keysWithStoredValues?: ReadonlySet },
): DeviceSettingGroup[] {
+ const platform = manifestPlatformFor(options?.devicePlatform);
const byGroup = new Map();
for (const key of keys) {
+ if (
+ options?.devicePlatform !== undefined &&
+ !settingAppliesToPlatform(key, platform) &&
+ !options?.keysWithStoredValues?.has(key)
+ ) {
+ continue;
+ }
const group = groupForDeviceSetting(key);
if (!group) continue;
const existing = byGroup.get(group);
diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts
index c329ca2d..11aab0a0 100644
--- a/web/src/lib/settingsContract.ts
+++ b/web/src/lib/settingsContract.ts
@@ -276,6 +276,10 @@ export interface SettingDefinition {
category: string;
control?: string;
unit?: string;
+ /** Advisory: platforms this setting is expected to matter on. Absent
+ * means everywhere. A client hides settings for platforms it is not,
+ * rather than disabling them without explanation. */
+ platforms?: readonly string[];
suggestedOptions?: SettingOptionSetId;
unsetLabel?: string;
values?: readonly { value: unknown; label: string; introducedIn: number }[];
@@ -343,6 +347,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Quality preset used for new downloads.",
category: "downloads",
control: "select",
+ platforms: ["ios", "android"],
values: [
{ value: "1mbps", label: "1 Mbps", introducedIn: 1 },
{ value: "2mbps", label: "2 Mbps", introducedIn: 1 },
@@ -367,6 +372,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Do not suggest reclaiming space from downloads you have finished.",
category: "downloads",
control: "switch",
+ platforms: ["ios", "android"],
},
"downloads.wifi_only": {
key: "downloads.wifi_only",
@@ -382,6 +388,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Only download while connected to Wi-Fi.",
category: "downloads",
control: "switch",
+ platforms: ["ios", "android"],
},
"nav.show_audiobooks": {
key: "nav.show_audiobooks",
@@ -397,6 +404,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Show the Audiobooks section in navigation.",
category: "nav",
control: "switch",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv"],
},
"playback.audio_language": {
key: "playback.audio_language",
@@ -644,6 +652,7 @@ export const SETTING_DEFINITIONS: Record = {
category: "player",
control: "slider",
unit: "milliseconds",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv"],
minimum: -5000,
maximum: 5000,
},
@@ -661,6 +670,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Allow Dolby Vision output on this device.",
category: "player",
control: "switch",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv"],
},
"player.dv_profile7_hdr10_fallback": {
key: "player.dv_profile7_hdr10_fallback",
@@ -676,6 +686,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Play Profile 7 sources as HDR10 when this device cannot decode them natively.",
category: "player",
control: "switch",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv"],
},
"player.hdr_enabled": {
key: "player.hdr_enabled",
@@ -706,6 +717,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Switch the display refresh rate to match what is playing.",
category: "player",
control: "switch",
+ platforms: ["android", "android_tv", "tvos"],
},
"player.orientation_mode": {
key: "player.orientation_mode",
@@ -721,6 +733,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Whether the player rotates with the device.",
category: "player",
control: "select",
+ platforms: ["ios", "android"],
values: [
{ value: "landscapeLocked", label: "Landscape", introducedIn: 1 },
{ value: "rotateFreely", label: "Rotate freely", introducedIn: 1 },
@@ -742,6 +755,7 @@ export const SETTING_DEFINITIONS: Record = {
category: "player",
control: "stepper",
unit: "episodes",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv", "web"],
minimum: 0,
maximum: 20,
},
@@ -759,6 +773,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Keep playing in a floating window when you leave the player.",
category: "player",
control: "switch",
+ platforms: ["ios", "macos", "android"],
},
"player.playback_speed": {
key: "player.playback_speed",
@@ -795,6 +810,7 @@ export const SETTING_DEFINITIONS: Record = {
category: "player",
control: "stepper",
unit: "seconds",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv", "web"],
minimum: 0,
maximum: 30,
},
@@ -903,6 +919,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Use the operating system's caption style instead of Silo's.",
category: "playback",
control: "switch",
+ platforms: ["ios", "tvos", "macos", "android", "android_tv"],
},
"ui.card_overlays": {
key: "ui.card_overlays",
@@ -917,6 +934,7 @@ export const SETTING_DEFINITIONS: Record = {
label: "Poster badges",
description: "Which badges appear on poster cards, and where.",
category: "appearance",
+ platforms: ["web"],
},
"ui.custom_css": {
key: "ui.custom_css",
@@ -932,6 +950,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Raw CSS applied on top of the selected theme.",
category: "appearance",
control: "text",
+ platforms: ["web"],
},
"ui.custom_theme_vars": {
key: "ui.custom_theme_vars",
@@ -947,6 +966,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Per-token overrides applied on top of the selected theme.",
category: "appearance",
control: "panel",
+ platforms: ["web"],
},
"ui.date_format": {
key: "ui.date_format",
@@ -997,6 +1017,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Increase contrast across the interface.",
category: "appearance",
control: "switch",
+ platforms: ["web"],
},
"ui.library_order": {
key: "ui.library_order",
@@ -1025,6 +1046,7 @@ export const SETTING_DEFINITIONS: Record = {
label: "Remembered library view",
description: "Saved browse state for each library.",
category: "navigation",
+ platforms: ["web"],
},
"ui.next_up_mode": {
key: "ui.next_up_mode",
@@ -1059,6 +1081,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Return to where you left off when reopening a library.",
category: "navigation",
control: "switch",
+ platforms: ["web"],
},
"ui.sidebar_pins": {
key: "ui.sidebar_pins",
@@ -1073,6 +1096,7 @@ export const SETTING_DEFINITIONS: Record = {
label: "Pinned sidebar items",
description: "Sections and collections pinned into the sidebar.",
category: "navigation",
+ platforms: ["web"],
},
"ui.text_scale": {
key: "ui.text_scale",
@@ -1088,6 +1112,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Overall interface text size.",
category: "appearance",
control: "select",
+ platforms: ["web"],
values: [
{ value: "default", label: "Default", introducedIn: 1 },
{ value: "large", label: "Large", introducedIn: 1 },
@@ -1109,6 +1134,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Use heavier interface text for readability.",
category: "appearance",
control: "select",
+ platforms: ["web"],
values: [
{ value: "default", label: "Default", introducedIn: 1 },
{ value: "strong", label: "Bolder", introducedIn: 1 },
@@ -1128,6 +1154,7 @@ export const SETTING_DEFINITIONS: Record = {
description: "Colour theme for the Silo interface.",
category: "appearance",
control: "select",
+ platforms: ["web"],
values: [
{ value: "midnight-cinema", label: "Midnight Cinema", introducedIn: 1 },
{ value: "cinema-light", label: "Cinema Light", introducedIn: 1 },
diff --git a/web/src/pages/settings/DeviceSettings.tsx b/web/src/pages/settings/DeviceSettings.tsx
index e8c50067..6aa13c8a 100644
--- a/web/src/pages/settings/DeviceSettings.tsx
+++ b/web/src/pages/settings/DeviceSettings.tsx
@@ -12,6 +12,7 @@ import {
} from "@/components/admin/deviceOverrides";
import { DeviceList, lastSeenLabel } from "@/components/settings/DeviceList";
import { DeviceSettingGroups } from "@/components/settings/DeviceSettingGroups";
+import { SubtitleAppearancePanelView } from "@/components/settings/SubtitleAppearancePanelView";
import { useClearDeviceSettings, useForgetDevice, useMyDevices } from "@/hooks/queries/devices";
import {
useClearSettingValue,
@@ -21,7 +22,8 @@ import {
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
-import type { SettingKey } from "@/lib/settingsContract";
+import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
+import { parseSubtitleAppearance, type SubtitleAppearance } from "@/lib/subtitleAppearance";
import { cn } from "@/lib/utils";
/**
@@ -252,6 +254,14 @@ function DeviceDetail({
profileId: targetProfileId,
};
+ // The subtitle appearance panel edits its object locally so dragging a
+ // colour or opacity feels instant, persisting each patch as it lands. The
+ // server value reasserts itself whenever the panel is (re)opened.
+ const [appearanceOpen, setAppearanceOpen] = useState(false);
+ const [appearance, setAppearance] = useState(null);
+ const appearanceSetting = settings[SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE];
+ const appearanceChangedHere = appearanceSetting?.scope === "profile_device";
+
const changedCount = device.changed_count;
const kind = classifyPlatform(device.device_platform);
@@ -380,6 +390,7 @@ function DeviceDetail({
setValue.mutate(
@@ -399,9 +410,44 @@ function DeviceDetail({
},
)
}
+ onOpenPanel={() => {
+ setAppearance(parseSubtitleAppearance(appearanceSetting?.value));
+ setAppearanceOpen(true);
+ }}
/>
)}
+ {
+ const next = { ...(appearance ?? parseSubtitleAppearance(undefined)), ...patch };
+ setAppearance(next);
+ setValue.mutate(
+ { key: SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE, value: next, identity },
+ {
+ onError: (error) =>
+ toast.error(error instanceof Error ? error.message : "Couldn't save"),
+ },
+ );
+ }}
+ onClose={() => setAppearanceOpen(false)}
+ canReset={appearanceChangedHere}
+ onReset={() => {
+ clearValue.mutate(
+ { key: SETTING_KEYS.PLAYBACK_SUBTITLE_APPEARANCE, identity },
+ {
+ onError: (error) =>
+ toast.error(error instanceof Error ? error.message : "Couldn't reset"),
+ },
+ );
+ setAppearanceOpen(false);
+ }}
+ resetLabel={`Use ${ownerLabel} style`}
+ eyebrow={device.device_name || "This device"}
+ status={setValue.isPending ? "Saving…" : "Changes saved automatically"}
+ />
+
{forSomeoneElse ? (
}>
What you can't see here.{" "}