They were hand-built divs: no menu roles, no arrow keys, and Escape left the focus on <body>. Both run on the popover engine now (media-tone rows), and the tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import { useLayoutEffect, type RefObject } from "react";
|
|
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";
|
|
|
|
type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
|
|
|
|
// The gear/tuning panel of the Plex player — a tabbed (sync / playback / subtitle / clock) sheet of
|
|
// Slider/Toggle rows over the per-account prefs. Extracted verbatim from PlexPlayer to keep that
|
|
// component focused on the media engine; every row is still `prefs.<field>` + `patchPrefs`, so the
|
|
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
|
|
// only for the clock-date-style preview buttons.
|
|
export function PlexPlayerSettings(props: {
|
|
// `| null` is not optional under React 19's types: `useRef<T>(null)` is now `RefObject<T | null>`,
|
|
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
|
|
// about that and 18's types let it.
|
|
menuRef: RefObject<HTMLDivElement | null>;
|
|
/** The ⋯ that opened the panel: the focus goes back to it when the panel closes with the focus
|
|
* inside — by Escape, Back or the ⋯ itself — instead of falling to <body> (E28 review D3). */
|
|
returnFocusTo: RefObject<HTMLElement | null>;
|
|
settingsTabs: { key: SettingsTab; label: string }[];
|
|
activeTab: SettingsTab;
|
|
setSettingsTab: (tab: SettingsTab) => void;
|
|
prefs: PlexPlayerPrefs;
|
|
patchPrefs: (patch: Partial<PlexPlayerPrefs>) => void;
|
|
changeAudioOffset: (v: number) => void;
|
|
now: Date;
|
|
}) {
|
|
const {
|
|
menuRef,
|
|
returnFocusTo,
|
|
settingsTabs,
|
|
activeTab,
|
|
setSettingsTab,
|
|
prefs,
|
|
patchPrefs,
|
|
changeAudioOffset,
|
|
now,
|
|
} = props;
|
|
const { t } = useTranslation();
|
|
// Into the panel on open (its first 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
|
|
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
|
|
useLayoutEffect(() => {
|
|
const panel = menuRef.current;
|
|
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
|
|
panel?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
|
|
return () => {
|
|
if (panel?.contains(document.activeElement))
|
|
queueMicrotask(() => back?.focus({ preventScroll: true }));
|
|
};
|
|
}, [menuRef, returnFocusTo]);
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
role="dialog"
|
|
aria-label={t("plex.player.settings")}
|
|
onWheel={(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"
|
|
>
|
|
<div className="mb-1.5 flex gap-1">
|
|
{settingsTabs.map((tab) => (
|
|
<Pressable
|
|
key={tab.key}
|
|
align="center"
|
|
aria-pressed={activeTab === tab.key}
|
|
onClick={() => 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}
|
|
</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
|
|
label={t("plex.player.autoSkipDelay")}
|
|
value={prefs.autoSkipDelay}
|
|
min={0}
|
|
max={10}
|
|
step={1}
|
|
suffix="s"
|
|
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "subtitle" && (
|
|
<>
|
|
<Slider
|
|
label={t("plex.player.subSize")}
|
|
value={prefs.subSize}
|
|
min={50}
|
|
max={250}
|
|
step={5}
|
|
suffix="%"
|
|
onChange={(v) => patchPrefs({ subSize: v })}
|
|
/>
|
|
<Slider
|
|
label={t("plex.player.subPos")}
|
|
value={prefs.subPos}
|
|
min={50}
|
|
max={95}
|
|
step={1}
|
|
suffix="%"
|
|
onChange={(v) => patchPrefs({ subPos: v })}
|
|
/>
|
|
<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>
|
|
<ColorInput value={prefs.subColor} onChange={(v) => patchPrefs({ subColor: v })} />
|
|
</div>
|
|
<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">
|
|
<label className="flex items-center gap-1 text-2xs text-white/70">
|
|
<Checkbox
|
|
checked={prefs.subBg === "transparent"}
|
|
onChange={(checked) =>
|
|
patchPrefs({
|
|
subBg: checked ? "transparent" : "#000000",
|
|
})
|
|
}
|
|
/>
|
|
{t("plex.player.subBgNone")}
|
|
</label>
|
|
{prefs.subBg !== "transparent" && (
|
|
<ColorInput value={prefs.subBg} onChange={(v) => patchPrefs({ subBg: v })} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Slider
|
|
label={t("plex.player.subShadow")}
|
|
value={prefs.subShadow}
|
|
min={0}
|
|
max={10}
|
|
step={1}
|
|
suffix="px"
|
|
onChange={(v) => patchPrefs({ subShadow: v })}
|
|
/>
|
|
{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}
|
|
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">
|
|
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
|
|
<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>
|
|
);
|
|
}
|