feat(settings): add user-facing device settings (#527)
* feat(settings): let users manage device settings across their devices
Adds the server half of the user-facing device settings screen: a viewer can
see the devices they watch on and change settings for any of them from
whichever device they are holding, and the household parent can do the same for
everyone on the account.
No schema change. user_devices and user_setting_values are already keyed
(user_id, profile_id, device_id), and both list queries are already
account-wide, so this is authorization plus routes.
Two identity widenings on the canonical settings API, each behind a guard:
- A caller may name a device_id other than the request's own. Authorized
against user_devices for that profile, which is why DeviceExists lands
first: completeIdentity validated an identity's shape but never that the
device belonged to the caller, safe only while the id came from the header.
- A household parent may name a profile_id other than their own. Guarded by
canManageHousehold, extracted from ProfileHandler so profile management and
settings management cannot drift apart. Existence resolves through the
caller's own store, so a foreign profile is 404 and the cross-account
boundary holds for free.
Both default to today's behavior when the parameter is absent, so existing
clients need no change.
New self-service routes: GET /devices, DELETE /devices/{id}, and
DELETE /devices/{id}/settings. The list filters to the calling profile in the
handler — ListDevices is account-wide by construction in both backends (no
WHERE at all in the per-user SQLite), so a passthrough would have shown every
household member's devices to everyone. ?scope=household is opt-in and guarded.
Also fixes a bug the widening exposed: registerWritingDevice fired on every
device write, so writing to another device — or on another profile's behalf —
would have registered the actor's browser under the target, inventing a device
nobody holds.
Cross-profile and admin mutations are audited. The record carries identity
only, never the value, for the same reason user_settings.changed does: admins
receive other accounts' events. Ordinary self-service writes are not audited —
a trail that records everything answers nothing.
Part of #215
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): add the user-facing device settings screen
Adds "Your devices" under Settings: a searchable device list and an editable
detail pane, so someone can fix how Silo behaves on any device they watch on
without borrowing that device. The household parent gets an "Everyone" switch
covering every profile on the account.
Layout is master-detail, and holds at eleven devices: fixed-height rows
carrying a name, when it was last used, and the one number that matters — how
many settings differ there. A device with nothing changed shows a dash rather
than a zero, so "which one did I change?" is answerable by scanning. Rows group
by recency, or by person in the household view.
Settings are grouped by what they affect — Picture, Sound, Subtitles,
Episodes — rather than in manifest order, and no raw key is ever shown: labels,
descriptions, controls, bounds and options all come from the contract. A test
asserts every device-scoped key lands in exactly one group or is deliberately
hidden, so a key added to the manifest cannot silently vanish from the screen.
Values round-trip as typed JSON rather than through strings, unlike the admin
console: a slider re-parsed from text is a hazard on a screen a viewer drives.
Policy caps are explained rather than hidden. A capped setting renders only the
permitted options and says which value the household limit displaced; a locked
one says so instead of presenting a disabled control with no reason.
Acting for someone else is stated, never implied — a persistent banner, and
reset actions that name the person ("Use Robin's setting"). The household view
also states what it does not show: this is how Silo is set up per device, not
what anyone watched.
Two fixes the work turned up:
- effectiveSettingsQueryKey was namespaced by active profile only, so reading
another device's values would have collided with the current device's cache
entry and served one device's settings as another's.
- The settings shell caps content at max-w-3xl, which is right for a single
column of rows and squeezes a two-pane page. Pages that manage their own
layout now opt out.
Part of #215
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): filter the household device list by profile
Adds profile chips above the device list in the household view: Everyone, then
one per person, each with a device count. Picking a person narrows the list to
their devices; picking the active chip again clears it.
The chips only appear in the household view, where more than one profile is on
screen. A viewer looking at their own devices has exactly one profile, so a
filter with a single option would be chrome that explains nothing.
Three details the interaction needs to be honest:
- Counts come from the unfiltered list, so a chip keeps saying how many devices
it would reveal instead of collapsing to zero once another chip is active.
- Grouping falls back to recency once a person is chosen, because a person
heading would only repeat the chip above it.
- The detail pane follows the filter. Leaving someone else's device open while
the list shows another person would make the list and the pane disagree about
whose settings are being edited — the one thing this screen cannot be vague
about. Leaving the household view clears the filter for the same reason.
The chip's count sits in its own element, so its accessible name is set
explicitly: without it a screen reader announces "Everyone3".
Part of #215
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): lead the profile filter with the viewer's own chip
Seeding a realistic eight-profile household made the ordering problem obvious:
chips were in device-arrival order, which put the person actually using the
screen last. Their own profile now comes first and the rest sort by name, so a
chip stays where it was last seen rather than moving as devices are used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): make the device screen work on a phone
The screen was built two-pane and stacked those panes vertically on narrow
viewports, which is the wrong shape for a phone: the device list ran past a
thousand pixels before the first setting, so reaching "turn HDR off" meant
scrolling through every other device to get there. The whole page measured 4726
CSS pixels — 5.6 screens — for four devices.
Below xl the list and the settings are now two screens rather than two panes.
Picking a device swaps to it and a back control returns; the page header and
scope switch belong to the list screen, and the detail screen's own header says
which device it is about. The list page is 1050px, and scroll position resets on
each swap so a tap does not land mid-settings.
Touch targets were 32-36px throughout. Rows, chips, the scope switch, the reset
link and the header actions now clear 44px on a phone and keep their compact
desktop sizing from xl. Device rows carry a chevron below xl, because there they
navigate rather than select in place. The device search input goes to 16px on
mobile — iOS Safari zooms the viewport for anything smaller and does not zoom
back out.
Profile chips wrapped to three lines at eight profiles and pushed the list off
screen; they scroll horizontally on one line instead, the same trade the
settings shell's own mobile tab bar makes. Switches now sit beside their labels
rather than below, saving a row on each of ~18 toggles, while selects and
sliders still take the full width they need.
Two fixes the pass turned up, neither mobile-specific:
- playback.max_bitrate_kbps is declared as an integer range with a select
control and no members, so it rendered as a dropdown with one blank entry —
unusable, and silent about the value it was storing. It now offers real
bandwidth choices bounded by the definition's own range, and keeps a
non-preset stored value selectable.
- Select triggers had no accessible name, announcing as bare comboboxes.
Forget is destructive and rare, so it no longer sits as a full-width sibling of
the common action.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): keep the device list usable at hundreds of devices
A real account carries 260 devices; the test fixtures had 14. Every browser
profile, private window, reinstall and test build registers a device identity
and nothing prunes them, so the list grew without limit — 13,681 CSS pixels,
sixteen screens, and the settings themselves never came into view. That is the
same failure the mobile pass just fixed, at a scale the fixtures never showed.
Three changes:
- The list is a bounded scroll area rather than an unbounded column. The page
is now ~1,300px whatever the device count, and section headings stick while
scrolling so the recency or person grouping stays legible.
- Devices nobody has used for 90 days that carry no settings of their own
collapse behind "Show N unused devices". Over half of the real fleet is that:
one-off sessions that never changed anything. The current device and anything
with settings always stay visible, however old.
- Search spans everything including the hidden tail, because searching means
looking for something specific and hiding a device from its own name would
read as the device having vanished.
The 90-day threshold matches the settings contract's own rule for removing
empty device records, which is specified but not yet implemented server-side —
until it is, this keeps the screen usable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): offer the whole bandwidth range the contract allows
The bandwidth cap stopped at 40 Mbps because the ladder behind it was a
hardcoded list I wrote, not anything the contract said. The definition allows
up to 200,000 kbps, and remuxed 4K HDR or an untouched Blu-ray rip needs well
past 40 — so the picker was silently capping people below what their own server
could already send them.
The ladder now runs to the definition's own ceiling. Its low end mirrors the
in-player quality switcher, so a cap chosen here lines up with what the player
offers mid-playback, and entries outside a definition's declared range are
filtered out as before.
Also stops duplicating the label format a third time: the player's
formatQualityBitrate is now exported and reused, since both surfaces pick from
the same ladder and should not disagree about how to spell a number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style(settings): use the app's overlay scrollbar in the device list
The bounded device list kept the browser's default scrollbar, which reads as a
heavy grey slab against a rounded dark panel. The app already has
.overlay-scroll for exactly this — a thin, low-contrast thumb over a
transparent track — and it now sits in the panel's gutter rather than flush
against the rounded edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(settings): stop effective-values from stalling on live catalog language scans
GET /settings/values/effective computed suggested language values with
three sequential full-catalog scans on every request. On a 439k-item
deployment that took ~25s, dominated by the subtitle listing whose UNION
deduplicated ~5M unnested track rows.
- Deduplicate each subtitle arm before merging (UNION ALL of two
DISTINCT arms instead of UNION across all rows) and bound the result
with the facet LIMIT: 22.8s -> ~8.5s of per-arm work on that catalog.
- Cache the observed lists per (list kind, access scope) for 15 minutes
and collapse concurrent misses with singleflight.
- Run the three lookups concurrently, and cap a cold-cache wait at 2s:
the response ships with the contract floor while the detached scan
finishes and fills the cache for the next request.
Part of the device settings screen work; the regression itself shipped
in #526.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(settings): replace track-language catalog scans with picker free entry
Deployment-observed suggestions now decorate catalog.metadata_language
only — original_language is one indexed DISTINCT scan (~0.2s on a 439k
item catalog) and feeds the metadata-exceptions panel, where observed
data is load-bearing. The audio and subtitle track listings (up to tens
of seconds of media-file walking) are no longer queried for settings at
all, which also removes the TTL cache and singleflight added to manage
them.
Those pickers keep the contract's authored floor and gain an explicit
escape hatch instead: a shared LanguageSelect with an "Other…" entry
that accepts a BCP 47 tag, previews the resolved language name, and
refuses invalid tags. The settings are open language_tag values, so a
typed tag needs no server change, and a stored off-floor value already
renders through the current-value merge. The device screen hides the
free entry when policy pins permitted_values.
The browse facet queries keep the subtitle UNION ALL rewrite from the
previous commit; catalog browse still lists observed track languages.
suggested_values on playback.audio_language and
playback.subtitle_language now returns only the contract floor plus the
stored value — Android and Apple pickers need the same free-entry
affordance as follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import PersonDetail from "@/pages/PersonDetail";
|
||||
import Collections from "@/pages/Collections";
|
||||
import CollectionEditor from "@/pages/CollectionEditor";
|
||||
import Notifications from "@/pages/Notifications";
|
||||
import DeviceSettings from "@/pages/settings/DeviceSettings";
|
||||
import NotificationsSettings from "@/pages/settings/NotificationsSettings";
|
||||
import Requests from "@/pages/Requests";
|
||||
import RequestBrowse from "@/pages/RequestBrowse";
|
||||
@@ -489,6 +490,7 @@ function AppRoutes() {
|
||||
<Route path="home-screen" element={<HomeScreenSettings />} />
|
||||
<Route path="card-overlays" element={<CardOverlaySettings />} />
|
||||
<Route path="personalize" element={<PersonalizeSettings />} />
|
||||
<Route path="devices" element={<DeviceSettings />} />
|
||||
<Route path="notifications" element={<NotificationsSettings />} />
|
||||
<Route path="connect-apps" element={<ConnectAppsSettings />} />
|
||||
<Route path="*" element={<Navigate to="/settings/playback" replace />} />
|
||||
|
||||
@@ -2993,6 +2993,24 @@ export interface AdminDeviceProfileSummary {
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
/** One device the signed-in viewer watches on. */
|
||||
export interface UserDevice {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
device_platform: string;
|
||||
last_seen_at: string;
|
||||
profile_id: string;
|
||||
profile_name: string;
|
||||
/** True for the device this browser is. */
|
||||
is_current_device: boolean;
|
||||
/** How many settings this (profile, device) pair overrides. */
|
||||
changed_count: number;
|
||||
}
|
||||
|
||||
export interface UserDeviceListResponse {
|
||||
devices: UserDevice[];
|
||||
}
|
||||
|
||||
export interface AdminDeviceSummary {
|
||||
user_id: number;
|
||||
username: string;
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { UserDevice } from "@/api/types";
|
||||
import { DeviceList, lastSeenLabel } from "@/components/settings/DeviceList";
|
||||
|
||||
const NOW = Date.parse("2026-07-31T12:00:00Z");
|
||||
|
||||
function device(overrides: Partial<UserDevice> = {}): UserDevice {
|
||||
return {
|
||||
device_id: "device-1",
|
||||
device_name: "Chrome on macOS",
|
||||
device_platform: "macOS Web",
|
||||
last_seen_at: new Date(NOW - 60 * 60 * 1000).toISOString(),
|
||||
profile_id: "profile-1",
|
||||
profile_name: "Sam",
|
||||
is_current_device: false,
|
||||
changed_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList(
|
||||
devices: UserDevice[],
|
||||
props: Partial<{
|
||||
groupByProfile: boolean;
|
||||
profileFilter: string | null;
|
||||
onProfileFilterChange: (id: string | null) => void;
|
||||
ownProfileId: string;
|
||||
}> = {},
|
||||
) {
|
||||
const onSelect = vi.fn();
|
||||
const onProfileFilterChange = props.onProfileFilterChange ?? vi.fn();
|
||||
render(
|
||||
<DeviceList
|
||||
devices={devices}
|
||||
selectedDeviceId={null}
|
||||
onSelect={onSelect}
|
||||
search=""
|
||||
onSearchChange={vi.fn()}
|
||||
now={NOW}
|
||||
onProfileFilterChange={onProfileFilterChange}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onSelect, onProfileFilterChange };
|
||||
}
|
||||
|
||||
const HOUSEHOLD: UserDevice[] = [
|
||||
device({ device_id: "a", device_name: "Sam's laptop", profile_name: "Sam" }),
|
||||
device({ device_id: "b", device_name: "Sam's TV", profile_name: "Sam" }),
|
||||
device({
|
||||
device_id: "c",
|
||||
device_name: "Robin's iPad",
|
||||
profile_id: "profile-2",
|
||||
profile_name: "Robin",
|
||||
}),
|
||||
];
|
||||
|
||||
describe("DeviceList", () => {
|
||||
it("groups by recency and marks the current device", () => {
|
||||
renderList([
|
||||
device({ device_id: "here", device_name: "This browser", is_current_device: true }),
|
||||
device({
|
||||
device_id: "recent",
|
||||
device_name: "Apple TV",
|
||||
last_seen_at: new Date(NOW - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
device({
|
||||
device_id: "old",
|
||||
device_name: "Old iPad",
|
||||
last_seen_at: new Date(NOW - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Using now")).toBeInTheDocument();
|
||||
expect(screen.getByText("This week")).toBeInTheDocument();
|
||||
expect(screen.getByText("Earlier")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("You're on this device")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A device with nothing changed shows a dash, not "0": the list exists to
|
||||
// answer "which one did I change?" at a glance.
|
||||
it("shows a dash rather than a zero when nothing is changed", () => {
|
||||
renderList([
|
||||
device({ device_id: "clean", device_name: "Clean", changed_count: 0 }),
|
||||
device({ device_id: "dirty", device_name: "Dirty", changed_count: 3 }),
|
||||
]);
|
||||
|
||||
expect(screen.getByLabelText("Nothing changed")).toHaveTextContent("—");
|
||||
expect(screen.getByLabelText("3 settings changed here")).toHaveTextContent("3");
|
||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects a device when its row is clicked", async () => {
|
||||
const { onSelect } = renderList([device({ device_id: "tv", device_name: "Apple TV" })]);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Apple TV/ }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ device_id: "tv" }));
|
||||
});
|
||||
|
||||
it("groups by person in the household view", () => {
|
||||
renderList(
|
||||
[
|
||||
device({ device_id: "a", device_name: "Sam's laptop", profile_name: "Sam" }),
|
||||
device({
|
||||
device_id: "b",
|
||||
device_name: "Robin's iPad",
|
||||
profile_id: "profile-2",
|
||||
profile_name: "Robin",
|
||||
}),
|
||||
],
|
||||
{ groupByProfile: true },
|
||||
);
|
||||
|
||||
// Scoped to headings: the profile-filter chips carry these names too.
|
||||
expect(screen.getByRole("heading", { name: "Sam" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Robin" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Using now")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays readable with many devices", () => {
|
||||
const many = Array.from({ length: 11 }, (_, index) =>
|
||||
device({
|
||||
device_id: `device-${index}`,
|
||||
device_name: `Device ${index}`,
|
||||
last_seen_at: new Date(NOW - index * 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
);
|
||||
renderList(many);
|
||||
|
||||
const list = screen.getAllByRole("listitem");
|
||||
expect(list).toHaveLength(11);
|
||||
expect(screen.getByLabelText("Search devices")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Search 11 devices",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters by platform as well as name", async () => {
|
||||
const onSearchChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<DeviceList
|
||||
devices={[
|
||||
device({ device_id: "tv", device_name: "Living Room", device_platform: "tvOS" }),
|
||||
device({ device_id: "web", device_name: "Chrome", device_platform: "macOS Web" }),
|
||||
]}
|
||||
selectedDeviceId={null}
|
||||
onSelect={vi.fn()}
|
||||
search=""
|
||||
onSearchChange={onSearchChange}
|
||||
now={NOW}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<DeviceList
|
||||
devices={[
|
||||
device({ device_id: "tv", device_name: "Living Room", device_platform: "tvOS" }),
|
||||
device({ device_id: "web", device_name: "Chrome", device_platform: "macOS Web" }),
|
||||
]}
|
||||
selectedDeviceId={null}
|
||||
onSelect={vi.fn()}
|
||||
search="tv"
|
||||
onSearchChange={onSearchChange}
|
||||
now={NOW}
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
expect(items).toHaveLength(1);
|
||||
const [onlyItem] = items;
|
||||
expect(within(onlyItem!).getByText("Living Room")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceList at scale", () => {
|
||||
const OLD = new Date(NOW - 200 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// A real account carried 260 devices, over half of them one-off sessions
|
||||
// that had never changed a setting. Listing them all produced a
|
||||
// thirteen-thousand-pixel page, so the settings never came into view.
|
||||
function bigFleet(): UserDevice[] {
|
||||
return [
|
||||
device({ device_id: "here", device_name: "This browser", is_current_device: true }),
|
||||
device({ device_id: "tv", device_name: "Apple TV", changed_count: 5 }),
|
||||
// Old but configured: still worth showing, since someone set it up.
|
||||
device({
|
||||
device_id: "kept",
|
||||
device_name: "Old but configured",
|
||||
last_seen_at: OLD,
|
||||
changed_count: 2,
|
||||
}),
|
||||
...Array.from({ length: 40 }, (_, i) =>
|
||||
device({ device_id: `junk-${i}`, device_name: `Silo-PR111-build-${i}`, last_seen_at: OLD }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
it("hides devices nobody has used and that carry no settings", () => {
|
||||
renderList(bigFleet());
|
||||
|
||||
expect(screen.getByText("This browser")).toBeInTheDocument();
|
||||
expect(screen.getByText("Apple TV")).toBeInTheDocument();
|
||||
expect(screen.getByText("Old but configured")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Silo-PR111-build-0")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("says how many it is holding back, and reveals them on request", async () => {
|
||||
renderList(bigFleet());
|
||||
|
||||
const toggle = screen.getByRole("button", { name: "Show 40 unused devices" });
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(screen.getByText("Silo-PR111-build-0")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(43);
|
||||
expect(screen.getByRole("button", { name: "Hide unused devices" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Searching means looking for something specific; hiding a device from its
|
||||
// own name would read as the device having disappeared.
|
||||
it("searches across hidden devices without expanding them", () => {
|
||||
render(
|
||||
<DeviceList
|
||||
devices={bigFleet()}
|
||||
selectedDeviceId={null}
|
||||
onSelect={vi.fn()}
|
||||
search="build-7"
|
||||
onSearchChange={vi.fn()}
|
||||
now={NOW}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Silo-PR111-build-7")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /unused devices/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the current device visible even when it is old and unconfigured", () => {
|
||||
renderList([
|
||||
device({
|
||||
device_id: "here",
|
||||
device_name: "This browser",
|
||||
is_current_device: true,
|
||||
last_seen_at: OLD,
|
||||
}),
|
||||
device({ device_id: "junk", device_name: "Forgotten", last_seen_at: OLD }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText("This browser")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Forgotten")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no toggle when nothing is dormant", () => {
|
||||
renderList([device({ device_id: "a", device_name: "Recent", changed_count: 1 })]);
|
||||
expect(screen.queryByRole("button", { name: /unused devices/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceList profile filter", () => {
|
||||
it("offers one chip per profile, plus everyone, with counts", () => {
|
||||
renderList(HOUSEHOLD, { groupByProfile: true });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Everyone, 3 devices" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sam, 2 devices" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Robin, 1 device" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// At eight profiles, arrival order buried the person actually using the
|
||||
// screen; the rest sort by name so a chip does not move as devices are used.
|
||||
it("leads with the viewer's own profile, then sorts by name", () => {
|
||||
renderList(
|
||||
[
|
||||
device({ device_id: "z", profile_id: "profile-9", profile_name: "Zoe" }),
|
||||
device({ device_id: "c", profile_id: "profile-3", profile_name: "Casey" }),
|
||||
device({ device_id: "a", profile_id: "profile-1", profile_name: "Sam" }),
|
||||
],
|
||||
{ groupByProfile: true, ownProfileId: "profile-1" },
|
||||
);
|
||||
|
||||
const chips = screen
|
||||
.getByRole("group", { name: "Filter by profile" })
|
||||
.querySelectorAll("button");
|
||||
const names = [...chips].map((chip) => chip.getAttribute("aria-label")?.split(",")[0]);
|
||||
expect(names).toEqual(["Everyone", "Sam", "Casey", "Zoe"]);
|
||||
});
|
||||
|
||||
// Someone looking at their own devices has exactly one profile, so a filter
|
||||
// with a single option would be chrome that explains nothing.
|
||||
it("stays hidden outside the household view", () => {
|
||||
renderList(HOUSEHOLD, { groupByProfile: false });
|
||||
expect(screen.queryByRole("group", { name: "Filter by profile" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays hidden when the household has only one profile", () => {
|
||||
renderList([HOUSEHOLD[0]!, HOUSEHOLD[1]!], { groupByProfile: true });
|
||||
expect(screen.queryByRole("group", { name: "Filter by profile" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the chosen profile", async () => {
|
||||
const { onProfileFilterChange } = renderList(HOUSEHOLD, { groupByProfile: true });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Robin, 1 device" }));
|
||||
|
||||
expect(onProfileFilterChange).toHaveBeenCalledWith("profile-2");
|
||||
});
|
||||
|
||||
it("clears the filter when the active chip is clicked again", async () => {
|
||||
const { onProfileFilterChange } = renderList(HOUSEHOLD, {
|
||||
groupByProfile: true,
|
||||
profileFilter: "profile-2",
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Robin, 1 device" }));
|
||||
|
||||
expect(onProfileFilterChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows only the filtered profile's devices", () => {
|
||||
renderList(HOUSEHOLD, { groupByProfile: true, profileFilter: "profile-2" });
|
||||
|
||||
expect(screen.getByText("Robin's iPad")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Sam's laptop")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
||||
});
|
||||
|
||||
// With one person selected, a person heading would just repeat the chip.
|
||||
it("falls back to recency headings once a profile is chosen", () => {
|
||||
renderList(HOUSEHOLD, { groupByProfile: true, profileFilter: "profile-2" });
|
||||
|
||||
expect(screen.getByText("This week")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "Robin" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps chip counts stable while a filter is active", () => {
|
||||
renderList(HOUSEHOLD, { groupByProfile: true, profileFilter: "profile-2" });
|
||||
|
||||
// Sam's chip still says 2 even though none of Sam's devices are listed.
|
||||
expect(screen.getByRole("button", { name: "Sam, 2 devices" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastSeenLabel", () => {
|
||||
it("reads as plain language", () => {
|
||||
expect(lastSeenLabel(new Date(NOW - 30 * 60 * 1000).toISOString(), NOW)).toBe(
|
||||
"Less than an hour ago",
|
||||
);
|
||||
expect(lastSeenLabel(new Date(NOW - 3 * 60 * 60 * 1000).toISOString(), NOW)).toBe(
|
||||
"3 hours ago",
|
||||
);
|
||||
expect(lastSeenLabel(new Date(NOW - 24 * 60 * 60 * 1000).toISOString(), NOW)).toBe("Yesterday");
|
||||
expect(lastSeenLabel(new Date(NOW - 10 * 24 * 60 * 60 * 1000).toISOString(), NOW)).toBe(
|
||||
"10 days ago",
|
||||
);
|
||||
expect(lastSeenLabel("not-a-date", NOW)).toBe("Never used");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronRight, Search } from "lucide-react";
|
||||
|
||||
import type { UserDevice } from "@/api/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
classifyPlatform,
|
||||
PlatformIcon,
|
||||
platformKindLabel,
|
||||
} from "@/components/admin/deviceOverrides";
|
||||
import { deviceRecencyGroup, type DeviceRecencyGroup } from "@/hooks/queries/devices";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GROUP_TITLES: Record<DeviceRecencyGroup, string> = {
|
||||
current: "Using now",
|
||||
week: "This week",
|
||||
earlier: "Earlier",
|
||||
};
|
||||
|
||||
const GROUP_ORDER: DeviceRecencyGroup[] = ["current", "week", "earlier"];
|
||||
|
||||
/**
|
||||
* A device is "dormant" when nobody has used it for three months and it carries
|
||||
* no settings of its own.
|
||||
*
|
||||
* Accounts accumulate these relentlessly — every browser profile, private
|
||||
* window, reinstall and test build registers an identity, and nothing prunes
|
||||
* them. A real account had 260 devices, over half of them one-off sessions that
|
||||
* had never changed a setting. Listing them alongside the living-room TV is
|
||||
* what turned this screen into sixteen screens of scrolling, so they collapse
|
||||
* behind a count until asked for.
|
||||
*/
|
||||
const DORMANT_AFTER_MS = 90 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isDormantDevice(device: UserDevice, now: number): boolean {
|
||||
if (device.is_current_device || device.changed_count > 0) return false;
|
||||
const seen = Date.parse(device.last_seen_at);
|
||||
if (Number.isNaN(seen)) return true;
|
||||
return now - seen > DORMANT_AFTER_MS;
|
||||
}
|
||||
|
||||
export interface DeviceListProps {
|
||||
devices: UserDevice[];
|
||||
selectedDeviceId: string | null;
|
||||
onSelect: (device: UserDevice) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
/** Group by person first. Only the household view sets this. */
|
||||
groupByProfile?: boolean;
|
||||
/**
|
||||
* Show only this profile's devices. Null means everyone. Only meaningful
|
||||
* alongside groupByProfile — a viewer looking at their own devices has just
|
||||
* the one profile, so a filter with a single option would be noise.
|
||||
*/
|
||||
profileFilter?: string | null;
|
||||
onProfileFilterChange?: (profileId: string | null) => void;
|
||||
/** The viewer's own profile, so their chip leads the row. */
|
||||
ownProfileId?: string;
|
||||
/**
|
||||
* "Now" for relative-time labels. Passed in rather than read during render so
|
||||
* the component stays pure — and so a test can pin the clock.
|
||||
*/
|
||||
now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device list.
|
||||
*
|
||||
* Fixed-height rows carrying a name, when it was last used, and the one number
|
||||
* that matters — how many settings differ there. A device with nothing changed
|
||||
* shows a dash rather than a zero, so "which one did I change?" is answerable
|
||||
* by scanning rather than by opening each device in turn.
|
||||
*/
|
||||
export function DeviceList({
|
||||
devices,
|
||||
selectedDeviceId,
|
||||
onSelect,
|
||||
search,
|
||||
onSearchChange,
|
||||
groupByProfile = false,
|
||||
profileFilter = null,
|
||||
onProfileFilterChange,
|
||||
ownProfileId,
|
||||
now,
|
||||
}: DeviceListProps) {
|
||||
const [showDormant, setShowDormant] = useState(false);
|
||||
// Counts come from the unfiltered list so a chip keeps showing how many
|
||||
// devices it would reveal, rather than collapsing to zero once another chip
|
||||
// is active.
|
||||
const profiles = useMemo(() => profileOptions(devices, ownProfileId), [devices, ownProfileId]);
|
||||
|
||||
const matching = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return devices.filter((device) => {
|
||||
if (profileFilter && device.profile_id !== profileFilter) return false;
|
||||
if (!query) return true;
|
||||
const platform = platformKindLabel(classifyPlatform(device.device_platform));
|
||||
return (
|
||||
device.device_name.toLowerCase().includes(query) ||
|
||||
device.device_platform.toLowerCase().includes(query) ||
|
||||
platform.toLowerCase().includes(query) ||
|
||||
device.profile_name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [devices, search, profileFilter]);
|
||||
|
||||
// Searching means you are looking for something specific, so a search spans
|
||||
// everything — hiding a dormant device from its own name would be a bug.
|
||||
const searching = search.trim().length > 0;
|
||||
const dormantCount = useMemo(
|
||||
() => (searching ? 0 : matching.filter((device) => isDormantDevice(device, now)).length),
|
||||
[matching, searching, now],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
searching || showDormant
|
||||
? matching
|
||||
: matching.filter((device) => !isDormantDevice(device, now)),
|
||||
[matching, searching, showDormant, now],
|
||||
);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
if (groupByProfile && !profileFilter) {
|
||||
const byProfile = new Map<string, { title: string; devices: UserDevice[] }>();
|
||||
for (const device of filtered) {
|
||||
const existing = byProfile.get(device.profile_id);
|
||||
if (existing) {
|
||||
existing.devices.push(device);
|
||||
} else {
|
||||
byProfile.set(device.profile_id, {
|
||||
title: device.profile_name || "Other profile",
|
||||
devices: [device],
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byProfile.values()];
|
||||
}
|
||||
|
||||
return GROUP_ORDER.map((group) => ({
|
||||
title: GROUP_TITLES[group],
|
||||
devices: filtered.filter((device) => deviceRecencyGroup(device, now) === group),
|
||||
})).filter((section) => section.devices.length > 0);
|
||||
}, [filtered, groupByProfile, profileFilter, now]);
|
||||
|
||||
return (
|
||||
<div className="surface-panel rounded-[1.5rem] border-0 p-3 shadow-none">
|
||||
<div className="relative mb-1">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder={`Search ${devices.length} ${devices.length === 1 ? "device" : "devices"}`}
|
||||
aria-label="Search devices"
|
||||
// text-base on mobile: iOS Safari zooms the viewport when a focused
|
||||
// input's font is under 16px, and the zoom does not undo itself.
|
||||
className="h-11 pl-8 text-base xl:h-9 xl:text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groupByProfile && profiles.length > 1 && onProfileFilterChange ? (
|
||||
// A wrapping row cost three lines and pushed the list off a phone
|
||||
// screen at eight profiles. Scrolling horizontally keeps it to one
|
||||
// line at any household size — the same trade the settings shell's own
|
||||
// mobile tab bar makes — and unwraps into a normal row from xl up.
|
||||
<div
|
||||
className="flex gap-1.5 overflow-x-auto px-0.5 pt-2 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] xl:flex-wrap xl:gap-1 xl:overflow-visible [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label="Filter by profile"
|
||||
>
|
||||
<ProfileChip
|
||||
label="Everyone"
|
||||
count={devices.length}
|
||||
active={profileFilter === null}
|
||||
onClick={() => onProfileFilterChange(null)}
|
||||
/>
|
||||
{profiles.map((profile) => (
|
||||
<ProfileChip
|
||||
key={profile.id}
|
||||
label={profile.name}
|
||||
count={profile.count}
|
||||
active={profileFilter === profile.id}
|
||||
onClick={() =>
|
||||
onProfileFilterChange(profileFilter === profile.id ? null : profile.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-6 text-center text-[13px]">
|
||||
{devices.length === 0
|
||||
? "No devices yet."
|
||||
: searching
|
||||
? "No devices match that search."
|
||||
: "Nothing here. Try showing unused devices."}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Capped and scrollable rather than growing without limit: an account
|
||||
with a few hundred devices produced a thirteen-thousand-pixel page,
|
||||
so the settings never came into view. The list keeps its own scroll
|
||||
and the page stays roughly one screen. */}
|
||||
<div className="overlay-scroll -mr-1.5 max-h-[min(60vh,520px)] overflow-y-auto overscroll-contain pr-1.5 xl:max-h-[calc(100vh-16rem)]">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="text-muted-foreground bg-surface sticky top-0 z-10 px-2 pt-3 pb-1 text-[10.5px] font-semibold tracking-[0.09em] uppercase">
|
||||
{section.title}
|
||||
</h3>
|
||||
<ul>
|
||||
{section.devices.map((device) => (
|
||||
<li key={`${device.profile_id}:${device.device_id}`}>
|
||||
<DeviceRow
|
||||
device={device}
|
||||
selected={device.device_id === selectedDeviceId}
|
||||
onSelect={onSelect}
|
||||
now={now}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* The long tail is opt-in, and says how big it is so nobody wonders
|
||||
whether a device is missing. */}
|
||||
{dormantCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDormant((shown) => !shown)}
|
||||
className="text-muted-foreground hover:text-foreground mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-xl text-[13px] transition-colors xl:min-h-9"
|
||||
>
|
||||
{showDormant
|
||||
? "Hide unused devices"
|
||||
: `Show ${dormantCount} unused ${dormantCount === 1 ? "device" : "devices"}`}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileOption {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per profile that owns at least one device.
|
||||
*
|
||||
* The viewer's own profile comes first — at eight profiles the arrival order
|
||||
* put the person actually using the screen last — and the rest are alphabetical
|
||||
* so a chip stays where someone last saw it, rather than moving whenever a
|
||||
* device is used.
|
||||
*/
|
||||
function profileOptions(devices: UserDevice[], ownProfileId?: string): ProfileOption[] {
|
||||
const byId = new Map<string, ProfileOption>();
|
||||
for (const device of devices) {
|
||||
const existing = byId.get(device.profile_id);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
byId.set(device.profile_id, {
|
||||
id: device.profile_id,
|
||||
name: device.profile_name || "Other profile",
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => {
|
||||
if (a.id === ownProfileId) return -1;
|
||||
if (b.id === ownProfileId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function ProfileChip({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
// The visible label and the count are separate elements, so without this
|
||||
// a screen reader announces them run together as "Everyone3".
|
||||
aria-label={`${label}, ${count} ${count === 1 ? "device" : "devices"}`}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium whitespace-nowrap transition-colors xl:min-h-0 xl:px-2.5 xl:py-1 xl:text-[12px]",
|
||||
active
|
||||
? "border-foreground/25 bg-surface-raised text-foreground"
|
||||
: "border-border/60 text-muted-foreground hover:bg-surface-hover hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn("text-[11px]", active ? "text-muted-foreground" : "text-muted-foreground/70")}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
selected,
|
||||
onSelect,
|
||||
now,
|
||||
}: {
|
||||
device: UserDevice;
|
||||
selected: boolean;
|
||||
onSelect: (device: UserDevice) => void;
|
||||
now: number;
|
||||
}) {
|
||||
const kind = classifyPlatform(device.device_platform);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={() => onSelect(device)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-xl px-2 py-2.5 text-left transition-colors",
|
||||
// Rows are a tap target on a phone and a selection on a desktop, so
|
||||
// they carry a comfortable minimum height only where that matters.
|
||||
"min-h-14 xl:min-h-0",
|
||||
selected ? "bg-surface-raised" : "hover:bg-surface-hover active:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<span className="bg-secondary text-muted-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-lg xl:h-7 xl:w-7">
|
||||
<PlatformIcon kind={kind} className="h-4 w-4 xl:h-3.5 xl:w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium xl:text-[13px]">
|
||||
{device.device_name || "Unknown device"}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-[12.5px] xl:text-[11.5px]">
|
||||
{device.is_current_device
|
||||
? "This is the device you're on"
|
||||
: lastSeenLabel(device.last_seen_at, now)}
|
||||
</span>
|
||||
</span>
|
||||
{device.is_current_device ? (
|
||||
<span
|
||||
className="bg-success h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
aria-label="You're on this device"
|
||||
/>
|
||||
) : null}
|
||||
<ChangedCount count={device.changed_count} />
|
||||
{/* Below xl a row navigates to another screen; say so. */}
|
||||
<ChevronRight className="text-muted-foreground/50 h-4 w-4 shrink-0 xl:hidden" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedCount({ count }: { count: number }) {
|
||||
if (count <= 0) {
|
||||
return (
|
||||
<span className="text-muted-foreground/50 shrink-0 text-[11px]" aria-label="Nothing changed">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 text-[11px] font-semibold text-amber-300"
|
||||
aria-label={`${count} ${count === 1 ? "setting" : "settings"} changed here`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain relative time; the exact timestamp is not what anyone is asking. */
|
||||
export function lastSeenLabel(iso: string, now: number): string {
|
||||
const seen = Date.parse(iso);
|
||||
if (Number.isNaN(seen)) return "Never used";
|
||||
const minutes = Math.max(0, Math.round((now - seen) / 60000));
|
||||
if (minutes < 60) return "Less than an hour ago";
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 30) return `${days} days ago`;
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return `${months} ${months === 1 ? "month" : "months"} ago`;
|
||||
const years = Math.round(months / 12);
|
||||
return `${years} ${years === 1 ? "year" : "years"} ago`;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DeviceSettingGroups } from "@/components/settings/DeviceSettingGroups";
|
||||
import type { EffectiveSetting } from "@/hooks/queries/settingValues";
|
||||
import type { SettingKey } from "@/lib/settingsContract";
|
||||
|
||||
// Radix Slider and Select read element sizes via ResizeObserver, which jsdom
|
||||
// does not provide. A no-op polyfill is enough to render them.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
||||
ResizeObserverStub;
|
||||
}
|
||||
// Radix Select opens through pointer capture, which jsdom also lacks.
|
||||
if (typeof window !== "undefined" && !window.HTMLElement.prototype.hasPointerCapture) {
|
||||
window.HTMLElement.prototype.hasPointerCapture = () => false;
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
function renderGroups(
|
||||
settings: Partial<Record<SettingKey, EffectiveSetting>>,
|
||||
props: Partial<{ ownerLabel: string }> = {},
|
||||
) {
|
||||
const onChange = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
render(
|
||||
<DeviceSettingGroups
|
||||
settings={settings}
|
||||
ownerLabel={props.ownerLabel ?? "your"}
|
||||
onChange={onChange}
|
||||
onReset={onReset}
|
||||
/>,
|
||||
);
|
||||
return { onChange, onReset };
|
||||
}
|
||||
|
||||
function effective(overrides: Partial<EffectiveSetting> = {}): EffectiveSetting {
|
||||
return { key: "player.hdr_enabled", value: true, source: "default", ...overrides };
|
||||
}
|
||||
|
||||
describe("DeviceSettingGroups", () => {
|
||||
it("groups settings under headings a viewer would look under", () => {
|
||||
renderGroups({});
|
||||
|
||||
// Scoped to headings: "Subtitles" is also a setting label inside the group.
|
||||
const headings = screen.getAllByRole("heading").map((node) => node.textContent);
|
||||
expect(headings).toEqual(["Picture", "Sound", "Subtitles", "Episodes"]);
|
||||
});
|
||||
|
||||
// The screen is for people who do not know what a manifest key is. Matching
|
||||
// on the dotted key shape rather than the prefix alone, because a manifest
|
||||
// description may legitimately end a sentence with the word "playback".
|
||||
it("never shows a raw setting key", () => {
|
||||
const { container } = render(
|
||||
<DeviceSettingGroups settings={{}} ownerLabel="your" onChange={vi.fn()} onReset={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).not.toMatch(/\b(?:player|playback|ui)\.[a-z0-9_]+/);
|
||||
});
|
||||
|
||||
it("marks a value stored on this device and offers to clear it", async () => {
|
||||
const { onReset } = renderGroups({
|
||||
"player.hdr_enabled": effective({
|
||||
value: false,
|
||||
source: "profile_device",
|
||||
scope: "profile_device",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText("Changed here")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Use your setting/ }));
|
||||
expect(onReset).toHaveBeenCalledWith("player.hdr_enabled");
|
||||
});
|
||||
|
||||
it("names the person when the household parent is acting for someone else", () => {
|
||||
renderGroups(
|
||||
{
|
||||
"player.hdr_enabled": effective({ source: "profile_device", scope: "profile_device" }),
|
||||
},
|
||||
{ ownerLabel: "Robin's" },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Use Robin's setting/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer a reset when nothing is stored on this device", () => {
|
||||
renderGroups({ "player.hdr_enabled": effective({ source: "profile" }) });
|
||||
|
||||
expect(screen.queryByText("Changed here")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Use your setting/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends typed values rather than strings", async () => {
|
||||
const { onChange } = renderGroups({
|
||||
"player.hdr_enabled": effective({ value: true, source: "default" }),
|
||||
});
|
||||
|
||||
const [firstToggle] = screen.getAllByRole("switch");
|
||||
await userEvent.click(firstToggle!);
|
||||
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
const [firstCall] = onChange.mock.calls;
|
||||
expect(typeof firstCall![1]).toBe("boolean");
|
||||
});
|
||||
|
||||
// A capped setting explains the cap. A disabled control with no reason is
|
||||
// exactly what the settings contract's UX rules forbid.
|
||||
it("explains a household limit instead of silently narrowing", () => {
|
||||
renderGroups({
|
||||
"playback.preferred_quality": effective({
|
||||
key: "playback.preferred_quality",
|
||||
value: "1080p",
|
||||
stored_value: "2160p",
|
||||
source: "profile_device",
|
||||
scope: "profile_device",
|
||||
constrained: true,
|
||||
constraint_kind: "ceiling",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText("Household limit")).toBeInTheDocument();
|
||||
expect(screen.getByText(/limit this to 1080p/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/your choice of 2160p isn't available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The bandwidth cap is declared as an integer range with a select control
|
||||
// and no members, so the generic path rendered a dropdown with one blank
|
||||
// entry — unusable, and silent about the value it was already storing.
|
||||
it("offers real bandwidth choices for a numeric range with no members", async () => {
|
||||
const { onChange } = renderGroups({
|
||||
"playback.max_bitrate_kbps": effective({
|
||||
key: "playback.max_bitrate_kbps",
|
||||
value: 2000,
|
||||
source: "profile_device",
|
||||
scope: "profile_device",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText("2 Mbps")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Maximum bitrate/i }));
|
||||
const option = await screen.findByRole("option", { name: "10 Mbps" });
|
||||
await userEvent.click(option);
|
||||
|
||||
// Indexed rather than .at(-1): the app tsconfig targets ES2020.
|
||||
const calls = onChange.mock.calls;
|
||||
const call = calls[calls.length - 1];
|
||||
expect(call?.[0]).toBe("playback.max_bitrate_kbps");
|
||||
expect(call?.[1]).toBe(10000);
|
||||
});
|
||||
|
||||
// The ladder is filtered by the definition's own range, which tops out at
|
||||
// 200 Mbps. An earlier hardcoded list stopped at 40 and silently capped
|
||||
// people below what their server could already send.
|
||||
it("offers the full range the contract allows", async () => {
|
||||
renderGroups({
|
||||
"playback.max_bitrate_kbps": effective({
|
||||
key: "playback.max_bitrate_kbps",
|
||||
value: null,
|
||||
source: "default",
|
||||
}),
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Maximum bitrate/i }));
|
||||
|
||||
expect(await screen.findByRole("option", { name: "200 Mbps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "100 Mbps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "1.5 Mbps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "No limit" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stored bandwidth value selectable even when it is not a preset", () => {
|
||||
renderGroups({
|
||||
"playback.max_bitrate_kbps": effective({
|
||||
key: "playback.max_bitrate_kbps",
|
||||
value: 3500,
|
||||
source: "profile_device",
|
||||
scope: "profile_device",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.getByText("3.5 Mbps")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("states who set a locked value rather than disabling it silently", () => {
|
||||
renderGroups({
|
||||
"playback.preferred_quality": effective({
|
||||
key: "playback.preferred_quality",
|
||||
value: "720p",
|
||||
source: "profile",
|
||||
constrained: true,
|
||||
constraint_kind: "locked",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/set for your household and can't be changed here/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
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,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LanguageSelect } from "@/components/settings/LanguageSelect";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { groupDeviceSettings } from "@/lib/deviceSettingGroups";
|
||||
import type { EffectiveSetting } from "@/hooks/queries/settingValues";
|
||||
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { formatQualityBitrate } from "@/player/hooks/useTranscodeQuality";
|
||||
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
|
||||
import { controlKindFor, optionsFor } from "@/lib/settingsDisplay";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EMPTY_SELECT_VALUE = "__empty__";
|
||||
|
||||
export interface DeviceSettingGroupsProps {
|
||||
/** Effective values resolved for the device being edited. */
|
||||
settings: Partial<Record<SettingKey, EffectiveSetting>>;
|
||||
/**
|
||||
* Whose settings these are, for the reset label. "your" on your own devices,
|
||||
* a name when the household parent is acting for someone else.
|
||||
*/
|
||||
ownerLabel: string;
|
||||
disabled?: boolean;
|
||||
onChange: (key: SettingKey, value: unknown) => void;
|
||||
onReset: (key: SettingKey) => void;
|
||||
/** Opens the subtitle appearance panel, which is not an inline control. */
|
||||
onOpenPanel?: (key: SettingKey) => void;
|
||||
}
|
||||
|
||||
export function DeviceSettingGroups({
|
||||
settings,
|
||||
ownerLabel,
|
||||
disabled = false,
|
||||
onChange,
|
||||
onReset,
|
||||
onOpenPanel,
|
||||
}: DeviceSettingGroupsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{groupDeviceSettings().map((group) => (
|
||||
<SettingsGroup key={group.id} title={group.title} description={group.description}>
|
||||
{group.keys.map((key) => (
|
||||
<DeviceSettingRow
|
||||
key={key}
|
||||
settingKey={key}
|
||||
effective={settings[key]}
|
||||
ownerLabel={ownerLabel}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onReset={onReset}
|
||||
onOpenPanel={onOpenPanel}
|
||||
/>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceSettingRowProps {
|
||||
settingKey: SettingKey;
|
||||
effective: EffectiveSetting | undefined;
|
||||
ownerLabel: string;
|
||||
disabled: boolean;
|
||||
onChange: (key: SettingKey, value: unknown) => void;
|
||||
onReset: (key: SettingKey) => void;
|
||||
onOpenPanel?: (key: SettingKey) => void;
|
||||
}
|
||||
|
||||
function DeviceSettingRow({
|
||||
settingKey,
|
||||
effective,
|
||||
ownerLabel,
|
||||
disabled,
|
||||
onChange,
|
||||
onReset,
|
||||
onOpenPanel,
|
||||
}: DeviceSettingRowProps) {
|
||||
const definition = SETTING_DEFINITIONS[settingKey];
|
||||
if (!definition) return null;
|
||||
|
||||
// "Changed here" means a row exists at this exact device, which is also what
|
||||
// makes the reset meaningful — reset clears that row rather than copying the
|
||||
// profile value into it.
|
||||
const changedHere = effective?.scope === "profile_device";
|
||||
const locked = effective?.constraint_kind === "locked";
|
||||
const constrained = Boolean(effective?.constrained);
|
||||
const value = effective?.value ?? definition.defaultValue;
|
||||
const inlineControl = controlKindFor(definition) === "switch";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 grid gap-3 border-t pt-4 first:border-t-0 first:pt-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center",
|
||||
// A switch fits beside its label even at 360px, and keeping it there
|
||||
// saves a whole row on each of the ~18 toggles this screen renders.
|
||||
// Wider controls still drop below, where they have room.
|
||||
inlineControl && "grid-cols-[minmax(0,1fr)_auto] items-center",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{definition.label}</span>
|
||||
{changedHere ? (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 py-px text-[10px] font-semibold tracking-[0.04em] text-amber-300 uppercase">
|
||||
Changed here
|
||||
</span>
|
||||
) : null}
|
||||
{constrained ? (
|
||||
<span className="border-info/30 bg-info/10 text-info inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-semibold tracking-[0.04em] uppercase">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
Household limit
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[13px] leading-relaxed">
|
||||
{definition.description}
|
||||
</p>
|
||||
{constrained ? (
|
||||
<p className="text-[12.5px] leading-relaxed text-amber-300/90">
|
||||
{constraintExplanation(effective)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-x-3 gap-y-1 sm:flex-nowrap sm:justify-end",
|
||||
inlineControl && "justify-end",
|
||||
)}
|
||||
>
|
||||
{changedHere && !locked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReset(settingKey)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground order-2 inline-flex min-h-11 shrink-0 items-center gap-1 text-[13px] transition-colors disabled:opacity-50 sm:order-none sm:min-h-0 sm:text-xs",
|
||||
// Inline rows have no room beside the switch; the reset sits
|
||||
// under the description instead.
|
||||
inlineControl && "col-start-1 row-start-2 -mt-1 sm:col-auto sm:row-auto sm:mt-0",
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||||
Use {ownerLabel} setting
|
||||
</button>
|
||||
) : null}
|
||||
<DeviceSettingControl
|
||||
settingKey={settingKey}
|
||||
effective={effective}
|
||||
value={value}
|
||||
disabled={disabled || locked}
|
||||
onChange={onChange}
|
||||
onOpenPanel={onOpenPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function constraintExplanation(effective: EffectiveSetting | undefined): string {
|
||||
if (!effective) return "";
|
||||
const permitted = effective.value;
|
||||
if (effective.constraint_kind === "locked") {
|
||||
return "This is set for your household and can't be changed here.";
|
||||
}
|
||||
// The stored preference is still theirs; it is just capped today. Saying so
|
||||
// beats silently showing a value they did not choose.
|
||||
if (effective.stored_value !== undefined && effective.stored_value !== permitted) {
|
||||
return `Your household settings limit this to ${String(permitted)}, so your choice of ${String(effective.stored_value)} isn't available right now.`;
|
||||
}
|
||||
return "Your household settings limit this option.";
|
||||
}
|
||||
|
||||
interface DeviceSettingControlProps {
|
||||
settingKey: SettingKey;
|
||||
effective: EffectiveSetting | undefined;
|
||||
value: unknown;
|
||||
disabled: boolean;
|
||||
onChange: (key: SettingKey, value: unknown) => void;
|
||||
onOpenPanel?: (key: SettingKey) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control for one device setting.
|
||||
*
|
||||
* Values are typed JSON here, not strings: a slider round-tripping through
|
||||
* text is a hazard on a screen a viewer uses, and the contract already knows
|
||||
* every value's type. When policy narrows the choices, the select renders the
|
||||
* permitted list rather than the manifest's.
|
||||
*/
|
||||
function DeviceSettingControl({
|
||||
settingKey,
|
||||
effective,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
onOpenPanel,
|
||||
}: DeviceSettingControlProps) {
|
||||
const definition = SETTING_DEFINITIONS[settingKey];
|
||||
const control = controlKindFor(definition);
|
||||
|
||||
if (control === "panel" || definition.type === "object") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenPanel?.(settingKey)}
|
||||
className="order-1 min-h-11 w-full sm:order-none sm:h-8 sm:min-h-0 sm:w-auto sm:px-3 sm:text-sm"
|
||||
>
|
||||
Change how they look
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (control === "switch") {
|
||||
return (
|
||||
<span className="order-1 flex min-h-11 items-center sm:order-none sm:min-h-0">
|
||||
<Switch
|
||||
checked={value === true}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => onChange(settingKey, checked)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const options = permittedOptions(settingKey, effective);
|
||||
const asString = value === null || value === undefined ? "" : String(value);
|
||||
|
||||
// Open language values get the shared picker with "Other…" free entry —
|
||||
// the contract floor is a short authored list, and any tag beyond it is
|
||||
// typed rather than fetched from the catalog. A permitted_values constraint
|
||||
// pins the list closed, so the free entry disappears with it.
|
||||
if (definition.type === "language_tag") {
|
||||
const permitted = (effective as { permitted_values?: unknown[] } | undefined)?.permitted_values;
|
||||
const languageOptions = namedLanguageOptionsFor(settingKey, asString || undefined).filter(
|
||||
(option) => !permitted?.length || permitted.some((entry) => String(entry) === option.value),
|
||||
);
|
||||
return (
|
||||
<div className="order-1 w-full sm:order-none sm:w-[220px] sm:min-w-[180px]">
|
||||
<LanguageSelect
|
||||
aria-label={definition.label}
|
||||
value={asString === "" ? EMPTY_SELECT_VALUE : asString}
|
||||
options={languageOptions}
|
||||
disabled={disabled}
|
||||
allowOther={!permitted?.length}
|
||||
className="h-11 w-full text-base sm:h-9 sm:text-sm"
|
||||
onValueChange={(next) => onChange(settingKey, next === EMPTY_SELECT_VALUE ? null : next)}
|
||||
>
|
||||
{definition.nullable && (
|
||||
<SelectItem value={EMPTY_SELECT_VALUE}>{definition.unsetLabel ?? "Unset"}</SelectItem>
|
||||
)}
|
||||
</LanguageSelect>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A numeric "select" the manifest gives no members — the bandwidth cap is
|
||||
// declared as a range, not a list — would render as a one-entry dropdown
|
||||
// showing nothing at all. Present the range as bandwidth choices people
|
||||
// recognise instead, and keep the stored value visible if it is not one of
|
||||
// them.
|
||||
const numericChoices = numericSelectChoices(settingKey, definition, options, asString);
|
||||
if (numericChoices) {
|
||||
return (
|
||||
<Select
|
||||
value={asString === "" ? EMPTY_SELECT_VALUE : asString}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) =>
|
||||
onChange(settingKey, next === EMPTY_SELECT_VALUE ? null : Number(next))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={definition.label}
|
||||
className={cn(
|
||||
"order-1 h-11 w-full text-base sm:order-none sm:h-9 sm:w-[220px] sm:min-w-[180px] sm:text-sm",
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numericChoices.map((choice) => (
|
||||
<SelectItem
|
||||
key={choice.value || EMPTY_SELECT_VALUE}
|
||||
value={choice.value === "" ? EMPTY_SELECT_VALUE : choice.value}
|
||||
>
|
||||
{choice.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={asString === "" ? EMPTY_SELECT_VALUE : asString}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) =>
|
||||
onChange(
|
||||
settingKey,
|
||||
next === EMPTY_SELECT_VALUE ? null : typedSelectValue(settingKey, next),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={definition.label}
|
||||
className={cn(
|
||||
"order-1 h-11 w-full text-base sm:order-none sm:h-9 sm:w-[220px] sm:min-w-[180px] sm:text-sm",
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value || EMPTY_SELECT_VALUE}
|
||||
value={option.value === "" ? EMPTY_SELECT_VALUE : option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The options this viewer may actually pick. `permitted_values` narrows the
|
||||
* manifest's list for a viewer under a policy cap, so a child's quality picker
|
||||
* shows what they can have rather than offering 4K and delivering 1080p.
|
||||
*/
|
||||
function permittedOptions(settingKey: SettingKey, effective: EffectiveSetting | undefined) {
|
||||
const definition = SETTING_DEFINITIONS[settingKey];
|
||||
const all = optionsFor(definition);
|
||||
const permitted = (effective as { permitted_values?: unknown[] } | undefined)?.permitted_values;
|
||||
if (!permitted?.length) return all;
|
||||
const allowed = new Set(permitted.map((entry) => String(entry)));
|
||||
const narrowed = all.filter((option) => option.value === "" || allowed.has(option.value));
|
||||
return narrowed.length > 0 ? narrowed : all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandwidth caps people recognise, bounded by the definition's own range.
|
||||
*
|
||||
* Returns null for any select the manifest actually gives members, which is
|
||||
* every other one — this exists only for a numeric range declared with a
|
||||
* select control.
|
||||
*/
|
||||
/**
|
||||
* The bandwidth ladder, in kbps.
|
||||
*
|
||||
* The low end matches the in-player quality switcher
|
||||
* (web/src/player/hooks/useTranscodeQuality.ts) so a cap chosen here lines up
|
||||
* with what the player offers mid-playback. Above that it keeps climbing to
|
||||
* the definition's own ceiling of 200 Mbps, which is what remuxed 4K HDR and
|
||||
* untouched Blu-ray rips actually need — a ladder that stopped short would cap
|
||||
* people below what their server can already send them.
|
||||
*
|
||||
* Entries outside a definition's declared range are filtered out, so this list
|
||||
* can cover more ground than any single setting allows.
|
||||
*/
|
||||
const BITRATE_CHOICES_KBPS = [
|
||||
1500, 2000, 4000, 6000, 10000, 15000, 20000, 30000, 40000, 60000, 80000, 100000, 150000, 200000,
|
||||
];
|
||||
|
||||
function numericSelectChoices(
|
||||
settingKey: SettingKey,
|
||||
definition: (typeof SETTING_DEFINITIONS)[SettingKey],
|
||||
options: { value: string; label: string }[],
|
||||
currentValue: string,
|
||||
): { value: string; label: string }[] | null {
|
||||
const isNumeric = definition.type === "integer" || definition.type === "number";
|
||||
const hasMembers = options.some((option) => option.value !== "");
|
||||
if (!isNumeric || hasMembers) return null;
|
||||
|
||||
const min = definition.minimum ?? 0;
|
||||
const max = definition.maximum ?? Number.MAX_SAFE_INTEGER;
|
||||
const choices = BITRATE_CHOICES_KBPS.filter((kbps) => kbps >= min && kbps <= max).map((kbps) => ({
|
||||
value: String(kbps),
|
||||
label: formatBitrate(kbps),
|
||||
}));
|
||||
|
||||
// A value set elsewhere (an API call, another client) must stay selectable
|
||||
// rather than silently reading as "No limit".
|
||||
if (currentValue !== "" && !choices.some((choice) => choice.value === currentValue)) {
|
||||
const parsed = Number(currentValue);
|
||||
if (Number.isFinite(parsed)) {
|
||||
choices.push({ value: currentValue, label: formatBitrate(parsed) });
|
||||
choices.sort((a, b) => Number(a.value) - Number(b.value));
|
||||
}
|
||||
}
|
||||
|
||||
const unsetLabel = settingKey === "playback.max_bitrate_kbps" ? "No limit" : "Unset";
|
||||
return [{ value: "", label: unsetLabel }, ...choices];
|
||||
}
|
||||
|
||||
function formatBitrate(kbps: number): string {
|
||||
return formatQualityBitrate(kbps);
|
||||
}
|
||||
|
||||
/** Selects edit strings; integers travel back as numbers. */
|
||||
function typedSelectValue(settingKey: SettingKey, raw: string): unknown {
|
||||
const definition = SETTING_DEFINITIONS[settingKey];
|
||||
if (definition.type === "integer" || definition.type === "number") {
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LanguageSelect } from "@/components/settings/LanguageSelect";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
|
||||
// Radix Select reads element sizes via ResizeObserver, which jsdom does not
|
||||
// provide, and opens through pointer capture, which jsdom also lacks.
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver =
|
||||
ResizeObserverStub;
|
||||
}
|
||||
if (typeof window !== "undefined" && !window.HTMLElement.prototype.hasPointerCapture) {
|
||||
window.HTMLElement.prototype.hasPointerCapture = () => false;
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
];
|
||||
|
||||
function renderSelect(props: Partial<Parameters<typeof LanguageSelect>[0]> = {}) {
|
||||
const onValueChange = vi.fn();
|
||||
render(
|
||||
<LanguageSelect
|
||||
aria-label="Spoken language"
|
||||
value="none"
|
||||
options={OPTIONS}
|
||||
onValueChange={onValueChange}
|
||||
{...props}
|
||||
>
|
||||
<SelectItem value="none">No preference</SelectItem>
|
||||
</LanguageSelect>,
|
||||
);
|
||||
return { onValueChange };
|
||||
}
|
||||
|
||||
describe("LanguageSelect", () => {
|
||||
it("commits a typed tag through the Other entry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onValueChange } = renderSelect();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Spoken language" }));
|
||||
await user.click(screen.getByRole("option", { name: "Other…" }));
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Language code" });
|
||||
await user.type(input, "is");
|
||||
// The preview names the language before anything is saved.
|
||||
expect(screen.getByText("Icelandic")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use" }));
|
||||
expect(onValueChange).toHaveBeenCalledWith("is");
|
||||
// Committing closes the free-entry row.
|
||||
expect(screen.queryByRole("textbox", { name: "Language code" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses to commit an invalid tag and explains why", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onValueChange } = renderSelect();
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Spoken language" }));
|
||||
await user.click(screen.getByRole("option", { name: "Other…" }));
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Language code" });
|
||||
await user.type(input, "not a language{Enter}");
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Not a valid language tag");
|
||||
expect(screen.getByRole("button", { name: "Use" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the current selection when Other is dismissed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onValueChange } = renderSelect({ value: "en" });
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Spoken language" }));
|
||||
await user.click(screen.getByRole("option", { name: "Other…" }));
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("combobox", { name: "Spoken language" })).toHaveTextContent("English");
|
||||
});
|
||||
|
||||
it("hides the Other entry when the value is constrained", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelect({ allowOther: false });
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Spoken language" }));
|
||||
expect(screen.queryByRole("option", { name: "Other…" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { canonicalLanguageTag, getLanguageName } from "@/lib/languageNames";
|
||||
import type { SettingOption } from "@/lib/languageOptions";
|
||||
|
||||
const OTHER_VALUE = "__other__";
|
||||
|
||||
interface LanguageSelectProps {
|
||||
/** Raw select value, including any caller sentinel ("none", inherit, …). */
|
||||
value: string;
|
||||
/**
|
||||
* Receives either a caller sentinel from {@link children}, an option value,
|
||||
* or a free-typed BCP 47 tag committed through "Other…".
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
options: readonly SettingOption[];
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
/** Class for the select trigger. */
|
||||
className?: string;
|
||||
/**
|
||||
* Whether the "Other…" free entry is offered. Callers pass false when the
|
||||
* value is constrained to an explicit permitted list, where a typed tag
|
||||
* would only be rejected on save.
|
||||
*/
|
||||
allowOther?: boolean;
|
||||
"aria-label"?: string;
|
||||
"aria-describedby"?: string;
|
||||
/** Leading sentinel items ("No preference", "Inherit", …) as SelectItems. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A language picker over the contract's advisory option floor.
|
||||
*
|
||||
* The floor deliberately stays a short authored list — the server no longer
|
||||
* scans the catalog for every audio and subtitle track language (that walk
|
||||
* took tens of seconds on large deployments). Because these settings are open
|
||||
* `language_tag` values, any language beyond the floor is reachable through
|
||||
* "Other…", which accepts a BCP 47 tag and previews the resolved language
|
||||
* name before committing. A stored off-floor value stays selectable because
|
||||
* the option builders always synthesize the current value into the list.
|
||||
*/
|
||||
export function LanguageSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
id,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
className,
|
||||
allowOther = true,
|
||||
"aria-label": ariaLabel,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
children,
|
||||
}: LanguageSelectProps) {
|
||||
// null = closed; otherwise the tag being typed.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
|
||||
const trimmed = (draft ?? "").trim();
|
||||
const draftTag = trimmed ? canonicalLanguageTag(trimmed) : null;
|
||||
const draftInvalid = trimmed.length > 0 && draftTag === null;
|
||||
|
||||
const commitDraft = () => {
|
||||
if (!draftTag) return;
|
||||
onValueChange(trimmed);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0 space-y-2">
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next === OTHER_VALUE) {
|
||||
// Keep the current selection; the free entry commits separately.
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
setDraft(null);
|
||||
onValueChange(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{children}
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{allowOther && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectItem value={OTHER_VALUE}>Other…</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{draft !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
placeholder="Language code, e.g. pt-BR"
|
||||
aria-label="Language code"
|
||||
aria-invalid={draftInvalid || undefined}
|
||||
className="h-9 flex-1"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
commitDraft();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setDraft(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
disabled={disabled || !draftTag}
|
||||
onClick={commitDraft}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
disabled={disabled}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground text-[11px] leading-tight"
|
||||
role={draftInvalid ? "alert" : undefined}
|
||||
>
|
||||
{draftInvalid
|
||||
? "Not a valid language tag. Use an ISO code such as is, yue, or pt-BR."
|
||||
: draftTag
|
||||
? getLanguageName(trimmed)
|
||||
: "Type an ISO language code to use a language not in the list."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { UserDevice } from "@/api/types";
|
||||
import { deviceRecencyGroup } from "@/hooks/queries/devices";
|
||||
import { effectiveSettingsQueryKey } from "@/hooks/queries/settingValues";
|
||||
|
||||
const NOW = Date.parse("2026-07-31T12:00:00Z");
|
||||
|
||||
function device(overrides: Partial<UserDevice> = {}): UserDevice {
|
||||
return {
|
||||
device_id: "device-1",
|
||||
device_name: "Chrome",
|
||||
device_platform: "macOS Web",
|
||||
last_seen_at: new Date(NOW).toISOString(),
|
||||
profile_id: "profile-1",
|
||||
profile_name: "Sam",
|
||||
is_current_device: false,
|
||||
changed_count: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deviceRecencyGroup", () => {
|
||||
it("puts the current device first regardless of its timestamp", () => {
|
||||
const stale = device({
|
||||
is_current_device: true,
|
||||
last_seen_at: new Date(NOW - 400 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
expect(deviceRecencyGroup(stale, NOW)).toBe("current");
|
||||
});
|
||||
|
||||
it("splits the rest at a week", () => {
|
||||
const recent = device({ last_seen_at: new Date(NOW - 6 * 24 * 60 * 60 * 1000).toISOString() });
|
||||
const older = device({ last_seen_at: new Date(NOW - 8 * 24 * 60 * 60 * 1000).toISOString() });
|
||||
expect(deviceRecencyGroup(recent, NOW)).toBe("week");
|
||||
expect(deviceRecencyGroup(older, NOW)).toBe("earlier");
|
||||
});
|
||||
|
||||
it("treats an unparseable timestamp as old rather than throwing", () => {
|
||||
expect(deviceRecencyGroup(device({ last_seen_at: "nonsense" }), NOW)).toBe("earlier");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveSettingsQueryKey", () => {
|
||||
// Without device and profile in the key, reading the Apple TV's values would
|
||||
// land on the same cache entry as this browser's and serve one device's
|
||||
// settings as another's.
|
||||
it("gives each device its own cache entry", () => {
|
||||
const keys = ["player.hdr_enabled"] as const;
|
||||
const own = effectiveSettingsQueryKey({ keys });
|
||||
const appleTv = effectiveSettingsQueryKey({ keys, deviceId: "apple-tv" });
|
||||
const iPad = effectiveSettingsQueryKey({ keys, deviceId: "ipad" });
|
||||
|
||||
expect(appleTv).not.toEqual(own);
|
||||
expect(appleTv).not.toEqual(iPad);
|
||||
});
|
||||
|
||||
it("gives each profile its own cache entry", () => {
|
||||
const keys = ["player.hdr_enabled"] as const;
|
||||
const mine = effectiveSettingsQueryKey({ keys, deviceId: "shared-tv" });
|
||||
const theirs = effectiveSettingsQueryKey({
|
||||
keys,
|
||||
deviceId: "shared-tv",
|
||||
profileId: "profile-2",
|
||||
});
|
||||
|
||||
expect(theirs).not.toEqual(mine);
|
||||
});
|
||||
|
||||
it("is stable for the same request", () => {
|
||||
const keys = ["player.hdr_enabled"] as const;
|
||||
expect(effectiveSettingsQueryKey({ keys, deviceId: "tv" })).toEqual(
|
||||
effectiveSettingsQueryKey({ keys, deviceId: "tv" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/api/client";
|
||||
import type { UserDevice, UserDeviceListResponse } from "@/api/types";
|
||||
|
||||
import { deviceKeys, settingsKeys } from "./keys";
|
||||
|
||||
/**
|
||||
* The signed-in viewer's own device registry.
|
||||
*
|
||||
* `household` is opt-in and only succeeds for the household parent — the server
|
||||
* answers 403 otherwise. It defaults off so the ordinary screen cannot show the
|
||||
* family's devices by forgetting to ask for less.
|
||||
*/
|
||||
export function useMyDevices(options?: { household?: boolean; enabled?: boolean }) {
|
||||
const household = options?.household ?? false;
|
||||
|
||||
return useQuery({
|
||||
queryKey: deviceKeys.list(household ? "household" : "own"),
|
||||
queryFn: async () => {
|
||||
const result = await api<UserDeviceListResponse>(
|
||||
`/devices${household ? "?scope=household" : ""}`,
|
||||
);
|
||||
return result.devices ?? [];
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Both mutations move settings, so both invalidate the value caches too. */
|
||||
function useDeviceMutation(path: (device: DeviceTarget) => string) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (device: DeviceTarget) => api(path(device), { method: "DELETE" }),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: deviceKeys.all });
|
||||
void qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface DeviceTarget {
|
||||
deviceId: string;
|
||||
/** Set only when acting for another profile, as the household parent. */
|
||||
profileId?: string;
|
||||
}
|
||||
|
||||
function targetQuery(device: DeviceTarget): string {
|
||||
return device.profileId ? `?profile_id=${encodeURIComponent(device.profileId)}` : "";
|
||||
}
|
||||
|
||||
/** Remove the device's settings and drop it from the registry. */
|
||||
export function useForgetDevice() {
|
||||
return useDeviceMutation(
|
||||
(device) => `/devices/${encodeURIComponent(device.deviceId)}${targetQuery(device)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return a device to the profile's own values without forgetting it. */
|
||||
export function useClearDeviceSettings() {
|
||||
return useDeviceMutation(
|
||||
(device) => `/devices/${encodeURIComponent(device.deviceId)}/settings${targetQuery(device)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices grouped the way the list reads them: by how recently they were used,
|
||||
* because that is how someone identifies a device they own.
|
||||
*/
|
||||
export type DeviceRecencyGroup = "current" | "week" | "earlier";
|
||||
|
||||
export function deviceRecencyGroup(device: UserDevice, now: number): DeviceRecencyGroup {
|
||||
if (device.is_current_device) return "current";
|
||||
const seen = Date.parse(device.last_seen_at);
|
||||
if (Number.isNaN(seen)) return "earlier";
|
||||
return now - seen < 7 * 24 * 60 * 60 * 1000 ? "week" : "earlier";
|
||||
}
|
||||
@@ -208,6 +208,11 @@ export const progressKeys = {
|
||||
list: (status?: string, libraryId?: number) => ["progress", "list", status, libraryId] as const,
|
||||
};
|
||||
|
||||
export const deviceKeys = {
|
||||
all: ["devices"] as const,
|
||||
list: (scope: "own" | "household") => ["devices", "list", scope] as const,
|
||||
};
|
||||
|
||||
export const settingsKeys = {
|
||||
// The canonical value queries live under ["settings", "values", …] and build
|
||||
// their own key (effectiveSettingsQueryKey), so one invalidation of that
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from "@/api/client";
|
||||
import { storage } from "@/utils/storage";
|
||||
import { SETTING_DEFINITIONS, SETTINGS_REVISION, type SettingKey } from "@/lib/settingsContract";
|
||||
import { useEventChannel } from "@/components/realtimeEventsContext";
|
||||
import { settingsKeys } from "./keys";
|
||||
import { deviceKeys, settingsKeys } from "./keys";
|
||||
|
||||
/**
|
||||
* Typed access to the canonical settings API.
|
||||
@@ -33,6 +33,16 @@ export interface SettingIdentity {
|
||||
libraryId?: number;
|
||||
/** Required for profile_series. */
|
||||
seriesId?: string;
|
||||
/**
|
||||
* A device other than the one this browser is. Omit to address the current
|
||||
* device, which is what every screen except "your devices" wants.
|
||||
*/
|
||||
deviceId?: string;
|
||||
/**
|
||||
* A profile other than the signed-in one. Only the household parent may set
|
||||
* this; the server answers 403 for anyone else.
|
||||
*/
|
||||
profileId?: string;
|
||||
}
|
||||
|
||||
export interface EffectiveSetting<T = unknown> {
|
||||
@@ -63,6 +73,8 @@ function identityQuery(identity: SettingIdentity): string {
|
||||
const params = new URLSearchParams({ scope: identity.scope });
|
||||
if (identity.libraryId !== undefined) params.set("library_id", String(identity.libraryId));
|
||||
if (identity.seriesId !== undefined) params.set("series_id", identity.seriesId);
|
||||
if (identity.deviceId !== undefined) params.set("device_id", identity.deviceId);
|
||||
if (identity.profileId !== undefined) params.set("profile_id", identity.profileId);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -79,13 +91,20 @@ export function effectiveSettingsQueryKey(options?: {
|
||||
keys?: readonly SettingKey[];
|
||||
libraryIds?: readonly number[];
|
||||
seriesIds?: readonly string[];
|
||||
deviceId?: string;
|
||||
profileId?: string;
|
||||
}) {
|
||||
const { keys, libraryIds, seriesIds } = options ?? {};
|
||||
const { keys, libraryIds, seriesIds, deviceId, profileId } = options ?? {};
|
||||
return [
|
||||
...settingsKeys.all,
|
||||
"values",
|
||||
"effective",
|
||||
activeProfileId(),
|
||||
// The device and profile a read resolved for are part of the identity of
|
||||
// the answer, not just of the request. Without them a read of the Apple
|
||||
// TV's values would land on the same cache entry as this browser's and
|
||||
// serve one device's settings as another's.
|
||||
profileId ?? activeProfileId(),
|
||||
deviceId ?? "",
|
||||
keys ? [...keys].sort().join(",") : "*",
|
||||
libraryIds ? [...libraryIds].sort().join(",") : "",
|
||||
seriesIds ? [...seriesIds].sort().join(",") : "",
|
||||
@@ -104,19 +123,27 @@ export function useEffectiveSettings(options?: {
|
||||
keys?: readonly SettingKey[];
|
||||
libraryIds?: readonly number[];
|
||||
seriesIds?: readonly string[];
|
||||
/** Resolve for a device other than this browser. */
|
||||
deviceId?: string;
|
||||
/** Resolve for another profile on the account (household parent only). */
|
||||
profileId?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const keys = options?.keys;
|
||||
const libraryIds = options?.libraryIds;
|
||||
const seriesIds = options?.seriesIds;
|
||||
const deviceId = options?.deviceId;
|
||||
const profileId = options?.profileId;
|
||||
|
||||
return useQuery({
|
||||
queryKey: effectiveSettingsQueryKey({ keys, libraryIds, seriesIds }),
|
||||
queryKey: effectiveSettingsQueryKey({ keys, libraryIds, seriesIds, deviceId, profileId }),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (keys?.length) params.set("keys", keys.join(","));
|
||||
if (libraryIds?.length) params.set("library_ids", libraryIds.join(","));
|
||||
if (seriesIds?.length) params.set("series_ids", seriesIds.join(","));
|
||||
if (deviceId) params.set("device_id", deviceId);
|
||||
if (profileId) params.set("profile_id", profileId);
|
||||
const query = params.toString();
|
||||
const result = await api<EffectiveResponse>(
|
||||
`/settings/values/effective${query ? `?${query}` : ""}`,
|
||||
@@ -181,8 +208,14 @@ export function useSetSettingValue() {
|
||||
},
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
onSettled: (_data, _error, variables) => {
|
||||
void qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
// A device-scoped write changes that device's "how many things differ"
|
||||
// count, which the device list shows. Without this the badge stays stale
|
||||
// until the list's own staleTime expires.
|
||||
if (variables.identity.scope === "profile_device") {
|
||||
void qc.invalidateQueries({ queryKey: deviceKeys.all });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -194,8 +227,11 @@ export function useClearSettingValue() {
|
||||
return useMutation({
|
||||
mutationFn: ({ key, identity }: { key: SettingKey; identity: SettingIdentity }) =>
|
||||
api(`/settings/values/${key}?${identityQuery(identity)}`, { method: "DELETE" }),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
onSettled: (_data, _error, variables) => {
|
||||
void qc.invalidateQueries({ queryKey: [...settingsKeys.all, "values"] });
|
||||
if (variables.identity.scope === "profile_device") {
|
||||
void qc.invalidateQueries({ queryKey: deviceKeys.all });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
groupDeviceSettings,
|
||||
groupForDeviceSetting,
|
||||
hiddenDeviceSettingKeys,
|
||||
} from "@/lib/deviceSettingGroups";
|
||||
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
|
||||
|
||||
describe("deviceSettingGroups", () => {
|
||||
// The guard that matters: a key added to the manifest must either land in a
|
||||
// group or be deliberately hidden. Without this, a new device setting simply
|
||||
// never appears on the screen and nobody notices.
|
||||
it("places every device-scoped key in exactly one group or hides it deliberately", () => {
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const group of groupDeviceSettings()) {
|
||||
for (const key of group.keys) {
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), group.id]);
|
||||
}
|
||||
}
|
||||
const hidden = new Set(hiddenDeviceSettingKeys());
|
||||
|
||||
const unplaced = ALL_DEVICE_SETTING_KEYS.filter((key) => !grouped.has(key) && !hidden.has(key));
|
||||
expect(unplaced).toEqual([]);
|
||||
|
||||
const duplicated = [...grouped.entries()].filter(([, groups]) => groups.length > 1);
|
||||
expect(duplicated).toEqual([]);
|
||||
});
|
||||
|
||||
it("puts each setting where someone would look for it", () => {
|
||||
expect(groupForDeviceSetting("player.hdr_enabled")).toBe("picture");
|
||||
expect(groupForDeviceSetting("player.audio_sync_ms")).toBe("sound");
|
||||
expect(groupForDeviceSetting("playback.audio_language")).toBe("sound");
|
||||
expect(groupForDeviceSetting("playback.subtitle_mode")).toBe("subtitles");
|
||||
expect(groupForDeviceSetting("player.subtitle_sync_ms")).toBe("subtitles");
|
||||
expect(groupForDeviceSetting("playback.auto_play_next")).toBe("episodes");
|
||||
});
|
||||
|
||||
it("keeps appearance settings off the device screen", () => {
|
||||
expect(groupForDeviceSetting("ui.theme")).toBeNull();
|
||||
expect(groupForDeviceSetting("ui.library_page_state")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns groups in reading order and omits empty ones", () => {
|
||||
const ids = groupDeviceSettings().map((group) => group.id);
|
||||
expect(ids).toEqual(["picture", "sound", "subtitles", "episodes"]);
|
||||
|
||||
const single = groupDeviceSettings(["player.hdr_enabled"]);
|
||||
expect(single.map((group) => group.id)).toEqual(["picture"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { SETTING_DEFINITIONS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { ALL_DEVICE_SETTING_KEYS } from "@/lib/settingsDisplay";
|
||||
|
||||
/**
|
||||
* Device settings, grouped by what they affect rather than by manifest order.
|
||||
*
|
||||
* The manifest's own `category` is close but not sufficient: `player.*` holds
|
||||
* both picture and sound keys, and `playback.*` spans subtitles and episode
|
||||
* behaviour. Someone looking for "why does the sound lag" reads down a Sound
|
||||
* heading, not down an alphabetical list of 30 keys.
|
||||
*
|
||||
* The small override table below is the only per-key knowledge in the settings
|
||||
* UI, and it is checked by a test that every device-scoped key lands in exactly
|
||||
* one group — so a key added to the manifest cannot silently disappear from
|
||||
* this screen.
|
||||
*/
|
||||
export type DeviceSettingGroupId = "picture" | "sound" | "subtitles" | "episodes";
|
||||
|
||||
export interface DeviceSettingGroup {
|
||||
id: DeviceSettingGroupId;
|
||||
title: string;
|
||||
/** Shown beside the title; names the device so the scope stays concrete. */
|
||||
description: string;
|
||||
keys: SettingKey[];
|
||||
}
|
||||
|
||||
const GROUP_META: Record<DeviceSettingGroupId, { title: string; description: string }> = {
|
||||
picture: { title: "Picture", description: "How video looks on this device" },
|
||||
sound: { title: "Sound", description: "Audio on this device" },
|
||||
subtitles: { title: "Subtitles", description: "On this device" },
|
||||
episodes: { title: "Episodes", description: "What happens between episodes" },
|
||||
};
|
||||
|
||||
const GROUP_ORDER: DeviceSettingGroupId[] = ["picture", "sound", "subtitles", "episodes"];
|
||||
|
||||
/** Keys whose group is not implied by their manifest category. */
|
||||
const EXPLICIT_GROUPS: Partial<Record<string, DeviceSettingGroupId>> = {
|
||||
"playback.audio_language": "sound",
|
||||
"playback.subtitle_language": "subtitles",
|
||||
"playback.subtitle_mode": "subtitles",
|
||||
"playback.show_forced_subtitles": "subtitles",
|
||||
"playback.subtitle_appearance": "subtitles",
|
||||
"playback.preferred_quality": "picture",
|
||||
"playback.max_bitrate_kbps": "picture",
|
||||
"playback.auto_skip_intro": "episodes",
|
||||
"playback.auto_skip_credits": "episodes",
|
||||
"playback.auto_skip_recap": "episodes",
|
||||
"playback.auto_play_next": "episodes",
|
||||
"playback.auto_play_next_preview": "episodes",
|
||||
"playback.next_up_prompt_seconds": "episodes",
|
||||
"player.hdr_enabled": "picture",
|
||||
"player.dolby_vision_enabled": "picture",
|
||||
"player.dv_profile7_hdr10_fallback": "picture",
|
||||
"player.match_frame_rate": "picture",
|
||||
"player.video_gravity": "picture",
|
||||
"player.orientation_mode": "picture",
|
||||
"player.seek_cache_enabled": "picture",
|
||||
"player.audio_sync_ms": "sound",
|
||||
"player.playback_speed": "sound",
|
||||
"player.subtitle_sync_ms": "subtitles",
|
||||
"player.sleep_timer_default_minutes": "episodes",
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys deliberately kept off this screen.
|
||||
*
|
||||
* `ui.*` device overrides exist in the contract but belong to the Appearance
|
||||
* screen, which already edits them at profile scope; showing them here would
|
||||
* give one setting two homes. `ui.library_page_state` is remembered browse
|
||||
* state rather than a preference — it has no control in the manifest at all.
|
||||
*/
|
||||
const HIDDEN_KEYS = new Set<string>([
|
||||
"ui.theme",
|
||||
"ui.text_scale",
|
||||
"ui.text_weight",
|
||||
"ui.high_contrast",
|
||||
"ui.library_page_state",
|
||||
"ui.remember_library_page_state",
|
||||
]);
|
||||
|
||||
export function groupForDeviceSetting(key: SettingKey): DeviceSettingGroupId | null {
|
||||
if (HIDDEN_KEYS.has(key)) return null;
|
||||
const explicit = EXPLICIT_GROUPS[key];
|
||||
if (explicit) return explicit;
|
||||
|
||||
// Anything new falls back to its manifest category, so an added key shows up
|
||||
// somewhere sensible rather than vanishing.
|
||||
const definition = SETTING_DEFINITIONS[key];
|
||||
switch (definition?.category) {
|
||||
case "player":
|
||||
case "playback":
|
||||
return "picture";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The groups to render, in reading order, with empty groups omitted. */
|
||||
export function groupDeviceSettings(
|
||||
keys: readonly SettingKey[] = ALL_DEVICE_SETTING_KEYS,
|
||||
): DeviceSettingGroup[] {
|
||||
const byGroup = new Map<DeviceSettingGroupId, SettingKey[]>();
|
||||
for (const key of keys) {
|
||||
const group = groupForDeviceSetting(key);
|
||||
if (!group) continue;
|
||||
const existing = byGroup.get(group);
|
||||
if (existing) {
|
||||
existing.push(key);
|
||||
} else {
|
||||
byGroup.set(group, [key]);
|
||||
}
|
||||
}
|
||||
|
||||
return GROUP_ORDER.filter((id) => (byGroup.get(id)?.length ?? 0) > 0).map((id) => ({
|
||||
id,
|
||||
title: GROUP_META[id].title,
|
||||
description: GROUP_META[id].description,
|
||||
keys: byGroup.get(id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** Every device-scoped key this screen deliberately does not show. */
|
||||
export function hiddenDeviceSettingKeys(): SettingKey[] {
|
||||
return ALL_DEVICE_SETTING_KEYS.filter((key) => HIDDEN_KEYS.has(key));
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const ADMIN_TITLES: Record<string, string> = {
|
||||
"api-keys": "Admin API Keys",
|
||||
collections: "Admin Collections",
|
||||
devices: "Admin Devices",
|
||||
"settings/devices": "Your Devices",
|
||||
diagnostics: "Admin Client Diagnostics",
|
||||
history: "Admin Playback History",
|
||||
"history-import": "Admin History Import",
|
||||
|
||||
@@ -32,11 +32,13 @@ export function withCurrentLanguageOption(
|
||||
/**
|
||||
* Suggested values for one open language setting.
|
||||
*
|
||||
* The generated contract list is the stable floor. A newer server may add
|
||||
* values observed in this deployment's catalog, and the current stored tag is
|
||||
* always synthesized into the list so an open value such as `pt-BR` never
|
||||
* leaves a select with no matching row. True language aliases are de-duplicated,
|
||||
* with the exact current wire value winning so the control remains selected.
|
||||
* The generated contract list is the stable floor. The server adds observed
|
||||
* original languages for catalog.metadata_language only (the audio/subtitle
|
||||
* track scans were too expensive; those pickers offer free entry via
|
||||
* LanguageSelect's "Other…" instead), and the current stored tag is always
|
||||
* synthesized into the list so an open value such as `pt-BR` never leaves a
|
||||
* select with no matching row. True language aliases are de-duplicated, with
|
||||
* the exact current wire value winning so the control remains selected.
|
||||
*/
|
||||
export function namedLanguageOptionsFor(
|
||||
key: SettingKey,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Server,
|
||||
Sparkles,
|
||||
Bell,
|
||||
MonitorSmartphone,
|
||||
} from "lucide-react";
|
||||
// Sparkles is used by the Personalization nav entry below.
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -49,6 +50,12 @@ interface NavSection {
|
||||
|
||||
const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }));
|
||||
|
||||
/**
|
||||
* Settings pages that manage their own multi-column layout, so the shell's
|
||||
* reading-width cap would squeeze them instead of helping.
|
||||
*/
|
||||
const WIDE_SETTINGS_PAGES = new Set(["devices"]);
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: "Playback",
|
||||
@@ -284,6 +291,37 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{
|
||||
path: "devices",
|
||||
label: "Your Devices",
|
||||
icon: MonitorSmartphone,
|
||||
description: "Settings for each device you watch on",
|
||||
keywords: [
|
||||
"devices",
|
||||
"tv",
|
||||
"phone",
|
||||
"tablet",
|
||||
"browser",
|
||||
"this device",
|
||||
"forget device",
|
||||
"hdr",
|
||||
"dolby vision",
|
||||
"sound delay",
|
||||
"lip sync",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Video quality",
|
||||
"Data use limit",
|
||||
"HDR",
|
||||
"Dolby Vision",
|
||||
"Play Dolby Vision films as HDR10",
|
||||
"Match content frame rate",
|
||||
"How video fills the screen",
|
||||
"Audio sync offset",
|
||||
"Subtitle sync offset",
|
||||
"Forget this device",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
label: "Notifications",
|
||||
@@ -358,6 +396,9 @@ export default function SettingsLayout() {
|
||||
const segments = location.pathname.split("/");
|
||||
const activeSegment = segments[2] || "playback";
|
||||
const canManageProfiles = actingAdmin || profile?.is_primary === true;
|
||||
// Most settings pages are a single column of rows and read best measured.
|
||||
// A page that is itself two panes needs the room, so it opts out.
|
||||
const wideSetting = WIDE_SETTINGS_PAGES.has(activeSegment);
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
@@ -469,7 +510,7 @@ export default function SettingsLayout() {
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className={cn("mx-auto w-full", wideSetting ? "max-w-6xl" : "max-w-3xl")}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronLeft, Info, ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { UserDevice } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
classifyPlatform,
|
||||
PlatformIcon,
|
||||
platformKindLabel,
|
||||
} from "@/components/admin/deviceOverrides";
|
||||
import { DeviceList, lastSeenLabel } from "@/components/settings/DeviceList";
|
||||
import { DeviceSettingGroups } from "@/components/settings/DeviceSettingGroups";
|
||||
import { useClearDeviceSettings, useForgetDevice, useMyDevices } from "@/hooks/queries/devices";
|
||||
import {
|
||||
useClearSettingValue,
|
||||
useEffectiveSettings,
|
||||
useSetSettingValue,
|
||||
} from "@/hooks/queries/settingValues";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Your devices" — see and change how Silo behaves on each device you watch on.
|
||||
*
|
||||
* Two things make this screen different from the admin device console. Every
|
||||
* device is editable from wherever you are, because the settings API accepts an
|
||||
* explicit device rather than only the one in the request headers. And the
|
||||
* household parent can switch to the whole household, so a parent can fix a
|
||||
* kid's iPad without borrowing it.
|
||||
*/
|
||||
export default function DeviceSettings() {
|
||||
const actingAdmin = useIsActingAdmin();
|
||||
const { profile } = useCurrentProfile();
|
||||
const canSeeHousehold = actingAdmin || profile?.is_primary === true;
|
||||
|
||||
const [household, setHousehold] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [profileFilter, setProfileFilter] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
/**
|
||||
* On a phone the list and the settings are two screens, not two panes.
|
||||
*
|
||||
* Stacking them vertically put a device list over a thousand pixels tall
|
||||
* above the first setting, so reaching "turn HDR off" meant scrolling past
|
||||
* every other device first. Below xl the list hands off to the detail view
|
||||
* and a back control returns; from xl up both are visible and this is
|
||||
* ignored.
|
||||
*/
|
||||
const [showDetailOnMobile, setShowDetailOnMobile] = useState(false);
|
||||
// One clock for the whole screen, taken once per mount: relative labels only
|
||||
// need to be right to the minute, and reading the clock during render is not
|
||||
// something a pure component may do.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
const { data: devices = [], isLoading } = useMyDevices({
|
||||
household: household && canSeeHousehold,
|
||||
});
|
||||
|
||||
// Filtering to one person must not leave someone else's device open in the
|
||||
// detail pane — the list and the pane would then disagree about who is being
|
||||
// edited, which is the one thing this screen cannot afford to be vague about.
|
||||
const selectable = useMemo(
|
||||
() =>
|
||||
profileFilter ? devices.filter((device) => device.profile_id === profileFilter) : devices,
|
||||
[devices, profileFilter],
|
||||
);
|
||||
|
||||
// Default to the device you are on: it is the one you can check the effect of
|
||||
// immediately, and the one most people came here for.
|
||||
const selected = useMemo(() => {
|
||||
if (selectable.length === 0) return null;
|
||||
return selectable.find((device) => device.device_id === selectedId) ?? selectable[0];
|
||||
}, [selectable, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected && selected.device_id !== selectedId) {
|
||||
setSelectedId(selected.device_id);
|
||||
}
|
||||
}, [selected, selectedId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Hidden below xl while the detail view is open: on a phone that screen
|
||||
is about one device, and its own header says which. */}
|
||||
<header className={cn("space-y-2", showDetailOnMobile && "hidden xl:block")}>
|
||||
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Your devices</h2>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Every phone, tablet, TV and browser you watch on. Pick one to see what's set
|
||||
differently there and change it — from here, whichever device you're holding.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{canSeeHousehold ? (
|
||||
<div className={cn(showDetailOnMobile && "hidden xl:block")}>
|
||||
<HouseholdSwitch
|
||||
household={household}
|
||||
onChange={(next) => {
|
||||
setHousehold(next);
|
||||
if (!next) setProfileFilter(null);
|
||||
}}
|
||||
count={devices.length}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(230px,270px)_minmax(0,1fr)] xl:items-start">
|
||||
{isLoading ? (
|
||||
<Skeleton
|
||||
className={cn("h-64 rounded-[1.5rem]", showDetailOnMobile && "hidden xl:block")}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(showDetailOnMobile && "hidden xl:block")}>
|
||||
<DeviceList
|
||||
devices={devices}
|
||||
selectedDeviceId={selected?.device_id ?? null}
|
||||
onSelect={(device) => {
|
||||
setSelectedId(device.device_id);
|
||||
setShowDetailOnMobile(true);
|
||||
// Swapping the panes leaves the scroll position where the list
|
||||
// was, which on a phone lands mid-settings with no context.
|
||||
if (window.matchMedia("(max-width: 1279px)").matches) {
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
}
|
||||
}}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
groupByProfile={household && canSeeHousehold}
|
||||
profileFilter={profileFilter}
|
||||
onProfileFilterChange={setProfileFilter}
|
||||
ownProfileId={profile?.id}
|
||||
now={now}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton
|
||||
className={cn("h-96 rounded-[1.5rem]", !showDetailOnMobile && "hidden xl:block")}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className={cn(!showDetailOnMobile && "hidden xl:block")}>
|
||||
<DeviceDetail
|
||||
key={`${selected.profile_id}:${selected.device_id}`}
|
||||
device={selected}
|
||||
actingProfileId={profile?.id ?? ""}
|
||||
now={now}
|
||||
onBack={() => {
|
||||
setShowDetailOnMobile(false);
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No devices yet. They appear here as you sign in on them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdSwitch({
|
||||
household,
|
||||
onChange,
|
||||
count,
|
||||
}: {
|
||||
household: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
count: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="border-border/60 bg-surface inline-flex gap-1 rounded-xl border p-1"
|
||||
role="group"
|
||||
aria-label="Whose devices to show"
|
||||
>
|
||||
<SwitchButton active={!household} onClick={() => onChange(false)}>
|
||||
Just mine
|
||||
</SwitchButton>
|
||||
<SwitchButton active={household} onClick={() => onChange(true)}>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Everyone{household && count > 0 ? ` (${count})` : ""}
|
||||
</SwitchButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center gap-1.5 rounded-lg px-4 text-sm font-medium transition-colors sm:min-h-9 sm:px-3 sm:text-[13px]",
|
||||
active
|
||||
? "bg-surface-raised text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceDetail({
|
||||
device,
|
||||
actingProfileId,
|
||||
now,
|
||||
onBack,
|
||||
}: {
|
||||
device: UserDevice;
|
||||
actingProfileId: string;
|
||||
now: number;
|
||||
/** Returns to the list below xl, where the two are separate screens. */
|
||||
onBack: () => void;
|
||||
}) {
|
||||
// Acting for someone else changes the copy throughout: the banner, the reset
|
||||
// labels, and every mutation's identity.
|
||||
const forSomeoneElse = Boolean(device.profile_id) && device.profile_id !== actingProfileId;
|
||||
const ownerLabel = forSomeoneElse ? `${device.profile_name}'s` : "your";
|
||||
const targetProfileId = forSomeoneElse ? device.profile_id : undefined;
|
||||
|
||||
const { data: settings = {}, isLoading } = useEffectiveSettings({
|
||||
keys: ALL_DEVICE_SETTING_KEYS,
|
||||
deviceId: device.device_id,
|
||||
profileId: targetProfileId,
|
||||
});
|
||||
|
||||
const setValue = useSetSettingValue();
|
||||
const clearValue = useClearSettingValue();
|
||||
const clearDevice = useClearDeviceSettings();
|
||||
const forgetDevice = useForgetDevice();
|
||||
|
||||
const identity = {
|
||||
scope: "profile_device" as const,
|
||||
deviceId: device.device_id,
|
||||
profileId: targetProfileId,
|
||||
};
|
||||
|
||||
const changedCount = device.changed_count;
|
||||
const kind = classifyPlatform(device.device_platform);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground -ml-1 inline-flex min-h-11 items-center gap-1 text-sm font-medium xl:hidden"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
All devices
|
||||
</button>
|
||||
|
||||
<section className="surface-panel rounded-[1.5rem] border-0 px-4 py-4 shadow-none sm:px-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="bg-surface-raised flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl">
|
||||
<PlatformIcon kind={kind} className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Wraps rather than truncating: on a phone the name is the whole
|
||||
subject of the screen, and "Living Room Apple…" is not it. */}
|
||||
<h3 className="text-lg leading-tight font-semibold tracking-tight">
|
||||
{device.device_name || "Unknown device"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-0.5 text-[13px] leading-snug">
|
||||
{[
|
||||
platformKindLabel(kind),
|
||||
device.is_current_device ? "using now" : lastSeenLabel(device.last_seen_at, now),
|
||||
changedCount > 0
|
||||
? `${changedCount} ${changedCount === 1 ? "thing" : "things"} set differently`
|
||||
: "nothing changed here",
|
||||
].join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
{changedCount > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full sm:h-8 sm:min-h-0 sm:w-auto sm:px-3 sm:text-sm"
|
||||
disabled={clearDevice.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Clear all ${changedCount} ${changedCount === 1 ? "change" : "changes"} on ${device.device_name}?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearDevice.mutate(
|
||||
{ deviceId: device.device_id, profileId: targetProfileId },
|
||||
{
|
||||
onSuccess: () => toast.success("Settings cleared on this device"),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : "Couldn't clear"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Clear all changes
|
||||
</Button>
|
||||
) : null}
|
||||
{!device.is_current_device ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive min-h-11 self-start px-0 text-[13px] sm:h-8 sm:min-h-0 sm:px-3 sm:text-sm"
|
||||
disabled={forgetDevice.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Forget ${device.device_name}? Its settings are removed and it disappears from this list until it's used again.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
forgetDevice.mutate(
|
||||
{ deviceId: device.device_id, profileId: targetProfileId },
|
||||
{
|
||||
onSuccess: () => toast.success("Device forgotten"),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : "Couldn't forget"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Forget
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{forSomeoneElse ? (
|
||||
<Callout tone="warning" icon={<Users className="h-4 w-4" />}>
|
||||
<strong className="text-foreground font-semibold">
|
||||
You're changing {device.profile_name}'s settings, not your own.
|
||||
</strong>{" "}
|
||||
{device.profile_name} will see these change on this device.
|
||||
</Callout>
|
||||
) : null}
|
||||
|
||||
{/* The scope sentence is required copy and must not be paraphrased away,
|
||||
but five lines of prose is a wall on a phone. The mandated clause
|
||||
leads; the elaboration is there for anyone who wants it. */}
|
||||
<Callout tone="info" icon={<Info className="h-4 w-4" />}>
|
||||
<span className="block">
|
||||
These apply to{" "}
|
||||
<strong className="text-foreground font-semibold">
|
||||
this device, for {forSomeoneElse ? `${device.profile_name}'s` : "your"} profile only
|
||||
</strong>
|
||||
.
|
||||
</span>
|
||||
<span className="text-muted-foreground/90 mt-1 block text-[12.5px]">
|
||||
{forSomeoneElse ? `${device.profile_name}'s` : "Your"} other devices, and anyone else who
|
||||
uses this one, are unaffected.
|
||||
{!device.is_current_device
|
||||
? " This device picks up your changes the next time it's used."
|
||||
: ""}
|
||||
</span>
|
||||
</Callout>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-96 rounded-[1.7rem]" />
|
||||
) : (
|
||||
<DeviceSettingGroups
|
||||
settings={settings}
|
||||
ownerLabel={ownerLabel}
|
||||
disabled={setValue.isPending || clearValue.isPending}
|
||||
onChange={(key: SettingKey, value: unknown) =>
|
||||
setValue.mutate(
|
||||
{ key, value, identity },
|
||||
{
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : "Couldn't save"),
|
||||
},
|
||||
)
|
||||
}
|
||||
onReset={(key: SettingKey) =>
|
||||
clearValue.mutate(
|
||||
{ key, identity },
|
||||
{
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : "Couldn't reset"),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forSomeoneElse ? (
|
||||
<Callout tone="muted" icon={<ShieldCheck className="h-4 w-4" />}>
|
||||
<strong className="text-foreground font-semibold">What you can't see here.</strong>{" "}
|
||||
This page shows how Silo is set up on each device — not what anyone watched. Viewing
|
||||
history stays private to each profile.
|
||||
</Callout>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Callout({
|
||||
tone,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
tone: "info" | "warning" | "muted";
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2.5 rounded-2xl border px-4 py-3 text-[13px] leading-relaxed",
|
||||
tone === "info" && "border-info/25 bg-info/5 text-muted-foreground",
|
||||
tone === "warning" && "border-amber-500/30 bg-amber-500/5 text-amber-100/90",
|
||||
tone === "muted" && "border-border/60 bg-surface text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-px shrink-0",
|
||||
tone === "info" && "text-info",
|
||||
tone === "warning" && "text-amber-400",
|
||||
tone === "muted" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<p className="min-w-0">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import type { UserLibrary } from "@/api/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { LanguageSelect } from "@/components/settings/LanguageSelect";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
NONE_VALUE,
|
||||
SUBTITLE_MODE_OPTIONS,
|
||||
} from "./libraryPlaybackPreferences";
|
||||
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
|
||||
import { namedLanguageOptionsFor, type SettingOption } from "@/lib/languageOptions";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, ChevronRight, Eye, EyeOff, GripVertical, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
@@ -174,6 +175,46 @@ function PlaybackField({
|
||||
);
|
||||
}
|
||||
|
||||
/** PlaybackField for open language values, with the shared "Other…" entry. */
|
||||
function LanguageField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled,
|
||||
hint,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
options: readonly SettingOption[];
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const controlId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={controlId} className="text-muted-foreground text-xs font-medium">
|
||||
{label}
|
||||
</Label>
|
||||
<LanguageSelect
|
||||
id={controlId}
|
||||
value={value}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
className="w-full"
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{children}
|
||||
</LanguageSelect>
|
||||
{hint && <p className="text-muted-foreground/70 text-[11px] leading-tight">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableLibraryCard({ id, children }: { id: number; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
@@ -371,9 +412,10 @@ function LibraryCard({
|
||||
automatically.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<PlaybackField
|
||||
<LanguageField
|
||||
label="Spoken language"
|
||||
value={editorState.audioLanguage}
|
||||
options={audioLanguageOptions}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultLanguageHint(profileDefaults.audioLanguage)}
|
||||
onChange={(value) => handlePlaybackChange("audioLanguage", value)}
|
||||
@@ -381,16 +423,12 @@ function LibraryCard({
|
||||
<SelectItem value={INHERIT_VALUE}>
|
||||
{buildInheritedLanguageLabel(profileDefaults.audioLanguage ?? "")}
|
||||
</SelectItem>
|
||||
{audioLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</PlaybackField>
|
||||
</LanguageField>
|
||||
|
||||
<PlaybackField
|
||||
<LanguageField
|
||||
label="Subtitle language"
|
||||
value={editorState.subtitleLanguage}
|
||||
options={subtitleLanguageOptions}
|
||||
disabled={playbackPending}
|
||||
hint={getProfileDefaultSubtitleLanguageHint(profileDefaults.subtitleLanguage)}
|
||||
onChange={(value) => handlePlaybackChange("subtitleLanguage", value)}
|
||||
@@ -399,12 +437,7 @@ function LibraryCard({
|
||||
{buildInheritedSubtitleLanguageLabel(profileDefaults.subtitleLanguage ?? "")}
|
||||
</SelectItem>
|
||||
<SelectItem value={NONE_VALUE}>None</SelectItem>
|
||||
{subtitleLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</PlaybackField>
|
||||
</LanguageField>
|
||||
|
||||
<PlaybackField
|
||||
label="Subtitle behavior"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { LanguageSelect } from "@/components/settings/LanguageSelect";
|
||||
import { MetadataLanguageSetting } from "@/components/settings/MetadataLanguageSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -236,27 +237,22 @@ export default function PlaybackSettings() {
|
||||
label="Spoken language"
|
||||
description="Prefer a spoken language for this profile when multiple tracks are available."
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
<div className="w-full sm:w-[220px]">
|
||||
<LanguageSelect
|
||||
id={id}
|
||||
value={audioLanguage ?? "none"}
|
||||
options={audioLanguageOptions}
|
||||
disabled={pending}
|
||||
placeholder="No preference"
|
||||
className="w-full"
|
||||
onValueChange={(value) =>
|
||||
// The contract spells "no preference" as null, where the
|
||||
// legacy profile column spelled it as the empty string.
|
||||
saveValue(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE, value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
|
||||
<SelectValue placeholder="No preference" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No preference</SelectItem>
|
||||
{audioLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectItem value="none">No preference</SelectItem>
|
||||
</LanguageSelect>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { LanguageSelect } from "@/components/settings/LanguageSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
@@ -234,34 +235,27 @@ export default function SubtitleAppearanceSettings() {
|
||||
description="Pick a subtitle language or leave subtitles off by default."
|
||||
>
|
||||
{({ id, descriptionId }) => (
|
||||
<Select
|
||||
value={subtitleLanguage || "none"}
|
||||
onValueChange={(value) =>
|
||||
// The contract spells "no preference" as null, not the empty
|
||||
// string the legacy profile column used.
|
||||
saveBehavior(
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
value === "none" ? null : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
<div className="w-full sm:w-[220px]">
|
||||
<LanguageSelect
|
||||
id={id}
|
||||
aria-describedby={descriptionId}
|
||||
className="w-full sm:w-[220px]"
|
||||
value={subtitleLanguage || "none"}
|
||||
options={subtitleLanguageOptions}
|
||||
disabled={behaviorPending}
|
||||
placeholder="None"
|
||||
className="w-full"
|
||||
onValueChange={(value) =>
|
||||
// The contract spells "no preference" as null, not the empty
|
||||
// string the legacy profile column used.
|
||||
saveBehavior(
|
||||
SETTING_KEYS.PLAYBACK_SUBTITLE_LANGUAGE,
|
||||
value === "none" ? null : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{subtitleLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</LanguageSelect>
|
||||
</div>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@@ -97,7 +97,11 @@ export const COMPATIBILITY_QUALITY_ID = "compatibility";
|
||||
// Quality-menu bitrate label: Mbps with collapsed integers ("8 Mbps", not
|
||||
// "8.0 Mbps") — a deliberately different display policy than the canonical
|
||||
// formatBitrate/formatMbpsFromKbps in @/lib/mediaFormat.
|
||||
function formatQualityBitrate(kbps: number): string {
|
||||
//
|
||||
// Exported so the device-settings bandwidth cap reads the same as the in-player
|
||||
// switcher; the two pick from the same ladder and should not disagree about how
|
||||
// to spell a number.
|
||||
export function formatQualityBitrate(kbps: number): string {
|
||||
if (kbps >= 1000) {
|
||||
const mbps = kbps / 1000;
|
||||
return mbps % 1 === 0 ? `${mbps} Mbps` : `${mbps.toFixed(1)} Mbps`;
|
||||
|
||||
Reference in New Issue
Block a user