diff --git a/js/app.js b/js/app.js
index 331d16fe..eaf17aee 100644
--- a/js/app.js
+++ b/js/app.js
@@ -643,15 +643,39 @@ class bitvidApp {
// 9) Event delegation on the video list container for playing videos
if (this.videoList) {
- this.videoList.addEventListener("click", (event) => {
- const trigger = event.target.closest("[data-video-id]");
- if (trigger) {
- if (event.button === 0 && !event.ctrlKey && !event.metaKey) {
- event.preventDefault();
- const eventId = trigger.getAttribute("data-video-id");
- if (eventId) {
- this.playVideoByEventId(eventId);
+ this.videoList.addEventListener("click", async (event) => {
+ const trigger = event.target.closest(
+ "[data-play-url], [data-play-magnet]"
+ );
+ if (!trigger) {
+ return;
+ }
+
+ if (event.button === 0 && !event.ctrlKey && !event.metaKey) {
+ event.preventDefault();
+
+ const decodeDataValue = (value) => {
+ if (!value) return "";
+ try {
+ return decodeURIComponent(value);
+ } catch (err) {
+ console.warn("Failed to decode data attribute:", err);
+ return "";
}
+ };
+
+ const encodedUrl = trigger.getAttribute("data-play-url") || "";
+ const encodedMagnet =
+ trigger.getAttribute("data-play-magnet") || "";
+
+ const url = decodeDataValue(encodedUrl);
+ const magnet = decodeDataValue(encodedMagnet);
+ const eventId = trigger.getAttribute("data-video-id");
+
+ if (eventId) {
+ await this.playVideoWithFallback({ eventId, url, magnet });
+ } else {
+ await this.playVideoWithFallback({ url, magnet });
}
}
});
@@ -1166,6 +1190,11 @@ class bitvidApp {
const authorSet = new Set();
// 3) Build each card
+ const encodeDataValue = (value) =>
+ typeof value === "string" && value.length > 0
+ ? encodeURIComponent(value)
+ : "";
+
videos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Video missing ID/title:", video);
@@ -1240,8 +1269,8 @@ class bitvidApp {
`
: "";
- const encodedMagnet = encodeURIComponent(video.magnet || "");
- const encodedUrl = encodeURIComponent(video.url || "");
+ const encodedMagnet = encodeDataValue(video.magnet);
+ const encodedUrl = encodeDataValue(video.url);
const cardHtml = `
@@ -1663,135 +1692,176 @@ class bitvidApp {
}
/**
- * Helper to open a video by event ID (like ?v=...).
+ * Unified playback helper that prefers HTTP URL sources
+ * and falls back to WebTorrent when needed.
*/
- async playVideoByEventId(eventId) {
- // 1) Event-level blacklist check
- if (this.blacklistedEventIds.has(eventId)) {
- this.showError("This content has been removed or is not allowed.");
- return;
- }
+ async playVideoWithFallback({
+ url: rawUrl = "",
+ magnet: rawMagnet = "",
+ eventId = null,
+ } = {}) {
+ const sanitize = (value) =>
+ typeof value === "string" ? value.trim() : "";
+
+ let trimmedUrl = sanitize(rawUrl);
+ let trimmedMagnet = sanitize(rawMagnet);
try {
- // Attempt to get the video from local cache or fetch
- let video = this.videosMap.get(eventId);
- if (!video) {
- video = await this.getOldEventById(eventId);
- }
- if (!video) {
- this.showError("Video not found.");
- return;
- }
+ let video = null;
- // 2) Author-level blacklist check
- const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
- if (initialBlacklist.includes(authorNpub)) {
- this.showError("This content has been removed or is not allowed.");
- return;
- }
-
- // 3) Whitelist check if enabled
- if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
- this.showError("This content is not from a whitelisted author.");
- return;
- }
-
- // Handle private videos (decrypt if owner is the current user)
- if (
- video.isPrivate &&
- video.pubkey === this.pubkey &&
- !video.alreadyDecrypted
- ) {
- video.magnet = fakeDecrypt(video.magnet);
- video.alreadyDecrypted = true;
- }
-
- const trimmedMagnet =
- typeof video.magnet === "string" ? video.magnet.trim() : "";
- const trimmedUrl =
- typeof video.url === "string" ? 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.currentMagnetUri = hasMagnet ? trimmedMagnet : null;
- 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);
- }
-
- // Update ?v= param in the URL
- const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
- const newUrl = `${window.location.pathname}?v=${encodeURIComponent(
- nevent
- )}`;
- window.history.pushState({}, "", newUrl);
-
- // Fetch author profile
- 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}`,
- };
+ 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
+ );
}
- } catch (error) {
- this.log("Error fetching creator profile:", error);
}
- // Update UI fields
- 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;
- }
+ const hasMagnetFinal = !!trimmedMagnet;
+ const hasUrlFinal = !!trimmedUrl;
+
+ this.currentMagnetUri = hasMagnetFinal ? trimmedMagnet : null;
- // Cleanup any previous WebTorrent streams, then start fresh playback
await torrentClient.cleanup();
- if (!hasMagnet) {
+ if (!hasMagnetFinal) {
if (this.modalPeers) {
this.modalPeers.textContent = "";
}
@@ -1808,16 +1878,18 @@ class bitvidApp {
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
- this.modalVideo.muted = !userWantsUnmuted;
-
- this.modalVideo.addEventListener("volumechange", () => {
- localStorage.setItem(
- "unmutedAutoplay",
- (!this.modalVideo.muted).toString()
- );
- });
+ if (this.modalVideo) {
+ this.modalVideo.muted = !userWantsUnmuted;
+ this.modalVideo.addEventListener("volumechange", () => {
+ localStorage.setItem(
+ "unmutedAutoplay",
+ (!this.modalVideo.muted).toString()
+ );
+ });
+ }
const attemptAutoplay = () => {
+ if (!this.modalVideo) return;
this.modalVideo.play().catch((err) => {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
@@ -1873,7 +1945,7 @@ class bitvidApp {
};
const startTorrentPlayback = async () => {
- if (!hasMagnet) {
+ if (!hasMagnetFinal || !this.modalVideo) {
return null;
}
const cacheBustedMagnet = `${trimmedMagnet}&ts=${Date.now()}`;
@@ -1889,7 +1961,7 @@ class bitvidApp {
return torrentInstance;
};
- if (hasUrl) {
+ if (hasUrlFinal && this.modalVideo) {
if (this.modalStatus) {
this.modalStatus.textContent = "Loading video...";
}
@@ -1904,7 +1976,7 @@ class bitvidApp {
this.modalVideo.src = trimmedUrl;
this.modalVideo.load();
- if (hasMagnet) {
+ if (hasMagnetFinal) {
const fallbackToTorrent = async () => {
this.modalVideo.removeEventListener("error", fallbackToTorrent);
this.log("HTTP playback failed, falling back to WebTorrent.");
@@ -1927,11 +1999,15 @@ class bitvidApp {
attemptAutoplay();
} catch (error) {
- this.log("Error in playVideoByEventId:", error);
+ this.log("Error in playVideoWithFallback:", error);
this.showError(`Playback error: ${error.message}`);
}
}
+ async playVideoByEventId(eventId) {
+ return this.playVideoWithFallback({ eventId });
+ }
+
/**
* Simple helper to safely encode an npub.
*/
diff --git a/js/channelProfile.js b/js/channelProfile.js
index 1896bf42..c4f0195d 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -240,6 +240,11 @@ async function loadUserVideos(pubkey) {
const fragment = document.createDocumentFragment();
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
+ const encodeDataValue = (value) =>
+ typeof value === "string" && value.length > 0
+ ? encodeURIComponent(value)
+ : "";
+
videos.forEach((video, index) => {
// Decrypt if user owns a private video
if (
@@ -331,11 +336,18 @@ async function loadUserVideos(pubkey) {
"duration-300"
);
- const encodedUrl = encodeURIComponent(video.url || "");
- const encodedMagnet = encodeURIComponent(video.magnet || "");
+ const encodedUrl = encodeDataValue(video.url);
+ const encodedMagnet = encodeDataValue(video.magnet);
+ const rawUrl = typeof video.url === "string" ? video.url : "";
+ const rawMagnet = typeof video.magnet === "string" ? video.magnet : "";
cardEl.innerHTML = `
-
+

open the video modal
cardEl.addEventListener("click", () => {
- app.playVideoByEventId(video.id);
+ app.playVideoWithFallback({
+ eventId: video.id,
+ url: rawUrl,
+ magnet: rawMagnet,
+ });
});
fragment.appendChild(cardEl);
diff --git a/js/subscriptions.js b/js/subscriptions.js
index acc44c24..5bd2372a 100644
--- a/js/subscriptions.js
+++ b/js/subscriptions.js
@@ -250,6 +250,11 @@ class SubscriptionsManager {
// Only declare localAuthorSet once
const localAuthorSet = new Set();
+ const encodeDataValue = (value) =>
+ typeof value === "string" && value.length > 0
+ ? encodeURIComponent(value)
+ : "";
+
videos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Missing ID or title:", video);
@@ -329,8 +334,8 @@ class SubscriptionsManager {
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
- const encodedMagnet = encodeURIComponent(video.magnet || "");
- const encodedUrl = encodeURIComponent(video.url || "");
+ const encodedMagnet = encodeDataValue(video.magnet);
+ const encodedUrl = encodeDataValue(video.url);
const cardHtml = `