fix(plex): warm up HLS buffer on session start + player loading/buffering spinner
The Plex player stalled ~2s a few seconds into every auto-advanced next episode. Root cause: each auto-advance spins up a cold ffmpeg HLS remux session, and for a fresh start (start_s==0) start_session returned immediately with zero buffer — hls.js drained the first segment, then rebuffered waiting for the still-warming ffmpeg to write the next one. Once ffmpeg gets ahead (stream-copy is faster than realtime) it never stalls again. - stream.py: before reporting a session ready, wait for the first _WARMUP_SEGMENTS (3) video segments to exist — the seg0 wait is now unconditional (was start_s>0 only) and warm-up runs for all starts. Best-effort and bounded: stop at the first segment not ready in time. - PlexPlayer.tsx: a loading/buffering spinner. The existing overlay only covered the INITIAL load (!ready); add a `buffering` state driven by the video's waiting/stalled/seeking (set) and playing/canplay/seeked/pause (clear) events so a mid-playback stall or in-flight seek reads as "loading" instead of a frozen frame, and surface a fatal loadError even once playback has started. Spinner uses the shared Loader2. Merges roadmap E6·item1 (spinner while loading/seeking) into this fix, since the server-side warm-up deliberately holds ready a beat longer. Tests: 3 new in test_plex_stream.py (warm-up waits for the first few segments; stops at the first missing one; no warm-up when seg0 never arrives). Gate: backend pytest 460 (Docker lane), FE tsc/lint/knip/ prettier + vitest 483.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Info,
|
||||
Keyboard,
|
||||
Languages,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Pause,
|
||||
@@ -108,6 +109,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||
const { fs, toggleFs } = useFullscreen(wrapRef);
|
||||
const [ready, setReady] = useState(false);
|
||||
// True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives
|
||||
// a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`,
|
||||
// which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the
|
||||
// brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up).
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
||||
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||
@@ -178,6 +184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setReady(false);
|
||||
setBuffering(false);
|
||||
setLoadError(null);
|
||||
let sess;
|
||||
try {
|
||||
@@ -362,12 +369,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
// button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events
|
||||
// so the icon can never contradict the video.
|
||||
const syncPlaying = () => setPlaying(!video.paused);
|
||||
// Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is
|
||||
// in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a
|
||||
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onResumed = () => setBuffering(false);
|
||||
video.addEventListener("timeupdate", onTime);
|
||||
video.addEventListener("play", onPlay);
|
||||
video.addEventListener("pause", onPause);
|
||||
video.addEventListener("emptied", syncPlaying);
|
||||
video.addEventListener("canplay", syncPlaying);
|
||||
video.addEventListener("seeked", syncPlaying);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("stalled", onWaiting);
|
||||
video.addEventListener("seeking", onWaiting);
|
||||
video.addEventListener("playing", onResumed);
|
||||
video.addEventListener("canplay", onResumed);
|
||||
video.addEventListener("seeked", onResumed);
|
||||
video.addEventListener("pause", onResumed);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTime);
|
||||
video.removeEventListener("play", onPlay);
|
||||
@@ -375,6 +394,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
video.removeEventListener("emptied", syncPlaying);
|
||||
video.removeEventListener("canplay", syncPlaying);
|
||||
video.removeEventListener("seeked", syncPlaying);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("stalled", onWaiting);
|
||||
video.removeEventListener("seeking", onWaiting);
|
||||
video.removeEventListener("playing", onResumed);
|
||||
video.removeEventListener("canplay", onResumed);
|
||||
video.removeEventListener("seeked", onResumed);
|
||||
video.removeEventListener("pause", onResumed);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
@@ -730,12 +756,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
)}
|
||||
</video>
|
||||
|
||||
{!ready && (
|
||||
{/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a
|
||||
mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so
|
||||
it can't hide behind a running video. Spinner on load/stall, error text otherwise. */}
|
||||
{(!ready || buffering || loadError) && (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||
{loadError ? (
|
||||
<span className="max-w-md text-danger">{loadError}</span>
|
||||
) : (
|
||||
<span className="text-white/70">{t("plex.player.loading")}</span>
|
||||
<div className="flex flex-col items-center gap-3 text-white/80">
|
||||
<Loader2 className="w-10 h-10 animate-spin" aria-hidden />
|
||||
{!ready && <span>{t("plex.player.loading")}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user