diff --git a/js/app/playbackCoordinator.js b/js/app/playbackCoordinator.js index 6b5bf904..b471c916 100644 --- a/js/app/playbackCoordinator.js +++ b/js/app/playbackCoordinator.js @@ -9,6 +9,28 @@ * Methods use `this` which is bound to the Application instance. */ +/** + * Normalize a deep-link / caller "force this playback source" value to the + * canonical 'url' | 'torrent' the playback pipeline understands, or null when + * unset/unrecognized. Accepts a few friendly aliases (the in-modal toggle + * labels CDN/P2P, plus webtorrent/hosted) so shared links are forgiving. + * @param {unknown} value + * @returns {'url' | 'torrent' | null} + */ +export function normalizeForcedSource(value) { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (["torrent", "p2p", "webtorrent", "magnet"].includes(normalized)) { + return "torrent"; + } + if (["url", "cdn", "hosted", "http", "https"].includes(normalized)) { + return "url"; + } + return null; +} + /** * @param {object} deps - Injected dependencies. * @returns {object} Methods to be bound to the Application instance. @@ -47,6 +69,14 @@ export function createPlaybackCoordinator(deps) { const maybeNevent = urlParams.get("v"); if (!maybeNevent) return; // no link param + // Optional deep-link override that forces a playback source, mirroring the + // embed player's `playback` param. Lets you test a specific path + // consistently: `?v=&playback=torrent` always streams via + // WebTorrent, `&playback=url` always uses the hosted CDN URL. If the + // requested source is missing for the video, playVideoByEventId surfaces + // a message instead of silently using the other source. + const forcedSource = normalizeForcedSource(urlParams.get("playback")); + try { const decoded = window.NostrTools.nip19.decode(maybeNevent); if (decoded.type === "nevent" && decoded.data.id) { @@ -58,13 +88,13 @@ export function createPlaybackCoordinator(deps) { // 1) check local map let localMatch = this.videosMap.get(eventId); if (localMatch) { - this.playVideoByEventId(eventId, { relay }); + this.playVideoByEventId(eventId, { relay, forcedSource }); } else { // 2) fallback => getOldEventById this.getOldEventById(eventId) .then((video) => { if (video) { - this.playVideoByEventId(eventId, { relay }); + this.playVideoByEventId(eventId, { relay, forcedSource }); } else { this.showError("No matching video found for that link."); } @@ -386,7 +416,37 @@ export function createPlaybackCoordinator(deps) { const torrentInstance = await torrentClient.streamVideo( cacheBustedMagnet, this.modalVideo, - { urlList: sanitizedUrlList } + { + urlList: sanitizedUrlList, + hooks: { + // Surface a mid-stream stall instead of silently freezing on a + // buffering frame: tell the user whether the swarm is just slow + // or has no seeders at all. + onStall: ({ peers } = {}) => { + if (!this.videoModal) { + return; + } + this.videoModal.updateStatus( + peers > 0 + ? "Buffering — the swarm is slow right now." + : "Waiting for peers — this video may have no active seeders." + ); + }, + // A torrent error after playback started can't reject the (already + // settled) start promise, so report it here. + onPlaybackError: (error) => { + this.log( + "[playViaWebTorrent] Torrent error during playback:", + error + ); + if (this.videoModal) { + this.videoModal.updateStatus( + "WebTorrent playback hit an error — the swarm may have dropped." + ); + } + }, + }, + } ); if (torrentClient.isServiceWorkerUnavailable()) { @@ -801,6 +861,29 @@ export function createPlaybackCoordinator(deps) { playbackOptions.trigger = this.lastModalTrigger; } + // Honor a forced-source deep link (e.g. ?playback=torrent). If the video + // actually has that source, force it so the test path is deterministic. + // If it does not, tell the user clearly and fall back to normal selection + // rather than opening a dead player. + const requestedSource = normalizeForcedSource(hint.forcedSource); + if (requestedSource === "torrent") { + if (sanitizedMagnet) { + playbackOptions.forcedSource = "torrent"; + } else { + this.showError( + "This video has no WebTorrent source — nothing to stream over P2P.", + ); + } + } else if (requestedSource === "url") { + if (trimmedUrl) { + playbackOptions.forcedSource = "url"; + } else { + this.showError( + "This video has no hosted CDN URL — only a WebTorrent source is available.", + ); + } + } + let playbackPromise = null; if (this.shouldDeferModeratedPlayback(this.currentVideo)) { const pendingVideoId = diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index c693e176..825f9c9e 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -3290,6 +3290,35 @@ export class VideoModal { ); } + /** + * Build a `?v=&playback=` deep link for the given video that + * forces a specific playback path (used by the share-menu "Copy WebTorrent + * URL" / "Copy CDN URL" items). Returns "" if a link can't be built. + * @param {object} video + * @param {'torrent'|'url'} playback + * @returns {string} + */ + buildForcedSourceShareUrl(video, playback) { + const eventId = + video && typeof video.id === "string" ? video.id.trim() : ""; + if (!eventId) { + return ""; + } + const win = this.window || globalThis; + const neventEncode = win?.NostrTools?.nip19?.neventEncode; + const loc = win?.location; + if (typeof neventEncode !== "function" || !loc) { + return ""; + } + try { + const nevent = neventEncode({ id: eventId }); + const base = `${loc.origin}${loc.pathname}`; + return `${base}?v=${encodeURIComponent(nevent)}&playback=${playback}`; + } catch (error) { + return ""; + } + } + setupModalSharePopover() { if (!this.playerModal || !this.shareBtn) { return; @@ -3319,6 +3348,8 @@ export class VideoModal { hasSigner: this.shareNostrAuthState.hasSigner, hasMagnet: Boolean(this.modalMoreMenuContext.playbackMagnet), // Re-use magnet from context hasCdn: Boolean(this.modalMoreMenuContext.playbackUrl), + buildForcedSourceUrl: (playback) => + this.buildForcedSourceShareUrl(this.activeVideo, playback), }); if (panel) { container.appendChild(panel); diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index 17c520a7..e9f26aeb 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -316,6 +316,13 @@ export function createVideoShareMenuPanel({ hasSigner = false, hasMagnet = false, hasCdn = false, + // Optional builder for forced-source test deep links: fn('torrent'|'url') -> + // a `?v=&playback=...` URL (or "" when unavailable). When provided, + // the menu offers "Copy WebTorrent URL" / "Copy CDN URL" items that copy a + // link which always plays via that source — handy for testing one path + // consistently. `clipboard` is injectable for tests. + buildForcedSourceUrl = null, + clipboard = null, designSystem = null, } = {}) { const doc = resolveDocument(documentRef); @@ -370,6 +377,71 @@ export function createVideoShareMenuPanel({ cdnBtn.title = "No CDN link available"; } + // Forced-source test deep links. Self-contained: they copy a link via the + // injected builder + clipboard rather than routing through the shared menu + // dispatch, so they work regardless of how the share popover is wired. + if (typeof buildForcedSourceUrl === "function") { + const resolveClipboard = () => + clipboard || + (typeof navigator !== "undefined" ? navigator.clipboard : null); + + const wireForcedCopy = (button, playback, available, unavailableTitle) => { + if (!available) { + button.disabled = true; + button.classList.add("opacity-50", "cursor-not-allowed"); + button.title = unavailableTitle; + return; + } + const originalText = button.textContent; + button.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const url = buildForcedSourceUrl(playback) || ""; + if (!url) { + return; + } + const clip = resolveClipboard(); + try { + if (clip && typeof clip.writeText === "function") { + await clip.writeText(url); + } + button.textContent = "Copied!"; + } catch (error) { + button.textContent = "Copy failed"; + } + if (typeof setTimeout === "function") { + setTimeout(() => { + button.textContent = originalText; + }, 1200); + } + }); + }; + + const torrentLinkBtn = appendMenuAction(doc, list, { + text: "Copy WebTorrent URL", + action: "copy-webtorrent-url", + dataset: { eventId: metadata.id }, + }); + wireForcedCopy( + torrentLinkBtn, + "torrent", + hasMagnet, + "No WebTorrent source for this video", + ); + + const cdnLinkBtn = appendMenuAction(doc, list, { + text: "Copy CDN URL", + action: "copy-cdn-url", + dataset: { eventId: metadata.id }, + }); + wireForcedCopy( + cdnLinkBtn, + "url", + hasCdn, + "No CDN source for this video", + ); + } + return panel; } diff --git a/js/webtorrent.js b/js/webtorrent.js index 4b1b939b..6c2c2bbd 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -27,6 +27,12 @@ import { emit } from "./embedDiagnostics.js"; const DEFAULT_PROBE_TRACKERS = Object.freeze([...WSS_TRACKERS]); const SERVICE_WORKER_PATH = "/sw.min.js"; const SERVICE_WORKER_SCOPE = "/"; +// Once torrent playback has actually started, treat this long without any +// playback progress (while buffering) as a stall worth surfacing to the caller. +const TORRENT_PLAYBACK_STALL_MS = 12000; +// Files the player can actually decode. Used to pick the file to stream from a +// multi-file torrent (largest match wins) and to deselect the rest. +const PLAYABLE_VIDEO_PATTERN = /\.(mp4|m4v|webm|mkv|mov|ogv|ogg)$/i; function compareServiceWorkerScripts(a = "", b = "") { if (a === b) { @@ -775,10 +781,21 @@ export class TorrentClient { videoElement, resolve, reject, - context = "chrome" + context = "chrome", + hooks = {} ) { const isChrome = context === "chrome"; const isFirefox = context === "firefox"; + const onStall = + hooks && typeof hooks.onStall === "function" ? hooks.onStall : null; + const onPlaybackError = + hooks && typeof hooks.onPlaybackError === "function" + ? hooks.onPlaybackError + : null; + const stallMs = + hooks && Number.isFinite(hooks.stallMs) && hooks.stallMs > 0 + ? hooks.stallMs + : TORRENT_PLAYBACK_STALL_MS; // Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS // and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. @@ -809,24 +826,170 @@ export class TorrentClient { }); } - const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name)); + // Stream only the *largest* decodable video file. `.find()` picked whatever + // came first (could be a sample clip), and leaving the other files selected + // makes WebTorrent download the whole multi-file torrent — wasting the + // swarm on data we never play. + const files = Array.isArray(torrent.files) ? torrent.files : []; + const videoFiles = files.filter((f) => + PLAYABLE_VIDEO_PATTERN.test((f && f.name) || "") + ); + const file = videoFiles + .slice() + .sort((a, b) => (Number(b?.length) || 0) - (Number(a?.length) || 0))[0]; if (!file) { return reject(new Error("No compatible video file found in torrent")); } + if (typeof file.select === "function") { + try { + for (const other of files) { + if (other !== file && typeof other.deselect === "function") { + other.deselect(); + } + } + file.select(); + } catch (err) { + this.log(`File selection failed (${context} path; non-fatal):`, err); + } + } // Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots). videoElement.crossOrigin = "anonymous"; - videoElement.addEventListener("error", (e) => { - this.log(`Video error (${context} path):`, e.target.error); - }); + // The modal reuses a single