diff --git a/frontend/src/components/PlexPlayerSettings.dom.test.tsx b/frontend/src/components/PlexPlayerSettings.dom.test.tsx new file mode 100644 index 00000000..a1403f57 --- /dev/null +++ b/frontend/src/components/PlexPlayerSettings.dom.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// +// E28 review D5 + L5 + D6: the Plex player's settings panel. Its section switch was a row of +// `aria-pressed` buttons (now an ARIA tab list on `ui/Tabs`), its rows sat under the 44 touch target, +// its on/off rows were a hand-laid label (now `CheckRow`), and its sliders and colour wells had no +// accessible name — the visible label beside them was not tied to them. +import { useRef, useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k }), +})); + +import { PlexPlayerSettings } from "./PlexPlayerSettings"; +import { DEFAULT_PREFS, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs"; + +afterEach(cleanup); + +type Tab = "sync" | "playback" | "subtitle" | "clock"; +const TABS: { key: Tab; label: string }[] = [ + { key: "sync", label: "Sync" }, + { key: "playback", label: "Playback" }, + { key: "subtitle", label: "Subtitles" }, + { key: "clock", label: "Clock" }, +]; + +function Harness({ start = "sync" as Tab, prefs: initial = {} as Partial }) { + const [tab, setTab] = useState(start); + const [prefs, setPrefs] = useState({ ...DEFAULT_PREFS, ...initial }); + const menuRef = useRef(null); + const back = useRef(null); + return ( + setPrefs((cur) => ({ ...cur, ...p }))} + changeAudioOffset={() => {}} + now={new Date(2026, 8, 27, 12, 0)} + /> + ); +} + +const tab = (name: string) => screen.getByRole("tab", { name }); + +describe("the Plex player's settings panel", () => { + it("switches its sections as a tab list, the focus on the selected tab", () => { + render(); + expect(screen.getByRole("tablist")).toBeTruthy(); + expect(document.activeElement).toBe(tab("Subtitles")); + fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" }); + expect(tab("Clock").getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("tabpanel", { name: "Clock" })).toBeTruthy(); + }); + + it("gives every slider and colour well the name its row shows", () => { + render(); + for (const name of ["plex.player.subSize", "plex.player.subPos", "plex.player.subShadow"]) + expect(screen.getByRole("slider", { name })).toBeTruthy(); + for (const name of ["plex.player.subColor", "plex.player.subBg", "plex.player.subShadowColor"]) + expect(screen.getByLabelText(name).getAttribute("type")).toBe("color"); + fireEvent.click(tab("Clock")); + expect(screen.getByLabelText("plex.player.clockColor").getAttribute("type")).toBe("color"); + }); + + it("lays its on/off rows on CheckRow and its buttons at the touch target", () => { + render(); + const row = screen.getByRole("checkbox", { name: "plex.player.clockShow" }).closest("label")!; + expect(row.className).toContain("pointer-coarse:min-h-(--touch-target)"); + for (const b of screen.getAllByRole("button")) expect(b.className).toContain("touch-target"); + for (const t of screen.getAllByRole("tab")) expect(t.className).toContain("touch-target"); + fireEvent.click(tab("Subtitles")); + const none = screen.getByRole("checkbox", { name: "plex.player.subBgNone" }).closest("label")!; + expect(none.className).toContain("pointer-coarse:min-h-(--touch-target)"); + }); +}); diff --git a/frontend/src/components/PlexPlayerSettings.tsx b/frontend/src/components/PlexPlayerSettings.tsx index 9b6ed5b9..82fc8a43 100644 --- a/frontend/src/components/PlexPlayerSettings.tsx +++ b/frontend/src/components/PlexPlayerSettings.tsx @@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next"; import { Slider, Toggle } from "./plexPlayerControls"; import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs"; import { Pressable } from "./ui/Pressable"; -import { ColorInput, Checkbox } from "./ui/NativeControls"; +import { ColorInput, CheckRow } from "./ui/NativeControls"; +import Tabs from "./ui/Tabs"; +import { useTabPanel } from "./ui/tabPanel"; type SettingsTab = "sync" | "playback" | "subtitle" | "clock"; @@ -41,15 +43,18 @@ export function PlexPlayerSettings(props: { now, } = props; const { t } = useTranslation(); - // Into the panel on open (its first tab), and back to the ⋯ on close. The cleanup runs while the + const panel = useTabPanel(); + // Into the panel on open (its selected tab), and back to the ⋯ on close. The cleanup runs while the // panel is still in the DOM, so "the focus was inside" is read before its removal drops it to // ; focus the reader moved elsewhere (a click on the video) stays where they put it. useLayoutEffect(() => { - const panel = menuRef.current; + const root = menuRef.current; const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger - panel?.querySelector("button")?.focus({ preventScroll: true }); + root + ?.querySelector('[role="tab"][aria-selected="true"]') + ?.focus({ preventScroll: true }); return () => { - if (panel?.contains(document.activeElement)) + if (root?.contains(document.activeElement)) queueMicrotask(() => back?.focus({ preventScroll: true })); }; }, [menuRef, returnFocusTo]); @@ -62,218 +67,225 @@ export function PlexPlayerSettings(props: { onClick={(e) => e.stopPropagation()} className="border border-white/15 bg-media-chrome/95 absolute bottom-full right-0 z-menu mb-2 w-80 rounded-xl p-2 text-sm text-white shadow-2xl" > -
- {settingsTabs.map((tab) => ( - setSettingsTab(tab.key)} - className={`flex-1 whitespace-nowrap rounded px-2 py-1 text-2xs ${ - activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10" - }`} - > - {tab.label} - - ))} -
- - {activeTab === "sync" && ( - <> - patchPrefs({ subOffset: v })} - onReset={() => patchPrefs({ subOffset: 0 })} - /> - changeAudioOffset(0)} - /> - - )} - - {activeTab === "playback" && ( - <> - patchPrefs({ seekStep: v })} - /> - patchPrefs({ fineSeekStep: v })} - /> - patchPrefs({ barAutoHide: v })} - /> - patchPrefs({ autoSkipIntro: v })} - /> - patchPrefs({ autoSkipCredits: v })} - /> - patchPrefs({ upNext: v })} - /> - {(prefs.autoSkipIntro || prefs.autoSkipCredits) && ( + ({ id: tab.key, label: tab.label }))} + active={activeTab} + onChange={(id) => setSettingsTab(id as SettingsTab)} + className="mb-1.5 flex gap-1" + /> +
+ {activeTab === "sync" && ( + <> patchPrefs({ subOffset: v })} + onReset={() => patchPrefs({ subOffset: 0 })} + /> + changeAudioOffset(0)} + /> + + )} + + {activeTab === "playback" && ( + <> + patchPrefs({ autoSkipDelay: v })} + onChange={(v) => patchPrefs({ seekStep: v })} /> - )} - - )} + patchPrefs({ fineSeekStep: v })} + /> + patchPrefs({ barAutoHide: v })} + /> + patchPrefs({ autoSkipIntro: v })} + /> + patchPrefs({ autoSkipCredits: v })} + /> + patchPrefs({ upNext: v })} + /> + {(prefs.autoSkipIntro || prefs.autoSkipCredits) && ( + patchPrefs({ autoSkipDelay: v })} + /> + )} + + )} - {activeTab === "subtitle" && ( - <> - patchPrefs({ subSize: v })} - /> - patchPrefs({ subPos: v })} - /> -
- {t("plex.player.subColor")} - patchPrefs({ subColor: v })} /> -
-
- {t("plex.player.subBg")} -
-
- patchPrefs({ subShadow: v })} - /> - {prefs.subShadow > 0 && ( + patchPrefs({ subShadow: v })} + /> + {prefs.subShadow > 0 && ( +
+ + {t("plex.player.subShadowColor")} + + patchPrefs({ subShadowColor: v })} + /> +
+ )} + + )} + + {activeTab === "clock" && ( + <> + patchPrefs({ clockShow: v })} + /> + patchPrefs({ clock24h: v })} + /> + patchPrefs({ clockSize: v })} + />
- - {t("plex.player.subShadowColor")} - + {t("plex.player.clockColor")} patchPrefs({ subShadowColor: v })} + value={prefs.clockColor} + aria-label={t("plex.player.clockColor")} + onChange={(v) => patchPrefs({ clockColor: v })} />
- )} - - )} - - {activeTab === "clock" && ( - <> - patchPrefs({ clockShow: v })} - /> - patchPrefs({ clock24h: v })} - /> - patchPrefs({ clockSize: v })} - /> -
- {t("plex.player.clockColor")} - patchPrefs({ clockColor: v })} /> -
- patchPrefs({ clockDate: v })} - /> - {prefs.clockDate && ( -
- {(["hu", "en"] as const).map((st) => ( - patchPrefs({ clockDateStyle: st })} - className={`flex-1 rounded px-2 py-1 text-2xs ${ - prefs.clockDateStyle === st - ? "bg-white/15 text-accent" - : "text-white/70 hover:bg-white/10" - }`} - > - {clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date} - - ))} -
- )} - - )} + patchPrefs({ clockDate: v })} + /> + {prefs.clockDate && ( +
+ {(["hu", "en"] as const).map((st) => ( + patchPrefs({ clockDateStyle: st })} + className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${ + prefs.clockDateStyle === st + ? "bg-white/15 text-accent" + : "text-white/70 hover:bg-white/10" + }`} + > + {clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date} + + ))} +
+ )} + + )} +
); } diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index 34d56888..04bb684e 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -2,7 +2,7 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { RotateCcw } from "lucide-react"; import { IconButton } from "./ui/IconButton"; -import { Slider as Range, Checkbox } from "./ui/NativeControls"; +import { Slider as Range, CheckRow } from "./ui/NativeControls"; // --- settings-panel building blocks ------------------------------------------------------------ export function PanelHead({ children }: { children: ReactNode }) { @@ -51,7 +51,14 @@ export function Slider({ )} - onChange(v)} /> + onChange(v)} + /> ); } @@ -64,11 +71,15 @@ export function Toggle({ checked: boolean; onChange: (v: boolean) => void; }) { + // The whole row toggles and grows to 44 on touch (CheckRow); the box sits at the row's end. return ( - + + {label} + ); } diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index 49d6a6a2..d615036d 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -15,6 +15,8 @@ import CountBadge from "./CountBadge"; // ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the // vertical rail is a row below `sm`), Home/End jump to the ends. The panel it controls is wired with // `useTabPanel`: the tabs point at it (`aria-controls`) and it is labelled by the selected tab. +// `tone="media"` is the same list painted for the player's dark chrome (the Plex player's settings +// panel), which looks the same on every theme because the picture under it does. export interface TabDef { id: string; @@ -32,6 +34,7 @@ export default function Tabs({ className, idBase, label, + tone, }: { tabs: TabDef[]; active: string; @@ -45,6 +48,8 @@ export default function Tabs({ idBase?: string; /** The tab list's accessible name, when the page heading beside it does not say what it switches. */ label?: string; + /** Painted for the media chrome instead of the page (equal-width, white on black). */ + tone?: "media"; }) { const ownBase = useId(); const base = idBase ?? ownBase; @@ -100,11 +105,17 @@ export default function Tabs({ tabIndex={tb.id === stop ? 0 : -1} data-testid={tb.testId} onClick={() => onChange(tb.id)} - className={`pill touch-target items-center rounded-lg px-3 text-sm ${ - vertical - ? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left" - : "inline-flex gap-1.5 py-1.5" - } ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`} + className={ + tone === "media" + ? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${ + on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10" + }` + : `pill touch-target items-center rounded-lg px-3 text-sm ${ + vertical + ? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left" + : "inline-flex gap-1.5 py-1.5" + } ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}` + } > {Icon && } {tb.label} diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index a8537dd6..1b1d270a 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -27,7 +27,7 @@ const PLAIN: Record = { "PlaylistsRail.tsx": [1, "the load-error retry"], "PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], "PlexInfo.tsx": [1, "the rating filter (a number)"], - "PlexPlayerSettings.tsx": [2, "setting options"], + "PlexPlayerSettings.tsx": [1, "the clock's date-style options"], "PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"], "PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"], "PlexSearchBox.tsx": [1, "a listbox option"],