fix(settings): make device setting controls work and fit the device

The device settings screen shipped with three defects:

- Sliders (audio sync, playback speed, subtitle sync, next-up prompt,
  sleep timer) were rendered controlled with only onValueCommit, so the
  thumb never moved and no gesture could change the value. A shared
  SettingSlider now keeps a draft during the drag and commits once on
  release; the admin RegistrySettingControl had the same bug and uses it
  too.
- "Change how they look" called an onOpenPanel the page never passed.
  The screen now opens the shared SubtitleAppearancePanelView scoped to
  the selected device and profile, with a reset that clears the row.
- Every setting was shown for every device, so a browser offered
  "Screen orientation" and native-only toggles. settingsgen now emits
  the manifest's advisory platforms field into the TypeScript contract,
  and the screen hides settings whose platforms exclude the target
  device — unless the device stores a value, which must stay clearable.
  audio_sync_ms, dolby_vision_enabled and dv_profile7_hdr10_fallback
  gain native-only tags (no web consumer exists); platforms is advisory
  UI metadata, so no revision bump.

Kotlin/Swift generators deliberately unchanged: the native clients
hardcode their own applicability today, and their vendored bindings are
regenerated from their own repos.

Part of #215

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-08-01 16:15:51 +00:00
co-authored by Claude Fable 5
parent 9aab2ead57
commit a28960819d
11 changed files with 370 additions and 33 deletions
+15
View File
@@ -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 {
+4
View File
@@ -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"]`,
},
},
{
+3
View File
@@ -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",
@@ -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(
<DeviceSettingGroups
settings={{}}
ownerLabel="your"
devicePlatform="macOS Web"
onChange={vi.fn()}
onReset={vi.fn()}
/>,
);
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(
<DeviceSettingGroups
settings={{
"player.audio_sync_ms": {
key: "player.audio_sync_ms",
value: 250,
source: "profile_device",
scope: "profile_device",
},
}}
ownerLabel="your"
devicePlatform="macOS Web"
onChange={vi.fn()}
onReset={vi.fn()}
/>,
);
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({
@@ -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 (
<div className="space-y-4">
{groupDeviceSettings().map((group) => (
{groupDeviceSettings(undefined, {
devicePlatform,
keysWithStoredValues: storedHere,
}).map((group) => (
<SettingsGroup key={group.id} title={group.title} description={group.description}>
{group.keys.map((key) => (
<DeviceSettingRow
@@ -238,20 +253,17 @@ function DeviceSettingControl({
if (control === "slider" || control === "stepper") {
const numeric = typeof value === "number" ? value : Number(definition.defaultValue ?? 0);
return (
<div className="order-1 flex w-full items-center gap-3 sm:order-none sm:max-w-[260px]">
<Slider
value={[numeric]}
min={definition.minimum}
max={definition.maximum}
step={definition.step}
disabled={disabled}
onValueCommit={(values) => onChange(settingKey, values[0] ?? numeric)}
/>
<span className="text-muted-foreground min-w-16 text-right text-xs font-medium">
{numeric}
{definition.unit ? ` ${definition.unit}` : ""}
</span>
</div>
<SettingSlider
className="order-1 flex w-full items-center gap-3 sm:order-none sm:max-w-[260px]"
value={numeric}
min={definition.minimum}
max={definition.maximum}
step={definition.step}
unit={definition.unit}
disabled={disabled}
aria-label={definition.label}
onCommit={(next) => onChange(settingKey, next)}
/>
);
}
@@ -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 (
<div className="flex w-full max-w-[260px] items-center gap-3">
<Slider
value={[numericValue]}
min={definition.minimum}
max={definition.maximum}
step={definition.step}
disabled={disabled}
onValueCommit={(values) => onChange(String(values[0] ?? numericValue))}
/>
<span className="text-muted-foreground min-w-16 text-right text-xs font-medium">
{numericValue}
{definition.unit ? ` ${definition.unit}` : ""}
</span>
</div>
<SettingSlider
className="flex w-full max-w-[260px] items-center gap-3"
value={numericValue}
min={definition.minimum}
max={definition.maximum}
step={definition.step}
unit={definition.unit}
disabled={disabled}
aria-label={definition.label}
onCommit={(next) => onChange(String(next))}
/>
);
}
@@ -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<number | null>(null);
const shown = draft ?? value;
return (
<div className={className ?? "flex w-full items-center gap-3 sm:max-w-[260px]"}>
<Slider
value={[shown]}
min={min}
max={max}
step={step}
disabled={disabled}
aria-label={ariaLabel}
thumbLabels={ariaLabel ? [ariaLabel] : undefined}
onValueChange={(values) => setDraft(values[0] ?? shown)}
onValueCommit={(values) => {
setDraft(null);
const next = values[0];
if (next !== undefined && next !== value) onCommit(next);
}}
/>
<span className="text-muted-foreground min-w-16 text-right text-xs font-medium tabular-nums">
{shown}
{unit ? ` ${unit}` : ""}
</span>
</div>
);
}
+46
View File
@@ -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");
});
});
+63 -1
View File
@@ -78,6 +78,51 @@ const HIDDEN_KEYS = new Set<string>([
"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<string> },
): DeviceSettingGroup[] {
const platform = manifestPlatformFor(options?.devicePlatform);
const byGroup = new Map<DeviceSettingGroupId, SettingKey[]>();
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);
+27
View File
@@ -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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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<SettingKey, SettingDefinition> = {
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 },
+47 -1
View File
@@ -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<SubtitleAppearance | null>(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({
<DeviceSettingGroups
settings={settings}
ownerLabel={ownerLabel}
devicePlatform={device.device_platform}
disabled={setValue.isPending || clearValue.isPending}
onChange={(key: SettingKey, value: unknown) =>
setValue.mutate(
@@ -399,9 +410,44 @@ function DeviceDetail({
},
)
}
onOpenPanel={() => {
setAppearance(parseSubtitleAppearance(appearanceSetting?.value));
setAppearanceOpen(true);
}}
/>
)}
<SubtitleAppearancePanelView
open={appearanceOpen && appearance !== null}
value={appearance ?? parseSubtitleAppearance(undefined)}
onChange={(patch) => {
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 ? (
<Callout tone="muted" icon={<ShieldCheck className="h-4 w-4" />}>
<strong className="text-foreground font-semibold">What you can&apos;t see here.</strong>{" "}