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.{" "}