From 1c51b29702f3db395e2053d7968d72268ae13a1b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:40:01 -0500 Subject: [PATCH] Improve media failure messaging and fallbacks --- js/services/playbackService.js | 208 +++++++++++++++++++++++-- js/ui/components/SimilarContentCard.js | 9 ++ js/ui/components/VideoCard.js | 9 ++ 3 files changed, 217 insertions(+), 9 deletions(-) diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 800bde0c..aedf529b 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -62,6 +62,159 @@ class SimpleEventEmitter { 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."; +const AUTH_STATUS_CODES = new Set([401, 403]); +const SSL_ERROR_PATTERN = /(ssl|cert|certificate)/i; +const CORS_ERROR_PATTERN = /cors/i; + +const getHostedUrlFailureDetails = (probeResult = {}) => { + const outcome = probeResult?.outcome || "error"; + const status = Number.isFinite(probeResult?.status) + ? probeResult.status + : null; + const error = probeResult?.error; + const isAuthStatus = status !== null && AUTH_STATUS_CODES.has(status); + + if (outcome === "timeout") { + return { + category: "external", + message: + "Hosted URL timed out. We’ll try WebTorrent if available.", + status, + }; + } + + if (outcome === "bad") { + if (isAuthStatus) { + return { + category: "auth", + message: + "Hosted URL requires authorization or a signed request. Please log in or re-sign.", + status, + }; + } + + if (status === 404) { + return { + category: "external", + message: "Hosted URL not found (404).", + status, + }; + } + + if (status === 403) { + return { + category: "external", + message: "Hosted URL blocked (403).", + status, + }; + } + + if (status && status >= 500) { + return { + category: "external", + message: `Hosted URL unavailable (HTTP ${status}).`, + status, + }; + } + + if (status) { + return { + category: "external", + message: `Hosted URL failed (HTTP ${status}).`, + status, + }; + } + } + + if (outcome === "opaque" || outcome === "unknown") { + return { + category: "external", + message: + "Hosted URL blocked by browser security (CORS/SSL). We’ll try WebTorrent if available.", + status, + }; + } + + if (outcome === "error") { + const message = error?.message || ""; + if (SSL_ERROR_PATTERN.test(message)) { + return { + category: "external", + message: + "Hosted URL blocked due to SSL certificate issues.", + status, + }; + } + if (CORS_ERROR_PATTERN.test(message)) { + return { + category: "external", + message: + "Hosted URL blocked by CORS. We’ll try WebTorrent if available.", + status, + }; + } + return { + category: "external", + message: + "Hosted URL failed to load due to network or security restrictions.", + status, + }; + } + + return { + category: "external", + message: "Hosted URL failed to load.", + status, + }; +}; + +const getHostedVideoErrorMessage = (videoElement) => { + if (!videoElement?.error) { + return ""; + } + + const error = videoElement.error; + const code = error.code; + const mediaErrorCodes = + typeof MediaError !== "undefined" + ? MediaError + : { + MEDIA_ERR_ABORTED: 1, + MEDIA_ERR_NETWORK: 2, + MEDIA_ERR_DECODE: 3, + MEDIA_ERR_SRC_NOT_SUPPORTED: 4, + }; + + switch (code) { + case mediaErrorCodes.MEDIA_ERR_ABORTED: + return "Hosted playback was interrupted."; + case mediaErrorCodes.MEDIA_ERR_NETWORK: + return "Hosted playback failed due to network/CORS restrictions."; + case mediaErrorCodes.MEDIA_ERR_DECODE: + return "Hosted playback failed to decode the video."; + case mediaErrorCodes.MEDIA_ERR_SRC_NOT_SUPPORTED: + return "Hosted playback failed: source not supported or blocked."; + default: + return "Hosted playback failed to load."; + } +}; + +const getTorrentErrorMessage = (error) => { + if (!error) { + return "WebTorrent could not start. Please try again."; + } + const message = error.message || ""; + if (message.includes(DEFAULT_UNSUPPORTED_BTITH_MESSAGE)) { + return DEFAULT_UNSUPPORTED_BTITH_MESSAGE; + } + if (/tracker|announce|peer/i.test(message)) { + return "WebTorrent could not reach any peers or trackers."; + } + if (/permission|blocked|denied/i.test(message)) { + return "WebTorrent was blocked by the browser or network."; + } + return "WebTorrent could not start. Please try again."; +}; export class PlaybackService { constructor({ @@ -602,10 +755,19 @@ class PlaybackSession extends SimpleEventEmitter { throw new Error("No torrent playback handler provided."); } - const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, { - fallbackMagnet: this.fallbackMagnet, - urlList: webSeedCandidates, - }); + let torrentInstance; + try { + torrentInstance = await playViaWebTorrent(this.magnetForPlayback, { + fallbackMagnet: this.fallbackMagnet, + urlList: webSeedCandidates, + }); + } catch (err) { + const errorMessage = getTorrentErrorMessage(err); + if (errorMessage) { + this.emit("status", { message: errorMessage }); + } + throw new Error(errorMessage, { cause: err }); + } this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" }); this.emit("sourcechange", { source: "torrent" }); @@ -726,7 +888,11 @@ class PlaybackSession extends SimpleEventEmitter { attachWatchdogs({ stallMs: 0 }); return; } - outcomeResolver({ status: "fallback", reason }); + const errorMessage = getHostedVideoErrorMessage(activeVideoEl); + if (errorMessage) { + this.emit("status", { message: errorMessage }); + } + outcomeResolver({ status: "fallback", reason, errorMessage }); }, }); }; @@ -792,12 +958,25 @@ class PlaybackSession extends SimpleEventEmitter { // Fallback triggered const fallbackReason = playbackOutcome?.reason || "watchdog-triggered"; - return { status: "fallback", reason: fallbackReason }; + return { + status: "fallback", + reason: fallbackReason, + errorMessage: playbackOutcome?.errorMessage, + }; + } + + const failureDetails = getHostedUrlFailureDetails(probeResult); + if (failureDetails?.message) { + this.emit("status", { message: failureDetails.message }); } cleanupHostedUrlStatusListeners(); cleanupDebugListeners(); - return { status: "fallback", reason: "probe-failed" }; + return { + status: "fallback", + reason: "probe-failed", + errorMessage: failureDetails?.message, + }; }; // --- Timeout Wrapper --- @@ -829,6 +1008,8 @@ class PlaybackSession extends SimpleEventEmitter { const effectiveTimeout = forcedSource ? 0 : playbackStartTimeout; + let hostedErrorMessage = ""; + if (tryUrlFirst) { if (httpsUrl) { // Wrap URL attempt in timeout @@ -842,6 +1023,10 @@ class PlaybackSession extends SimpleEventEmitter { return urlResult; } + if (typeof urlResult?.errorMessage === "string") { + hostedErrorMessage = urlResult.errorMessage; + } + // If timeout or failure, proceed to fallback if (urlResult?.reason === "timeout") { // Need to ensure URL probing/playback is effectively cancelled @@ -905,13 +1090,18 @@ class PlaybackSession extends SimpleEventEmitter { if (urlResult && urlResult.source === "url") { return urlResult; } + + if (typeof urlResult?.errorMessage === "string") { + hostedErrorMessage = urlResult.errorMessage; + } } } const message = - this.magnetProvided && !this.magnetForPlayback + hostedErrorMessage || + (this.magnetProvided && !this.magnetForPlayback ? unsupportedBtihMessage - : "No playable source found."; + : "No playable source found."); this.emit("status", { message }); this.service.handleAnalyticsEvent("sourcechange", { source: null }); this.emit("sourcechange", { source: null }); diff --git a/js/ui/components/SimilarContentCard.js b/js/ui/components/SimilarContentCard.js index b0f39e8e..93705b69 100644 --- a/js/ui/components/SimilarContentCard.js +++ b/js/ui/components/SimilarContentCard.js @@ -985,6 +985,15 @@ export class SimilarContentCard { avatarImg.decoding = "async"; avatarImg.loading = "lazy"; avatarImg.alt = ""; + if (!avatarImg.dataset.avatarFallbackBound) { + avatarImg.addEventListener("error", () => { + if (avatarImg.getAttribute("src") !== DEFAULT_PROFILE_AVATAR) { + avatarImg.src = DEFAULT_PROFILE_AVATAR; + } + avatarImg.dataset.avatarLoadFailed = "true"; + }); + avatarImg.dataset.avatarFallbackBound = "true"; + } avatarWrapper.appendChild(avatarImg); // Append avatar then text diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 4e36c461..576db2ba 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -916,6 +916,15 @@ export class VideoCard { "rounded-full", "object-cover" ); + if (!avatar.dataset.avatarFallbackBound) { + avatar.addEventListener("error", () => { + if (avatar.getAttribute("src") !== DEFAULT_PROFILE_AVATAR) { + avatar.src = DEFAULT_PROFILE_AVATAR; + } + avatar.dataset.avatarLoadFailed = "true"; + }); + avatar.dataset.avatarFallbackBound = "true"; + } if (this.video.pubkey) { avatar.dataset.pubkey = this.video.pubkey; }