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.
69 lines
3.3 KiB
TypeScript
69 lines
3.3 KiB
TypeScript
// 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);
|
||
}
|