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
+40 -1
View File
@@ -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 <video> takes a float, so 0.37 must stay 0.37 (the YT
// scale would round it to a whole percent).
expect(normalizeVolumeIn(0.37, undefined, PLEX_SCALE)).toBeCloseTo(0.37, 10);
expect(normalizeVolumeIn(1.5, undefined, PLEX_SCALE)).toBe(1); // clamps to max
expect(normalizeVolumeIn(-0.2, undefined, PLEX_SCALE)).toBe(0); // clamps to min
});
it("falls back to the scale default on a corrupt Plex volume instead of muting", () => {
// The Plex-side latent bug this consolidation fixes: a bare clamp turned a corrupt persisted
// volume (null / "0.5" / NaN) into 0 — silently muting — where the shared core falls back to the
// last-known level, else PLEX_SCALE.default (1).
expect(normalizeVolumeIn(null, undefined, PLEX_SCALE)).toBe(PLEX_SCALE.default);
expect(normalizeVolumeIn("0.5", 0.3, PLEX_SCALE)).toBeCloseTo(0.3, 10);
expect(normalizeVolumeIn(NaN, NaN, PLEX_SCALE)).toBe(PLEX_SCALE.default);
});
it("steps and clamps on the Plex scale", () => {
expect(stepVolumeIn(0.5, { delta: 0.05, fallback: 0.5 }, PLEX_SCALE)).toBeCloseTo(0.55, 10);
expect(stepVolumeIn(0.98, { delta: 0.05, fallback: 1 }, PLEX_SCALE)).toBe(1); // clamps at max
expect(stepVolumeIn(0.03, { delta: -0.05, fallback: 1 }, PLEX_SCALE)).toBe(0); // clamps at min
});
it("still rounds to whole percents on the YouTube scale via the generic core", () => {
expect(normalizeVolumeIn(42.6, undefined, YT_SCALE)).toBe(43);
expect(YT_SCALE.round).toBe(true);
expect(PLEX_SCALE.round).toBe(false);
});
});
+47 -12
View File
@@ -1,33 +1,68 @@
// The YouTube player's volume arithmetic, kept out of PlayerModal so it can be unit-tested. Every
// Volume arithmetic for BOTH players, kept out of the components so it can be unit-tested. Every
// rule here was established by a bug: a wheel spin that lost notches to the iframe's lagging
// getVolume, a partial stored object that became NaN and persisted as `null`, and a "corrupt →
// default" fallback that jumped an already-quiet session to full blast.
//
// The two players run on different native scales — the YouTube iframe API takes 0–100 integers, the
// HTML5 <video> element a 0..1 float with a separate `.muted` flag — so the pure core below is
// scale-agnostic (a `VolumeScale` parameterises the range, default, and integer rounding) and each
// player binds it to its own scale. The YouTube-bound wrappers keep the original 0–100 API.
/** Level used when nothing trustworthy is available. */
export const DEFAULT_VOLUME = 100;
/** A player's volume scale. `round` is true for the integer YouTube API, false for the 0..1 float. */
export interface VolumeScale {
min: number;
max: number;
/** Level used when nothing trustworthy is available. */
default: number;
round: boolean;
}
const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n)));
/** YouTube iframe API: 0–100 integers, no mute flag (level 0 IS muted). */
export const YT_SCALE: VolumeScale = { min: 0, max: 100, default: 100, round: true };
/** HTML5 <video>: a 0..1 float (mute is a separate flag the caller carries). */
export const PLEX_SCALE: VolumeScale = { min: 0, max: 1, default: 1, round: false };
function clampTo(n: number, s: VolumeScale): number {
const c = Math.max(s.min, Math.min(s.max, n));
return s.round ? Math.round(c) : c;
}
/**
* Coerce anything that claims to be a volume into a usable 0–100 level.
* Coerce anything that claims to be a volume into a usable level ON `scale`.
*
* Only a real, finite `number` is accepted: the stored blob is JSON, so a level is a number or it
* is corrupt — `null`, `undefined`, `"80"` and `NaN` all mean "we don't know", and `Number(null)`
* being `0` (silently muting the player) is exactly the coercion to avoid. `fallback` is the last
* level known to work; it is itself validated, so a corrupt value can never travel through it.
*/
export function normalizeVolume(raw: unknown, fallback: unknown = DEFAULT_VOLUME): number {
if (typeof raw === "number" && Number.isFinite(raw)) return clamp(raw);
if (typeof fallback === "number" && Number.isFinite(fallback)) return clamp(fallback);
return DEFAULT_VOLUME;
export function normalizeVolumeIn(raw: unknown, fallback: unknown, scale: VolumeScale): number {
if (typeof raw === "number" && Number.isFinite(raw)) return clampTo(raw, scale);
if (typeof fallback === "number" && Number.isFinite(fallback)) return clampTo(fallback, scale);
return scale.default;
}
/** One volume step (wheel notch / arrow key) from `current`.
/** One volume step (wheel notch / arrow key) from `current`, on `scale`.
*
* `delta` and `fallback` ride in an options object on purpose: as two adjacent `number`
* parameters they were transposable without a type error, and swapping them silently steps the
* volume BY the current level. */
export function stepVolumeIn(
current: unknown,
opts: { delta: number; fallback: unknown },
scale: VolumeScale,
): number {
return clampTo(normalizeVolumeIn(current, opts.fallback, scale) + opts.delta, scale);
}
// --- YouTube-bound convenience wrappers (0–100 integers) — the original API, behaviour unchanged. ---
/** Level used when nothing trustworthy is available (YouTube scale). */
export const DEFAULT_VOLUME = YT_SCALE.default;
export function normalizeVolume(raw: unknown, fallback: unknown = DEFAULT_VOLUME): number {
return normalizeVolumeIn(raw, fallback, YT_SCALE);
}
export function stepVolume(current: unknown, opts: { delta: number; fallback: unknown }): number {
const base = normalizeVolume(current, opts.fallback);
return clamp(base + opts.delta);
return stepVolumeIn(current, opts, YT_SCALE);
}
+12 -7
View File
@@ -17,7 +17,8 @@ import {
} from "./fullscreen";
import { api, type PlexItemDetail, type PlexMarker } from "./api";
import { qk } from "./queryKeys";
import { clamp01, type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
// A live wall clock for the top bar, re-rendered every 15s (minute resolution is enough for the
// HH:MM display, and a coarse tick keeps the whole player from re-rendering each second).
@@ -138,9 +139,10 @@ export function usePlexProgress(opts: {
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
// the per-account prefs (survives F5) and re-applies on each fresh session (the element resets to its
// default volume after a load/seek-restart). NOTE: this is the Plex-side model; the YouTube player has
// its own 0-100 / no-mute-flag arithmetic in playerVolume.ts. Sharing the tested pure core across both
// is a separate R12 consolidation sprint — this hook is the seam it will rework internally.
// default volume after a load/seek-restart). The 0..1 arithmetic is the shared, unit-tested core in
// playerVolume.ts bound to PLEX_SCALE (the YouTube player binds the same core to YT_SCALE, 0–100); the
// separate .muted flag stays here since it's Plex-only. Using normalizeVolumeIn (not a bare clamp)
// also means a corrupt persisted volume falls back to the default instead of silently muting.
export function usePlayerVolume(opts: {
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
@@ -160,7 +162,7 @@ export function usePlayerVolume(opts: {
(vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = clamp01(vol);
v.volume = normalizeVolumeIn(vol, undefined, PLEX_SCALE);
v.muted = mute;
},
[videoRef],
@@ -169,12 +171,15 @@ export function usePlayerVolume(opts: {
const setVol = useCallback(
(vol: number, mute: boolean) => {
applyVolume(vol, mute);
patchPrefs({ volume: clamp01(vol), muted: mute });
patchPrefs({ volume: normalizeVolumeIn(vol, undefined, PLEX_SCALE), muted: mute });
},
[applyVolume, patchPrefs],
);
const nudgeVolume = useCallback(
(d: number) => setVol(clamp01(volume + d), clamp01(volume + d) === 0),
(d: number) => {
const next = stepVolumeIn(volume, { delta: d, fallback: volume }, PLEX_SCALE);
setVol(next, next === 0);
},
[volume, setVol],
);
const toggleMute = useCallback(() => setVol(volume, !muted), [volume, muted, setVol]);