Files
siftlode/frontend/src/lib/playerVolume.ts
T
peter cba992379c 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.
2026-08-13 03:20:01 +02:00

69 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
/** 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;
}
/** 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 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 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`, 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 {
return stepVolumeIn(current, opts, YT_SCALE);
}