Files
siftlode/frontend/src/components/PlexPlayerSettings.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

292 lines
11 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, CheckRow } from "./ui/NativeControls";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
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>. */
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();
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
// <body>; focus the reader moved elsewhere (a click on the video) stays where they put it.
useLayoutEffect(() => {
const root = menuRef.current;
const back = returnFocusTo.current; // the ⋯ outlives the panel: it is the panel's own trigger
root
?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]')
?.focus({ preventScroll: true });
return () => {
if (root?.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"
>
<Tabs
tone="media"
idBase={panel.idBase}
tabs={settingsTabs.map((tab) => ({ id: tab.key, label: tab.label }))}
active={activeTab}
onChange={(id) => setSettingsTab(id as SettingsTab)}
className="mb-1.5 flex gap-1"
/>
<div {...panel.panelProps(activeTab)}>
{activeTab === "sync" && (
<>
<Slider
label={t("plex.player.subOffset")}
value={prefs.subOffset}
min={-10}
max={10}
step={0.1}
seconds
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}
seconds
onChange={changeAudioOffset}
onReset={() => changeAudioOffset(0)}
/>
</>
)}
{activeTab === "playback" && (
<>
<Slider
label={t("plex.player.seekStep")}
value={prefs.seekStep}
min={5}
max={30}
step={1}
seconds
onChange={(v) => patchPrefs({ seekStep: v })}
/>
<Slider
label={t("plex.player.fineSeek")}
value={prefs.fineSeekStep}
min={0.1}
max={5}
step={0.1}
seconds
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}
seconds
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}
aria-label={t("plex.player.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">
<CheckRow
checked={prefs.subBg === "transparent"}
onChange={(checked) =>
patchPrefs({
subBg: checked ? "transparent" : "#000000",
})
}
className="gap-1 text-2xs text-white/70"
>
{t("plex.player.subBgNone")}
</CheckRow>
{prefs.subBg !== "transparent" && (
<ColorInput
value={prefs.subBg}
aria-label={t("plex.player.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}
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">
<span className="text-[0.75rem] text-white/80">{t("plex.player.clockColor")}</span>
<ColorInput
value={prefs.clockColor}
aria-label={t("plex.player.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={`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}
</Pressable>
))}
</div>
)}
</>
)}
</div>
</div>
);
}