diff --git a/js/app.js b/js/app.js index 7e73739d..331d16fe 100644 --- a/js/app.js +++ b/js/app.js @@ -644,13 +644,14 @@ class bitvidApp { // 9) Event delegation on the video list container for playing videos if (this.videoList) { this.videoList.addEventListener("click", (event) => { - const magnetTrigger = event.target.closest("[data-play-magnet]"); - if (magnetTrigger) { - // For a normal left-click (button 0, no Ctrl/Cmd), prevent navigation: + const trigger = event.target.closest("[data-video-id]"); + if (trigger) { if (event.button === 0 && !event.ctrlKey && !event.metaKey) { - event.preventDefault(); // Stop browser from following the href - const magnet = magnetTrigger.dataset.playMagnet; - this.playVideo(magnet); + event.preventDefault(); + const eventId = trigger.getAttribute("data-video-id"); + if (eventId) { + this.playVideoByEventId(eventId); + } } } }); @@ -1239,12 +1240,16 @@ class bitvidApp { ` : ""; + const encodedMagnet = encodeURIComponent(video.magnet || ""); + const encodedUrl = encodeURIComponent(video.url || ""); const cardHtml = `
@@ -1259,7 +1264,9 @@ class bitvidApp {

${this.escapeHTML(video.title)}

@@ -1469,6 +1476,10 @@ class bitvidApp { "New Magnet? (blank=keep existing)", video.magnet ); + const newUrl = prompt( + "New URL? (blank=keep existing)", + video.url || "" + ); const newThumb = prompt( "New Thumbnail? (blank=keep existing)", video.thumbnail @@ -1484,6 +1495,7 @@ class bitvidApp { !newTitle || !newTitle.trim() ? video.title : newTitle.trim(); const magnet = !newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim(); + const url = !newUrl || !newUrl.trim() ? video.url : newUrl.trim(); const thumbnail = !newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim(); const description = @@ -1495,6 +1507,7 @@ class bitvidApp { // isPrivate: wantPrivate, title, magnet, + url, thumbnail, description, mode: isDevMode ? "dev" : "live", @@ -1693,10 +1706,35 @@ class bitvidApp { 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 = video.magnet; + 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( @@ -1750,12 +1788,24 @@ class bitvidApp { this.creatorAvatar.alt = creatorProfile.name; } - // Cleanup any previous WebTorrent streams, then start a fresh one + // Cleanup any previous WebTorrent streams, then start fresh playback await torrentClient.cleanup(); - const cacheBustedMagnet = video.magnet + "&ts=" + Date.now(); - this.log("Starting video stream with:", cacheBustedMagnet); - // Autoplay preferences + if (!hasMagnet) { + 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%"; + } + } + const storedUnmuted = localStorage.getItem("unmutedAutoplay"); const userWantsUnmuted = storedUnmuted === "true"; this.modalVideo.muted = !userWantsUnmuted; @@ -1767,61 +1817,115 @@ class bitvidApp { ); }); - const realTorrent = await torrentClient.streamVideo( - cacheBustedMagnet, - this.modalVideo - ); + const attemptAutoplay = () => { + 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); + }); + } + }); + }; - // Try playing; if autoplay fails, fallback to muted - 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 (!hasMagnet) { + 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 (hasUrl) { + 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 (hasMagnet) { + 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 { + await startTorrentPlayback(); + } - // Update torrent stats every 3s - const updateInterval = setInterval(() => { - if (!document.body.contains(this.modalVideo)) { - clearInterval(updateInterval); - return; - } - this.updateTorrentStatus(realTorrent); - }, 3000); - this.activeIntervals.push(updateInterval); - - // Mirror stats into the modal if needed - 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); + attemptAutoplay(); } catch (error) { this.log("Error in playVideoByEventId:", error); this.showError(`Playback error: ${error.message}`); diff --git a/js/channelProfile.js b/js/channelProfile.js index 968b7dd3..1896bf42 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -331,6 +331,9 @@ async function loadUserVideos(pubkey) { "duration-300" ); + const encodedUrl = encodeURIComponent(video.url || ""); + const encodedMagnet = encodeURIComponent(video.magnet || ""); + cardEl.innerHTML = `
@@ -345,7 +348,9 @@ async function loadUserVideos(pubkey) {

${safeTitle}

@@ -453,13 +458,15 @@ function localConvertEventToVideo(event) { try { const content = JSON.parse(event.content || "{}"); const isSupportedVersion = content.version >= 2; - const hasRequiredFields = !!(content.title && content.magnet); + const hasRequiredFields = !!( + content.title && (content.url || content.magnet) + ); if (!isSupportedVersion) { return { id: event.id, invalid: true, reason: "version <2" }; } if (!hasRequiredFields) { - return { id: event.id, invalid: true, reason: "missing title/magnet" }; + return { id: event.id, invalid: true, reason: "missing title/url+magnet" }; } return { @@ -468,6 +475,7 @@ function localConvertEventToVideo(event) { version: content.version, isPrivate: content.isPrivate ?? false, title: content.title ?? "", + url: content.url ?? "", magnet: content.magnet ?? "", thumbnail: content.thumbnail ?? "", description: content.description ?? "", diff --git a/js/nostr.js b/js/nostr.js index a1293f89..d753698f 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -53,7 +53,9 @@ function convertEventToVideo(event) { // Example checks: const isSupportedVersion = content.version >= 2; - const hasRequiredFields = !!(content.title && content.magnet); + const hasRequiredFields = !!( + content.title && (content.url || content.magnet) + ); if (!isSupportedVersion) { return { @@ -66,7 +68,7 @@ function convertEventToVideo(event) { return { id: event.id, invalid: true, - reason: "missing title/magnet", + reason: "missing title/url+magnet", }; } @@ -76,6 +78,7 @@ function convertEventToVideo(event) { version: content.version, isPrivate: content.isPrivate ?? false, title: content.title ?? "", + url: content.url ?? "", magnet: content.magnet ?? "", thumbnail: content.thumbnail ?? "", description: content.description ?? "", @@ -222,10 +225,13 @@ class NostrClient { console.log("Publishing new video with data:", videoData); } - let finalMagnet = videoData.magnet; - if (videoData.isPrivate) { + const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : ""; + let finalMagnet = rawMagnet.trim(); + if (videoData.isPrivate && finalMagnet) { finalMagnet = fakeEncrypt(finalMagnet); } + const finalUrl = + typeof videoData.url === "string" ? videoData.url.trim() : ""; // brand-new root & d const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -237,6 +243,7 @@ class NostrClient { deleted: false, isPrivate: videoData.isPrivate ?? false, title: videoData.title || "", + url: finalUrl, magnet: finalMagnet, thumbnail: videoData.thumbnail || "", description: videoData.description || "", @@ -322,14 +329,23 @@ class NostrClient { oldPlainMagnet = fakeDecrypt(oldPlainMagnet); } + const oldUrl = baseEvent.url || ""; + // Determine if the updated note should be private const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false; // Use the new magnet if provided; otherwise, fall back to the decrypted old magnet - let finalPlainMagnet = (updatedData.magnet || "").trim() || oldPlainMagnet; - let finalMagnet = wantPrivate - ? fakeEncrypt(finalPlainMagnet) - : finalPlainMagnet; + const newMagnetValue = + typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : ""; + let finalPlainMagnet = newMagnetValue || oldPlainMagnet; + let finalMagnet = + wantPrivate && finalPlainMagnet + ? fakeEncrypt(finalPlainMagnet) + : finalPlainMagnet; + + const newUrlValue = + typeof updatedData.url === "string" ? updatedData.url.trim() : ""; + const finalUrl = newUrlValue || oldUrl; // Use the existing videoRootId (or fall back to the base event's ID) const oldRootId = baseEvent.videoRootId || baseEvent.id; @@ -344,6 +360,7 @@ class NostrClient { deleted: false, isPrivate: wantPrivate, title: updatedData.title ?? baseEvent.title, + url: finalUrl, magnet: finalMagnet, thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail, description: updatedData.description ?? baseEvent.description, @@ -420,6 +437,7 @@ class NostrClient { deleted: fetched.deleted, isPrivate: fetched.isPrivate, title: fetched.title, + url: fetched.url, magnet: fetched.magnet, thumbnail: fetched.thumbnail, description: fetched.description, @@ -451,6 +469,7 @@ class NostrClient { deleted: true, isPrivate: oldContent.isPrivate ?? false, title: oldContent.title || "", + url: "", magnet: "", thumbnail: "", description: "This version was reverted by the creator.", @@ -530,6 +549,7 @@ class NostrClient { deleted: vid.deleted, isPrivate: vid.isPrivate, title: vid.title, + url: vid.url, magnet: vid.magnet, thumbnail: vid.thumbnail, description: vid.description, diff --git a/js/subscriptions.js b/js/subscriptions.js index 802c5b02..acc44c24 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -329,11 +329,15 @@ 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 cardHtml = `