[playback] harden WebTorrent streaming + add forced-source test deep links

WebTorrent (handleTorrentStream) hardening:
- Resolve only when playback is actually viable (loadeddata/canplay/playing),
  not the instant streamTo() is called. A dead/too-slow swarm is no longer
  reported as success, so the caller's fallback can run.
- Pre-playback torrent errors reject (caller falls back); post-playback errors
  go to an onPlaybackError hook instead of a dead reject().
- Mid-stream stall watchdog surfaces an onStall hook ("waiting for peers").
- Fix a <video> listener leak: stream listeners are tracked and torn down
  before the next stream and on cleanup() (the modal reuses one element).
- Stream the largest decodable file from a multi-file torrent and deselect the
  rest (wider ext set: mp4/m4v/webm/mkv/mov/ogv/ogg) so the swarm isn't spent
  downloading files we never play.
playbackCoordinator wires onStall/onPlaybackError to user-facing status.

Forced-source deep links (test affordance):
- The main app honors ?v=<nevent>&playback=torrent|url (mirrors the embed
  player) to force a playback path; a missing source shows a clear message.
- The video share menu gains "Copy WebTorrent URL" / "Copy CDN URL" items that
  copy the forced-source deep link (disabled with a reason when unavailable).

Tests: webtorrent-handlers (resolve-on-readiness, pre/post-ready errors, stall
fires/clears, largest-file select/deselect, no listener accumulation),
playback-forced-source (param normalization), videoMenuRenderers (copy items +
availability). All mutation-verified.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-18 12:15:27 -04:00
1 parent 577ea5af15
commit 4e5bfaa6af
7 files changed
+678 -21

No files matched your search

+86 -3
View File
@@ -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=<nevent>&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 =
+31
View File
@@ -3290,6 +3290,35 @@ export class VideoModal {
);
}
/**
* Build a `?v=<nevent>&playback=<source>` 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);
+72
View File
@@ -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=<nevent>&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;
}
+204 -18
View File
@@ -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 <video> across plays, so listeners added here
// would otherwise accumulate. Track them and tear down a previous stream's
// listeners before wiring this one.
if (typeof this._teardownTorrentStreamListeners === "function") {
try {
this._teardownTorrentStreamListeners();
} catch (err) {
// ignore
}
}
let settled = false;
let stallTimerId = null;
const videoListeners = [];
const addVideoListener = (eventName, handler, options) => {
videoElement.addEventListener(eventName, handler, options);
videoListeners.push([eventName, handler]);
};
const onTorrentError = (err) => {
this.log(`Torrent error (${context} path):`, err);
if (!settled) {
// Pre-playback failure: reject so the caller can fall back (e.g. URL).
settled = true;
teardown();
reject(err);
return;
}
// Post-playback failure: the success promise is long gone, so surface it
// through the hook instead of a dead reject().
if (onPlaybackError) {
try {
onPlaybackError(toError(err));
} catch (hookErr) {
this.log("onPlaybackError hook threw:", hookErr);
}
}
};
const teardown = () => {
if (stallTimerId) {
clearTimeout(stallTimerId);
stallTimerId = null;
}
for (const [eventName, handler] of videoListeners) {
try {
videoElement.removeEventListener(eventName, handler);
} catch (err) {
// ignore
}
}
videoListeners.length = 0;
if (torrent && typeof torrent.removeListener === "function") {
try {
torrent.removeListener("error", onTorrentError);
} catch (err) {
// ignore
}
}
if (this._teardownTorrentStreamListeners === teardown) {
this._teardownTorrentStreamListeners = null;
}
};
this._teardownTorrentStreamListeners = teardown;
const onVideoError = () => {
this.log(`Video error (${context} path):`, videoElement.error);
};
addVideoListener("error", onVideoError);
const tryStart = () => {
this.attemptAutoplay(videoElement, context);
};
addVideoListener("canplay", tryStart, { once: true });
addVideoListener("loadeddata", tryStart, { once: true });
videoElement.addEventListener("canplay", tryStart, { once: true });
videoElement.addEventListener("loadeddata", tryStart, { once: true });
// Resolve only when playback is actually viable (first frame / can play),
// NOT the instant streamTo() is called. Previously success was declared
// synchronously, so a swarm that fetched metadata but could never sustain
// playback was reported as a success — the caller cleared its fallback
// timeout and the user was left on a frozen frame with no recovery. Waiting
// for a real readiness signal lets the caller's playback timeout fall back
// to the hosted URL (or surface "no peers") for a dead/too-slow swarm.
const markReady = () => {
if (settled) {
return;
}
settled = true;
resolve(torrent);
};
addVideoListener("loadeddata", markReady, { once: true });
addVideoListener("canplay", markReady, { once: true });
addVideoListener("playing", markReady, { once: true });
// Mid-stream stall detection: if playback buffers without progress for a
// sustained window, notify via the hook (so the UI can say "waiting for
// peers" instead of silently freezing). Progress clears the timer.
const armStallTimer = () => {
if (stallTimerId) {
clearTimeout(stallTimerId);
}
stallTimerId = setTimeout(() => {
stallTimerId = null;
const peers = Math.max(
0,
Math.floor(normalizeNumber(torrent?.numPeers, 0))
);
this.log(
`Torrent playback stalled (${context} path); peers=${peers}.`
);
if (onStall) {
try {
onStall({ peers });
} catch (hookErr) {
this.log("onStall hook threw:", hookErr);
}
}
}, stallMs);
};
const clearStallTimer = () => {
if (stallTimerId) {
clearTimeout(stallTimerId);
stallTimerId = null;
}
};
addVideoListener("waiting", armStallTimer);
addVideoListener("stalled", armStallTimer);
addVideoListener("timeupdate", clearStallTimer);
addVideoListener("playing", clearStallTimer);
// Set this up front (not gated on readiness) so cleanup() can always tear
// down the torrent even if playback never becomes ready and the caller
// times out.
this.currentTorrent = torrent;
torrent.on("error", onTorrentError);
try {
const streamOptions = {};
@@ -835,21 +998,19 @@ export class TorrentClient {
}
file.streamTo(videoElement, streamOptions);
// If the video is already ready (e.g. from cache or fast load), try starting immediately
// Already buffered enough (cache/fast load): start + mark ready now.
if (videoElement.readyState >= 3) {
tryStart();
markReady();
}
this.currentTorrent = torrent;
resolve(torrent);
} catch (err) {
this.log(`Streaming error (${context} path):`, err);
reject(err);
if (!settled) {
settled = true;
teardown();
reject(err);
}
}
torrent.on("error", (err) => {
this.log(`Torrent error (${context} path):`, err);
reject(err);
});
}
async destroyWithTimeout(
@@ -944,6 +1105,8 @@ export class TorrentClient {
}
const isFirefoxBrowser = this.isFirefox();
const streamHooks =
opts && typeof opts.hooks === "object" && opts.hooks ? opts.hooks : {};
const candidateUrls = Array.isArray(opts?.urlList)
? opts.urlList
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
@@ -969,14 +1132,28 @@ export class TorrentClient {
{ ...chromeOptions, maxWebConns: 4 },
(torrent) => {
this.log("Torrent added (Firefox path):", torrent.name);
this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox");
this.handleTorrentStream(
torrent,
videoElement,
resolve,
reject,
"firefox",
streamHooks
);
}
);
} else {
this.log("Starting torrent download (Chrome path)");
this.client.add(magnetURI, chromeOptions, (torrent) => {
this.log("Torrent added (Chrome path):", torrent.name);
this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome");
this.handleTorrentStream(
torrent,
videoElement,
resolve,
reject,
"chrome",
streamHooks
);
});
}
});
@@ -993,6 +1170,15 @@ export class TorrentClient {
*/
async cleanup() {
try {
if (typeof this._teardownTorrentStreamListeners === "function") {
try {
this._teardownTorrentStreamListeners();
} catch (err) {
// ignore
}
this._teardownTorrentStreamListeners = null;
}
if (this.currentTorrent) {
try {
await this.destroyWithTimeout(this.currentTorrent, {
+35
View File
@@ -0,0 +1,35 @@
// Unit test for the forced-source deep-link normalizer.
//
// SCN-forced-source-deep-link:
// Given a `?playback=` deep-link value (or a caller hint),
// When normalized,
// Then it maps to the canonical 'url' | 'torrent' the playback pipeline
// understands (accepting the friendly aliases shown in the UI toggle),
// And anything unset/unrecognized yields null so normal source selection runs
// (a bad value must NEVER silently coerce to a real source).
import { test } from "node:test";
import assert from "node:assert/strict";
import { normalizeForcedSource } from "../js/app/playbackCoordinator.js";
test("maps torrent aliases to 'torrent'", () => {
for (const v of ["torrent", "p2p", "P2P", "WebTorrent", "magnet", " torrent "]) {
assert.equal(normalizeForcedSource(v), "torrent", `"${v}" -> torrent`);
}
});
test("maps CDN/url aliases to 'url'", () => {
for (const v of ["url", "cdn", "CDN", "hosted", "http", "https", " URL "]) {
assert.equal(normalizeForcedSource(v), "url", `"${v}" -> url`);
}
});
test("returns null for unset / unrecognized / non-string values", () => {
for (const v of ["", "bogus", "torrnt", "0", null, undefined, 42, {}, []]) {
assert.equal(
normalizeForcedSource(v),
null,
`${JSON.stringify(v)} must not coerce to a real source`,
);
}
});
@@ -170,6 +170,88 @@ test('createVideoShareMenuPanel - magnet/cdn logic', () => {
assert.ok(cdnBtn.disabled, 'CDN button should be disabled');
});
test('createVideoShareMenuPanel - forced-source deep links copy via builder', async () => {
const { document } = setupDom();
const video = { id: 'v1' };
const built = [];
const copied = [];
const buildForcedSourceUrl = (playback) => {
built.push(playback);
return `https://bitvid.test/?v=nevent1&playback=${playback}`;
};
const clipboard = {
writeText: async (text) => {
copied.push(text);
},
};
const panel = createVideoShareMenuPanel({
document,
video,
hasMagnet: true,
hasCdn: true,
buildForcedSourceUrl,
clipboard,
});
const torrentBtn = panel.querySelector('[data-action="copy-webtorrent-url"]');
const cdnBtn = panel.querySelector('[data-action="copy-cdn-url"]');
assert.ok(torrentBtn, 'WebTorrent URL item should exist');
assert.ok(cdnBtn, 'CDN URL item should exist');
assert.ok(!torrentBtn.disabled, 'WebTorrent URL enabled when a magnet exists');
assert.ok(!cdnBtn.disabled, 'CDN URL enabled when a CDN url exists');
torrentBtn.click();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(built, ['torrent'], 'builder asked for the torrent source');
assert.deepEqual(
copied,
['https://bitvid.test/?v=nevent1&playback=torrent'],
'the forced-torrent deep link is copied to the clipboard',
);
});
test('createVideoShareMenuPanel - forced-source items disabled (with message) when source missing', () => {
const { document } = setupDom();
const panel = createVideoShareMenuPanel({
document,
video: { id: 'v1' },
hasMagnet: false,
hasCdn: true,
buildForcedSourceUrl: () => 'https://bitvid.test/?v=nevent1&playback=torrent',
});
const torrentBtn = panel.querySelector('[data-action="copy-webtorrent-url"]');
assert.ok(torrentBtn.disabled, 'WebTorrent URL disabled when no magnet');
assert.match(
torrentBtn.title,
/no webtorrent source/i,
'disabled WebTorrent URL explains why',
);
const cdnBtn = panel.querySelector('[data-action="copy-cdn-url"]');
assert.ok(!cdnBtn.disabled, 'CDN URL enabled when a CDN url exists');
});
test('createVideoShareMenuPanel - no forced-source items without a builder', () => {
const { document } = setupDom();
const panel = createVideoShareMenuPanel({
document,
video: { id: 'v1' },
hasMagnet: true,
hasCdn: true,
});
assert.equal(
panel.querySelector('[data-action="copy-webtorrent-url"]'),
null,
'no WebTorrent URL item when no builder is provided',
);
});
test('createChannelProfileMenuPanel - basic actions', () => {
const { document } = setupDom();
const panel = createChannelProfileMenuPanel({
+168
View File
@@ -126,3 +126,171 @@ describe("TorrentClient Handlers", () => {
assert.strictEqual(torrent._opts.announce[0], "ws://good.com", "Should keep good tracker");
});
});
// --- Hardened streaming behavior (audit follow-ups) ---
class SelectableFile {
constructor(name, length) {
this.name = name;
this.length = length;
this.selected = false;
this.deselected = false;
}
select() { this.selected = true; }
deselect() { this.deselected = true; }
streamTo(elem) { elem.src = "blob:mock-stream"; }
}
class MultiFileTorrent extends EventEmitter {
constructor(files, { numPeers = 1 } = {}) {
super();
this.files = files;
this.numPeers = numPeers;
this._opts = {};
}
destroy() {}
}
function freshClient() {
const c = new TorrentClient();
c.log = () => {};
return c;
}
describe("TorrentClient streaming hardening", () => {
it("streams the LARGEST playable file and deselects the rest (multi-file)", () => {
const client = freshClient();
const small = new SelectableFile("sample.mp4", 100);
const big = new SelectableFile("feature.mp4", 100000);
const other = new SelectableFile("readme.txt", 5);
const torrent = new MultiFileTorrent([small, big, other]);
const videoElement = new MockVideoElement();
client.handleTorrentStream(torrent, videoElement, () => {}, () => {}, "chrome");
assert.strictEqual(big.selected, true, "largest video file is selected");
assert.strictEqual(small.deselected, true, "smaller video file is deselected");
assert.strictEqual(other.deselected, true, "non-video file is deselected");
assert.strictEqual(videoElement.src, "blob:mock-stream", "streamTo ran on the chosen file");
});
it("does NOT resolve until the video is actually ready to play", async () => {
const client = freshClient();
// streamTo here does NOT advance readyState or emit canplay.
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
const torrent = new MultiFileTorrent([file]);
const videoElement = new MockVideoElement();
let resolved = false;
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, () => {}, "chrome");
await new Promise((r) => setTimeout(r, 20));
assert.strictEqual(resolved, false, "no premature success while playback is not viable");
assert.strictEqual(client.currentTorrent, torrent, "currentTorrent set up front so cleanup can tear it down");
// Once real playback data arrives, it resolves.
videoElement.emit("loadeddata");
assert.strictEqual(resolved, true, "resolves once playback is viable");
});
it("pre-playback torrent error rejects (so the caller can fall back)", async () => {
const client = freshClient();
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
const torrent = new MultiFileTorrent([file]);
const videoElement = new MockVideoElement();
let rejected = null;
let resolved = false;
client.handleTorrentStream(torrent, videoElement, () => { resolved = true; }, (e) => { rejected = e; }, "chrome");
torrent.emit("error", new Error("swarm dead"));
assert.ok(rejected, "rejects before playback starts");
assert.strictEqual(resolved, false, "does not also resolve");
});
it("post-playback torrent error goes to onPlaybackError, not a dead reject", async () => {
const client = freshClient();
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
const torrent = new MultiFileTorrent([file]);
const videoElement = new MockVideoElement();
const playbackErrors = [];
let rejectedAfter = false;
client.handleTorrentStream(
torrent,
videoElement,
() => {},
() => { rejectedAfter = true; },
"chrome",
{ onPlaybackError: (e) => playbackErrors.push(e) }
);
videoElement.emit("canplay"); // becomes ready -> resolved
torrent.emit("error", new Error("peers dropped"));
assert.strictEqual(playbackErrors.length, 1, "post-start error surfaced via hook");
assert.strictEqual(rejectedAfter, false, "promise was already settled; no dead reject");
});
it("surfaces a stall via onStall when playback buffers without progress", async () => {
const client = freshClient();
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
const torrent = new MultiFileTorrent([file], { numPeers: 0 });
const videoElement = new MockVideoElement();
const stalls = [];
client.handleTorrentStream(
torrent,
videoElement,
() => {},
() => {},
"chrome",
{ onStall: (info) => stalls.push(info), stallMs: 15 }
);
videoElement.emit("canplay"); // ready
videoElement.emit("waiting"); // buffering with no progress
await new Promise((r) => setTimeout(r, 40));
assert.strictEqual(stalls.length, 1, "stall surfaced once");
assert.strictEqual(stalls[0].peers, 0, "stall reports current peer count");
});
it("clears the stall timer when playback progresses", async () => {
const client = freshClient();
const file = { name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } };
const torrent = new MultiFileTorrent([file]);
const videoElement = new MockVideoElement();
const stalls = [];
client.handleTorrentStream(
torrent, videoElement, () => {}, () => {}, "chrome",
{ onStall: (i) => stalls.push(i), stallMs: 30 }
);
videoElement.emit("canplay");
videoElement.emit("waiting");
await new Promise((r) => setTimeout(r, 10));
videoElement.emit("timeupdate"); // progress -> clears the pending stall
await new Promise((r) => setTimeout(r, 40));
assert.strictEqual(stalls.length, 0, "no stall fired because playback resumed");
});
it("does not leak <video> listeners across streams (reused element)", () => {
const client = freshClient();
const videoElement = new MockVideoElement();
const mk = () => new MultiFileTorrent([
{ name: "v.mp4", length: 10, streamTo: (el) => { el.src = "blob:x"; } },
]);
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
client.handleTorrentStream(mk(), videoElement, () => {}, () => {}, "chrome");
assert.strictEqual(
videoElement.listenerCount("error"),
1,
"previous streams' error listeners are torn down (no accumulation)",
);
});
});