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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user