From 0da94012357b113654f0902bc6470bdec2c6cb34 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 04:27:31 +0000 Subject: [PATCH] Refactor playback selection strategy into dedicated service Extracted the complex playback logic from `js/app/playbackCoordinator.js` into a new `js/services/playbackStrategyService.js`. This improves maintainability by separating the orchestration logic from the application glue code. The coordinator now delegates to the service, passing a context object for UI interactions and state synchronization. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/app/playbackCoordinator.js | 339 ++++++------------------ js/services/playbackStrategyService.js | 345 +++++++++++++++++++++++++ js/ui/applicationBootstrap.js | 5 + 3 files changed, 422 insertions(+), 267 deletions(-) create mode 100644 js/services/playbackStrategyService.js diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 2ca17efe..43343cf6 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -448,290 +448,95 @@ export function createPlaybackCoordinator(deps) { * and falls back to WebTorrent when needed. */ async playVideoWithFallback(options = {}) { - const { url = "", magnet = "", trigger, forcedSource } = options || {}; + const playbackStrategyService = this.playbackStrategyService; + if (!playbackStrategyService) { + this.showError("Playback strategy service is not available."); + return { source: null, error: new Error("Service missing") }; + } - emit("playback-decision", { - method: forcedSource || (magnet ? "webtorrent" : "url"), // heuristic - details: { - url: Boolean(url), - magnet: Boolean(magnet), - forcedSource, + return playbackStrategyService.play(options, { + getModalVideo: () => this.modalVideo, + setModalVideo: (videoElement) => { + this.modalVideo = videoElement; }, - }); - - const hasTrigger = Object.prototype.hasOwnProperty.call( - options || {}, - "trigger" - ); - if (hasTrigger) { - this.setLastModalTrigger(trigger); - } - const sanitizedUrl = typeof url === "string" ? url.trim() : ""; - const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; - const previousSource = this.playSource || null; - const requestSignature = JSON.stringify({ - url: sanitizedUrl, - magnet: trimmedMagnet, - forcedSource, - }); - - const modalVideoIsConnected = (() => { - if (!this.modalVideo) { - return false; - } - if (typeof this.modalVideo.isConnected === "boolean") { - return this.modalVideo.isConnected; - } - const ownerDocument = this.modalVideo.ownerDocument || - (typeof document !== "undefined" ? document : null); - if (ownerDocument?.contains) { - try { - return ownerDocument.contains(this.modalVideo); - } catch (error) { - devLogger.warn( - "[playVideoWithFallback] Failed to determine modal video connection state", - error, - ); + getVideoModalElement: () => + this.videoModal && typeof this.videoModal.getVideoElement === "function" + ? this.videoModal.getVideoElement() + : null, + setVideoModalElement: (videoElement) => { + if ( + this.videoModal && + typeof this.videoModal.setVideoElement === "function" + ) { + this.videoModal.setVideoElement(videoElement); } - } - return true; - })(); + }, + cleanupVideoModalPoster: () => { + if ( + this.videoModal && + typeof this.videoModal.clearPosterCleanup === "function" + ) { + this.videoModal.clearPosterCleanup(); + } + }, + ensureVideoModalReady: (opts) => this.ensureVideoModalReady(opts), + teardownVideoElement: (videoEl, opts) => + this.teardownVideoElement(videoEl, opts), + applyModalLoadingPoster: () => this.applyModalLoadingPoster(), - const shouldReuseActiveSession = - modalVideoIsConnected && - this.activePlaybackSession && - typeof this.activePlaybackSession.matchesRequestSignature === "function" && - this.activePlaybackSession.matchesRequestSignature(requestSignature); - - if (shouldReuseActiveSession) { - this.log( - "[playVideoWithFallback] Duplicate playback request detected; reusing active session." - ); - if (this.activePlaybackResultPromise) { - return this.activePlaybackResultPromise; - } - if (typeof this.activePlaybackSession.getResult === "function") { - return this.activePlaybackSession.getResult(); - } - return { source: null }; - } - - await this.waitForCleanup(); - this.cancelPendingViewLogging(); - - if ( - previousSource === "torrent" && - sanitizedUrl && - this.playbackService && - this.playbackService.torrentClient && - typeof this.playbackService.torrentClient.cleanup === "function" - ) { - try { - this.log( - "[playVideoWithFallback] Previous playback used WebTorrent; cleaning up before preparing hosted session.", - ); - await this.playbackService.torrentClient.cleanup(); - } catch (error) { - devLogger.warn( - "[playVideoWithFallback] Pre-playback torrent cleanup threw:", - error, - ); - } - } - - let modalVideoEl = this.modalVideo; - const modalVideoFromController = - this.videoModal && typeof this.videoModal.getVideoElement === "function" - ? this.videoModal.getVideoElement() - : null; - if (modalVideoFromController && modalVideoFromController !== modalVideoEl) { - modalVideoEl = modalVideoFromController; - this.modalVideo = modalVideoEl; - } - const modalVideoConnected = Boolean( - modalVideoEl && modalVideoEl.isConnected, - ); - if (!modalVideoEl || !modalVideoConnected) { - try { - const { videoElement } = await this.ensureVideoModalReady({ - ensureVideoElement: true, - }); - modalVideoEl = videoElement; - this.modalVideo = modalVideoEl; - } catch (error) { - this.log( - "[playVideoWithFallback] Failed to load video modal before playback:", - error - ); - this.showError("Could not prepare the video player. Please try again."); - return { source: null, error }; - } - } - - if (!modalVideoEl) { - const error = new Error("Video element is not ready for playback."); - this.log( - "[playVideoWithFallback] Video element missing after modal load attempt." - ); - this.showError("Video player is not ready yet. Please try again."); - return { source: null, error }; - } - - if ( - this.videoModal && - typeof this.videoModal.clearPosterCleanup === "function" - ) { - try { - this.videoModal.clearPosterCleanup(); - } catch (err) { - devLogger.warn( - "[playVideoWithFallback] video modal poster cleanup threw:", - err - ); - } - } - - const refreshedModal = this.teardownVideoElement(modalVideoEl, { - replaceNode: true, - }); - if (refreshedModal) { - if ( - this.videoModal && - typeof this.videoModal.setVideoElement === "function" - ) { - this.videoModal.setVideoElement(refreshedModal); - } - this.modalVideo = refreshedModal; - modalVideoEl = this.modalVideo; - this.applyModalLoadingPoster(); - } else { - this.applyModalLoadingPoster(); - } - - const session = this.playbackService.createSession({ - url: sanitizedUrl, - magnet: trimmedMagnet, - requestSignature, - 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), + playViaWebTorrent: (magnetUri, opts) => + this.playViaWebTorrent(magnetUri, opts), autoplay: () => this.autoplayModalVideo(), + unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, - forcedSource, - }); - this.activePlaybackSession = session; - this.activePlaybackResultPromise = null; + showError: (msg) => this.showError(msg), + setLastModalTrigger: (trigger) => this.setLastModalTrigger(trigger), - this.resetTorrentStats(); - this.playSource = null; + resetTorrentStats: () => this.resetTorrentStats(), + setPlaySource: (source) => { + this.playSource = source; + }, + setActivePlaybackSession: (session) => { + this.activePlaybackSession = session; + }, + setActivePlaybackResultPromise: (promise) => { + this.activePlaybackResultPromise = promise; + }, - 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) { - this.currentVideo.legacyInfoHash = playbackConfig.infoHash; - } - this.currentVideo.torrentSupported = !!magnetForPlayback; - } - this.currentMagnetUri = magnetForPlayback || null; - // this.setCopyMagnetState(!!magnetForPlayback); // Removed - - const unsubscribers = []; - const subscribe = (eventName, handler) => { - const off = session.on(eventName, handler); - unsubscribers.push(off); - }; - - subscribe("status", ({ message } = {}) => { - if (this.videoModal) { - this.videoModal.updateStatus( - typeof message === "string" ? message : "" - ); - } - }); - - subscribe("video-prepared", ({ videoElement } = {}) => { - if (videoElement && videoElement !== this.modalVideo) { - this.modalVideo = videoElement; - } - }); - - subscribe("view-logging-request", ({ videoElement } = {}) => { - if (videoElement) { - this.preparePlaybackLogging(videoElement); - } - }); - - subscribe("poster-remove", ({ reason } = {}) => { - this.forceRemoveModalPoster(reason || "playback"); - }); - - subscribe("sourcechange", ({ source } = {}) => { - this.playSource = source || null; - const usingTorrent = source === "torrent"; - if (this.videoModal) { - this.videoModal.setTorrentStatsVisibility?.(usingTorrent); - } - }); - - subscribe("error", ({ error, message } = {}) => { - const displayMessage = - typeof message === "string" - ? message - : error && error.message - ? `Playback error: ${error.message}` - : "Playback error"; - this.showError(displayMessage); - }); - - subscribe("finished", () => { - if (this.activePlaybackSession === session) { - this.activePlaybackSession = null; - this.activePlaybackResultPromise = null; - } - while (unsubscribers.length) { - const off = unsubscribers.pop(); - if (typeof off === "function") { - try { - off(); - } catch (err) { - devLogger.warn( - "[playVideoWithFallback] Listener cleanup error:", - err - ); + updateCurrentVideoMetadata: (metadata) => { + if (this.currentVideo) { + this.currentVideo.magnet = metadata.magnet; + this.currentVideo.normalizedMagnet = metadata.normalizedMagnet; + this.currentVideo.normalizedMagnetFallback = + metadata.normalizedMagnetFallback; + if (metadata.legacyInfoHash && !this.currentVideo.legacyInfoHash) { + this.currentVideo.legacyInfoHash = metadata.legacyInfoHash; } + this.currentVideo.torrentSupported = metadata.torrentSupported; } - } + this.currentMagnetUri = metadata.magnet || null; + }, + + updateVideoModalStatus: (message) => { + if (this.videoModal) { + this.videoModal.updateStatus(message); + } + }, + preparePlaybackLogging: (videoElement) => + this.preparePlaybackLogging(videoElement), + forceRemoveModalPoster: (reason) => this.forceRemoveModalPoster(reason), + setTorrentStatsVisibility: (visible) => { + if (this.videoModal) { + this.videoModal.setTorrentStatsVisibility?.(visible); + } + }, }); - - const startPromise = session.start(); - this.activePlaybackResultPromise = startPromise; - const result = await startPromise; - - if (!result || result.error) { - return result; - } - - emit("playback-started", { - method: result.source, - details: { startedAt: Date.now() }, - }); - - return result; }, async playVideoByEventId(eventId, playbackHint = {}) { diff --git a/js/services/playbackStrategyService.js b/js/services/playbackStrategyService.js new file mode 100644 index 00000000..cff8074a --- /dev/null +++ b/js/services/playbackStrategyService.js @@ -0,0 +1,345 @@ +import { emit } from "../embedDiagnostics.js"; + +/** + * PlaybackStrategyService orchestrates the decision-making process for video playback. + * It determines whether to reuse an existing session, manages the lifecycle of the + * playback session, and coordinates between the PlaybackService (mechanism) and + * the Application (state/UI). + */ +export class PlaybackStrategyService { + constructor({ playbackService, logger }) { + this.playbackService = playbackService; + this.logger = logger || console; + this.activePlaybackSession = null; + this.activePlaybackResultPromise = null; + this.playSource = null; + } + + log(...args) { + if (this.logger && typeof this.logger.log === "function") { + const prefix = "[PlaybackStrategy]"; + if (typeof args[0] === "string") { + this.logger.log(`${prefix} ${args[0]}`, ...args.slice(1)); + } else { + this.logger.log(prefix, ...args); + } + } + } + + /** + * Orchestrates the playback flow. + * + * @param {object} options - Playback options (url, magnet, trigger, forcedSource). + * @param {object} context - Application context providing UI and State access. + * @returns {Promise} The result of the playback attempt. + */ + async play(options = {}, context = {}) { + const { url = "", magnet = "", trigger, forcedSource } = options || {}; + + emit("playback-decision", { + method: forcedSource || (magnet ? "webtorrent" : "url"), // heuristic + details: { + url: Boolean(url), + magnet: Boolean(magnet), + forcedSource, + }, + }); + + const hasTrigger = Object.prototype.hasOwnProperty.call(options || {}, "trigger"); + if (hasTrigger && typeof context.setLastModalTrigger === "function") { + context.setLastModalTrigger(trigger); + } + + const sanitizedUrl = typeof url === "string" ? url.trim() : ""; + const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; + const previousSource = this.playSource || null; + const requestSignature = JSON.stringify({ + url: sanitizedUrl, + magnet: trimmedMagnet, + forcedSource, + }); + + const modalVideoIsConnected = (() => { + const modalVideo = context.getModalVideo ? context.getModalVideo() : null; + if (!modalVideo) { + return false; + } + if (typeof modalVideo.isConnected === "boolean") { + return modalVideo.isConnected; + } + const ownerDocument = modalVideo.ownerDocument || + (typeof document !== "undefined" ? document : null); + if (ownerDocument?.contains) { + try { + return ownerDocument.contains(modalVideo); + } catch (error) { + this.log("Failed to determine modal video connection state", error); + } + } + return true; + })(); + + const shouldReuseActiveSession = + modalVideoIsConnected && + this.activePlaybackSession && + typeof this.activePlaybackSession.matchesRequestSignature === "function" && + this.activePlaybackSession.matchesRequestSignature(requestSignature); + + if (shouldReuseActiveSession) { + this.log("Duplicate playback request detected; reusing active session."); + if (this.activePlaybackResultPromise) { + return this.activePlaybackResultPromise; + } + if (typeof this.activePlaybackSession.getResult === "function") { + return this.activePlaybackSession.getResult(); + } + return { source: null }; + } + + if (typeof context.waitForCleanup === "function") { + await context.waitForCleanup(); + } + if (typeof context.cancelPendingViewLogging === "function") { + context.cancelPendingViewLogging(); + } + + if ( + previousSource === "torrent" && + sanitizedUrl && + this.playbackService && + this.playbackService.torrentClient && + typeof this.playbackService.torrentClient.cleanup === "function" + ) { + try { + this.log("Previous playback used WebTorrent; cleaning up before preparing hosted session."); + await this.playbackService.torrentClient.cleanup(); + } catch (error) { + this.log("Pre-playback torrent cleanup threw:", error); + } + } + + let modalVideoEl = context.getModalVideo ? context.getModalVideo() : null; + const modalVideoFromController = + context.getVideoModalElement ? context.getVideoModalElement() : null; + + if (modalVideoFromController && modalVideoFromController !== modalVideoEl) { + modalVideoEl = modalVideoFromController; + if (context.setModalVideo) { + context.setModalVideo(modalVideoEl); + } + } + + const modalVideoConnected = Boolean(modalVideoEl && modalVideoEl.isConnected); + if (!modalVideoEl || !modalVideoConnected) { + try { + if (typeof context.ensureVideoModalReady === "function") { + const { videoElement } = await context.ensureVideoModalReady({ + ensureVideoElement: true, + }); + modalVideoEl = videoElement; + if (context.setModalVideo) { + context.setModalVideo(modalVideoEl); + } + } + } catch (error) { + this.log("Failed to load video modal before playback:", error); + if (typeof context.showError === "function") { + context.showError("Could not prepare the video player. Please try again."); + } + return { source: null, error }; + } + } + + if (!modalVideoEl) { + const error = new Error("Video element is not ready for playback."); + this.log("Video element missing after modal load attempt."); + if (typeof context.showError === "function") { + context.showError("Video player is not ready yet. Please try again."); + } + return { source: null, error }; + } + + if (context.cleanupVideoModalPoster) { + try { + context.cleanupVideoModalPoster(); + } catch (err) { + this.log("video modal poster cleanup threw:", err); + } + } + + let refreshedModal = null; + if (typeof context.teardownVideoElement === "function") { + refreshedModal = context.teardownVideoElement(modalVideoEl, { + replaceNode: true, + }); + } + + if (refreshedModal) { + if (context.setVideoModalElement) { + context.setVideoModalElement(refreshedModal); + } + if (context.setModalVideo) { + context.setModalVideo(refreshedModal); + } + modalVideoEl = refreshedModal; // Ensure we use the new element + if (typeof context.applyModalLoadingPoster === "function") { + context.applyModalLoadingPoster(); + } + } else { + if (typeof context.applyModalLoadingPoster === "function") { + context.applyModalLoadingPoster(); + } + } + + // Double check that modalVideoEl is current (it might have been replaced) + modalVideoEl = context.getModalVideo ? context.getModalVideo() : modalVideoEl; + + const session = this.playbackService.createSession({ + url: sanitizedUrl, + magnet: trimmedMagnet, + requestSignature, + videoElement: modalVideoEl, + waitForCleanup: context.waitForCleanup, + cancelPendingViewLogging: context.cancelPendingViewLogging, + clearActiveIntervals: context.clearActiveIntervals, + showModalWithPoster: context.showModalWithPoster, + teardownVideoElement: context.teardownVideoElement, + probeUrl: context.probeUrl, + playViaWebTorrent: context.playViaWebTorrent, + autoplay: context.autoplay, + unsupportedBtihMessage: context.unsupportedBtihMessage, + forcedSource, + }); + + this.activePlaybackSession = session; + if (typeof context.setActivePlaybackSession === "function") { + context.setActivePlaybackSession(session); + } + this.activePlaybackResultPromise = null; + if (typeof context.setActivePlaybackResultPromise === "function") { + context.setActivePlaybackResultPromise(null); + } + + if (typeof context.resetTorrentStats === "function") { + context.resetTorrentStats(); + } + this.playSource = null; + if (typeof context.setPlaySource === "function") { + context.setPlaySource(null); + } + + const playbackConfig = session.getPlaybackConfig(); + const magnetForPlayback = session.getMagnetForPlayback(); + const fallbackMagnet = session.getFallbackMagnet(); + // const magnetProvided = session.getMagnetProvided(); + + if (context.updateCurrentVideoMetadata) { + context.updateCurrentVideoMetadata({ + magnet: magnetForPlayback, + normalizedMagnet: magnetForPlayback, + normalizedMagnetFallback: fallbackMagnet, + legacyInfoHash: playbackConfig?.infoHash || null, + torrentSupported: !!magnetForPlayback + }); + } + + // this.currentMagnetUri = magnetForPlayback || null; // Logic is in context if needed + + const unsubscribers = []; + const subscribe = (eventName, handler) => { + const off = session.on(eventName, handler); + unsubscribers.push(off); + }; + + subscribe("status", ({ message } = {}) => { + if (typeof context.updateVideoModalStatus === "function") { + context.updateVideoModalStatus(typeof message === "string" ? message : ""); + } + }); + + subscribe("video-prepared", ({ videoElement } = {}) => { + if (videoElement && videoElement !== (context.getModalVideo ? context.getModalVideo() : null)) { + if (context.setModalVideo) { + context.setModalVideo(videoElement); + } + } + }); + + subscribe("view-logging-request", ({ videoElement } = {}) => { + if (videoElement && typeof context.preparePlaybackLogging === "function") { + context.preparePlaybackLogging(videoElement); + } + }); + + subscribe("poster-remove", ({ reason } = {}) => { + if (typeof context.forceRemoveModalPoster === "function") { + context.forceRemoveModalPoster(reason || "playback"); + } + }); + + subscribe("sourcechange", ({ source } = {}) => { + this.playSource = source || null; + if (typeof context.setPlaySource === "function") { + context.setPlaySource(this.playSource); + } + const usingTorrent = source === "torrent"; + if (typeof context.setTorrentStatsVisibility === "function") { + context.setTorrentStatsVisibility(usingTorrent); + } + }); + + subscribe("error", ({ error, message } = {}) => { + const displayMessage = + typeof message === "string" + ? message + : error && error.message + ? `Playback error: ${error.message}` + : "Playback error"; + if (typeof context.showError === "function") { + context.showError(displayMessage); + } + }); + + subscribe("finished", () => { + if (this.activePlaybackSession === session) { + this.activePlaybackSession = null; + if (typeof context.setActivePlaybackSession === "function") { + context.setActivePlaybackSession(null); + } + this.activePlaybackResultPromise = null; + if (typeof context.setActivePlaybackResultPromise === "function") { + context.setActivePlaybackResultPromise(null); + } + } + while (unsubscribers.length) { + const off = unsubscribers.pop(); + if (typeof off === "function") { + try { + off(); + } catch (err) { + this.log("Listener cleanup error:", err); + } + } + } + }); + + const startPromise = session.start(); + this.activePlaybackResultPromise = startPromise; + if (typeof context.setActivePlaybackResultPromise === "function") { + context.setActivePlaybackResultPromise(startPromise); + } + const result = await startPromise; + + if (!result || result.error) { + return result; + } + + emit("playback-started", { + method: result.source, + details: { startedAt: Date.now() }, + }); + + return result; + } +} +export default PlaybackStrategyService; diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index b518a079..a381943e 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -6,6 +6,7 @@ import { createWatchHistoryRenderer } from "../historyView.js"; import WatchHistoryController from "./watchHistoryController.js"; import WatchHistoryTelemetry from "../services/watchHistoryTelemetry.js"; import PlaybackService from "../services/playbackService.js"; +import PlaybackStrategyService from "../services/playbackStrategyService.js"; import AuthService from "../services/authService.js"; import DiscussionCountService from "../services/discussionCountService.js"; import CommentThreadService from "../services/commentThreadService.js"; @@ -350,6 +351,10 @@ export default class ApplicationBootstrap { }, }, }); + app.playbackStrategyService = new PlaybackStrategyService({ + playbackService: app.playbackService, + logger: devLogger, + }); app.activePlaybackResultPromise = null; app.activePlaybackSession = null;