All files / src/lib player-prefs.ts

100% Statements 65/65
100% Branches 20/20
100% Functions 15/15
100% Lines 61/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188                        7x 7x 7x   7x 7x                 28x   7x 76x           7x 174x 174x 174x 174x   2x                 7x 11x 11x 11x         7x 78x 78x 78x 78x 2x     76x 76x     7x 12x 12x         7x 130x 130x   1x       7x 5x 5x                 7x             1384x 14x 1356x 1356x 1356x   4x     14x 14x   1356x   32x 28x 28x 28x             7x 7x     7x                 7x                 7x       7x                                   7x   7x         666x       7x       7x  
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
// Player preferences kept in web storage.
//
// The volume picked in the player lasts until the app quits (sessionStorage),
// so leaving and reopening the player doesn't reset it. The default volume,
// hardware decoding and skip lengths are Settings choices and persist across
// launches (localStorage), as do the skip modes for intros and credits.
// Storage can throw (private mode, quota), so every access is guarded and
// falls back to the built-in defaults.
 
const VOLUME_KEY = "mvp_volume";
const DEFAULT_VOLUME_KEY = "mvp_default_volume";
const HWDEC_KEY = "mvp_hwdec";
 
export const MAX_VOLUME = 150;
const BUILTIN_VOLUME = 100;
 
export interface VolumePreference {
	/** The volume the user last chose. */
	volume: number;
	/** Volume to restore when unmuting. */
	preMute: number;
}
 
const clampVolume = (v: number): number => Math.min(MAX_VOLUME, Math.max(0, Math.round(v)));
 
const isVolumePreference = (v: unknown): v is VolumePreference =>
	typeof v === "object" &&
	v !== null &&
	Number.isFinite((v as VolumePreference).volume) &&
	Number.isFinite((v as VolumePreference).preMute);
 
/** The volume playback starts at in a new session (Settings → Default volume). */
export const readDefaultVolume = (): number => {
	try {
		const raw = localStorage.getItem(DEFAULT_VOLUME_KEY);
		const n = raw === null ? NaN : Number.parseFloat(raw);
		return Number.isFinite(n) ? clampVolume(n) : BUILTIN_VOLUME;
	} catch {
		return BUILTIN_VOLUME;
	}
};
 
/**
 * Save a new default volume. It also drops the volume remembered for this
 * session, so the next stream starts at the new default instead of waiting
 * for a restart.
 */
export const writeDefaultVolume = (volume: number): void => {
	try {
		localStorage.setItem(DEFAULT_VOLUME_KEY, String(clampVolume(volume)));
		sessionStorage.removeItem(VOLUME_KEY);
	} catch {}
};
 
/** The volume to use for the next stream: this session's choice, else the default. */
export const readVolumePreference = (): VolumePreference => {
	try {
		const raw = sessionStorage.getItem(VOLUME_KEY);
		const saved: unknown = raw ? JSON.parse(raw) : null;
		if (isVolumePreference(saved)) {
			return { volume: clampVolume(saved.volume), preMute: clampVolume(saved.preMute) };
		}
	} catch {}
	const volume = readDefaultVolume();
	return { volume, preMute: volume > 0 ? volume : BUILTIN_VOLUME };
};
 
export const writeVolumePreference = (pref: VolumePreference): void => {
	try {
		sessionStorage.setItem(VOLUME_KEY, JSON.stringify(pref));
	} catch {}
};
 
/** Whether mpv may use hardware decoding. On unless turned off in Settings. */
export const readHwdecEnabled = (): boolean => {
	try {
		return localStorage.getItem(HWDEC_KEY) !== "off";
	} catch {
		return true;
	}
};
 
export const writeHwdecEnabled = (enabled: boolean): void => {
	try {
		localStorage.setItem(HWDEC_KEY, enabled ? "on" : "off");
	} catch {}
};
 
/**
 * Reads and saves a group of Settings choices, one localStorage key per
 * field. A value that isn't in `allowed` reads as `fallback` and isn't
 * saved. Saving fires `event`, which `usePlayerPref` listens for.
 */
const choiceGroup = <F extends string, V extends string | number>(
	keys: Record<F, string>,
	allowed: readonly V[],
	fallback: V,
	event: string,
	parse: (raw: string | null) => unknown
) => {
	const isAllowed = (v: unknown): v is V => allowed.includes(v as V);
	const readOne = (field: F): V => {
		try {
			const v = parse(localStorage.getItem(keys[field]));
			return isAllowed(v) ? v : fallback;
		} catch {
			return fallback;
		}
	};
	const fields = Object.keys(keys) as F[];
	return {
		read: (): Record<F, V> =>
			Object.fromEntries(fields.map((f) => [f, readOne(f)])) as Record<F, V>,
		write: (field: F, value: V): void => {
			if (!isAllowed(value)) return;
			try {
				localStorage.setItem(keys[field], String(value));
				window.dispatchEvent(new Event(event));
			} catch {}
		},
	};
};
 
/** The skip lengths Settings offers, in seconds. */
export const SKIP_OPTIONS: readonly number[] = [5, 10, 15, 30, 60];
const BUILTIN_SKIP = 10;
 
/** Window event fired after either skip length changes. */
export const SKIP_INTERVALS_EVENT = "mvp-skip-intervals";
 
export interface SkipIntervals {
	/** Seconds the skip back button, double-tap and left arrow jump. */
	back: number;
	/** Seconds the skip forward button, double-tap and right arrow jump. */
	forward: number;
}
 
const skipIntervals = choiceGroup<keyof SkipIntervals, number>(
	{ back: "mvp_skip_back", forward: "mvp_skip_forward" },
	SKIP_OPTIONS,
	BUILTIN_SKIP,
	SKIP_INTERVALS_EVENT,
	Number
);
 
/** How far the player skips back and forward. 10 seconds each unless changed in Settings. */
export const readSkipIntervals: () => SkipIntervals = skipIntervals.read;
 
/** Save one skip length. Values that aren't in SKIP_OPTIONS are ignored. */
export const writeSkipInterval: (direction: keyof SkipIntervals, seconds: number) => void =
	skipIntervals.write;
 
/**
 * What the player does at a part of a movie or episode that can be skipped.
 * "button" shows a Skip button, "auto" skips on its own with a way to undo,
 * and "off" does neither.
 */
export type SkipSegmentMode = "button" | "auto" | "off";
 
/** The two groups of skippable parts, each with its own Settings choice. */
export interface SkipSegmentModes {
	/** Intros and recaps. */
	opening: SkipSegmentMode;
	/** End credits and previews of the next episode. */
	ending: SkipSegmentMode;
}
 
/** Window event fired after either skip mode changes. */
export const SKIP_SEGMENT_MODES_EVENT = "mvp-skip-segment-modes";
 
const skipSegmentModes = choiceGroup<keyof SkipSegmentModes, SkipSegmentMode>(
	{ opening: "mvp_skip_opening", ending: "mvp_skip_ending" },
	["button", "auto", "off"],
	"button",
	SKIP_SEGMENT_MODES_EVENT,
	(raw) => raw
);
 
/** How the player handles intros and credits. A Skip button for both unless changed in Settings. */
export const readSkipSegmentModes: () => SkipSegmentModes = skipSegmentModes.read;
 
/** Save the mode for one group. Unknown modes are ignored. */
export const writeSkipSegmentMode: (group: keyof SkipSegmentModes, mode: SkipSegmentMode) => void =
	skipSegmentModes.write;