refactor(player): share the volume core across both players + extract PlexPlayerSettings (R12 S5 frontend)

Volume-consolidation (conservative — no behaviour or prefs-format change):
- playerVolume.ts is now scale-generic (a VolumeScale parameterises range /
  default / integer rounding). YT_SCALE (0-100 ints) and PLEX_SCALE (0..1
  float) bind the same tested core; the YouTube wrappers keep the original API
  so the existing tests are unchanged.
- The Plex usePlayerVolume hook drops its bespoke clamp01 arithmetic for the
  shared normalizeVolumeIn / stepVolumeIn at PLEX_SCALE. Side effect (a latent
  bug fix): a corrupt persisted volume now falls back to the default instead of
  a bare clamp silently muting it. The separate .muted flag stays Plex-only.
- clamp01 stays for the seek-bar fraction (its non-volume use).
- Tests: the generic core is covered at both scales (float no-round, corrupt ->
  default, step/clamp) — the Plex-side arithmetic finally has unit coverage.

PlexPlayerSettings extraction (verbatim JSX move, behaviour-equivalent):
- The ~240-line gear/tuning panel (sync / playback / subtitle / clock tabs)
  moves out of PlexPlayer into PlexPlayerSettings.tsx (a pure prefs+patchPrefs
  view owning no state). PlexPlayer shrinks 1402 -> 1173 lines; Slider/Toggle
  imports it no longer uses are dropped.

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234). Dev
server smoke: app builds + loads clean, no console errors. The interactive
settings panel needs a playing Plex video (unreachable on localdev) -> owed to
the user's UAT.
This commit is contained in:
2026-08-13 03:20:01 +02:00
parent f3fce67e4c
commit cba992379c
5 changed files with 377 additions and 261 deletions
+12 -241
View File
@@ -36,7 +36,8 @@ import {
subShadowCss,
type PlexPlayerPrefs,
} from "../lib/plexPlayerPrefs";
import { Ctrl, PanelHead, Slider, Toggle } from "./plexPlayerControls";
import { Ctrl, PanelHead } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import {
useAutoSkip,
useControlsVisibility,
@@ -1048,246 +1049,16 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
<Settings className="w-5 h-5" />
</button>
{menuOpen && (
<div
ref={menuRef}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 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) => (
<button
key={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}
</button>
))}
</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 })}
/>
{(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>
<input
type="color"
value={prefs.subColor}
onChange={(e) => patchPrefs({ subColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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">
<input
type="checkbox"
checked={prefs.subBg === "transparent"}
onChange={(e) =>
patchPrefs({
subBg: e.target.checked ? "transparent" : "#000000",
})
}
className="accent-accent"
/>
{t("plex.player.subBgNone")}
</label>
{prefs.subBg !== "transparent" && (
<input
type="color"
value={prefs.subBg}
onChange={(e) => patchPrefs({ subBg: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
)}
</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>
<input
type="color"
value={prefs.subShadowColor}
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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>
<input
type="color"
value={prefs.clockColor}
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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) => (
<button
key={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
}
</button>
))}
</div>
)}
</>
)}
</div>
<PlexPlayerSettings
menuRef={menuRef}
settingsTabs={settingsTabs}
activeTab={activeTab}
setSettingsTab={setSettingsTab}
prefs={prefs}
patchPrefs={patchPrefs}
changeAudioOffset={changeAudioOffset}
now={now}
/>
)}
</div>
)}
@@ -0,0 +1,266 @@
import { type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Slider, Toggle } from "./plexPlayerControls";
import { clockParts, type PlexPlayerPrefs } from "../lib/plexPlayerPrefs";
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: {
menuRef: RefObject<HTMLDivElement>;
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,
settingsTabs,
activeTab,
setSettingsTab,
prefs,
patchPrefs,
changeAudioOffset,
now,
} = props;
const { t } = useTranslation();
return (
<div
ref={menuRef}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="border border-white/15 bg-neutral-900/95 absolute bottom-full right-0 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) => (
<button
key={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}
</button>
))}
</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 })}
/>
{(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>
<input
type="color"
value={prefs.subColor}
onChange={(e) => patchPrefs({ subColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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">
<input
type="checkbox"
checked={prefs.subBg === "transparent"}
onChange={(e) =>
patchPrefs({
subBg: e.target.checked ? "transparent" : "#000000",
})
}
className="accent-accent"
/>
{t("plex.player.subBgNone")}
</label>
{prefs.subBg !== "transparent" && (
<input
type="color"
value={prefs.subBg}
onChange={(e) => patchPrefs({ subBg: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
)}
</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>
<input
type="color"
value={prefs.subShadowColor}
onChange={(e) => patchPrefs({ subShadowColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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>
<input
type="color"
value={prefs.clockColor}
onChange={(e) => patchPrefs({ clockColor: e.target.value })}
className="h-6 w-10 cursor-pointer rounded-sm bg-transparent"
/>
</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) => (
<button
key={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}
</button>
))}
</div>
)}
</>
)}
</div>
);
}