fix(r5): address the /code-review high round — 10 findings
- HLS sweep only deletes directories matching the session-name shape; the root is admin-configurable and may be a shared path - playlist unwrap picks the first entry that actually downloaded, not entries[0] - concat quoting keeps backslashes literal (ffmpeg treats them so inside '') - ArrowUp/Down defer while the focused element can still scroll that way - the staging fallback skips yt-dlp working files (.part-Frag/.ytdl/.temp/.fNNN) - the boot HLS sweep runs off the event loop - volume persistence is debounced (was a setItem per wheel notch) and a spin no longer loses notches to the iframe's lagging getVolume - _Breaker.run owns the whole open/call/record protocol; four call sites shrink - should_prune extracted and unit-tested, plus RSS error-vs-empty tests - the worker exits non-zero when the schema never lands
This commit is contained in:
@@ -55,6 +55,21 @@ type LoopMode = "off" | "one" | "all";
|
||||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||
|
||||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
||||
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
||||
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||||
* behind the modal is `overflow:hidden` anyway. */
|
||||
function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
|
||||
for (let node = el; node && node !== document.body; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (!/(auto|scroll|overlay)/.test(style.overflowY)) continue;
|
||||
const room =
|
||||
dir < 0 ? node.scrollTop > 0 : node.scrollTop + node.clientHeight < node.scrollHeight - 1; // -1: sub-pixel heights
|
||||
if (room) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- IFrame Player API loader (singleton) ---
|
||||
let apiPromise: Promise<any> | null = null;
|
||||
function loadYouTubeApi(): Promise<any> {
|
||||
@@ -168,9 +183,15 @@ export default function PlayerModal({
|
||||
const [playerPrefs, patchPlayerPrefs] = useAccountPersistedObject(LS.ytPlayerPrefs, {
|
||||
volume: 100,
|
||||
});
|
||||
// The window/wheel listeners below are bound once, so they must read the LIVE prefs.
|
||||
const prefsRef = useRef(playerPrefs);
|
||||
prefsRef.current = playerPrefs;
|
||||
// The live volume: updated on every notch, while the localStorage write behind it is debounced
|
||||
// (a wheel spin fires ~16 events a second, and each patch is a synchronous setItem on the frame
|
||||
// the player is decoding on). Everything that READS the level reads this ref, so it never sees
|
||||
// the stale persisted value mid-spin. The window/wheel listeners are bound once, so a ref — not
|
||||
// state — is what they can safely close over.
|
||||
const volumeRef = useRef(playerPrefs.volume);
|
||||
const volSaveTimerRef = useRef<number | undefined>(undefined);
|
||||
// When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player).
|
||||
const lastNudgeAtRef = useRef(0);
|
||||
|
||||
const focusModal = () => cardRef.current?.focus();
|
||||
const togglePlay = () => {
|
||||
@@ -193,6 +214,11 @@ export default function PlayerModal({
|
||||
};
|
||||
/** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero
|
||||
* volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */
|
||||
const persistVolume = () => {
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = undefined;
|
||||
patchPlayerPrefs({ volume: volumeRef.current });
|
||||
};
|
||||
const applyVolume = (level: number, flash = true) => {
|
||||
const next = Math.max(0, Math.min(100, Math.round(level)));
|
||||
const p = playerRef.current;
|
||||
@@ -201,15 +227,29 @@ export default function PlayerModal({
|
||||
p.setVolume(next);
|
||||
if (next === 0) p.mute?.();
|
||||
}
|
||||
patchPlayerPrefs({ volume: next });
|
||||
volumeRef.current = next;
|
||||
// Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes
|
||||
// a pending write, so the last change is never lost.
|
||||
window.clearTimeout(volSaveTimerRef.current);
|
||||
volSaveTimerRef.current = window.setTimeout(persistVolume, 400);
|
||||
if (flash) flashVolume(next);
|
||||
};
|
||||
const nudgeVolume = (delta: number) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getVolume !== "function") return;
|
||||
// Read the live player (the user may have used YouTube's own slider), falling back to the
|
||||
// stored level when the player can't answer yet.
|
||||
const cur = p.isMuted?.() ? 0 : (p.getVolume?.() ?? prefsRef.current.volume);
|
||||
// Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read
|
||||
// the player, because the user may have moved YouTube's own slider in the meantime.
|
||||
// `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so
|
||||
// re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five
|
||||
// wheel notches moved 100 → 90 instead of 75).
|
||||
const now = Date.now();
|
||||
const continuing = now - lastNudgeAtRef.current < 1000;
|
||||
lastNudgeAtRef.current = now;
|
||||
const cur = continuing
|
||||
? volumeRef.current
|
||||
: p.isMuted?.()
|
||||
? 0
|
||||
: (p.getVolume?.() ?? volumeRef.current);
|
||||
applyVolume(cur + delta);
|
||||
};
|
||||
|
||||
@@ -425,10 +465,14 @@ export default function PlayerModal({
|
||||
togglePlay();
|
||||
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe.
|
||||
if (typing) return;
|
||||
// fullscreen, where the wheel goes to YouTube's own iframe. But the modal card scrolls
|
||||
// (a long description) and Up/Down is how you scroll it from the keyboard — so defer
|
||||
// whenever the focused element can still scroll that way, exactly like Space defers to a
|
||||
// focused button.
|
||||
const up = e.key === "ArrowUp";
|
||||
if (typing || canScrollBy(el, up ? -1 : 1)) return;
|
||||
e.preventDefault();
|
||||
nudgeVolume(e.key === "ArrowUp" ? 5 : -5);
|
||||
nudgeVolume(up ? 5 : -5);
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
// Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
|
||||
// previous/next video in the queue (the feed's order or a playlist).
|
||||
@@ -453,6 +497,9 @@ export default function PlayerModal({
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
window.clearTimeout(volTimerRef.current);
|
||||
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
|
||||
// way to leave) must not drop the level the user just set.
|
||||
if (volSaveTimerRef.current != null) persistVolume();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
@@ -582,7 +629,7 @@ export default function PlayerModal({
|
||||
// restore the remembered volume — a fresh player always starts at 100.
|
||||
onReady: () => {
|
||||
focusModal();
|
||||
applyVolume(prefsRef.current.volume, false);
|
||||
applyVolume(volumeRef.current, false);
|
||||
},
|
||||
onStateChange: (e: any) => {
|
||||
// 1 === playing → sync the navigated-to video's title/author for display.
|
||||
|
||||
Reference in New Issue
Block a user