fix(r5): address the eighth /code-review high round — 7 findings

The worst one was, again, the previous round's own fix.

- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
  also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
  live-session set BEFORE listing the root and rmtree'd outside the lock. Since
  `start_session` reuses a directory path for the same (key, offset, tag), a stale
  name can become a live session at any moment. Two guards now: nothing whose mtime
  is newer than this process's start is ever deleted, and the live check + rmtree
  happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
  0.0 read as "never ran" — unreachable in production, but `now` exists to be
  injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
  failed" is also what ONE dead feed id looks like on a small instance, and a red
  card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
  too — it had the same unprefixed-only reads in three places, including an Escape
  branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
  still on the element and wins. Comment now states the real mechanism.

Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
  band and overlaps NO YouTube control — the collision this round suspected does not
  reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
  level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
  change listener fires exactly once per transition, exit returns to 889.

Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
This commit is contained in:
2026-07-24 04:44:20 +02:00
parent a5336d20e0
commit bdf7c03b59
10 changed files with 251 additions and 52 deletions
+4 -22
View File
@@ -35,6 +35,7 @@ import {
relativeTime,
} from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement, onFullscreenChange } from "../lib/fullscreen";
import { useBackToClose } from "../lib/history";
import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -57,18 +58,6 @@ type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The element currently holding the browser's fullscreen lock, WebKit included.
*
* Safari/iPadOS still ship only the prefixed API: no `fullscreenchange`, no `fullscreenElement`.
* We never request fullscreen ourselves any more (F maximises via CSS), but YouTube's own button
* inside the embed does — and on WebKit that arrives as `webkitfullscreenchange`, so an unprefixed
* -only reader thinks the page never entered fullscreen and skips both the overlay yield and the
* focus reclaim. */
function fullscreenEl(): Element | null {
const d = document as Document & { webkitFullscreenElement?: Element | null };
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** 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
@@ -481,7 +470,7 @@ export default function PlayerModal({
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenEl()) return;
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
e.preventDefault();
@@ -576,7 +565,7 @@ export default function PlayerModal({
// this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc.
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenEl();
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
@@ -590,14 +579,7 @@ export default function PlayerModal({
// way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends.
if (!fsEl) focusModal();
};
// Both spellings: WebKit (Safari/iPadOS) fires only the prefixed one, and a browser that
// supports both never fires both for the same transition.
document.addEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
}, []);
// Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
+14 -8
View File
@@ -35,6 +35,12 @@ import {
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import {
exitFullscreen,
fullscreenElement,
onFullscreenChange,
requestFullscreen,
} from "../lib/fullscreen";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
@@ -579,17 +585,17 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
[nudgeVolume],
);
// Via lib/fullscreen, not the raw API: WebKit (Safari/iPadOS) ships only the prefixed spelling,
// so reading `document.fullscreenElement` alone made this player believe it is never fullscreen
// — the button did nothing, `fs` never flipped, and Escape (below) read "not fullscreen" as
// permission to close the whole player instead of just leaving fullscreen.
const toggleFs = useCallback(() => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else el.requestFullscreen().catch(() => {});
}, []);
useEffect(() => {
const onFs = () => setFs(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onFs);
return () => document.removeEventListener("fullscreenchange", onFs);
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(el);
}, []);
useEffect(() => onFullscreenChange(() => setFs(!!fullscreenElement())), []);
const go = useCallback((otherId: string | null | undefined) => {
if (otherId) {
@@ -876,7 +882,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setTracksOpen(false);
} else if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (!document.fullscreenElement) onClose();
else if (!fullscreenElement()) onClose();
break;
}
};
+7 -4
View File
@@ -597,10 +597,13 @@ html[data-scheme="matrix"][data-theme="light"] {
height: 100vh;
}
/* `inset: 0` alone does the sizing here — adding width/height would over-constrain the box (left +
width win, right/bottom are ignored), which is how `100vw` sneaks a horizontal overflow in next
to a classic scrollbar and `100vh` pushes the bottom edge — YouTube's own control bar — under a
mobile browser's chrome. */
/* Sizing, precisely: the element keeps Tailwind's `w-full` from its base className, so the WIDTH is
`100%` of the containing block — which, for a fixed element, is the viewport minus any classic
scrollbar. The HEIGHT comes from `inset: 0` (top+bottom, once `aspect-ratio` is released above).
`right: 0` is therefore ignored (over-constrained: left + width win) — harmless, but don't read
this rule as inset-sized. What had to go are the viewport units it used to inherit: `100vw`
INCLUDES the scrollbar (horizontal overflow) and `100vh` includes a mobile browser's retracting
chrome, which pushes the bottom edge — YouTube's own control bar — off screen. */
.player-stage--max {
position: fixed;
inset: 0;
+49
View File
@@ -0,0 +1,49 @@
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
// otherwise close the whole player instead of just leaving fullscreen).
//
// Both video surfaces (PlayerModal's YouTube embed, PlexPlayer's <video>) go through here, so the
// prefix handling lives in ONE place — the previous shape had it in neither, then in one.
type WebkitDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => void;
};
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
/** The element currently holding the fullscreen lock, or null. */
export function fullscreenElement(): Element | null {
const d = document as WebkitDocument;
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
else e.webkitRequestFullscreen?.();
}
/** Leave fullscreen, whoever holds it. */
export function exitFullscreen(): void {
const d = document as WebkitDocument;
if (d.exitFullscreen) void d.exitFullscreen().catch(() => {});
else d.webkitExitFullscreen?.();
}
/** Subscribe to fullscreen transitions; returns the unsubscribe. Registers BOTH spellings — a
* browser that supports both never fires both for the same transition, so the handler still runs
* once. */
export function onFullscreenChange(handler: () => void): () => void {
document.addEventListener("fullscreenchange", handler);
document.addEventListener("webkitfullscreenchange", handler);
return () => {
document.removeEventListener("fullscreenchange", handler);
document.removeEventListener("webkitfullscreenchange", handler);
};
}