Merge pull request #1243 from PR0M3TH3AN/docs/playback-service-comments-12088992108818376989

Add developer documentation to playback services
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-15 15:17:02 -05:00
commit ffacc96c4a
2 files changed
+104 -1

No files matched your search

+81 -1
View File
@@ -1,6 +1,23 @@
import { userLogger } from "../utils/logger.js";
// js/services/playbackService.js
/**
* PlaybackService acts as the central conductor for video playback.
* It manages the lifecycle of a "PlaybackSession", which encapsulates the complex
* logic of attempting to play a video from a direct URL (HTTP/CDN) first, and
* falling back to a WebTorrent (P2P) stream if the URL is unreachable or stalls.
*
* Key Responsibilities:
* - Singleton-ish orchestration (only one active session at a time usually).
* - "Watchdog" monitoring: It attaches listeners to the <video> element to detect
* frozen playback (stalls) or errors.
* - Fallback logic: If the direct URL fails (404, network error) or playback
* stalls, it seamlessly triggers the P2P engine (WebTorrent).
* - Race Condition Management: Uses "request signatures" to ensure that if the user
* clicks "Play" on a new video while the previous one is still loading, the
* old session is cancelled and doesn't overwrite the new one.
*/
class SimpleEventEmitter {
constructor(logger = null) {
this.logger = typeof logger === "function" ? logger : null;
@@ -116,6 +133,22 @@ export class PlaybackService {
}
}
/**
* Registers a set of event listeners ("watchdogs") on the video element to detect
* when playback has stalled or failed.
*
* This is critical for the "Hybrid" playback strategy. We attempt to play the
* direct URL (which is cheap and fast). If it hangs (e.g., buffering forever)
* or errors out, this watchdog fires the `onFallback` callback, which triggers
* the switch to P2P.
*
* @param {HTMLVideoElement} videoElement
* @param {object} options
* @param {number} options.stallMs - How long to wait without progress before considering it a "stall".
* @param {Function} options.onSuccess - Called if playback starts successfully (clears the watchdog).
* @param {Function} options.onFallback - Called if playback fails or stalls.
* @returns {Function} cleanup function to remove listeners.
*/
registerUrlPlaybackWatchdogs(
videoElement,
{ stallMs = 8000, onSuccess, onFallback } = {}
@@ -171,6 +204,8 @@ export class PlaybackService {
}
};
// The timer is reset on any "progress" event. If `stallMs` passes without
// a reset, we assume the connection is dead/stalled and fallback.
const resetTimer = () => {
if (!active || !normalizedStallMs) {
return;
@@ -186,12 +221,16 @@ export class PlaybackService {
listeners.push([eventName, handler]);
};
// Immediate failure conditions
addListener("error", () => triggerFallback("error"));
addListener("abort", () => triggerFallback("abort"));
addListener("stalled", () => triggerFallback("stalled"));
// Success conditions
addListener("playing", handleSuccess);
addListener("ended", handleSuccess);
// Keep-alive events that reset the stall timer
const timerEvents = [
"timeupdate",
"progress",
@@ -228,6 +267,19 @@ export class PlaybackService {
}
}
/**
* PlaybackSession represents a single attempt to play a specific video.
* It manages the state machine of:
* 1. Probing the direct URL.
* 2. Attempting to play the URL.
* 3. Monitoring for stalls.
* 4. Falling back to WebTorrent if needed.
*
* It uses a `requestSignature` (JSON of url+magnet) to uniquely identify the request.
* This is used by the UI layer to prevent race conditions: if the user clicks a new video,
* the UI can check `matchesRequestSignature` to see if it can reuse the active session
* or if it needs to spin up a new one.
*/
class PlaybackSession extends SimpleEventEmitter {
constructor(service, options = {}) {
super((message, ...args) => service.log(message, ...args));
@@ -242,6 +294,9 @@ class PlaybackSession extends SimpleEventEmitter {
typeof options.url === "string" ? options.url.trim() : "";
this.trimmedMagnet =
typeof options.magnet === "string" ? options.magnet.trim() : "";
// The signature prevents duplicate work if the same video is requested again
// while already loading/playing.
this.requestSignature =
typeof options.requestSignature === "string"
? options.requestSignature
@@ -389,6 +444,10 @@ class PlaybackSession extends SimpleEventEmitter {
};
}
/**
* Begins the playback flow. Returns a promise that resolves when playback
* has either successfully started (URL or Torrent) or fatally failed.
*/
async start() {
if (this.startPromise) {
return this.startPromise;
@@ -397,6 +456,17 @@ class PlaybackSession extends SimpleEventEmitter {
return this.startPromise;
}
/**
* The core execution loop for the session.
* Flow:
* 1. Check if forced source (e.g. user manually switched to "Torrent")
* 2. If URL available & URL-first enabled:
* a. Probe the URL (HEAD request) to see if it's reachable.
* b. If probe succeeds, attempt to play.
* c. Attach watchdogs.
* d. If watchdog triggers (stall/error), trigger `startTorrentFallback`.
* 3. If URL fails or not available, call `startTorrentFallback`.
*/
async execute() {
const {
videoElement,
@@ -480,6 +550,8 @@ class PlaybackSession extends SimpleEventEmitter {
);
}
// -- Fallback Logic --
// This function switches from the "URL" path to the "Torrent" path.
let fallbackStarted = false;
const startTorrentFallback = async (reason) => {
if (fallbackStarted) {
@@ -493,6 +565,7 @@ class PlaybackSession extends SimpleEventEmitter {
cleanupHostedUrlStatusListeners();
cleanupDebugListeners();
// Reset the video element state so WebTorrent can take over clean.
if (activeVideoEl) {
try {
activeVideoEl.pause();
@@ -543,6 +616,7 @@ class PlaybackSession extends SimpleEventEmitter {
return null;
}
// Delegate to the WebTorrent driver (see js/webtorrent.js interactions in app.js)
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
fallbackMagnet: this.fallbackMagnet,
urlList: webSeedCandidates,
@@ -556,6 +630,7 @@ class PlaybackSession extends SimpleEventEmitter {
return torrentInstance;
};
// -- Primary Path: Direct URL --
if (
this.service.urlFirstEnabled &&
httpsUrl &&
@@ -651,12 +726,15 @@ class PlaybackSession extends SimpleEventEmitter {
};
});
// Install the watchdogs before we assign src and play.
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
this.registerWatchdogs(activeVideoEl, {
stallMs,
onSuccess: () => outcomeResolver({ status: "success" }),
onFallback: (reason) => {
if (autoplayBlocked && reason === "stall") {
// If browser blocked autoplay, a stall is expected (video is paused).
// Don't fallback yet; wait for user interaction.
this.service.log(
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
);
@@ -664,7 +742,7 @@ class PlaybackSession extends SimpleEventEmitter {
message: "Press play to start the hosted video.",
});
this.emit("autoplay-blocked", { reason });
attachWatchdogs({ stallMs: 0 });
attachWatchdogs({ stallMs: 0 }); // Disable timer
return;
}
@@ -730,6 +808,7 @@ class PlaybackSession extends SimpleEventEmitter {
return this.result;
}
// If we are here, the watchdog triggered a fallback.
const fallbackReason =
playbackOutcome?.reason || "watchdog-triggered";
const torrentInstance = await startTorrentFallback(fallbackReason);
@@ -746,6 +825,7 @@ class PlaybackSession extends SimpleEventEmitter {
cleanupDebugListeners();
}
// -- Secondary Path: Magnet / Torrent --
if (this.magnetForPlayback) {
const torrentInstance = await startTorrentFallback("magnet-primary");
this.result = torrentInstance
+23
View File
@@ -1,5 +1,23 @@
//js/webtorrent.js
/**
* js/webtorrent.js
*
* This module wraps the WebTorrent client and manages the Service Worker integration.
* It is responsible for the "Torrent" half of the playback strategy.
*
* Key Responsibilities:
* - Singleton WebTorrent client: It ensures we reuse a single client instance to
* avoid resource leaks and connection churn.
* - Service Worker Registration: It registers and manages `/sw.min.js`. This is critical
* because WebTorrent in the browser streams data via a Service Worker "proxy" that
* intercepts HTTP requests from the <video> element and serves bytes fetched from peers.
* - "Claim" Logic: It handles the complex dance of ensuring the Service Worker has
* active control over the page (clients.claim) before attempting to stream, fixing
* common "grey screen" regressions on page reload.
* - Browser Quirks: It handles Brave/Firefox specific logic.
*/
import WebTorrent from "./webtorrent.min.js";
import { WSS_TRACKERS } from "./constants.js";
import { safeDecodeURIComponent } from "./utils/safeDecode.js";
@@ -151,6 +169,10 @@ export class TorrentClient {
return this.probeClient;
}
/**
* Helper to check if a magnet link has active peers without starting a full
* download. Used for "Stream Health" badges/indicators in the UI.
*/
async probePeers(
magnetURI,
{ timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {}
@@ -837,6 +859,7 @@ export class TorrentClient {
!!(initResult?.serviceWorkerReady && this.swRegistration);
// 2) Create the server once if not already created.
// This "server" is the bridge that feeds the Service Worker.
if (serviceWorkerReady && !this.serverCreated) {
this.client.createServer({
controller: this.swRegistration,