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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user