From cba992379c13e4cdfee15805ebcd05fb26189a75 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 13 Aug 2026 03:20:01 +0200 Subject: [PATCH 1/3] refactor(player): share the volume core across both players + extract PlexPlayerSettings (R12 S5 frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/PlexPlayer.tsx | 253 +---------------- .../src/components/PlexPlayerSettings.tsx | 266 ++++++++++++++++++ frontend/src/lib/playerVolume.test.ts | 41 ++- frontend/src/lib/playerVolume.ts | 59 +++- frontend/src/lib/plexPlayerHooks.ts | 19 +- 5 files changed, 377 insertions(+), 261 deletions(-) create mode 100644 frontend/src/components/PlexPlayerSettings.tsx diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 437bad27..108ca1a3 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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) { {menuOpen && ( -
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" - > -
- {settingsTabs.map((tab) => ( - - ))} -
- - {activeTab === "sync" && ( - <> - patchPrefs({ subOffset: v })} - onReset={() => patchPrefs({ subOffset: 0 })} - /> - changeAudioOffset(0)} - /> - - )} - - {activeTab === "playback" && ( - <> - patchPrefs({ seekStep: v })} - /> - patchPrefs({ fineSeekStep: v })} - /> - patchPrefs({ barAutoHide: v })} - /> - patchPrefs({ autoSkipIntro: v })} - /> - patchPrefs({ autoSkipCredits: v })} - /> - {(prefs.autoSkipIntro || prefs.autoSkipCredits) && ( - patchPrefs({ autoSkipDelay: v })} - /> - )} - - )} - - {activeTab === "subtitle" && ( - <> - patchPrefs({ subSize: v })} - /> - patchPrefs({ subPos: v })} - /> -
- - {t("plex.player.subColor")} - - patchPrefs({ subColor: e.target.value })} - className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" - /> -
-
- - {t("plex.player.subBg")} - -
- - {prefs.subBg !== "transparent" && ( - patchPrefs({ subBg: e.target.value })} - className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" - /> - )} -
-
- patchPrefs({ subShadow: v })} - /> - {prefs.subShadow > 0 && ( -
- - {t("plex.player.subShadowColor")} - - patchPrefs({ subShadowColor: e.target.value })} - className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" - /> -
- )} - - )} - - {activeTab === "clock" && ( - <> - patchPrefs({ clockShow: v })} - /> - patchPrefs({ clock24h: v })} - /> - patchPrefs({ clockSize: v })} - /> -
- - {t("plex.player.clockColor")} - - patchPrefs({ clockColor: e.target.value })} - className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" - /> -
- patchPrefs({ clockDate: v })} - /> - {prefs.clockDate && ( -
- {(["hu", "en"] as const).map((st) => ( - - ))} -
- )} - - )} -
+ )} )} diff --git a/frontend/src/components/PlexPlayerSettings.tsx b/frontend/src/components/PlexPlayerSettings.tsx new file mode 100644 index 00000000..c96ab238 --- /dev/null +++ b/frontend/src/components/PlexPlayerSettings.tsx @@ -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.` + `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; + settingsTabs: { key: SettingsTab; label: string }[]; + activeTab: SettingsTab; + setSettingsTab: (tab: SettingsTab) => void; + prefs: PlexPlayerPrefs; + patchPrefs: (patch: Partial) => void; + changeAudioOffset: (v: number) => void; + now: Date; +}) { + const { + menuRef, + settingsTabs, + activeTab, + setSettingsTab, + prefs, + patchPrefs, + changeAudioOffset, + now, + } = props; + const { t } = useTranslation(); + return ( +
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" + > +
+ {settingsTabs.map((tab) => ( + + ))} +
+ + {activeTab === "sync" && ( + <> + patchPrefs({ subOffset: v })} + onReset={() => patchPrefs({ subOffset: 0 })} + /> + changeAudioOffset(0)} + /> + + )} + + {activeTab === "playback" && ( + <> + patchPrefs({ seekStep: v })} + /> + patchPrefs({ fineSeekStep: v })} + /> + patchPrefs({ barAutoHide: v })} + /> + patchPrefs({ autoSkipIntro: v })} + /> + patchPrefs({ autoSkipCredits: v })} + /> + {(prefs.autoSkipIntro || prefs.autoSkipCredits) && ( + patchPrefs({ autoSkipDelay: v })} + /> + )} + + )} + + {activeTab === "subtitle" && ( + <> + patchPrefs({ subSize: v })} + /> + patchPrefs({ subPos: v })} + /> +
+ {t("plex.player.subColor")} + patchPrefs({ subColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" + /> +
+
+ {t("plex.player.subBg")} +
+ + {prefs.subBg !== "transparent" && ( + patchPrefs({ subBg: e.target.value })} + className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" + /> + )} +
+
+ patchPrefs({ subShadow: v })} + /> + {prefs.subShadow > 0 && ( +
+ + {t("plex.player.subShadowColor")} + + patchPrefs({ subShadowColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" + /> +
+ )} + + )} + + {activeTab === "clock" && ( + <> + patchPrefs({ clockShow: v })} + /> + patchPrefs({ clock24h: v })} + /> + patchPrefs({ clockSize: v })} + /> +
+ {t("plex.player.clockColor")} + patchPrefs({ clockColor: e.target.value })} + className="h-6 w-10 cursor-pointer rounded-sm bg-transparent" + /> +
+ patchPrefs({ clockDate: v })} + /> + {prefs.clockDate && ( +
+ {(["hu", "en"] as const).map((st) => ( + + ))} +
+ )} + + )} +
+ ); +} diff --git a/frontend/src/lib/playerVolume.test.ts b/frontend/src/lib/playerVolume.test.ts index 606fa739..5a941757 100644 --- a/frontend/src/lib/playerVolume.test.ts +++ b/frontend/src/lib/playerVolume.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume"; +import { + DEFAULT_VOLUME, + PLEX_SCALE, + YT_SCALE, + normalizeVolume, + normalizeVolumeIn, + stepVolume, + stepVolumeIn, +} from "./playerVolume"; describe("normalizeVolume", () => { it("keeps a valid level", () => { @@ -56,3 +64,34 @@ describe("stepVolume", () => { expect(stepVolume(NaN, { delta: -5, fallback: 90 })).toBe(85); }); }); + +describe("scale-generic core (shared by both players)", () => { + it("keeps 0..1 floats WITHOUT rounding on the Plex scale", () => { + // PLEX_SCALE.round is false — the HTML5