From 2b42d0ada642ccedcba22ecccd40038594260950 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:59:12 -0400 Subject: [PATCH] Refactor playback into service --- js/app.js | 641 ++++++----------------------- js/services/playbackService.js | 731 +++++++++++++++++++++++++++++++++ 2 files changed, 860 insertions(+), 512 deletions(-) create mode 100644 js/services/playbackService.js diff --git a/js/app.js b/js/app.js index 99a8f4d3..087168e2 100644 --- a/js/app.js +++ b/js/app.js @@ -21,6 +21,7 @@ import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; +import PlaybackService from "./services/playbackService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; @@ -358,10 +359,40 @@ class bitvidApp { this.videoDiscussionCountCache = new Map(); this.inFlightDiscussionCounts = new Map(); this.activeIntervals = []; - this.urlPlaybackWatchdogCleanup = null; this.watchHistoryMetadataEnabled = null; this.watchHistoryPreferenceUnsubscribe = null; + this.playbackService = new PlaybackService({ + logger: (message, ...args) => this.log(message, ...args), + torrentClient, + deriveTorrentPlaybackConfig, + isValidMagnetUri, + urlFirstEnabled: URL_FIRST_ENABLED, + analyticsCallbacks: { + "session-start": (detail) => { + const urlProvided = detail?.urlProvided ? "true" : "false"; + const magnetProvided = detail?.magnetProvided ? "true" : "false"; + const magnetUsable = detail?.magnetUsable ? "true" : "false"; + this.log( + `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}` + ); + }, + fallback: (detail) => { + if (detail?.reason) { + this.log( + `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).` + ); + } + }, + error: (detail) => { + if (detail?.error) { + this.log("[playVideoWithFallback] Playback error observed", detail.error); + } + }, + }, + }); + this.activePlaybackSession = null; + // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; this.profileAvatar = document.getElementById("profileAvatar") || null; @@ -6015,7 +6046,9 @@ class bitvidApp { } ); this.clearActiveIntervals(); - this.cleanupUrlPlaybackWatchdog(); + if (this.playbackService) { + this.playbackService.cleanupWatchdog(); + } this.teardownModalViewCountSubscription(); if (!preserveObservers && this.mediaLoader) { @@ -6106,18 +6139,6 @@ class bitvidApp { this.activeIntervals = []; } - cleanupUrlPlaybackWatchdog() { - if (typeof this.urlPlaybackWatchdogCleanup === "function") { - try { - this.urlPlaybackWatchdogCleanup(); - } catch (err) { - console.warn("[cleanupUrlPlaybackWatchdog]", err); - } finally { - this.urlPlaybackWatchdogCleanup = null; - } - } - } - cancelPendingViewLogging() { const state = this.playbackTelemetryState; if (!state) { @@ -6934,110 +6955,6 @@ class bitvidApp { return clone; } - registerUrlPlaybackWatchdogs( - videoElement, - { stallMs = 8000, onSuccess, onFallback } = {} - ) { - this.cleanupUrlPlaybackWatchdog(); - - if (!videoElement || typeof onFallback !== "function") { - return () => {}; - } - - 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; - - const listeners = []; - - const cleanup = () => { - if (!active) { - return; - } - active = false; - if (stallTimerId) { - clearTimeout(stallTimerId); - stallTimerId = null; - } - for (const [eventName, handler] of listeners) { - videoElement.removeEventListener(eventName, handler); - } - this.urlPlaybackWatchdogCleanup = null; - }; - - const triggerFallback = (reason) => { - if (!active) { - return; - } - this.log( - `[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}` - ); - cleanup(); - onFallback(reason); - }; - - const handleSuccess = () => { - if (!active) { - return; - } - this.log( - "[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs." - ); - cleanup(); - if (typeof onSuccess === "function") { - onSuccess(); - } - }; - - const resetTimer = () => { - if (!active || !normalizedStallMs) { - return; - } - if (stallTimerId) { - clearTimeout(stallTimerId); - } - stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs); - }; - - const addListener = (eventName, handler) => { - videoElement.addEventListener(eventName, handler); - listeners.push([eventName, handler]); - }; - - addListener("error", () => triggerFallback("error")); - addListener("abort", () => triggerFallback("abort")); - addListener("stalled", () => triggerFallback("stalled")); - addListener("playing", handleSuccess); - addListener("ended", handleSuccess); - - const timerEvents = [ - "timeupdate", - "progress", - "loadeddata", - "canplay", - "canplaythrough", - "suspend", - "waiting", - ]; - for (const eventName of timerEvents) { - addListener(eventName, resetTimer); - } - - if (normalizedStallMs) { - resetTimer(); - } - - this.urlPlaybackWatchdogCleanup = () => { - this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked."); - cleanup(); - }; - - return this.urlPlaybackWatchdogCleanup; - } - resetTorrentStats() { if (this.modalPeers) { this.modalPeers.textContent = ""; @@ -7143,26 +7060,6 @@ class bitvidApp { return dedupeToNewestByRoot(videos); } - prepareModalVideoForPlayback() { - if (!this.modalVideo) { - return; - } - - const storedUnmuted = localStorage.getItem("unmutedAutoplay"); - const userWantsUnmuted = storedUnmuted === "true"; - this.modalVideo.muted = !userWantsUnmuted; - - if (!this.modalVideo.dataset.autoplayBound) { - this.modalVideo.addEventListener("volumechange", () => { - localStorage.setItem( - "unmutedAutoplay", - (!this.modalVideo.muted).toString() - ); - }); - this.modalVideo.dataset.autoplayBound = "true"; - } - } - autoplayModalVideo() { if (!this.modalVideo) return; this.modalVideo.play().catch((err) => { @@ -9762,40 +9659,65 @@ class bitvidApp { * and falls back to WebTorrent when needed. */ async playVideoWithFallback({ url = "", magnet = "" } = {}) { - // When the modal closes we run an asynchronous cleanup that tears down the - // previous MediaSource attachment. If the user immediately selects another - // video we must wait for that teardown to finish or the stale cleanup will - // wipe the freshly attached `src`/`srcObject`, leaving playback stuck on a - // blank frame. Guard every playback entry point with waitForCleanup() so the - // race never reappears. await this.waitForCleanup(); this.cancelPendingViewLogging(); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; - const playbackConfig = deriveTorrentPlaybackConfig({ - magnet: trimmedMagnet, + 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; + } + + const session = this.playbackService.createSession({ url: sanitizedUrl, - logger: (message) => this.log(message), + magnet: trimmedMagnet, + videoElement: this.modalVideo, + waitForCleanup: () => this.waitForCleanup(), + cancelPendingViewLogging: () => this.cancelPendingViewLogging(), + clearActiveIntervals: () => this.clearActiveIntervals(), + showModalWithPoster: () => this.showModalWithPoster(), + teardownVideoElement: (videoEl, options) => + this.teardownVideoElement(videoEl, options), + probeUrl: (candidateUrl) => this.probeUrl(candidateUrl), + playViaWebTorrent: (magnetUri, options) => + this.playViaWebTorrent(magnetUri, options), + autoplay: () => this.autoplayModalVideo(), + unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, }); - const magnetIsUsable = isValidMagnetUri(playbackConfig.magnet); - const magnetForPlayback = magnetIsUsable ? playbackConfig.magnet : ""; - const fallbackMagnet = magnetIsUsable - ? playbackConfig.fallbackMagnet - : ""; - const magnetProvided = playbackConfig.provided; + this.activePlaybackSession = session; - this.log( - `[playVideoWithFallback] Session start urlProvided=${!!sanitizedUrl} magnetProvided=${magnetProvided} magnetUsable=${!!magnetForPlayback}` - ); + this.resetTorrentStats(); + this.playSource = null; + + const playbackConfig = session.getPlaybackConfig(); + const magnetForPlayback = session.getMagnetForPlayback(); + const fallbackMagnet = session.getFallbackMagnet(); + const magnetProvided = session.getMagnetProvided(); if (this.currentVideo) { this.currentVideo.magnet = magnetForPlayback; this.currentVideo.normalizedMagnet = magnetForPlayback; this.currentVideo.normalizedMagnetFallback = fallbackMagnet; - if (playbackConfig.infoHash && !this.currentVideo.legacyInfoHash) { + if (playbackConfig?.infoHash && !this.currentVideo.legacyInfoHash) { this.currentVideo.legacyInfoHash = playbackConfig.infoHash; } this.currentVideo.torrentSupported = !!magnetForPlayback; @@ -9803,383 +9725,78 @@ 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 HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL"; + const unsubscribers = []; + const subscribe = (eventName, handler) => { + const off = session.on(eventName, handler); + unsubscribers.push(off); + }; + subscribe("status", ({ message } = {}) => { if (this.modalStatus) { - this.modalStatus.textContent = "Preparing video..."; + this.modalStatus.textContent = + typeof message === "string" ? message : ""; } + }); - this.clearActiveIntervals(); - this.prepareModalVideoForPlayback(); - - await torrentClient.cleanup(); - - const activeVideoEl = this.teardownVideoElement(this.modalVideo); - if (activeVideoEl) { - this.modalVideo = activeVideoEl; - this.log( - `[playVideoWithFallback] Modal video prepared (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` - ); + subscribe("video-prepared", ({ videoElement } = {}) => { + if (videoElement && videoElement !== this.modalVideo) { + this.modalVideo = videoElement; } + }); - 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]