Merge pull request #21 from PR0M3TH3AN/codex/add-methods-and-refactor-video-playback

Implement URL-first playback with torrent fallback
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-09-22 14:12:21 -04:00
commit bd1c68d202
1 file changed
+475 -296
+475 -296
View File
@@ -365,8 +365,8 @@ class bitvidApp {
}
if (this.shareBtn) {
this.shareBtn.addEventListener("click", () => {
if (!this.currentVideo) {
this.showError("No video is loaded to share.");
if (!this.currentVideo || !this.currentVideo.id) {
this.showError("No shareable video is loaded.");
return;
}
try {
@@ -673,9 +673,9 @@ class bitvidApp {
const eventId = trigger.getAttribute("data-video-id");
if (eventId) {
await this.playVideoWithFallback({ eventId, url, magnet });
await this.playVideoByEventId(eventId);
} else {
await this.playVideoWithFallback({ url, magnet });
await this.playVideoWithoutEvent({ url, magnet });
}
}
});
@@ -1019,15 +1019,132 @@ class bitvidApp {
}
}
clearActiveIntervals() {
if (!Array.isArray(this.activeIntervals) || !this.activeIntervals.length) {
return;
}
this.activeIntervals.forEach((id) => clearInterval(id));
this.activeIntervals = [];
}
resetTorrentStats() {
if (this.modalPeers) {
this.modalPeers.textContent = "";
}
if (this.modalSpeed) {
this.modalSpeed.textContent = "";
}
if (this.modalDownloaded) {
this.modalDownloaded.textContent = "";
}
if (this.modalProgress) {
this.modalProgress.style.width = "0%";
}
}
setCopyMagnetState(enabled) {
if (!this.copyMagnetBtn) {
return;
}
this.copyMagnetBtn.disabled = !enabled;
this.copyMagnetBtn.setAttribute("aria-disabled", (!enabled).toString());
this.copyMagnetBtn.classList.toggle("opacity-50", !enabled);
this.copyMagnetBtn.classList.toggle("cursor-not-allowed", !enabled);
}
setShareButtonState(enabled) {
if (!this.shareBtn) {
return;
}
this.shareBtn.disabled = !enabled;
this.shareBtn.setAttribute("aria-disabled", (!enabled).toString());
this.shareBtn.classList.toggle("opacity-50", !enabled);
this.shareBtn.classList.toggle("cursor-not-allowed", !enabled);
}
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) => {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
this.log("Falling back to muted autoplay.");
this.modalVideo.muted = true;
this.modalVideo.play().catch((err2) => {
this.log("Muted autoplay also failed:", err2);
});
}
});
}
startTorrentStatusMirrors(torrentInstance) {
if (!torrentInstance) {
return;
}
const updateInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(updateInterval);
return;
}
this.updateTorrentStatus(torrentInstance);
}, 3000);
this.activeIntervals.push(updateInterval);
const mirrorInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(mirrorInterval);
return;
}
const status = document.getElementById("status");
const progress = document.getElementById("progress");
const peers = document.getElementById("peers");
const speed = document.getElementById("speed");
const downloaded = document.getElementById("downloaded");
if (status && this.modalStatus) {
this.modalStatus.textContent = status.textContent;
}
if (progress && this.modalProgress) {
this.modalProgress.style.width = progress.style.width;
}
if (peers && this.modalPeers) {
this.modalPeers.textContent = peers.textContent;
}
if (speed && this.modalSpeed) {
this.modalSpeed.textContent = speed.textContent;
}
if (downloaded && this.modalDownloaded) {
this.modalDownloaded.textContent = downloaded.textContent;
}
}, 3000);
this.activeIntervals.push(mirrorInterval);
}
/**
* Hide the video modal.
*/
async hideModal() {
// 1) Clear intervals, cleanup, etc. (unchanged)
if (this.activeIntervals && this.activeIntervals.length) {
this.activeIntervals.forEach((id) => clearInterval(id));
this.activeIntervals = [];
}
this.clearActiveIntervals();
try {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
@@ -1691,313 +1808,202 @@ class bitvidApp {
}
}
async probeUrl(url) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return false;
}
const rangeProbe = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(trimmed, {
method: "GET",
headers: { Range: "bytes=0-1023" },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return false;
}
const acceptRanges = response.headers.get("accept-ranges");
if (!acceptRanges || acceptRanges.toLowerCase() !== "bytes") {
console.warn(
`[probeUrl] Range probe missing Accept-Ranges header for ${trimmed}`
);
}
return true;
} catch (err) {
console.warn(`[probeUrl] Range probe failed for ${trimmed}:`, err);
return false;
}
};
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(trimmed, {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 405 || response.status === 501) {
return rangeProbe();
}
return false;
}
const acceptRanges = response.headers.get("accept-ranges");
if (!acceptRanges || acceptRanges.toLowerCase() !== "bytes") {
console.warn(
`[probeUrl] HEAD missing Accept-Ranges header for ${trimmed}`
);
}
return true;
} catch (err) {
console.warn(`[probeUrl] HEAD probe failed for ${trimmed}:`, err);
return rangeProbe();
}
}
async playHttp(url, { fallbackMagnet = "" } = {}) {
if (!this.modalVideo) {
throw new Error("No modal video element available for playback.");
}
const trimmedMagnet =
typeof fallbackMagnet === "string" ? fallbackMagnet.trim() : "";
this.resetTorrentStats();
if (this.modalStatus) {
this.modalStatus.textContent = "Loading video...";
}
const markUrlReady = () => {
if (this.modalStatus) {
this.modalStatus.textContent = "Streaming from URL";
}
};
this.modalVideo.addEventListener("loadeddata", markUrlReady, {
once: true,
});
if (trimmedMagnet) {
this.modalVideo.addEventListener(
"error",
async () => {
this.log("HTTP playback failed, falling back to WebTorrent.");
if (this.modalStatus) {
this.modalStatus.textContent = "Switching to WebTorrent...";
}
try {
await this.playViaWebTorrent(trimmedMagnet);
this.autoplayModalVideo();
} catch (torrentErr) {
console.error("WebTorrent fallback failed:", torrentErr);
this.showError("Unable to play this video.");
}
},
{ once: true }
);
}
this.modalVideo.src = url;
this.modalVideo.load();
}
async playViaWebTorrent(magnet) {
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
if (!trimmedMagnet) {
throw new Error("No magnet URI provided for torrent playback.");
}
if (!this.modalVideo) {
throw new Error("No modal video element available for torrent playback.");
}
let cacheBustedMagnet = trimmedMagnet;
try {
const parsed = new URL(trimmedMagnet);
parsed.searchParams.set("ts", Date.now().toString());
cacheBustedMagnet = parsed.toString();
} catch (err) {
const separator = trimmedMagnet.includes("?") ? "&" : "?";
cacheBustedMagnet = `${trimmedMagnet}${separator}ts=${Date.now()}`;
}
await torrentClient.cleanup();
this.resetTorrentStats();
if (this.modalStatus) {
this.modalStatus.textContent = "Streaming via WebTorrent";
}
const torrentInstance = await torrentClient.streamVideo(
cacheBustedMagnet,
this.modalVideo
);
this.startTorrentStatusMirrors(torrentInstance);
return torrentInstance;
}
/**
* Unified playback helper that prefers HTTP URL sources
* and falls back to WebTorrent when needed.
*/
async playVideoWithFallback({
url: rawUrl = "",
magnet: rawMagnet = "",
eventId = null,
url = "",
magnet = "",
title = "",
description = "",
} = {}) {
const sanitize = (value) =>
typeof value === "string" ? value.trim() : "";
let trimmedUrl = sanitize(rawUrl);
let trimmedMagnet = sanitize(rawMagnet);
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const sanitizedMagnet = typeof magnet === "string" ? magnet.trim() : "";
try {
let video = null;
if (eventId) {
if (this.blacklistedEventIds.has(eventId)) {
this.showError("This content has been removed or is not allowed.");
return;
}
video = this.videosMap.get(eventId);
if (!video) {
video = await this.getOldEventById(eventId);
}
if (!video) {
this.showError("Video not found.");
return;
}
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
return;
}
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
this.showError("This content is not from a whitelisted author.");
return;
}
if (
video.isPrivate &&
video.pubkey === this.pubkey &&
!video.alreadyDecrypted
) {
video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true;
}
if (!trimmedMagnet && typeof video.magnet === "string") {
trimmedMagnet = video.magnet.trim();
}
if (!trimmedUrl && typeof video.url === "string") {
trimmedUrl = video.url.trim();
}
const hasMagnet = !!trimmedMagnet;
const hasUrl = !!trimmedUrl;
if (!hasMagnet && !hasUrl) {
this.showError("This video has no playable source.");
return;
}
video.magnet = trimmedMagnet;
video.url = trimmedUrl;
this.currentVideo = video;
this.showModalWithPoster();
if (this.copyMagnetBtn) {
this.copyMagnetBtn.disabled = !hasMagnet;
this.copyMagnetBtn.setAttribute(
"aria-disabled",
(!hasMagnet).toString()
);
this.copyMagnetBtn.classList.toggle("opacity-50", !hasMagnet);
this.copyMagnetBtn.classList.toggle(
"cursor-not-allowed",
!hasMagnet
);
}
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const newUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
window.history.pushState({}, "", newUrl);
let creatorProfile = {
name: "Unknown",
picture: `https://robohash.org/${video.pubkey}`,
};
try {
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [video.pubkey], limit: 1 },
]);
if (userEvents.length > 0 && userEvents[0]?.content) {
const data = JSON.parse(userEvents[0].content);
creatorProfile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || `https://robohash.org/${video.pubkey}`,
};
}
} catch (error) {
this.log("Error fetching creator profile:", error);
}
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoTitle) {
this.videoTitle.textContent = video.title || "Untitled";
}
if (this.videoDescription) {
this.videoDescription.textContent =
video.description || "No description available.";
}
if (this.videoTimestamp) {
this.videoTimestamp.textContent = this.formatTimeAgo(
video.created_at
);
}
if (this.creatorName) {
this.creatorName.textContent = creatorProfile.name;
}
if (this.creatorNpub) {
this.creatorNpub.textContent = `${creatorNpub.slice(
0,
8
)}...${creatorNpub.slice(-4)}`;
}
if (this.creatorAvatar) {
this.creatorAvatar.src = creatorProfile.picture;
this.creatorAvatar.alt = creatorProfile.name;
}
} else {
const hasMagnet = !!trimmedMagnet;
const hasUrl = !!trimmedUrl;
if (!hasMagnet && !hasUrl) {
this.showError("This video has no playable source.");
return;
}
this.showModalWithPoster();
if (this.copyMagnetBtn) {
this.copyMagnetBtn.disabled = !hasMagnet;
this.copyMagnetBtn.setAttribute(
"aria-disabled",
(!hasMagnet).toString()
);
this.copyMagnetBtn.classList.toggle("opacity-50", !hasMagnet);
this.copyMagnetBtn.classList.toggle(
"cursor-not-allowed",
!hasMagnet
);
}
if (!this.modalVideo) {
throw new Error("Video element is not ready for playback.");
}
const hasMagnetFinal = !!trimmedMagnet;
const hasUrlFinal = !!trimmedUrl;
if (this.modalStatus) {
this.modalStatus.textContent = "Preparing video...";
}
this.currentMagnetUri = hasMagnetFinal ? trimmedMagnet : null;
this.clearActiveIntervals();
this.prepareModalVideoForPlayback();
await torrentClient.cleanup();
if (!hasMagnetFinal) {
if (this.modalPeers) {
this.modalPeers.textContent = "";
}
if (this.modalSpeed) {
this.modalSpeed.textContent = "";
}
if (this.modalDownloaded) {
this.modalDownloaded.textContent = "";
}
if (this.modalProgress) {
this.modalProgress.style.width = "0%";
}
this.modalVideo.pause();
this.modalVideo.src = "";
this.modalVideo.removeAttribute("src");
this.modalVideo.load();
this.resetTorrentStats();
let urlPlayable = false;
if (sanitizedUrl) {
urlPlayable = await this.probeUrl(sanitizedUrl);
}
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
if (this.modalVideo) {
this.modalVideo.muted = !userWantsUnmuted;
this.modalVideo.addEventListener("volumechange", () => {
localStorage.setItem(
"unmutedAutoplay",
(!this.modalVideo.muted).toString()
);
if (urlPlayable) {
await this.playHttp(sanitizedUrl, {
fallbackMagnet: sanitizedMagnet,
});
}
const attemptAutoplay = () => {
if (!this.modalVideo) return;
this.modalVideo.play().catch((err) => {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
this.log("Falling back to muted autoplay.");
this.modalVideo.muted = true;
this.modalVideo.play().catch((err2) => {
this.log("Muted autoplay also failed:", err2);
});
}
});
};
const setupTorrentMonitors = (torrentInstance) => {
if (!torrentInstance) {
return;
}
const updateInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(updateInterval);
return;
}
this.updateTorrentStatus(torrentInstance);
}, 3000);
this.activeIntervals.push(updateInterval);
const mirrorInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(mirrorInterval);
return;
}
const status = document.getElementById("status");
const progress = document.getElementById("progress");
const peers = document.getElementById("peers");
const speed = document.getElementById("speed");
const downloaded = document.getElementById("downloaded");
if (status && this.modalStatus) {
this.modalStatus.textContent = status.textContent;
}
if (progress && this.modalProgress) {
this.modalProgress.style.width = progress.style.width;
}
if (peers && this.modalPeers) {
this.modalPeers.textContent = peers.textContent;
}
if (speed && this.modalSpeed) {
this.modalSpeed.textContent = speed.textContent;
}
if (downloaded && this.modalDownloaded) {
this.modalDownloaded.textContent = downloaded.textContent;
}
}, 3000);
this.activeIntervals.push(mirrorInterval);
};
const startTorrentPlayback = async () => {
if (!hasMagnetFinal || !this.modalVideo) {
return null;
}
const cacheBustedMagnet = `${trimmedMagnet}&ts=${Date.now()}`;
this.log("Starting video stream with:", cacheBustedMagnet);
if (this.modalStatus) {
this.modalStatus.textContent = "Streaming via WebTorrent";
}
const torrentInstance = await torrentClient.streamVideo(
cacheBustedMagnet,
this.modalVideo
);
setupTorrentMonitors(torrentInstance);
return torrentInstance;
};
if (hasUrlFinal && this.modalVideo) {
if (this.modalStatus) {
this.modalStatus.textContent = "Loading video...";
}
const markUrlReady = () => {
if (this.modalStatus) {
this.modalStatus.textContent = "Streaming from URL";
}
};
this.modalVideo.addEventListener("loadeddata", markUrlReady, {
once: true,
});
this.modalVideo.src = trimmedUrl;
this.modalVideo.load();
if (hasMagnetFinal) {
const fallbackToTorrent = async () => {
this.modalVideo.removeEventListener("error", fallbackToTorrent);
this.log("HTTP playback failed, falling back to WebTorrent.");
try {
await torrentClient.cleanup();
await startTorrentPlayback();
attemptAutoplay();
} catch (torrentErr) {
console.error("WebTorrent fallback failed:", torrentErr);
this.showError("Unable to play this video.");
}
};
this.modalVideo.addEventListener("error", fallbackToTorrent, {
once: true,
});
}
} else if (sanitizedMagnet) {
await this.playViaWebTorrent(sanitizedMagnet);
} else {
await startTorrentPlayback();
if (this.modalStatus) {
this.modalStatus.textContent = "No playable source available.";
}
this.showError("This video has no playable source.");
return;
}
attemptAutoplay();
this.autoplayModalVideo();
} catch (error) {
this.log("Error in playVideoWithFallback:", error);
this.showError(`Playback error: ${error.message}`);
@@ -2005,7 +2011,180 @@ class bitvidApp {
}
async playVideoByEventId(eventId) {
return this.playVideoWithFallback({ eventId });
if (!eventId) {
this.showError("No video identifier provided.");
return;
}
if (this.blacklistedEventIds.has(eventId)) {
this.showError("This content has been removed or is not allowed.");
return;
}
let video = this.videosMap.get(eventId);
if (!video) {
video = await this.getOldEventById(eventId);
}
if (!video) {
this.showError("Video not found or has been removed.");
return;
}
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
return;
}
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
this.showError("This content is not from a whitelisted author.");
return;
}
if (
video.isPrivate &&
video.pubkey === this.pubkey &&
!video.alreadyDecrypted
) {
video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true;
}
const trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
const trimmedMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
this.currentVideo = {
...video,
url: trimmedUrl,
magnet: trimmedMagnet,
};
this.currentMagnetUri = trimmedMagnet || null;
await this.showModalWithPoster();
this.setCopyMagnetState(!!trimmedMagnet);
this.setShareButtonState(true);
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const newUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
window.history.pushState({}, "", newUrl);
let creatorProfile = {
name: "Unknown",
picture: `https://robohash.org/${video.pubkey}`,
};
try {
const userEvents = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [video.pubkey], limit: 1 },
]);
if (userEvents.length > 0 && userEvents[0]?.content) {
const data = JSON.parse(userEvents[0].content);
creatorProfile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || `https://robohash.org/${video.pubkey}`,
};
}
} catch (error) {
this.log("Error fetching creator profile:", error);
}
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoTitle) {
this.videoTitle.textContent = video.title || "Untitled";
}
if (this.videoDescription) {
this.videoDescription.textContent =
video.description || "No description available.";
}
if (this.videoTimestamp) {
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
}
if (this.creatorName) {
this.creatorName.textContent = creatorProfile.name;
}
if (this.creatorNpub) {
this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(
-4
)}`;
}
if (this.creatorAvatar) {
this.creatorAvatar.src = creatorProfile.picture;
this.creatorAvatar.alt = creatorProfile.name;
}
await this.playVideoWithFallback({
url: trimmedUrl,
magnet: trimmedMagnet,
title: video.title,
description: video.description,
});
}
async playVideoWithoutEvent({
url = "",
magnet = "",
title = "Untitled",
description = "",
} = {}) {
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const sanitizedMagnet = typeof magnet === "string" ? magnet.trim() : "";
if (!sanitizedUrl && !sanitizedMagnet) {
this.showError("This video has no playable source.");
return;
}
await this.showModalWithPoster();
this.currentVideo = {
id: null,
title,
description,
url: sanitizedUrl,
magnet: sanitizedMagnet,
};
this.currentMagnetUri = sanitizedMagnet || null;
this.setCopyMagnetState(!!sanitizedMagnet);
this.setShareButtonState(false);
if (this.videoTitle) {
this.videoTitle.textContent = title || "Untitled";
}
if (this.videoDescription) {
this.videoDescription.textContent =
description || "No description available.";
}
if (this.videoTimestamp) {
this.videoTimestamp.textContent = "";
}
if (this.creatorName) {
this.creatorName.textContent = "Unknown";
}
if (this.creatorNpub) {
this.creatorNpub.textContent = "";
}
if (this.creatorAvatar) {
this.creatorAvatar.src = "assets/svg/default-profile.svg";
this.creatorAvatar.alt = "Unknown";
}
const urlObj = new URL(window.location.href);
urlObj.searchParams.delete("v");
const cleaned = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
window.history.replaceState({}, "", cleaned);
await this.playVideoWithFallback({
url: sanitizedUrl,
magnet: sanitizedMagnet,
title,
description,
});
}
/**