From 7921ab6a2e2dbdc2a7c1efe2d1b6f9fe81c36706 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:55:11 +0000 Subject: [PATCH] feat(playback): add configurable timeout for initial playback start Introduces a race condition between the primary playback source (URL or Torrent) and a configurable timeout. If the primary source fails to reach the 'playing' state within the timeout window, the system automatically falls back to the alternative source. - Adds `DEFAULT_PLAYBACK_START_TIMEOUT` to instance config (3000ms). - Updates `PlaybackService` to implement timeout logic using `withTimeout` wrapper. - Ensures proper cleanup of stalled attempts (resetting video element, destroying torrent client). --- config/instance-config.js | 10 +++++ js/config.js | 2 + js/constants.js | 7 ++++ js/services/playbackService.js | 75 ++++++++++++++++++++++++++++++---- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/config/instance-config.js b/config/instance-config.js index b5ecc5ae..4af52563 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -436,3 +436,13 @@ export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60; * - "torrent": Try WebTorrent first, fall back to CDN/URL. */ export const DEFAULT_PLAYBACK_SOURCE = "url"; + +/** + * Timeout (in milliseconds) for the initial playback attempt. + * + * If the preferred source (URL or Torrent) does not reach the "playing" state + * within this window, the player will automatically switch to the fallback + * source. This prevents users from staring at a loading spinner indefinitely + * if the primary source is slow or stalled. + */ +export const DEFAULT_PLAYBACK_START_TIMEOUT = 3000; diff --git a/js/config.js b/js/config.js index dab99460..73d5fe76 100644 --- a/js/config.js +++ b/js/config.js @@ -42,6 +42,7 @@ import { VIEW_COUNT_CACHE_TTL_MS, ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, DEFAULT_PLAYBACK_SOURCE, + DEFAULT_PLAYBACK_START_TIMEOUT, } from "../config/instance-config.js"; export const WATCH_HISTORY_VERSION_TAG_VALUE = "2"; @@ -135,3 +136,4 @@ export { VIEW_COUNT_BACKFILL_MAX_DAYS }; export { VIEW_COUNT_CACHE_TTL_MS }; export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS }; export { DEFAULT_PLAYBACK_SOURCE }; +export { DEFAULT_PLAYBACK_START_TIMEOUT }; diff --git a/js/constants.js b/js/constants.js index 53ec4c65..8978f265 100644 --- a/js/constants.js +++ b/js/constants.js @@ -5,6 +5,7 @@ import { DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD as CONFIG_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, DEFAULT_PLAYBACK_SOURCE, + DEFAULT_PLAYBACK_START_TIMEOUT, IS_DEV_MODE, } from "./config.js"; @@ -135,6 +136,11 @@ const SANITIZED_DEFAULT_TRUST_SEED_NPUBS = Object.freeze( sanitizeTrustSeedList(CONFIG_DEFAULT_TRUST_SEED_NPUBS) ); +const SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT = coerceNonNegativeInteger( + DEFAULT_PLAYBACK_START_TIMEOUT, + 3000 +); + export const DEFAULT_BLUR_THRESHOLD = SANITIZED_DEFAULT_BLUR_THRESHOLD; export const DEFAULT_AUTOPLAY_BLOCK_THRESHOLD = SANITIZED_DEFAULT_AUTOPLAY_BLOCK_THRESHOLD; @@ -143,6 +149,7 @@ export const DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD = export const DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD = SANITIZED_DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD; export const DEFAULT_TRUST_SEED_NPUBS = SANITIZED_DEFAULT_TRUST_SEED_NPUBS; +export const PLAYBACK_START_TIMEOUT = SANITIZED_DEFAULT_PLAYBACK_START_TIMEOUT; const DEFAULT_FLAGS = Object.freeze({ URL_FIRST_ENABLED: DEFAULT_PLAYBACK_SOURCE !== "torrent", // try URL before magnet in the player diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 9ef2f2b0..b6bfe7dd 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -1,4 +1,5 @@ import { userLogger } from "../utils/logger.js"; +import { PLAYBACK_START_TIMEOUT } from "../constants.js"; // js/services/playbackService.js /** @@ -70,6 +71,7 @@ export class PlaybackService { isValidMagnetUri, urlFirstEnabled = true, analyticsCallbacks = {}, + playbackStartTimeout = PLAYBACK_START_TIMEOUT, } = {}) { if (typeof logger === "function") { this.logger = logger; @@ -84,6 +86,7 @@ export class PlaybackService { this.isValidMagnetUri = isValidMagnetUri; this.urlFirstEnabled = !!urlFirstEnabled; this.analyticsCallbacks = analyticsCallbacks || {}; + this.playbackStartTimeout = playbackStartTimeout; this.currentSession = null; } @@ -261,7 +264,10 @@ export class PlaybackService { } createSession(options = {}) { - const session = new PlaybackSession(this, options); + const session = new PlaybackSession(this, { + ...options, + playbackStartTimeout: this.playbackStartTimeout, + }); this.currentSession = session; return session; } @@ -480,6 +486,7 @@ class PlaybackSession extends SimpleEventEmitter { autoplay, unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE, forcedSource = null, + playbackStartTimeout = PLAYBACK_START_TIMEOUT, } = this.options; this.result = { source: null }; @@ -724,6 +731,7 @@ class PlaybackSession extends SimpleEventEmitter { }); }; + // Use default stall timeout here; the initial start timeout is handled by the race wrapper attachWatchdogs({ stallMs: 8000 }); const handleFatalPlaybackError = (err) => { @@ -792,6 +800,27 @@ class PlaybackSession extends SimpleEventEmitter { return { status: "fallback", reason: "probe-failed" }; }; + // --- Timeout Wrapper --- + const withTimeout = (promise, ms, label = "Operation") => { + if (!ms || ms <= 0) return promise; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.service.log(`[playVideoWithFallback] ${label} timed out after ${ms}ms.`); + resolve({ status: "fallback", reason: "timeout", source: "timeout" }); + }, ms); + + promise + .then((result) => { + clearTimeout(timer); + resolve(result); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); + }; + // --- Execution Flow --- let tryUrlFirst = this.service.urlFirstEnabled; @@ -800,28 +829,60 @@ class PlaybackSession extends SimpleEventEmitter { if (tryUrlFirst) { if (httpsUrl) { - const urlResult = await attemptHostedPlayback(); + // Wrap URL attempt in timeout + const urlResult = await withTimeout( + attemptHostedPlayback(), + playbackStartTimeout, + "URL Playback" + ); + if (urlResult && urlResult.source === "url") { return urlResult; } - // URL Failed, try Torrent + + // If timeout or failure, proceed to fallback + if (urlResult?.reason === "timeout") { + // Need to ensure URL probing/playback is effectively cancelled + this.cleanupWatchdog(); + cleanupHostedUrlStatusListeners(); + resetVideoElement(); + } + const fallbackReason = urlResult?.reason || "url-unavailable"; if (this.magnetForPlayback) { return await attemptTorrentPlayback(fallbackReason); } } else if (this.magnetForPlayback) { - // No URL, try Torrent return await attemptTorrentPlayback("url-missing"); } } else { // Try Torrent First if (this.magnetForPlayback) { try { - const torrentResult = await attemptTorrentPlayback("preference"); - if (torrentResult) return torrentResult; + // Wrap Torrent attempt in timeout + const torrentResult = await withTimeout( + attemptTorrentPlayback("preference"), + playbackStartTimeout, + "Torrent Playback" + ); + + if (torrentResult && torrentResult.source === "torrent") { + return torrentResult; + } + + if (torrentResult?.reason === "timeout") { + this.service.log("[playVideoWithFallback] Torrent timed out; cleaning up."); + if ( + this.service.torrentClient && + typeof this.service.torrentClient.cleanup === "function" + ) { + await this.service.torrentClient.cleanup(); + } + resetVideoElement(); + } + } catch (err) { this.service.log("[playVideoWithFallback] Torrent preference failed, trying URL:", err); - // Clean up before trying URL if ( this.service.torrentClient && typeof this.service.torrentClient.cleanup === "function"