mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 08:22:24 +00:00
Merge pull request #267 from PR0M3TH3AN/codex/create-playback-service-and-update-ui
Refactor playback into service
This commit is contained in:
2 files changed
+860
-512
No files matched your search
@@ -21,6 +21,7 @@ import { userBlocks } from "./userBlocks.js";
|
||||
import { relayManager } from "./relayManager.js";
|
||||
import watchHistoryService from "./watchHistoryService.js";
|
||||
import r2Service from "./services/r2Service.js";
|
||||
import PlaybackService from "./services/playbackService.js";
|
||||
import { initQuickR2Upload } from "./r2-quick.js";
|
||||
import { createWatchHistoryRenderer } from "./historyView.js";
|
||||
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
|
||||
@@ -358,10 +359,40 @@ class bitvidApp {
|
||||
this.videoDiscussionCountCache = new Map();
|
||||
this.inFlightDiscussionCounts = new Map();
|
||||
this.activeIntervals = [];
|
||||
this.urlPlaybackWatchdogCleanup = null;
|
||||
this.watchHistoryMetadataEnabled = null;
|
||||
this.watchHistoryPreferenceUnsubscribe = null;
|
||||
|
||||
this.playbackService = new PlaybackService({
|
||||
logger: (message, ...args) => this.log(message, ...args),
|
||||
torrentClient,
|
||||
deriveTorrentPlaybackConfig,
|
||||
isValidMagnetUri,
|
||||
urlFirstEnabled: URL_FIRST_ENABLED,
|
||||
analyticsCallbacks: {
|
||||
"session-start": (detail) => {
|
||||
const urlProvided = detail?.urlProvided ? "true" : "false";
|
||||
const magnetProvided = detail?.magnetProvided ? "true" : "false";
|
||||
const magnetUsable = detail?.magnetUsable ? "true" : "false";
|
||||
this.log(
|
||||
`[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}`
|
||||
);
|
||||
},
|
||||
fallback: (detail) => {
|
||||
if (detail?.reason) {
|
||||
this.log(
|
||||
`[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).`
|
||||
);
|
||||
}
|
||||
},
|
||||
error: (detail) => {
|
||||
if (detail?.error) {
|
||||
this.log("[playVideoWithFallback] Playback error observed", detail.error);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
this.activePlaybackSession = null;
|
||||
|
||||
// Optional: a "profile" button or avatar (if used)
|
||||
this.profileButton = document.getElementById("profileButton") || null;
|
||||
this.profileAvatar = document.getElementById("profileAvatar") || null;
|
||||
@@ -6015,7 +6046,9 @@ class bitvidApp {
|
||||
}
|
||||
);
|
||||
this.clearActiveIntervals();
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
if (this.playbackService) {
|
||||
this.playbackService.cleanupWatchdog();
|
||||
}
|
||||
this.teardownModalViewCountSubscription();
|
||||
|
||||
if (!preserveObservers && this.mediaLoader) {
|
||||
@@ -6106,18 +6139,6 @@ class bitvidApp {
|
||||
this.activeIntervals = [];
|
||||
}
|
||||
|
||||
cleanupUrlPlaybackWatchdog() {
|
||||
if (typeof this.urlPlaybackWatchdogCleanup === "function") {
|
||||
try {
|
||||
this.urlPlaybackWatchdogCleanup();
|
||||
} catch (err) {
|
||||
console.warn("[cleanupUrlPlaybackWatchdog]", err);
|
||||
} finally {
|
||||
this.urlPlaybackWatchdogCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelPendingViewLogging() {
|
||||
const state = this.playbackTelemetryState;
|
||||
if (!state) {
|
||||
@@ -6934,110 +6955,6 @@ class bitvidApp {
|
||||
return clone;
|
||||
}
|
||||
|
||||
registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
{ stallMs = 8000, onSuccess, onFallback } = {}
|
||||
) {
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
|
||||
if (!videoElement || typeof onFallback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
let active = true;
|
||||
let stallTimerId = null;
|
||||
|
||||
const listeners = [];
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
for (const [eventName, handler] of listeners) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
this.urlPlaybackWatchdogCleanup = null;
|
||||
};
|
||||
|
||||
const triggerFallback = (reason) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
cleanup();
|
||||
onFallback(reason);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
|
||||
);
|
||||
cleanup();
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
if (!active || !normalizedStallMs) {
|
||||
return;
|
||||
}
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
}
|
||||
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
|
||||
};
|
||||
|
||||
const addListener = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
listeners.push([eventName, handler]);
|
||||
};
|
||||
|
||||
addListener("error", () => triggerFallback("error"));
|
||||
addListener("abort", () => triggerFallback("abort"));
|
||||
addListener("stalled", () => triggerFallback("stalled"));
|
||||
addListener("playing", handleSuccess);
|
||||
addListener("ended", handleSuccess);
|
||||
|
||||
const timerEvents = [
|
||||
"timeupdate",
|
||||
"progress",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"suspend",
|
||||
"waiting",
|
||||
];
|
||||
for (const eventName of timerEvents) {
|
||||
addListener(eventName, resetTimer);
|
||||
}
|
||||
|
||||
if (normalizedStallMs) {
|
||||
resetTimer();
|
||||
}
|
||||
|
||||
this.urlPlaybackWatchdogCleanup = () => {
|
||||
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
|
||||
cleanup();
|
||||
};
|
||||
|
||||
return this.urlPlaybackWatchdogCleanup;
|
||||
}
|
||||
|
||||
resetTorrentStats() {
|
||||
if (this.modalPeers) {
|
||||
this.modalPeers.textContent = "";
|
||||
@@ -7143,26 +7060,6 @@ class bitvidApp {
|
||||
return dedupeToNewestByRoot(videos);
|
||||
}
|
||||
|
||||
prepareModalVideoForPlayback() {
|
||||
if (!this.modalVideo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||||
const userWantsUnmuted = storedUnmuted === "true";
|
||||
this.modalVideo.muted = !userWantsUnmuted;
|
||||
|
||||
if (!this.modalVideo.dataset.autoplayBound) {
|
||||
this.modalVideo.addEventListener("volumechange", () => {
|
||||
localStorage.setItem(
|
||||
"unmutedAutoplay",
|
||||
(!this.modalVideo.muted).toString()
|
||||
);
|
||||
});
|
||||
this.modalVideo.dataset.autoplayBound = "true";
|
||||
}
|
||||
}
|
||||
|
||||
autoplayModalVideo() {
|
||||
if (!this.modalVideo) return;
|
||||
this.modalVideo.play().catch((err) => {
|
||||
@@ -9762,40 +9659,65 @@ class bitvidApp {
|
||||
* and falls back to WebTorrent when needed.
|
||||
*/
|
||||
async playVideoWithFallback({ url = "", magnet = "" } = {}) {
|
||||
// When the modal closes we run an asynchronous cleanup that tears down the
|
||||
// previous MediaSource attachment. If the user immediately selects another
|
||||
// video we must wait for that teardown to finish or the stale cleanup will
|
||||
// wipe the freshly attached `src`/`srcObject`, leaving playback stuck on a
|
||||
// blank frame. Guard every playback entry point with waitForCleanup() so the
|
||||
// race never reappears.
|
||||
await this.waitForCleanup();
|
||||
this.cancelPendingViewLogging();
|
||||
|
||||
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||
|
||||
const playbackConfig = deriveTorrentPlaybackConfig({
|
||||
magnet: trimmedMagnet,
|
||||
if (!this.modalVideo) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
if (typeof this.modalPosterCleanup === "function") {
|
||||
try {
|
||||
this.modalPosterCleanup();
|
||||
} catch (err) {
|
||||
console.warn("[playVideoWithFallback] modal poster cleanup threw:", err);
|
||||
} finally {
|
||||
this.modalPosterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedModal = this.teardownVideoElement(this.modalVideo, {
|
||||
replaceNode: true,
|
||||
});
|
||||
if (refreshedModal) {
|
||||
this.modalVideo = refreshedModal;
|
||||
}
|
||||
|
||||
const session = this.playbackService.createSession({
|
||||
url: sanitizedUrl,
|
||||
logger: (message) => this.log(message),
|
||||
magnet: trimmedMagnet,
|
||||
videoElement: this.modalVideo,
|
||||
waitForCleanup: () => this.waitForCleanup(),
|
||||
cancelPendingViewLogging: () => this.cancelPendingViewLogging(),
|
||||
clearActiveIntervals: () => this.clearActiveIntervals(),
|
||||
showModalWithPoster: () => this.showModalWithPoster(),
|
||||
teardownVideoElement: (videoEl, options) =>
|
||||
this.teardownVideoElement(videoEl, options),
|
||||
probeUrl: (candidateUrl) => this.probeUrl(candidateUrl),
|
||||
playViaWebTorrent: (magnetUri, options) =>
|
||||
this.playViaWebTorrent(magnetUri, options),
|
||||
autoplay: () => this.autoplayModalVideo(),
|
||||
unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE,
|
||||
});
|
||||
|
||||
const magnetIsUsable = isValidMagnetUri(playbackConfig.magnet);
|
||||
const magnetForPlayback = magnetIsUsable ? playbackConfig.magnet : "";
|
||||
const fallbackMagnet = magnetIsUsable
|
||||
? playbackConfig.fallbackMagnet
|
||||
: "";
|
||||
const magnetProvided = playbackConfig.provided;
|
||||
this.activePlaybackSession = session;
|
||||
|
||||
this.log(
|
||||
`[playVideoWithFallback] Session start urlProvided=${!!sanitizedUrl} magnetProvided=${magnetProvided} magnetUsable=${!!magnetForPlayback}`
|
||||
);
|
||||
this.resetTorrentStats();
|
||||
this.playSource = null;
|
||||
|
||||
const playbackConfig = session.getPlaybackConfig();
|
||||
const magnetForPlayback = session.getMagnetForPlayback();
|
||||
const fallbackMagnet = session.getFallbackMagnet();
|
||||
const magnetProvided = session.getMagnetProvided();
|
||||
|
||||
if (this.currentVideo) {
|
||||
this.currentVideo.magnet = magnetForPlayback;
|
||||
this.currentVideo.normalizedMagnet = magnetForPlayback;
|
||||
this.currentVideo.normalizedMagnetFallback = fallbackMagnet;
|
||||
if (playbackConfig.infoHash && !this.currentVideo.legacyInfoHash) {
|
||||
if (playbackConfig?.infoHash && !this.currentVideo.legacyInfoHash) {
|
||||
this.currentVideo.legacyInfoHash = playbackConfig.infoHash;
|
||||
}
|
||||
this.currentVideo.torrentSupported = !!magnetForPlayback;
|
||||
@@ -9803,383 +9725,78 @@ class bitvidApp {
|
||||
this.currentMagnetUri = magnetForPlayback || null;
|
||||
this.setCopyMagnetState(!!magnetForPlayback);
|
||||
|
||||
let ensureDebugListenersRemoved = () => {};
|
||||
|
||||
try {
|
||||
if (!this.modalVideo) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
if (typeof this.modalPosterCleanup === "function") {
|
||||
try {
|
||||
this.modalPosterCleanup();
|
||||
} catch (err) {
|
||||
console.warn("[playVideoWithFallback] modal poster cleanup threw:", err);
|
||||
} finally {
|
||||
this.modalPosterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedModal = this.teardownVideoElement(this.modalVideo, {
|
||||
replaceNode: true,
|
||||
});
|
||||
if (refreshedModal) {
|
||||
this.modalVideo = refreshedModal;
|
||||
}
|
||||
|
||||
this.showModalWithPoster();
|
||||
const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL";
|
||||
const unsubscribers = [];
|
||||
const subscribe = (eventName, handler) => {
|
||||
const off = session.on(eventName, handler);
|
||||
unsubscribers.push(off);
|
||||
};
|
||||
|
||||
subscribe("status", ({ message } = {}) => {
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = "Preparing video...";
|
||||
this.modalStatus.textContent =
|
||||
typeof message === "string" ? message : "";
|
||||
}
|
||||
});
|
||||
|
||||
this.clearActiveIntervals();
|
||||
this.prepareModalVideoForPlayback();
|
||||
|
||||
await torrentClient.cleanup();
|
||||
|
||||
const activeVideoEl = this.teardownVideoElement(this.modalVideo);
|
||||
if (activeVideoEl) {
|
||||
this.modalVideo = activeVideoEl;
|
||||
this.log(
|
||||
`[playVideoWithFallback] Modal video prepared (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
);
|
||||
subscribe("video-prepared", ({ videoElement } = {}) => {
|
||||
if (videoElement && videoElement !== this.modalVideo) {
|
||||
this.modalVideo = videoElement;
|
||||
}
|
||||
});
|
||||
|
||||
if (activeVideoEl) {
|
||||
const debugEvents = [
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"play",
|
||||
"playing",
|
||||
"pause",
|
||||
"stalled",
|
||||
"suspend",
|
||||
"waiting",
|
||||
"ended",
|
||||
"error",
|
||||
];
|
||||
const debugHandlers = [];
|
||||
ensureDebugListenersRemoved = () => {
|
||||
if (!debugHandlers.length) {
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler] of debugHandlers) {
|
||||
activeVideoEl.removeEventListener(eventName, handler);
|
||||
}
|
||||
debugHandlers.length = 0;
|
||||
ensureDebugListenersRemoved = () => {};
|
||||
};
|
||||
for (const eventName of debugEvents) {
|
||||
const handler = () => {
|
||||
const { readyState, networkState, currentTime, paused, error } =
|
||||
activeVideoEl;
|
||||
let suffix = `readyState=${readyState} networkState=${networkState} currentTime=${Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime} paused=${paused}`;
|
||||
if (eventName === "error" && error) {
|
||||
const errorMessage = error.message || "";
|
||||
suffix += ` code=${error.code || ""} message=${errorMessage}`;
|
||||
}
|
||||
this.log(
|
||||
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
|
||||
);
|
||||
};
|
||||
activeVideoEl.addEventListener(eventName, handler);
|
||||
debugHandlers.push([eventName, handler]);
|
||||
}
|
||||
} else {
|
||||
ensureDebugListenersRemoved = () => {};
|
||||
subscribe("view-logging-request", ({ videoElement } = {}) => {
|
||||
if (videoElement) {
|
||||
this.preparePlaybackViewLogging(videoElement);
|
||||
}
|
||||
});
|
||||
|
||||
this.resetTorrentStats();
|
||||
this.playSource = null;
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
this.preparePlaybackViewLogging(activeVideoEl);
|
||||
subscribe("poster-remove", ({ reason } = {}) => {
|
||||
this.forceRemoveModalPoster(reason || "playback");
|
||||
});
|
||||
|
||||
if (magnetForPlayback) {
|
||||
const label = playbackConfig.didMutate
|
||||
? "[playVideoWithFallback] Using normalized magnet URI:"
|
||||
: "[playVideoWithFallback] Using magnet URI:";
|
||||
this.log(`${label} ${magnetForPlayback}`);
|
||||
subscribe("sourcechange", ({ source } = {}) => {
|
||||
this.playSource = source || null;
|
||||
});
|
||||
|
||||
subscribe("error", ({ error, message } = {}) => {
|
||||
const displayMessage =
|
||||
typeof message === "string"
|
||||
? message
|
||||
: error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
this.showError(displayMessage);
|
||||
});
|
||||
|
||||
subscribe("finished", () => {
|
||||
if (this.activePlaybackSession === session) {
|
||||
this.activePlaybackSession = null;
|
||||
}
|
||||
|
||||
const httpsUrl =
|
||||
sanitizedUrl && /^https:\/\//i.test(sanitizedUrl) ? sanitizedUrl : "";
|
||||
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
|
||||
let cleanupHostedUrlStatusListeners = () => {};
|
||||
const cleanupDebugListeners = () => {
|
||||
ensureDebugListenersRemoved();
|
||||
};
|
||||
|
||||
let fallbackStarted = false;
|
||||
const startTorrentFallback = async (reason) => {
|
||||
if (fallbackStarted) {
|
||||
this.log(
|
||||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fallbackStarted = true;
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
|
||||
if (activeVideoEl) {
|
||||
while (unsubscribers.length) {
|
||||
const off = unsubscribers.pop();
|
||||
if (typeof off === "function") {
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
off();
|
||||
} catch (err) {
|
||||
this.log(
|
||||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// removeAttribute throws in old browsers when the attribute does not exist
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.log(
|
||||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||||
console.warn(
|
||||
"[playVideoWithFallback] Listener cleanup error:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!magnetForPlayback) {
|
||||
const message =
|
||||
"Hosted playback failed and no magnet fallback is available.";
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = message;
|
||||
}
|
||||
this.playSource = null;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = "Switching to WebTorrent...";
|
||||
}
|
||||
this.log(
|
||||
`[playVideoWithFallback] Falling back to WebTorrent (${reason}).`
|
||||
);
|
||||
this.log(
|
||||
`[playVideoWithFallback] startTorrentFallback invoked with magnet=${!!magnetForPlayback} readyState=${
|
||||
activeVideoEl ? activeVideoEl.readyState : "n/a"
|
||||
} networkState=${
|
||||
activeVideoEl ? activeVideoEl.networkState : "n/a"
|
||||
}`
|
||||
);
|
||||
console.debug("[WT] add magnet =", magnetForPlayback);
|
||||
const torrentInstance = await this.playViaWebTorrent(
|
||||
magnetForPlayback,
|
||||
{
|
||||
fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
}
|
||||
);
|
||||
this.playSource = "torrent";
|
||||
this.autoplayModalVideo();
|
||||
return torrentInstance;
|
||||
};
|
||||
|
||||
if (URL_FIRST_ENABLED && httpsUrl) {
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = "Checking hosted URL...";
|
||||
}
|
||||
this.log(
|
||||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl?.readyState} networkState=${activeVideoEl?.networkState}).`
|
||||
);
|
||||
let hostedStatusResolved = false;
|
||||
const hostedStatusHandlers = [];
|
||||
const addHostedStatusListener = (eventName, handler, options) => {
|
||||
if (!activeVideoEl) {
|
||||
return;
|
||||
}
|
||||
activeVideoEl.addEventListener(eventName, handler, options);
|
||||
hostedStatusHandlers.push([eventName, handler, options]);
|
||||
};
|
||||
const markHostedUrlAsLive = () => {
|
||||
if (hostedStatusResolved) {
|
||||
return;
|
||||
}
|
||||
hostedStatusResolved = true;
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = HOSTED_URL_SUCCESS_MESSAGE;
|
||||
}
|
||||
cleanupHostedUrlStatusListeners();
|
||||
};
|
||||
const maybeMarkHostedUrl = () => {
|
||||
if (
|
||||
hostedStatusResolved ||
|
||||
!activeVideoEl ||
|
||||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
|
||||
markHostedUrlAsLive();
|
||||
}
|
||||
};
|
||||
cleanupHostedUrlStatusListeners = () => {
|
||||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler, options] of hostedStatusHandlers) {
|
||||
activeVideoEl.removeEventListener(eventName, handler, options);
|
||||
}
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
};
|
||||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("error", () => {
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
}, { once: true });
|
||||
const probeResult = await this.probeUrl(httpsUrl);
|
||||
const probeOutcome = probeResult?.outcome || "error";
|
||||
const shouldAttemptHosted =
|
||||
probeOutcome !== "bad" && probeOutcome !== "error";
|
||||
|
||||
this.log(
|
||||
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
|
||||
);
|
||||
|
||||
if (shouldAttemptHosted) {
|
||||
let outcomeResolved = false;
|
||||
let outcomeResolver = () => {};
|
||||
let autoplayBlocked = false;
|
||||
|
||||
const playbackOutcomePromise = new Promise((resolve) => {
|
||||
outcomeResolver = (value) => {
|
||||
if (outcomeResolved) {
|
||||
return;
|
||||
}
|
||||
outcomeResolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerUrlPlaybackWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||||
onFallback: (reason) => {
|
||||
if (autoplayBlocked && reason === "stall") {
|
||||
this.log(
|
||||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||||
);
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent =
|
||||
"Press play to start the hosted video.";
|
||||
}
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
err
|
||||
);
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
try {
|
||||
activeVideoEl.src = httpsUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
autoplayBlocked = true;
|
||||
this.log(
|
||||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||||
err
|
||||
);
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent =
|
||||
"Press play to start the hosted video.";
|
||||
}
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
const restoreOnPlay = () => {
|
||||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||||
this.cleanupUrlPlaybackWatchdog();
|
||||
autoplayBlocked = false;
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
};
|
||||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFatalPlaybackError(err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
const playbackOutcome = await playbackOutcomePromise;
|
||||
if (playbackOutcome?.status === "success") {
|
||||
this.forceRemoveModalPoster("http-success");
|
||||
this.playSource = "url";
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = HOSTED_URL_SUCCESS_MESSAGE;
|
||||
}
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackReason = playbackOutcome?.reason || "watchdog-triggered";
|
||||
await startTorrentFallback(fallbackReason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(
|
||||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||||
);
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
}
|
||||
});
|
||||
|
||||
if (magnetForPlayback) {
|
||||
await startTorrentFallback("magnet-primary");
|
||||
return;
|
||||
}
|
||||
const result = await session.start();
|
||||
|
||||
const message = magnetProvided && !magnetForPlayback
|
||||
? UNSUPPORTED_BTITH_MESSAGE
|
||||
: "No playable source found.";
|
||||
if (this.modalStatus) {
|
||||
this.modalStatus.textContent = message;
|
||||
}
|
||||
this.playSource = null;
|
||||
cleanupDebugListeners();
|
||||
this.showError(message);
|
||||
} catch (error) {
|
||||
ensureDebugListenersRemoved();
|
||||
this.log("Error in playVideoWithFallback:", error);
|
||||
this.showError(`Playback error: ${error.message}`);
|
||||
if (!result || result.error) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
async playVideoByEventId(eventId) {
|
||||
if (!eventId) {
|
||||
this.showError("No video identifier provided.");
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
// js/services/playbackService.js
|
||||
|
||||
class SimpleEventEmitter {
|
||||
constructor(logger = null) {
|
||||
this.logger = typeof logger === "function" ? logger : null;
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
if (typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, new Set());
|
||||
}
|
||||
const handlers = this.listeners.get(eventName);
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
if (!handlers.size) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName, detail) {
|
||||
const handlers = this.listeners.get(eventName);
|
||||
if (!handlers || !handlers.size) {
|
||||
return;
|
||||
}
|
||||
for (const handler of Array.from(handlers)) {
|
||||
try {
|
||||
handler(detail);
|
||||
} catch (err) {
|
||||
if (this.logger) {
|
||||
this.logger("[PlaybackService] Listener error", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
|
||||
export class PlaybackService {
|
||||
constructor({
|
||||
logger,
|
||||
torrentClient,
|
||||
deriveTorrentPlaybackConfig,
|
||||
isValidMagnetUri,
|
||||
urlFirstEnabled = true,
|
||||
analyticsCallbacks = {},
|
||||
} = {}) {
|
||||
if (typeof logger === "function") {
|
||||
this.logger = logger;
|
||||
} else if (logger && typeof logger.log === "function") {
|
||||
this.logger = (...args) => logger.log(...args);
|
||||
} else {
|
||||
this.logger = () => {};
|
||||
}
|
||||
|
||||
this.torrentClient = torrentClient;
|
||||
this.deriveTorrentPlaybackConfig = deriveTorrentPlaybackConfig;
|
||||
this.isValidMagnetUri = isValidMagnetUri;
|
||||
this.urlFirstEnabled = !!urlFirstEnabled;
|
||||
this.analyticsCallbacks = analyticsCallbacks || {};
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
try {
|
||||
this.logger(...args);
|
||||
} catch (err) {
|
||||
console.warn("[PlaybackService] logger threw", err);
|
||||
}
|
||||
}
|
||||
|
||||
handleAnalyticsEvent(eventName, detail) {
|
||||
const callbacks = this.analyticsCallbacks || {};
|
||||
if (typeof callbacks.onEvent === "function") {
|
||||
try {
|
||||
callbacks.onEvent(eventName, detail);
|
||||
} catch (err) {
|
||||
this.log("[PlaybackService] onEvent callback threw", err);
|
||||
}
|
||||
}
|
||||
const specific = callbacks[eventName];
|
||||
if (typeof specific === "function") {
|
||||
try {
|
||||
specific(detail);
|
||||
} catch (err) {
|
||||
this.log(`[PlaybackService] ${eventName} callback threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareVideoElement(videoElement) {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
|
||||
const userWantsUnmuted = storedUnmuted === "true";
|
||||
videoElement.muted = !userWantsUnmuted;
|
||||
|
||||
if (!videoElement.dataset.autoplayBound) {
|
||||
videoElement.addEventListener("volumechange", () => {
|
||||
localStorage.setItem(
|
||||
"unmutedAutoplay",
|
||||
(!videoElement.muted).toString()
|
||||
);
|
||||
});
|
||||
videoElement.dataset.autoplayBound = "true";
|
||||
}
|
||||
}
|
||||
|
||||
registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
{ stallMs = 8000, onSuccess, onFallback } = {}
|
||||
) {
|
||||
if (!videoElement || typeof onFallback !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0;
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
|
||||
let active = true;
|
||||
let stallTimerId = null;
|
||||
const listeners = [];
|
||||
|
||||
const cleanup = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
stallTimerId = null;
|
||||
}
|
||||
for (const [eventName, handler] of listeners) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFallback = (reason) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}`
|
||||
);
|
||||
cleanup();
|
||||
onFallback(reason);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
"[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs."
|
||||
);
|
||||
cleanup();
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
if (!active || !normalizedStallMs) {
|
||||
return;
|
||||
}
|
||||
if (stallTimerId) {
|
||||
clearTimeout(stallTimerId);
|
||||
}
|
||||
stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs);
|
||||
};
|
||||
|
||||
const addListener = (eventName, handler) => {
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
listeners.push([eventName, handler]);
|
||||
};
|
||||
|
||||
addListener("error", () => triggerFallback("error"));
|
||||
addListener("abort", () => triggerFallback("abort"));
|
||||
addListener("stalled", () => triggerFallback("stalled"));
|
||||
addListener("playing", handleSuccess);
|
||||
addListener("ended", handleSuccess);
|
||||
|
||||
const timerEvents = [
|
||||
"timeupdate",
|
||||
"progress",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"suspend",
|
||||
"waiting",
|
||||
];
|
||||
for (const eventName of timerEvents) {
|
||||
addListener(eventName, resetTimer);
|
||||
}
|
||||
|
||||
if (normalizedStallMs) {
|
||||
resetTimer();
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked.");
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (this.currentSession) {
|
||||
this.currentSession.cleanupWatchdog();
|
||||
}
|
||||
}
|
||||
|
||||
createSession(options = {}) {
|
||||
const session = new PlaybackSession(this, options);
|
||||
this.currentSession = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
class PlaybackSession extends SimpleEventEmitter {
|
||||
constructor(service, options = {}) {
|
||||
super((message, ...args) => service.log(message, ...args));
|
||||
this.service = service;
|
||||
this.options = options;
|
||||
this.watchdogCleanup = null;
|
||||
this.result = { source: null };
|
||||
|
||||
this.sanitizedUrl =
|
||||
typeof options.url === "string" ? options.url.trim() : "";
|
||||
this.trimmedMagnet =
|
||||
typeof options.magnet === "string" ? options.magnet.trim() : "";
|
||||
|
||||
if (typeof service.deriveTorrentPlaybackConfig === "function") {
|
||||
this.playbackConfig = service.deriveTorrentPlaybackConfig({
|
||||
magnet: this.trimmedMagnet,
|
||||
url: this.sanitizedUrl,
|
||||
logger: (message) => service.log(message),
|
||||
appProtocol: options.appProtocol,
|
||||
});
|
||||
} else {
|
||||
this.playbackConfig = {
|
||||
magnet: this.trimmedMagnet,
|
||||
fallbackMagnet: "",
|
||||
provided: !!this.trimmedMagnet,
|
||||
usedInfoHash: false,
|
||||
originalInput: this.trimmedMagnet,
|
||||
didMutate: false,
|
||||
infoHash: "",
|
||||
};
|
||||
}
|
||||
|
||||
const magnetIsUsable =
|
||||
typeof service.isValidMagnetUri === "function"
|
||||
? service.isValidMagnetUri(this.playbackConfig.magnet)
|
||||
: false;
|
||||
|
||||
this.magnetForPlayback = magnetIsUsable
|
||||
? this.playbackConfig.magnet
|
||||
: "";
|
||||
this.fallbackMagnet = magnetIsUsable
|
||||
? this.playbackConfig.fallbackMagnet
|
||||
: "";
|
||||
this.magnetProvided = !!this.playbackConfig.provided;
|
||||
}
|
||||
|
||||
getPlaybackConfig() {
|
||||
return this.playbackConfig;
|
||||
}
|
||||
|
||||
getMagnetForPlayback() {
|
||||
return this.magnetForPlayback;
|
||||
}
|
||||
|
||||
getFallbackMagnet() {
|
||||
return this.fallbackMagnet;
|
||||
}
|
||||
|
||||
getMagnetProvided() {
|
||||
return this.magnetProvided;
|
||||
}
|
||||
|
||||
cleanupWatchdog() {
|
||||
if (typeof this.watchdogCleanup === "function") {
|
||||
try {
|
||||
this.watchdogCleanup();
|
||||
} catch (err) {
|
||||
this.service.log("[PlaybackSession] Watchdog cleanup threw", err);
|
||||
} finally {
|
||||
this.watchdogCleanup = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerWatchdogs(videoElement, options) {
|
||||
this.cleanupWatchdog();
|
||||
this.watchdogCleanup = this.service.registerUrlPlaybackWatchdogs(
|
||||
videoElement,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
attachDebugListeners(videoElement) {
|
||||
if (!videoElement) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const debugEvents = [
|
||||
"loadedmetadata",
|
||||
"loadeddata",
|
||||
"canplay",
|
||||
"canplaythrough",
|
||||
"play",
|
||||
"playing",
|
||||
"pause",
|
||||
"stalled",
|
||||
"suspend",
|
||||
"waiting",
|
||||
"ended",
|
||||
"error",
|
||||
];
|
||||
|
||||
const handlers = [];
|
||||
for (const eventName of debugEvents) {
|
||||
const handler = () => {
|
||||
const { readyState, networkState, currentTime, paused, error } =
|
||||
videoElement;
|
||||
let suffix =
|
||||
`readyState=${readyState} networkState=${networkState} currentTime=${
|
||||
Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime
|
||||
} paused=${paused}`;
|
||||
if (eventName === "error" && error) {
|
||||
const errorMessage = error.message || "";
|
||||
suffix += ` code=${error.code || ""} message=${errorMessage}`;
|
||||
}
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] <video> event ${eventName}; ${suffix}`
|
||||
);
|
||||
};
|
||||
videoElement.addEventListener(eventName, handler);
|
||||
handlers.push([eventName, handler]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!handlers.length) {
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler] of handlers) {
|
||||
videoElement.removeEventListener(eventName, handler);
|
||||
}
|
||||
handlers.length = 0;
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
const {
|
||||
videoElement,
|
||||
waitForCleanup,
|
||||
cancelPendingViewLogging,
|
||||
clearActiveIntervals,
|
||||
showModalWithPoster,
|
||||
teardownVideoElement,
|
||||
probeUrl,
|
||||
playViaWebTorrent,
|
||||
autoplay,
|
||||
unsupportedBtihMessage = DEFAULT_UNSUPPORTED_BTITH_MESSAGE,
|
||||
} = this.options;
|
||||
|
||||
this.result = { source: null };
|
||||
|
||||
const detail = {
|
||||
urlProvided: !!this.sanitizedUrl,
|
||||
magnetProvided: this.magnetProvided,
|
||||
magnetUsable: !!this.magnetForPlayback,
|
||||
};
|
||||
this.service.handleAnalyticsEvent("session-start", detail);
|
||||
this.emit("session-start", detail);
|
||||
|
||||
try {
|
||||
if (typeof waitForCleanup === "function") {
|
||||
await waitForCleanup();
|
||||
}
|
||||
if (typeof cancelPendingViewLogging === "function") {
|
||||
cancelPendingViewLogging();
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
if (typeof showModalWithPoster === "function") {
|
||||
showModalWithPoster();
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Preparing video..." });
|
||||
|
||||
if (typeof clearActiveIntervals === "function") {
|
||||
clearActiveIntervals();
|
||||
}
|
||||
|
||||
this.service.prepareVideoElement(videoElement);
|
||||
|
||||
if (
|
||||
this.service.torrentClient &&
|
||||
typeof this.service.torrentClient.cleanup === "function"
|
||||
) {
|
||||
await this.service.torrentClient.cleanup();
|
||||
}
|
||||
|
||||
const activeVideoEl =
|
||||
typeof teardownVideoElement === "function"
|
||||
? teardownVideoElement(videoElement) || videoElement
|
||||
: videoElement;
|
||||
|
||||
if (!activeVideoEl) {
|
||||
throw new Error("Video element is not ready for playback.");
|
||||
}
|
||||
|
||||
this.options.videoElement = activeVideoEl;
|
||||
this.emit("video-prepared", { videoElement: activeVideoEl });
|
||||
this.emit("view-logging-request", { videoElement: activeVideoEl });
|
||||
|
||||
const cleanupDebugListeners = this.attachDebugListeners(activeVideoEl);
|
||||
let cleanupHostedUrlStatusListeners = () => {};
|
||||
|
||||
const httpsUrl = this.sanitizedUrl;
|
||||
const webSeedCandidates = httpsUrl ? [httpsUrl] : [];
|
||||
|
||||
let fallbackStarted = false;
|
||||
const startTorrentFallback = async (reason) => {
|
||||
if (fallbackStarted) {
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Duplicate fallback request ignored (${reason}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
fallbackStarted = true;
|
||||
this.cleanupWatchdog();
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
|
||||
if (activeVideoEl) {
|
||||
try {
|
||||
activeVideoEl.pause();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring pause error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
try {
|
||||
activeVideoEl.removeAttribute("src");
|
||||
} catch (err) {
|
||||
// ignore attribute removal errors
|
||||
}
|
||||
activeVideoEl.src = "";
|
||||
activeVideoEl.srcObject = null;
|
||||
try {
|
||||
activeVideoEl.load();
|
||||
} catch (err) {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Ignoring load error before torrent fallback:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.magnetForPlayback) {
|
||||
const message =
|
||||
"Hosted playback failed and no magnet fallback is available.";
|
||||
this.emit("status", { message });
|
||||
this.emit("sourcechange", { source: null });
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
this.emit("status", { message: "Switching to WebTorrent..." });
|
||||
this.service.handleAnalyticsEvent("fallback", { reason });
|
||||
this.emit("fallback", { reason });
|
||||
|
||||
if (typeof playViaWebTorrent !== "function") {
|
||||
const error = new Error("No torrent playback handler provided.");
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message: error.message });
|
||||
this.result = { source: null, error };
|
||||
return null;
|
||||
}
|
||||
|
||||
const torrentInstance = await playViaWebTorrent(this.magnetForPlayback, {
|
||||
fallbackMagnet: this.fallbackMagnet,
|
||||
urlList: webSeedCandidates,
|
||||
});
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: "torrent" });
|
||||
this.emit("sourcechange", { source: "torrent" });
|
||||
if (typeof autoplay === "function") {
|
||||
autoplay();
|
||||
}
|
||||
this.result = { source: "torrent", torrentInstance };
|
||||
return torrentInstance;
|
||||
};
|
||||
|
||||
if (this.service.urlFirstEnabled && httpsUrl) {
|
||||
this.emit("status", { message: "Checking hosted URL..." });
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Probing hosted URL ${httpsUrl} (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).`
|
||||
);
|
||||
|
||||
let hostedStatusResolved = false;
|
||||
const hostedStatusHandlers = [];
|
||||
const addHostedStatusListener = (eventName, handler, options) => {
|
||||
if (!activeVideoEl) {
|
||||
return;
|
||||
}
|
||||
activeVideoEl.addEventListener(eventName, handler, options);
|
||||
hostedStatusHandlers.push([eventName, handler, options]);
|
||||
};
|
||||
const markHostedUrlAsLive = () => {
|
||||
if (hostedStatusResolved) {
|
||||
return;
|
||||
}
|
||||
hostedStatusResolved = true;
|
||||
this.emit("status", { message: HOSTED_URL_SUCCESS_MESSAGE });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
};
|
||||
const maybeMarkHostedUrl = () => {
|
||||
if (
|
||||
hostedStatusResolved ||
|
||||
!activeVideoEl ||
|
||||
activeVideoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeVideoEl.currentTime > 0 || !activeVideoEl.paused) {
|
||||
markHostedUrlAsLive();
|
||||
}
|
||||
};
|
||||
cleanupHostedUrlStatusListeners = () => {
|
||||
if (!hostedStatusHandlers.length || !activeVideoEl) {
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
return;
|
||||
}
|
||||
for (const [eventName, handler, options] of hostedStatusHandlers) {
|
||||
activeVideoEl.removeEventListener(eventName, handler, options);
|
||||
}
|
||||
hostedStatusHandlers.length = 0;
|
||||
cleanupHostedUrlStatusListeners = () => {};
|
||||
};
|
||||
addHostedStatusListener("playing", markHostedUrlAsLive, { once: true });
|
||||
addHostedStatusListener("loadeddata", maybeMarkHostedUrl);
|
||||
addHostedStatusListener("canplay", maybeMarkHostedUrl);
|
||||
addHostedStatusListener(
|
||||
"error",
|
||||
() => {
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const probeResult =
|
||||
typeof probeUrl === "function" ? await probeUrl(httpsUrl) : null;
|
||||
const probeOutcome = probeResult?.outcome || "error";
|
||||
const shouldAttemptHosted =
|
||||
probeOutcome !== "bad" && probeOutcome !== "error";
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe outcome=${probeOutcome} shouldAttemptHosted=${shouldAttemptHosted}`
|
||||
);
|
||||
|
||||
if (shouldAttemptHosted) {
|
||||
let outcomeResolved = false;
|
||||
let outcomeResolver = () => {};
|
||||
let autoplayBlocked = false;
|
||||
|
||||
const playbackOutcomePromise = new Promise((resolve) => {
|
||||
outcomeResolver = (value) => {
|
||||
if (outcomeResolved) {
|
||||
return;
|
||||
}
|
||||
outcomeResolved = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
const attachWatchdogs = ({ stallMs = 8000 } = {}) => {
|
||||
this.registerWatchdogs(activeVideoEl, {
|
||||
stallMs,
|
||||
onSuccess: () => outcomeResolver({ status: "success" }),
|
||||
onFallback: (reason) => {
|
||||
if (autoplayBlocked && reason === "stall") {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked; waiting for user gesture before falling back."
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.emit("autoplay-blocked", { reason });
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
outcomeResolver({ status: "fallback", reason });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
|
||||
const handleFatalPlaybackError = (err) => {
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Direct URL playback threw:",
|
||||
err
|
||||
);
|
||||
outcomeResolver({ status: "fallback", reason: "play-error" });
|
||||
};
|
||||
|
||||
try {
|
||||
activeVideoEl.src = httpsUrl;
|
||||
const playPromise = activeVideoEl.play();
|
||||
if (playPromise && typeof playPromise.catch === "function") {
|
||||
playPromise.catch((err) => {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
autoplayBlocked = true;
|
||||
this.service.log(
|
||||
"[playVideoWithFallback] Autoplay blocked by browser; awaiting user interaction.",
|
||||
err
|
||||
);
|
||||
this.emit("status", {
|
||||
message: "Press play to start the hosted video.",
|
||||
});
|
||||
this.cleanupWatchdog();
|
||||
attachWatchdogs({ stallMs: 0 });
|
||||
const restoreOnPlay = () => {
|
||||
activeVideoEl.removeEventListener("play", restoreOnPlay);
|
||||
this.cleanupWatchdog();
|
||||
autoplayBlocked = false;
|
||||
attachWatchdogs({ stallMs: 8000 });
|
||||
};
|
||||
activeVideoEl.addEventListener("play", restoreOnPlay, {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleFatalPlaybackError(err);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
handleFatalPlaybackError(err);
|
||||
}
|
||||
|
||||
const playbackOutcome = await playbackOutcomePromise;
|
||||
if (playbackOutcome?.status === "success") {
|
||||
this.emit("poster-remove", { reason: "http-success" });
|
||||
this.service.handleAnalyticsEvent("sourcechange", {
|
||||
source: "url",
|
||||
});
|
||||
this.emit("sourcechange", { source: "url" });
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
this.result = { source: "url" };
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const fallbackReason =
|
||||
playbackOutcome?.reason || "watchdog-triggered";
|
||||
const torrentInstance = await startTorrentFallback(fallbackReason);
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
this.service.log(
|
||||
`[playVideoWithFallback] Hosted URL probe reported "${probeOutcome}"; deferring to WebTorrent.`
|
||||
);
|
||||
cleanupHostedUrlStatusListeners();
|
||||
cleanupDebugListeners();
|
||||
}
|
||||
|
||||
if (this.magnetForPlayback) {
|
||||
const torrentInstance = await startTorrentFallback("magnet-primary");
|
||||
this.result = torrentInstance
|
||||
? { source: "torrent", torrentInstance }
|
||||
: this.result;
|
||||
return this.result;
|
||||
}
|
||||
|
||||
const message = this.magnetProvided && !this.magnetForPlayback
|
||||
? unsupportedBtihMessage
|
||||
: "No playable source found.";
|
||||
this.emit("status", { message });
|
||||
this.service.handleAnalyticsEvent("sourcechange", { source: null });
|
||||
this.emit("sourcechange", { source: null });
|
||||
cleanupDebugListeners();
|
||||
const error = new Error(message);
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} catch (error) {
|
||||
this.service.log("Error in playVideoWithFallback:", error);
|
||||
const message = error && error.message
|
||||
? `Playback error: ${error.message}`
|
||||
: "Playback error";
|
||||
this.service.handleAnalyticsEvent("error", { error });
|
||||
this.emit("error", { error, message });
|
||||
this.result = { source: null, error };
|
||||
return this.result;
|
||||
} finally {
|
||||
this.cleanupWatchdog();
|
||||
if (this.result) {
|
||||
this.emit("finished", this.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaybackService;
|
||||
Reference in new issue
Block a user