fix(plex): the player's settings panel is a tab list with named, touch-sized rows
- Its section switch is `ui/Tabs` (new `tone="media"` paint for the dark chrome) with a wired tabpanel; the panel opens with the focus on the selected tab. - On/off rows are `CheckRow` (the whole row, 44 on touch), the "no background" checkbox too. - Every slider and the four colour wells carry the name their row shows. - The clock's date-style buttons reach the touch target.
This commit is contained in:
@@ -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<typeof import("react-i18next")>()),
|
||||||
|
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<PlexPlayerPrefs> }) {
|
||||||
|
const [tab, setTab] = useState<Tab>(start);
|
||||||
|
const [prefs, setPrefs] = useState<PlexPlayerPrefs>({ ...DEFAULT_PREFS, ...initial });
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const back = useRef<HTMLElement>(null);
|
||||||
|
return (
|
||||||
|
<PlexPlayerSettings
|
||||||
|
menuRef={menuRef}
|
||||||
|
returnFocusTo={back}
|
||||||
|
settingsTabs={TABS}
|
||||||
|
activeTab={tab}
|
||||||
|
setSettingsTab={setTab}
|
||||||
|
prefs={prefs}
|
||||||
|
patchPrefs={(p) => 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(<Harness start="subtitle" />);
|
||||||
|
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(<Harness start="subtitle" prefs={{ subBg: "#000000", subShadow: 2 }} />);
|
||||||
|
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(<Harness start="clock" prefs={{ clockDate: true }} />);
|
||||||
|
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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Slider, Toggle } from "./plexPlayerControls";
|
import { Slider, Toggle } from "./plexPlayerControls";
|
||||||
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
|
||||||
import { Pressable } from "./ui/Pressable";
|
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";
|
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
||||||
|
|
||||||
@@ -41,15 +43,18 @@ export function PlexPlayerSettings(props: {
|
|||||||
now,
|
now,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
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
|
// panel is still in the DOM, so "the focus was inside" is read before its removal drops it to
|
||||||
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
|
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const panel = menuRef.current;
|
const root = menuRef.current;
|
||||||
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
|
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
|
||||||
panel?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
|
root
|
||||||
|
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
return () => {
|
return () => {
|
||||||
if (panel?.contains(document.activeElement))
|
if (root?.contains(document.activeElement))
|
||||||
queueMicrotask(() => back?.focus({ preventScroll: true }));
|
queueMicrotask(() => back?.focus({ preventScroll: true }));
|
||||||
};
|
};
|
||||||
}, [menuRef, returnFocusTo]);
|
}, [menuRef, returnFocusTo]);
|
||||||
@@ -62,218 +67,225 @@ export function PlexPlayerSettings(props: {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex gap-1">
|
<Tabs
|
||||||
{settingsTabs.map((tab) => (
|
tone="media"
|
||||||
<Pressable
|
idBase={panel.idBase}
|
||||||
key={tab.key}
|
tabs={settingsTabs.map((tab) => ({ id: tab.key, label: tab.label }))}
|
||||||
align="center"
|
active={activeTab}
|
||||||
aria-pressed={activeTab === tab.key}
|
onChange={(id) => setSettingsTab(id as SettingsTab)}
|
||||||
onClick={() => setSettingsTab(tab.key)}
|
className="mb-1.5 flex gap-1"
|
||||||
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"
|
<div {...panel.panelProps(activeTab)}>
|
||||||
}`}
|
{activeTab === "sync" && (
|
||||||
>
|
<>
|
||||||
{tab.label}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "sync" && (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.subOffset")}
|
|
||||||
value={prefs.subOffset}
|
|
||||||
min={-10}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ subOffset: v })}
|
|
||||||
onReset={() => patchPrefs({ subOffset: 0 })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.audioOffset")}
|
|
||||||
value={prefs.audioOffset}
|
|
||||||
min={-5}
|
|
||||||
max={5}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={changeAudioOffset}
|
|
||||||
onReset={() => changeAudioOffset(0)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "playback" && (
|
|
||||||
<>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.seekStep")}
|
|
||||||
value={prefs.seekStep}
|
|
||||||
min={5}
|
|
||||||
max={30}
|
|
||||||
step={1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ seekStep: v })}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("plex.player.fineSeek")}
|
|
||||||
value={prefs.fineSeekStep}
|
|
||||||
min={0.1}
|
|
||||||
max={5}
|
|
||||||
step={0.1}
|
|
||||||
suffix="s"
|
|
||||||
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoHide")}
|
|
||||||
checked={prefs.barAutoHide}
|
|
||||||
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoSkipIntro")}
|
|
||||||
checked={prefs.autoSkipIntro}
|
|
||||||
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.autoSkipCredits")}
|
|
||||||
checked={prefs.autoSkipCredits}
|
|
||||||
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.upNext.pref")}
|
|
||||||
checked={prefs.upNext}
|
|
||||||
onChange={(v) => patchPrefs({ upNext: v })}
|
|
||||||
/>
|
|
||||||
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
|
||||||
<Slider
|
<Slider
|
||||||
label={t("plex.player.autoSkipDelay")}
|
label={t("plex.player.subOffset")}
|
||||||
value={prefs.autoSkipDelay}
|
value={prefs.subOffset}
|
||||||
min={0}
|
min={-10}
|
||||||
max={10}
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ subOffset: v })}
|
||||||
|
onReset={() => patchPrefs({ subOffset: 0 })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.audioOffset")}
|
||||||
|
value={prefs.audioOffset}
|
||||||
|
min={-5}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={changeAudioOffset}
|
||||||
|
onReset={() => changeAudioOffset(0)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "playback" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.seekStep")}
|
||||||
|
value={prefs.seekStep}
|
||||||
|
min={5}
|
||||||
|
max={30}
|
||||||
step={1}
|
step={1}
|
||||||
suffix="s"
|
suffix="s"
|
||||||
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
onChange={(v) => patchPrefs({ seekStep: v })}
|
||||||
/>
|
/>
|
||||||
)}
|
<Slider
|
||||||
</>
|
label={t("plex.player.fineSeek")}
|
||||||
)}
|
value={prefs.fineSeekStep}
|
||||||
|
min={0.1}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoHide")}
|
||||||
|
checked={prefs.barAutoHide}
|
||||||
|
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoSkipIntro")}
|
||||||
|
checked={prefs.autoSkipIntro}
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipIntro: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoSkipCredits")}
|
||||||
|
checked={prefs.autoSkipCredits}
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipCredits: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.upNext.pref")}
|
||||||
|
checked={prefs.upNext}
|
||||||
|
onChange={(v) => patchPrefs({ upNext: v })}
|
||||||
|
/>
|
||||||
|
{(prefs.autoSkipIntro || prefs.autoSkipCredits) && (
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.autoSkipDelay")}
|
||||||
|
value={prefs.autoSkipDelay}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === "subtitle" && (
|
{activeTab === "subtitle" && (
|
||||||
<>
|
<>
|
||||||
<Slider
|
<Slider
|
||||||
label={t("plex.player.subSize")}
|
label={t("plex.player.subSize")}
|
||||||
value={prefs.subSize}
|
value={prefs.subSize}
|
||||||
min={50}
|
min={50}
|
||||||
max={250}
|
max={250}
|
||||||
step={5}
|
step={5}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
onChange={(v) => patchPrefs({ subSize: v })}
|
onChange={(v) => patchPrefs({ subSize: v })}
|
||||||
/>
|
/>
|
||||||
<Slider
|
<Slider
|
||||||
label={t("plex.player.subPos")}
|
label={t("plex.player.subPos")}
|
||||||
value={prefs.subPos}
|
value={prefs.subPos}
|
||||||
min={50}
|
min={50}
|
||||||
max={95}
|
max={95}
|
||||||
step={1}
|
step={1}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
onChange={(v) => patchPrefs({ subPos: v })}
|
onChange={(v) => patchPrefs({ subPos: v })}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.subColor")}</span>
|
||||||
<ColorInput value={prefs.subColor} onChange={(v) => patchPrefs({ subColor: v })} />
|
<ColorInput
|
||||||
</div>
|
value={prefs.subColor}
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
aria-label={t("plex.player.subColor")}
|
||||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
|
onChange={(v) => patchPrefs({ subColor: v })}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<label className="flex items-center gap-1 text-2xs text-white/70">
|
</div>
|
||||||
<Checkbox
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.subBg")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckRow
|
||||||
checked={prefs.subBg === "transparent"}
|
checked={prefs.subBg === "transparent"}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
patchPrefs({
|
patchPrefs({
|
||||||
subBg: checked ? "transparent" : "#000000",
|
subBg: checked ? "transparent" : "#000000",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
className="gap-1 text-2xs text-white/70"
|
||||||
{t("plex.player.subBgNone")}
|
>
|
||||||
</label>
|
{t("plex.player.subBgNone")}
|
||||||
{prefs.subBg !== "transparent" && (
|
</CheckRow>
|
||||||
<ColorInput value={prefs.subBg} onChange={(v) => patchPrefs({ subBg: v })} />
|
{prefs.subBg !== "transparent" && (
|
||||||
)}
|
<ColorInput
|
||||||
|
value={prefs.subBg}
|
||||||
|
aria-label={t("plex.player.subBg")}
|
||||||
|
onChange={(v) => patchPrefs({ subBg: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Slider
|
||||||
<Slider
|
label={t("plex.player.subShadow")}
|
||||||
label={t("plex.player.subShadow")}
|
value={prefs.subShadow}
|
||||||
value={prefs.subShadow}
|
min={0}
|
||||||
min={0}
|
max={10}
|
||||||
max={10}
|
step={1}
|
||||||
step={1}
|
suffix="px"
|
||||||
suffix="px"
|
onChange={(v) => patchPrefs({ subShadow: v })}
|
||||||
onChange={(v) => patchPrefs({ subShadow: v })}
|
/>
|
||||||
/>
|
{prefs.subShadow > 0 && (
|
||||||
{prefs.subShadow > 0 && (
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[0.75rem] text-white/80">
|
||||||
|
{t("plex.player.subShadowColor")}
|
||||||
|
</span>
|
||||||
|
<ColorInput
|
||||||
|
value={prefs.subShadowColor}
|
||||||
|
aria-label={t("plex.player.subShadowColor")}
|
||||||
|
onChange={(v) => patchPrefs({ subShadowColor: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "clock" && (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.clockShow")}
|
||||||
|
checked={prefs.clockShow}
|
||||||
|
onChange={(v) => patchPrefs({ clockShow: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.clock24h")}
|
||||||
|
checked={prefs.clock24h}
|
||||||
|
onChange={(v) => patchPrefs({ clock24h: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.clockSize")}
|
||||||
|
value={prefs.clockSize}
|
||||||
|
min={70}
|
||||||
|
max={200}
|
||||||
|
step={5}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ clockSize: v })}
|
||||||
|
/>
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
<span className="text-[0.75rem] text-white/80">
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
|
||||||
{t("plex.player.subShadowColor")}
|
|
||||||
</span>
|
|
||||||
<ColorInput
|
<ColorInput
|
||||||
value={prefs.subShadowColor}
|
value={prefs.clockColor}
|
||||||
onChange={(v) => patchPrefs({ subShadowColor: v })}
|
aria-label={t("plex.player.clockColor")}
|
||||||
|
onChange={(v) => patchPrefs({ clockColor: v })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Toggle
|
||||||
</>
|
label={t("plex.player.clockDate")}
|
||||||
)}
|
checked={prefs.clockDate}
|
||||||
|
onChange={(v) => patchPrefs({ clockDate: v })}
|
||||||
{activeTab === "clock" && (
|
/>
|
||||||
<>
|
{prefs.clockDate && (
|
||||||
<Toggle
|
<div className="flex gap-1 px-1 py-1">
|
||||||
label={t("plex.player.clockShow")}
|
{(["hu", "en"] as const).map((st) => (
|
||||||
checked={prefs.clockShow}
|
<Pressable
|
||||||
onChange={(v) => patchPrefs({ clockShow: v })}
|
key={st}
|
||||||
/>
|
align="center"
|
||||||
<Toggle
|
aria-pressed={prefs.clockDateStyle === st}
|
||||||
label={t("plex.player.clock24h")}
|
onClick={() => patchPrefs({ clockDateStyle: st })}
|
||||||
checked={prefs.clock24h}
|
className={`touch-target flex-1 rounded px-2 py-1 text-2xs ${
|
||||||
onChange={(v) => patchPrefs({ clock24h: v })}
|
prefs.clockDateStyle === st
|
||||||
/>
|
? "bg-white/15 text-accent"
|
||||||
<Slider
|
: "text-white/70 hover:bg-white/10"
|
||||||
label={t("plex.player.clockSize")}
|
}`}
|
||||||
value={prefs.clockSize}
|
>
|
||||||
min={70}
|
{clockParts(now, { ...prefs, clockDate: true, clockDateStyle: st }).date}
|
||||||
max={200}
|
</Pressable>
|
||||||
step={5}
|
))}
|
||||||
suffix="%"
|
</div>
|
||||||
onChange={(v) => patchPrefs({ clockSize: v })}
|
)}
|
||||||
/>
|
</>
|
||||||
<div className="flex items-center justify-between px-1 py-1.5">
|
)}
|
||||||
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
|
</div>
|
||||||
<ColorInput value={prefs.clockColor} onChange={(v) => patchPrefs({ clockColor: v })} />
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
label={t("plex.player.clockDate")}
|
|
||||||
checked={prefs.clockDate}
|
|
||||||
onChange={(v) => patchPrefs({ clockDate: v })}
|
|
||||||
/>
|
|
||||||
{prefs.clockDate && (
|
|
||||||
<div className="flex gap-1 px-1 py-1">
|
|
||||||
{(["hu", "en"] as const).map((st) => (
|
|
||||||
<Pressable
|
|
||||||
key={st}
|
|
||||||
align="center"
|
|
||||||
aria-pressed={prefs.clockDateStyle === st}
|
|
||||||
onClick={() => 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}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RotateCcw } from "lucide-react";
|
import { RotateCcw } from "lucide-react";
|
||||||
import { IconButton } from "./ui/IconButton";
|
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 ------------------------------------------------------------
|
// --- settings-panel building blocks ------------------------------------------------------------
|
||||||
export function PanelHead({ children }: { children: ReactNode }) {
|
export function PanelHead({ children }: { children: ReactNode }) {
|
||||||
@@ -51,7 +51,14 @@ export function Slider({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Range min={min} max={max} step={step} value={value} onChange={(v) => onChange(v)} />
|
<Range
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(v) => onChange(v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,11 +71,15 @@ export function Toggle({
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// The whole row toggles and grows to 44 on touch (CheckRow); the box sits at the row's end.
|
||||||
return (
|
return (
|
||||||
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
|
<CheckRow
|
||||||
<span className="text-[0.75rem] text-white/80">{label}</span>
|
checked={checked}
|
||||||
<Checkbox checked={checked} onChange={onChange} />
|
onChange={onChange}
|
||||||
</label>
|
className="w-full flex-row-reverse justify-between px-1 py-1.5 text-[0.75rem] text-white/80"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</CheckRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// `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 {
|
export interface TabDef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +34,7 @@ export default function Tabs({
|
|||||||
className,
|
className,
|
||||||
idBase,
|
idBase,
|
||||||
label,
|
label,
|
||||||
|
tone,
|
||||||
}: {
|
}: {
|
||||||
tabs: TabDef[];
|
tabs: TabDef[];
|
||||||
active: string;
|
active: string;
|
||||||
@@ -45,6 +48,8 @@ export default function Tabs({
|
|||||||
idBase?: string;
|
idBase?: string;
|
||||||
/** The tab list's accessible name, when the page heading beside it does not say what it switches. */
|
/** The tab list's accessible name, when the page heading beside it does not say what it switches. */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Painted for the media chrome instead of the page (equal-width, white on black). */
|
||||||
|
tone?: "media";
|
||||||
}) {
|
}) {
|
||||||
const ownBase = useId();
|
const ownBase = useId();
|
||||||
const base = idBase ?? ownBase;
|
const base = idBase ?? ownBase;
|
||||||
@@ -100,11 +105,17 @@ export default function Tabs({
|
|||||||
tabIndex={tb.id === stop ? 0 : -1}
|
tabIndex={tb.id === stop ? 0 : -1}
|
||||||
data-testid={tb.testId}
|
data-testid={tb.testId}
|
||||||
onClick={() => onChange(tb.id)}
|
onClick={() => onChange(tb.id)}
|
||||||
className={`pill touch-target items-center rounded-lg px-3 text-sm ${
|
className={
|
||||||
vertical
|
tone === "media"
|
||||||
? "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"
|
? `touch-target flex-1 items-center justify-center whitespace-nowrap rounded px-2 py-1 text-2xs ${
|
||||||
: "inline-flex gap-1.5 py-1.5"
|
on ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||||
} ${on ? "font-medium shadow-md shadow-accent/20" : "state-layer"}`}
|
}`
|
||||||
|
: `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 && <Icon className="h-4 w-4 shrink-0" />}
|
{Icon && <Icon className="h-4 w-4 shrink-0" />}
|
||||||
{tb.label}
|
{tb.label}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"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"],
|
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||||
"PlexSearchBox.tsx": [1, "a listbox option"],
|
"PlexSearchBox.tsx": [1, "a listbox option"],
|
||||||
|
|||||||
Reference in New Issue
Block a user