// js/services/playbackService.js class SimpleEventEmitter { constructor(logger = null) { this.logger = typeof logger === "function" ? logger : null; this.listeners = new Map(); } on(eventName, handler) { if (typeof handler !== "function") { return () => {}; } if (!this.listeners.has(eventName)) { this.listeners.set(eventName, new Set()); } const handlers = this.listeners.get(eventName); handlers.add(handler); return () => { handlers.delete(handler); if (!handlers.size) { this.listeners.delete(eventName); } }; } emit(eventName, detail) { const handlers = this.listeners.get(eventName); if (!handlers || !handlers.size) { return; } for (const handler of Array.from(handlers)) { try { handler(detail); } catch (err) { if (this.logger) { this.logger("[PlaybackService] Listener error", err); } } } } } const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL"; const DEFAULT_UNSUPPORTED_BTITH_MESSAGE = "This magnet link is missing a compatible BitTorrent v1 info hash."; export class PlaybackService { constructor({ logger, torrentClient, deriveTorrentPlaybackConfig, isValidMagnetUri, urlFirstEnabled = true, analyticsCallbacks = {}, } = {}) { if (typeof logger === "function") { this.logger = logger; } else if (logger && typeof logger.log === "function") { this.logger = (...args) => logger.log(...args); } else { this.logger = () => {}; } this.torrentClient = torrentClient; this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig; this.isValidMagnetUri = isValidMagnetUri; this.urlFirstEnabled = !!urlFirstEnabled; this.analyticsCallbacks = analyticsCallbacks || {}; this.currentSession = null; } log(...args) { try { this.logger(...args); } catch (err) { console.warn("[PlaybackService] logger threw", err); } } handleAnalyticsEvent(eventName, detail) { const callbacks = this.analyticsCallbacks || {}; if (typeof callbacks.onEvent === "function") { try { callbacks.onEvent(eventName, detail); } catch (err) { this.log("[PlaybackService] onEvent callback threw", err); } } const specific = callbacks[eventName]; if (typeof specific === "function") { try { specific(detail); } catch (err) { this.log(`[PlaybackService] ${eventName} callback threw`, err); } } } prepareVideoElement(videoElement) { if (!videoElement) { return; } const storedUnmuted = localStorage.getItem("unmutedAutoplay"); const userWantsUnmuted = storedUnmuted === "true"; videoElement.muted = !userWantsUnmuted; if (!videoElement.dataset.autoplayBound) { videoElement.addEventListener("volumechange", () => { localStorage.setItem( "unmutedAutoplay", (!videoElement.muted).toString() ); }); videoElement.dataset.autoplayBound = "true"; } } registerUrlPlaybackWatchdogs( videoElement, { stallMs = 8000, onSuccess, onFallback } = {} ) { 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); } }; 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(); } return () => { this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked."); cleanup(); }; } cleanupWatchdog() { if (this.currentSession) { this.currentSession.cleanupWatchdog(); } } createSession(options = {}) { const session = new PlaybackSession(this, options); this.currentSession = session; return session; } } class PlaybackSession extends SimpleEventEmitter { constructor(service, options = {}) { super((message, ...args) => service.log(message, ...args)); this.service = service; this.options = options; this.watchdogCleanup = null; this.result = { source: null }; this.finished = false; this.startPromise = null; this.sanitizedUrl = typeof options.url === "string" ? options.url.trim() : ""; this.trimmedMagnet = typeof options.magnet === "string" ? options.magnet.trim() : ""; this.requestSignature = typeof options.requestSignature === "string" ? options.requestSignature : JSON.stringify({ url: this.sanitizedUrl, magnet: this.trimmedMagnet, }); if (typeof service.deriveTorrentPlaybackConfig === "function") { this.playbackConfig = service.deriveTorrentPlaybackConfig({ magnet: this.trimmedMagnet, url: this.sanitizedUrl, logger: (message) => service.log(message), appProtocol: options.appProtocol, }); } else { this.playbackConfig = { magnet: this.trimmedMagnet, fallbackMagnet: "", provided: !!this.trimmedMagnet, usedInfoHash: false, originalInput: this.trimmedMagnet, didMutate: false, infoHash: "", }; } const magnetIsUsable = typeof service.isValidMagnetUri === "function" ? service.isValidMagnetUri(this.playbackConfig.magnet) : false; this.magnetForPlayback = magnetIsUsable ? this.playbackConfig.magnet : ""; this.fallbackMagnet = magnetIsUsable ? this.playbackConfig.fallbackMagnet : ""; this.magnetProvided = !!this.playbackConfig.provided; } isActive() { return !this.finished; } matchesRequestSignature(signature) { if (!this.isActive()) { return false; } if (typeof signature !== "string" || !signature) { return false; } return signature === this.requestSignature; } getResult() { return this.result; } getPlaybackConfig() { return this.playbackConfig; } getMagnetForPlayback() { return this.magnetForPlayback; } getFallbackMagnet() { return this.fallbackMagnet; } getMagnetProvided() { return this.magnetProvided; } cleanupWatchdog() { if (typeof this.watchdogCleanup === "function") { try { this.watchdogCleanup(); } catch (err) { this.service.log("[PlaybackSession] Watchdog cleanup threw", err); } finally { this.watchdogCleanup = null; } } } registerWatchdogs(videoElement, options) { this.cleanupWatchdog(); this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs( videoElement, options ); } attachDebugListeners(videoElement) { if (!videoElement) { return () => {}; } const debugEvents = [ "loadedmetadata", "loadeddata", "canplay", "canplaythrough", "play", "playing", "pause", "stalled", "suspend", "waiting", "ended", "error", ]; const handlers = []; for (const eventName of debugEvents) { const handler = () => { const { readyState, networkState, currentTime, paused, error } = videoElement; 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.service.log( `[playVideoWithFallback]