diff --git a/js/app.js b/js/app.js index e160c6d1..909b6635 100644 --- a/js/app.js +++ b/js/app.js @@ -5382,9 +5382,13 @@ class bitvidApp { * Cleanup resources on unload or modal close. */ async cleanup({ preserveSubscriptions = false, preserveObservers = false } = {}) { + this.log( + `[cleanup] Requested (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})` + ); // Serialise teardown so overlapping calls (e.g. close button spam) don't // race each other and clobber a fresh playback setup. if (this.cleanupPromise) { + this.log("[cleanup] Waiting for in-flight cleanup to finish before starting a new run."); try { await this.cleanupPromise; } catch (err) { @@ -5393,6 +5397,9 @@ class bitvidApp { } const runCleanup = async () => { + this.log( + `[cleanup] Begin (preserveSubscriptions=${preserveSubscriptions}, preserveObservers=${preserveObservers})` + ); try { this.clearActiveIntervals(); this.cleanupUrlPlaybackWatchdog(); @@ -5415,28 +5422,33 @@ class bitvidApp { // If there's a small inline player if (this.videoElement) { - this.videoElement.pause(); - this.videoElement.src = ""; - // When WebTorrent (or other MediaSource based flows) mount a stream they - // set `srcObject` behind the scenes. Forgetting to clear it leaves the - // detached MediaSource hanging around which breaks the next playback - // attempt. Always null it out alongside the normal `src` reset. - this.videoElement.srcObject = null; - this.videoElement.load(); + this.videoElement = this.teardownVideoElement(this.videoElement); } // If there's a modal video if (this.modalVideo) { - this.modalVideo.pause(); - this.modalVideo.src = ""; - // See comment above—keep the `srcObject` reset paired with the `src` - // wipe so magnet-only replays do not regress into the grey screen bug. - this.modalVideo.srcObject = null; - this.modalVideo.load(); + if (typeof this.modalPosterCleanup === "function") { + try { + this.modalPosterCleanup(); + } catch (err) { + console.warn("[cleanup] modal poster cleanup threw:", err); + } finally { + this.modalPosterCleanup = null; + } + } + const refreshedModal = this.teardownVideoElement(this.modalVideo, { + replaceNode: true, + }); + if (refreshedModal) { + this.modalVideo = refreshedModal; + } } // Tell webtorrent to cleanup await torrentClient.cleanup(); + this.log("[cleanup] WebTorrent cleanup resolved."); } catch (err) { console.error("Cleanup error:", err); + } finally { + this.log("[cleanup] Finished."); } }; @@ -5458,7 +5470,9 @@ class bitvidApp { } try { + this.log("[waitForCleanup] Awaiting previous cleanup before continuing."); await this.cleanupPromise; + this.log("[waitForCleanup] Previous cleanup completed."); } catch (err) { console.warn("waitForCleanup observed a rejected cleanup:", err); } @@ -5484,6 +5498,131 @@ class bitvidApp { } } + teardownVideoElement(videoElement, { replaceNode = false } = {}) { + if (!videoElement) { + this.log( + `[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.` + ); + return videoElement; + } + + const safe = (fn) => { + try { + fn(); + } catch (err) { + console.warn("[teardownVideoElement]", err); + } + }; + + const describeSource = () => { + try { + return videoElement.currentSrc || videoElement.src || ""; + } catch (err) { + return ""; + } + }; + + this.log( + `[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}` + ); + + safe(() => videoElement.pause()); + + safe(() => { + videoElement.removeAttribute("src"); + videoElement.src = ""; + }); + + safe(() => { + videoElement.srcObject = null; + }); + + safe(() => { + if ("crossOrigin" in videoElement) { + videoElement.crossOrigin = null; + } + if (videoElement.hasAttribute("crossorigin")) { + videoElement.removeAttribute("crossorigin"); + } + }); + + safe(() => { + if (typeof videoElement.load === "function") { + videoElement.load(); + } + }); + + if (!replaceNode || !videoElement.parentNode) { + this.log( + `[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).` + ); + return videoElement; + } + + const parent = videoElement.parentNode; + const clone = videoElement.cloneNode(false); + + if (clone.dataset && "autoplayBound" in clone.dataset) { + delete clone.dataset.autoplayBound; + } + if (clone.hasAttribute("data-autoplay-bound")) { + clone.removeAttribute("data-autoplay-bound"); + } + + safe(() => { + clone.removeAttribute("src"); + clone.src = ""; + }); + + safe(() => { + clone.srcObject = null; + }); + + safe(() => { + if ("crossOrigin" in clone) { + clone.crossOrigin = null; + } + if (clone.hasAttribute("crossorigin")) { + clone.removeAttribute("crossorigin"); + } + }); + + clone.autoplay = videoElement.autoplay; + clone.controls = videoElement.controls; + clone.loop = videoElement.loop; + clone.muted = videoElement.muted; + clone.defaultMuted = videoElement.defaultMuted; + clone.preload = videoElement.preload; + clone.playsInline = videoElement.playsInline; + + clone.poster = ""; + if (clone.hasAttribute("poster")) { + clone.removeAttribute("poster"); + } + + let replaced = false; + safe(() => { + parent.replaceChild(clone, videoElement); + replaced = true; + }); + + if (!replaced) { + return videoElement; + } + + safe(() => { + if (typeof clone.load === "function") { + clone.load(); + } + }); + + this.log( + `[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).` + ); + + return clone; + } + registerUrlPlaybackWatchdogs( videoElement, { stallMs = 8000, onSuccess, onFallback } = {} @@ -5495,6 +5634,9 @@ class bitvidApp { } const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0; + this.log( + `[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}` + ); let active = true; let stallTimerId = null; @@ -5519,6 +5661,9 @@ class bitvidApp { if (!active) { return; } + this.log( + `[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}` + ); cleanup(); onFallback(reason); }; @@ -5527,6 +5672,9 @@ class bitvidApp { if (!active) { return; } + this.log( + "[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs." + ); cleanup(); if (typeof onSuccess === "function") { onSuccess(); @@ -5551,7 +5699,6 @@ class bitvidApp { addListener("error", () => triggerFallback("error")); addListener("abort", () => triggerFallback("abort")); addListener("stalled", () => triggerFallback("stalled")); - addListener("emptied", () => triggerFallback("emptied")); addListener("playing", handleSuccess); addListener("ended", handleSuccess); @@ -5573,6 +5720,7 @@ class bitvidApp { } this.urlPlaybackWatchdogCleanup = () => { + this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked."); cleanup(); }; @@ -7972,6 +8120,10 @@ class bitvidApp { : ""; const magnetProvided = playbackConfig.provided; + this.log( + `[playVideoWithFallback] Session start urlProvided=${!!sanitizedUrl} magnetProvided=${magnetProvided} magnetUsable=${!!magnetForPlayback}` + ); + if (this.currentVideo) { this.currentVideo.magnet = magnetForPlayback; this.currentVideo.normalizedMagnet = magnetForPlayback; @@ -7984,13 +8136,31 @@ class bitvidApp { this.currentMagnetUri = magnetForPlayback || null; this.setCopyMagnetState(!!magnetForPlayback); + let ensureDebugListenersRemoved = () => {}; + try { if (!this.modalVideo) { throw new Error("Video element is not ready for playback."); } + if (typeof this.modalPosterCleanup === "function") { + try { + this.modalPosterCleanup(); + } catch (err) { + console.warn("[playVideoWithFallback] modal poster cleanup threw:", err); + } finally { + this.modalPosterCleanup = null; + } + } + + const refreshedModal = this.teardownVideoElement(this.modalVideo, { + replaceNode: true, + }); + if (refreshedModal) { + this.modalVideo = refreshedModal; + } + this.showModalWithPoster(); - const videoEl = this.modalVideo; const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL"; if (this.modalStatus) { @@ -8002,15 +8172,59 @@ class bitvidApp { await torrentClient.cleanup(); - videoEl.pause(); - // Clearing the WebTorrent attachment requires wiping both `src` and - // `srcObject`. Multiple regressions have shipped where we only blanked - // `src`, leaving the old MediaSource wired up and the next magnet would - // stall on a grey frame. Always clear both before calling `load()`. - videoEl.src = ""; - videoEl.removeAttribute("src"); - videoEl.srcObject = null; - videoEl.load(); + const activeVideoEl = this.teardownVideoElement(this.modalVideo); + if (activeVideoEl) { + this.modalVideo = activeVideoEl; + this.log( + `[playVideoWithFallback] Modal video prepared (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` + ); + } + + if (activeVideoEl) { + const debugEvents = [ + "loadedmetadata", + "loadeddata", + "canplay", + "canplaythrough", + "play", + "playing", + "pause", + "stalled", + "suspend", + "waiting", + "ended", + "error", + ]; + const debugHandlers = []; + ensureDebugListenersRemoved = () => { + if (!debugHandlers.length) { + return; + } + for (const [eventName, handler] of debugHandlers) { + activeVideoEl.removeEventListener(eventName, handler); + } + debugHandlers.length = 0; + ensureDebugListenersRemoved = () => {}; + }; + for (const eventName of debugEvents) { + const handler = () => { + const { readyState, networkState, currentTime, paused, error } = + activeVideoEl; + let suffix = `readyState=${readyState} networkState=${networkState} currentTime=${Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime} paused=${paused}`; + if (eventName === "error" && error) { + const errorMessage = error.message || ""; + suffix += ` code=${error.code || ""} message=${errorMessage}`; + } + this.log( + `[playVideoWithFallback]