fix(player): a media reset left the preview stuck on "loading" forever
Recovery keyed on v.error, which a reset never sets, so nothing restarted the load and _isSeeking() froze the transport at readyState 0. Play took the same wait-for-metadata branch, so it was a no-op.
This commit is contained in:
@@ -65,6 +65,13 @@
|
||||
looked exactly like one where the feature was switched off.
|
||||
|
||||
### Fixed
|
||||
- **Preview playback could stall at "Video is loading…" and stay there**, with the transport
|
||||
buttons dead. A media reset leaves a `<video>` with no metadata and no load running, and
|
||||
every recovery path keyed on the element having errored — which a reset does not do. Play
|
||||
went down the same wait-for-metadata branch, so pressing it changed nothing. Elements are
|
||||
now checked after a reset and restarted if no load followed; Play restarts a stalled one
|
||||
outright.
|
||||
|
||||
- **A linked input driven by `Project · Prompt (global)` claimed to encode "the same text
|
||||
Studio would" and did not.** Studio appends the postfix to every scene itself, so the
|
||||
global prompt is strictly less than what it encodes — and the postfix is usually where
|
||||
|
||||
@@ -477,6 +477,34 @@
|
||||
}, delayMs != null ? delayMs : 1500 * n);
|
||||
}
|
||||
|
||||
// A media reset (browser dropping an aborted/poisoned stream, a src swap that never
|
||||
// started, the resource being reclaimed) leaves an element with no metadata AND no load
|
||||
// in flight. Nothing will ever fire `loadedmetadata` for it, and because `error` never
|
||||
// fired it has v.error === null — which is what every other recovery path here keys on.
|
||||
// Left alone it sits behind "Video is loading…" forever and the transport freezes, since
|
||||
// _isSeeking() treats readyState < 1 as a seek in progress and the tick refuses to
|
||||
// advance the playhead. networkState 2 is NETWORK_LOADING: bytes are on the way, leave
|
||||
// it be.
|
||||
function _isStalledEmpty(v) {
|
||||
if (!v || v.readyState >= 1) return false;
|
||||
try { return v.networkState !== 2; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
// Confirm the stall before acting on it: a fresh `v.src = …` also reads as EMPTY for a
|
||||
// beat before the resource selection algorithm starts, so an immediate check would
|
||||
// cancel loads that were about to succeed.
|
||||
const STALL_WATCHDOG_MS = 1200;
|
||||
function _armStallWatchdog(url, v) {
|
||||
clearTimeout(v._pmStallTimer);
|
||||
v._pmStallTimer = setTimeout(() => {
|
||||
v._pmStallTimer = null;
|
||||
if (pool.get(url) !== v || !_urlClips.has(url)) return;
|
||||
if (!_isStalledEmpty(v)) return; // loading, or metadata already arrived
|
||||
if (v._pmRetryTimer) return; // an error-driven retry already owns recovery
|
||||
_scheduleVideoRetry(url, v, 0);
|
||||
}, STALL_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
// If the active clip's element changed readiness, repaint the slate/badge over it.
|
||||
function _refreshClipStatusUi(url) {
|
||||
if (_currentClip && !_currentClip.pending && _clipUrl(_currentClip) === url) {
|
||||
@@ -529,6 +557,7 @@
|
||||
viewport.append(zoom);
|
||||
v.addEventListener("loadedmetadata", () => {
|
||||
v._pmRetries = 0; // healthy again — restore the full retry budget
|
||||
clearTimeout(v._pmStallTimer); v._pmStallTimer = null;
|
||||
v._pmStatus = "ready";
|
||||
_refreshClipStatusUi(url);
|
||||
const pending = v._pmSeekPending != null ? v._pmSeekPending : (v === _active ? _seekPending : null);
|
||||
@@ -552,6 +581,8 @@
|
||||
// A media reset means the bytes are gone — downgrade so the slate covers the element
|
||||
// instead of it blinking black while the retry reloads.
|
||||
if (v._pmStatus === "ready") { v._pmStatus = "loading"; _refreshClipStatusUi(url); }
|
||||
// The reset may or may not be followed by a load. Check back and restart it if not.
|
||||
_armStallWatchdog(url, v);
|
||||
if (v !== _active || !_currentClip || _currentClip.pending) return;
|
||||
if (_clipUrl(_currentClip) !== url) return;
|
||||
if (v._pmSeekPending == null) {
|
||||
@@ -613,6 +644,7 @@
|
||||
if (_active === v) _active = null;
|
||||
v.pause();
|
||||
clearTimeout(v._pmRetryTimer);
|
||||
clearTimeout(v._pmStallTimer); v._pmStallTimer = null;
|
||||
// Release the socket BEFORE detaching: a detached media element that still has a
|
||||
// src holds its per-origin connection until GC (media-connection-pool class).
|
||||
try { v.removeAttribute("src"); v.load(); } catch (_) {}
|
||||
@@ -653,7 +685,9 @@
|
||||
if (clip) _registerUrlClip(u, clip);
|
||||
// Clip rebuild (e.g. a run just completed): give exhausted-but-errored elements a
|
||||
// fresh recovery attempt — the failure was likely the server being busy back then.
|
||||
if (v.error && !v._pmRetryTimer) { v._pmRetries = 0; _scheduleVideoRetry(u, v); }
|
||||
if ((v.error || _isStalledEmpty(v)) && !v._pmRetryTimer) {
|
||||
v._pmRetries = 0; _scheduleVideoRetry(u, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,6 +880,14 @@
|
||||
_seekPending = null;
|
||||
if (play) { v.play().catch(() => {}); _startTick(); } else { _playPending = false; _clipBoundaryToken = 0; }
|
||||
} else {
|
||||
// Waiting on loadedmetadata is only correct while a load is actually running. On a
|
||||
// stalled-empty element it waits forever — and this is the branch Play goes through,
|
||||
// which is why play/pause reads as dead. Restart it, with a fresh budget: the user
|
||||
// asking again is a better reason to retry than any timer.
|
||||
if (_isStalledEmpty(v) && !v._pmRetryTimer) {
|
||||
v._pmRetries = 0;
|
||||
_scheduleVideoRetry(_clipUrl(clip), v, 0);
|
||||
}
|
||||
v._pmSeekPending = target;
|
||||
_seekPending = target;
|
||||
_playPending = !!play;
|
||||
|
||||
Reference in New Issue
Block a user