fix(player): hold last-known Plex volume on a corrupt read, normalise once (review)

- applyVolume/setVol now pass the element's CURRENT level as the normalize
  fallback instead of undefined, so a corrupt persisted volume holds the
  playing level rather than jumping to PLEX_SCALE.default=1 (full) — the "full
  blast" failure mode playerVolume.ts's header explicitly warns against. (The
  old bare clamp muted instead; both are wrong for corrupt data.) readMerged
  shallow-merges stored prefs without per-field validation, so a corrupt
  volume is reachable.
- setVol normalises the level ONCE and both applies + persists that same value,
  so the <video> element and the stored pref can never disagree.
- Test documents the hold-last-known contract (corrupt raw + valid fallback ->
  fallback; only an unusable fallback lands on the scale default).

Gate green (tsc / eslint-0 / prettier / knip / vitest 83 / pytest 234).
This commit is contained in:
2026-08-13 03:43:02 +02:00
parent cba992379c
commit 92ef6116f0
2 changed files with 20 additions and 10 deletions
+12 -6
View File
@@ -142,7 +142,8 @@ export function usePlexProgress(opts: {
// 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.
// means a corrupt persisted volume falls back to the element's CURRENT level (last applied) rather
// than either silently muting (the old bare clamp) or jumping to full — see playerVolume.ts's header.
export function usePlayerVolume(opts: {
videoRef: RefObject<HTMLVideoElement | null>;
volume: number;
@@ -162,18 +163,23 @@ export function usePlayerVolume(opts: {
(vol: number, mute: boolean) => {
const v = videoRef.current;
if (!v) return;
v.volume = normalizeVolumeIn(vol, undefined, PLEX_SCALE);
// Fallback = the element's current level, so a corrupt `vol` HOLDS the playing level instead
// of jumping to full (PLEX_SCALE.default). On a fresh element that current level is the
// browser default, which is the sensible "nothing to restore" value anyway.
v.volume = normalizeVolumeIn(vol, v.volume, PLEX_SCALE);
v.muted = mute;
},
[videoRef],
);
// Set volume/mute on the <video> AND persist (survives F5).
// Set volume/mute on the <video> AND persist (survives F5). Normalise ONCE so the element and the
// stored pref can never disagree; a corrupt input holds the element's current level.
const setVol = useCallback(
(vol: number, mute: boolean) => {
applyVolume(vol, mute);
patchPrefs({ volume: normalizeVolumeIn(vol, undefined, PLEX_SCALE), muted: mute });
const level = normalizeVolumeIn(vol, videoRef.current?.volume, PLEX_SCALE);
applyVolume(level, mute);
patchPrefs({ volume: level, muted: mute });
},
[applyVolume, patchPrefs],
[applyVolume, patchPrefs, videoRef],
);
const nudgeVolume = useCallback(
(d: number) => {