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:
@@ -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 {
|
||||
|
||||
@@ -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"]`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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't see here.</strong>{" "}
|
||||
|
||||
Reference in New Issue
Block a user