No public videos available yet. Be the first to upload one!
`; return; } this.log("Processing filtered videos:", displayedVideos); displayedVideos.forEach((video, index) => { this.log(`Video ${index} details:`, { id: video.id, title: video.title, magnet: video.magnet, isPrivate: video.isPrivate, pubkey: video.pubkey, created_at: video.created_at, }); }); await this.renderVideoList(displayedVideos); this.log(`Rendered ${displayedVideos.length} videos successfully`); } catch (error) { this.log("Failed to fetch videos:", error); this.showError( "An error occurred while loading videos. Please try again later." ); this.videoList.innerHTML = `No videos available at the moment. Please try again later.
`; } } async renderVideoList(videos) { try { console.log("RENDER VIDEO LIST - Start", { videosReceived: videos, videosCount: videos ? videos.length : "N/A", videosType: typeof videos, }); if (!videos) { console.error("NO VIDEOS RECEIVED"); this.videoList.innerHTML = `No videos found.
`; return; } const videoArray = Array.isArray(videos) ? videos : [videos]; if (videoArray.length === 0) { console.error("VIDEO ARRAY IS EMPTY"); this.videoList.innerHTML = `No videos available.
`; return; } // Sort newest first videoArray.sort((a, b) => b.created_at - a.created_at); // Fetch user profiles const userProfiles = new Map(); const uniquePubkeys = [...new Set(videoArray.map((v) => v.pubkey))]; for (const pubkey of uniquePubkeys) { try { const userEvents = await nostrClient.pool.list(nostrClient.relays, [ { kinds: [0], authors: [pubkey], limit: 1 }, ]); if (userEvents[0]?.content) { const profile = JSON.parse(userEvents[0].content); userProfiles.set(pubkey, { name: profile.name || profile.display_name || "Unknown", picture: profile.picture || `https://robohash.org/${pubkey}`, }); } else { userProfiles.set(pubkey, { name: "Unknown", picture: `https://robohash.org/${pubkey}`, }); } } catch (error) { console.error(`Profile fetch error for ${pubkey}:`, error); userProfiles.set(pubkey, { name: "Unknown", picture: `https://robohash.org/${pubkey}`, }); } } // Build each video card const renderedVideos = videoArray .map((video, index) => { try { if (!this.validateVideo(video, index)) { console.error(`Invalid video: ${video.title}`); return ""; } // Create a share URL const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(nevent)}`; // Gather profile info const profile = userProfiles.get(video.pubkey) || { name: "Unknown", picture: `https://robohash.org/${video.pubkey}`, }; const timeAgo = this.formatTimeAgo(video.created_at); // Check if user can edit const canEdit = video.pubkey === this.pubkey; const highlightClass = video.isPrivate && canEdit ? "border-2 border-yellow-500" : "border-none"; // Gear menu if canEdit const gearMenu = canEdit ? `No valid videos to display.
`; return; } this.videoList.innerHTML = renderedVideos.join(""); console.log("Videos rendered successfully"); } catch (error) { console.error("Rendering error:", error); this.videoList.innerHTML = `Error loading videos.
`; } } validateVideo(video, index) { const validationResults = { hasId: Boolean(video?.id), isValidId: typeof video?.id === "string" && video.id.trim().length > 0, hasVideo: Boolean(video), hasTitle: Boolean(video?.title), hasMagnet: Boolean(video?.magnet), hasMode: Boolean(video?.mode), hasPubkey: Boolean(video?.pubkey), isValidTitle: typeof video?.title === "string" && video.title.length > 0, isValidMagnet: typeof video?.magnet === "string" && video.magnet.length > 0, isValidMode: typeof video?.mode === "string" && ["dev", "live"].includes(video.mode), }; const passed = Object.values(validationResults).every(Boolean); console.log( `Video ${video?.title} validation results:`, validationResults, passed ? "PASSED" : "FAILED" ); return passed; } getErrorMessage(error) { if (error.message.includes("404")) { return "Service worker not found. Please check server configuration."; } else if (error.message.includes("Brave")) { return "Please disable Brave Shields for this site to play videos."; } else if (error.message.includes("timeout")) { return "Connection timeout. Please check your internet connection."; } else { return "Failed to play video. Please try again."; } } showError(message) { if (this.errorContainer) { this.errorContainer.textContent = message; this.errorContainer.classList.remove("hidden"); setTimeout(() => { this.errorContainer.classList.add("hidden"); this.errorContainer.textContent = ""; }, 5000); } else { alert(message); } } showSuccess(message) { if (this.successContainer) { this.successContainer.textContent = message; this.successContainer.classList.remove("hidden"); setTimeout(() => { this.successContainer.classList.add("hidden"); this.successContainer.textContent = ""; }, 5000); } else { alert(message); } } escapeHTML(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } log(message) { console.log(message); } /** * Plays a video given its magnet URI. * This method handles the logic to initiate torrent download and play the video. */ async playVideo(magnetURI) { try { if (!magnetURI) { this.showError("Invalid Magnet URI."); return; } // Decode in case the magnet was URI-encoded const decodedMagnet = decodeURIComponent(magnetURI); // Prevent re-invoking the exact same magnet link if it's already in use if (this.currentMagnetUri === decodedMagnet) { this.log("Same video requested - already playing"); return; } this.currentMagnetUri = decodedMagnet; // Set a looping "please stand by" GIF as a temporary placeholder this.modalVideo.poster = "assets/gif/please-stand-by.gif"; // Show the modal this.playerModal.style.display = "flex"; this.playerModal.classList.remove("hidden"); // Fetch (or re-fetch) videos from relays const videos = await nostrClient.fetchVideos(); const video = videos.find((v) => v.magnet === decodedMagnet); if (!video) { this.showError("Video data not found."); return; } // Keep a reference to the current video this.currentVideo = video; // If the user owns a private video, decrypt it just once if ( video.isPrivate && video.pubkey === this.pubkey && !video.alreadyDecrypted ) { this.log("User owns a private video => decrypting magnet link..."); video.magnet = fakeDecrypt(video.magnet); video.alreadyDecrypted = true; } const finalMagnet = video.magnet; // Update the URL so the service worker has a consistent scope try { const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const newUrl = window.location.pathname + `?v=${encodeURIComponent(nevent)}`; window.history.pushState({}, "", newUrl); } catch (err) { console.error("Error pushing new URL state:", err); } // Fetch creator 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 profile = JSON.parse(userEvents[0].content); creatorProfile = { name: profile.name || profile.display_name || "Unknown", picture: profile.picture || `https://robohash.org/${video.pubkey}`, }; } } catch (error) { this.log("Error fetching creator profile:", error); } // Derive a short display of the pubkey let creatorNpub = "Unknown"; try { creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey); } catch (error) { this.log("Error converting pubkey to npub:", error); creatorNpub = video.pubkey; } // Populate modal fields this.videoTitle.textContent = video.title || "Untitled"; this.videoDescription.textContent = video.description || "No description available."; this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); this.creatorName.textContent = creatorProfile.name; this.creatorNpub.textContent = `${creatorNpub.slice( 0, 8 )}...${creatorNpub.slice(-4)}`; this.creatorAvatar.src = creatorProfile.picture; this.creatorAvatar.alt = creatorProfile.name; // Start streaming this.log("Starting video stream with:", finalMagnet); await torrentClient.streamVideo(finalMagnet, this.modalVideo); // Remove the loading GIF once the video can play this.modalVideo.addEventListener("canplay", () => { this.modalVideo.removeAttribute("poster"); }); // Periodically mirror main player stats into the modal const updateInterval = setInterval(() => { if (!document.body.contains(this.modalVideo)) { clearInterval(updateInterval); 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.textContent = status.textContent; if (progress) this.modalProgress.style.width = progress.style.width; if (peers) this.modalPeers.textContent = peers.textContent; if (speed) this.modalSpeed.textContent = speed.textContent; if (downloaded) { this.modalDownloaded.textContent = downloaded.textContent; } }, 1000); } catch (error) { this.log("Error in playVideo:", error); this.showError(`Playback error: ${error.message}`); } } updateTorrentStatus(torrent) { if (!torrent) return; this.modalStatus.textContent = torrent.status; this.modalProgress.style.width = `${(torrent.progress * 100).toFixed(2)}%`; this.modalPeers.textContent = `Peers: ${torrent.numPeers}`; this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed( 2 )} KB/s`; this.modalDownloaded.textContent = `${( torrent.downloaded / (1024 * 1024) ).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`; if (torrent.ready) { this.modalStatus.textContent = "Ready to play"; } else { setTimeout(() => this.updateTorrentStatus(torrent), 1000); } } /** * Allows the user to edit a video note (only if they are the owner). */ async handleEditVideo(index) { try { const videos = await nostrClient.fetchVideos(); const video = videos[index]; if (!this.pubkey) { this.showError("Please login to edit videos."); return; } if (video.pubkey !== this.pubkey) { this.showError("You do not own this video."); return; } const newTitle = prompt( "New Title? (Leave blank to keep existing)", video.title ); const newMagnet = prompt( "New Magnet? (Leave blank to keep existing)", video.magnet ); const newThumbnail = prompt( "New Thumbnail? (Leave blank to keep existing)", video.thumbnail ); const newDescription = prompt( "New Description? (Leave blank to keep existing)", video.description ); const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); const title = newTitle === null || newTitle.trim() === "" ? video.title : newTitle.trim(); const magnet = newMagnet === null || newMagnet.trim() === "" ? video.magnet : newMagnet.trim(); const thumbnail = newThumbnail === null || newThumbnail.trim() === "" ? video.thumbnail : newThumbnail.trim(); const description = newDescription === null || newDescription.trim() === "" ? video.description : newDescription.trim(); const updatedData = { version: video.version || 2, isPrivate: wantPrivate, title, magnet, thumbnail, description, mode: isDevMode ? "dev" : "live", }; const originalEvent = { id: video.id, pubkey: video.pubkey, tags: video.tags, }; await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); this.showSuccess("Video updated successfully!"); await this.loadVideos(); } catch (err) { this.log("Failed to edit video:", err.message); this.showError("Failed to edit video. Please try again later."); } } /** * Allows the user to delete (soft-delete) a video by marking it as deleted. */ async handleDeleteVideo(index) { try { const videos = await nostrClient.fetchVideos(); const video = videos[index]; if (!this.pubkey) { this.showError("Please login to delete videos."); return; } if (video.pubkey !== this.pubkey) { this.showError("You do not own this video."); return; } if ( !confirm( `Are you sure you want to delete "${video.title}"? This action cannot be undone.` ) ) { return; } const originalEvent = { id: video.id, pubkey: video.pubkey, tags: video.tags, }; await nostrClient.deleteVideo(originalEvent, this.pubkey); this.showSuccess("Video deleted (hidden) successfully!"); await this.loadVideos(); } catch (err) { this.log("Failed to delete video:", err.message); this.showError("Failed to delete video. Please try again later."); } } // NEW: Parse ?v=nevent after videos are loaded checkUrlParams() { const urlParams = new URLSearchParams(window.location.search); const maybeNevent = urlParams.get("v"); if (maybeNevent) { try { const decoded = window.NostrTools.nip19.decode(maybeNevent); if (decoded.type === "nevent" && decoded.data.id) { const eventId = decoded.data.id; // Fetch videos again (or rely on in-memory) and find a match nostrClient .fetchVideos() .then((allVideos) => { const matched = allVideos.find((v) => v.id === eventId); if (matched) { // We could directly call this.playVideo(matched.magnet), // but that can fail if magnet changed or is encrypted. // Instead, let's do a dedicated method: this.playVideoByEventId(eventId); } else { this.showError("No matching video found for that link."); } }) .catch((err) => { console.error("Error re-fetching videos:", err); this.showError("Could not load videos for the share link."); }); } } catch (err) { console.error("Error decoding nevent:", err); this.showError("Invalid share link."); } } } // NEW: A helper to play by event ID so we don't rely on magnet string async playVideoByEventId(eventId) { try { const videos = await nostrClient.fetchVideos(); const video = videos.find((v) => v.id === eventId); if (!video) { this.showError("Video not found."); return; } // Store as current video for sharing, etc. this.currentVideo = video; // If private + user owns it => decrypt once if ( video.isPrivate && video.pubkey === this.pubkey && !video.alreadyDecrypted ) { this.log("User owns a private video => decrypting magnet link..."); video.magnet = fakeDecrypt(video.magnet); video.alreadyDecrypted = true; } const finalMagnet = video.magnet; this.currentMagnetUri = finalMagnet; this.playerModal.style.display = "flex"; this.playerModal.classList.remove("hidden"); // Update the browser URL to keep the same path, just add ?v=... const nevent = window.NostrTools.nip19.neventEncode({ id: eventId }); const newUrl = window.location.pathname + `?v=${encodeURIComponent(nevent)}`; window.history.pushState({}, "", newUrl); // Fetch creator 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 profile = JSON.parse(userEvents[0].content); creatorProfile = { name: profile.name || profile.display_name || "Unknown", picture: profile.picture || `https://robohash.org/${video.pubkey}`, }; } } catch (error) { this.log("Error fetching creator profile:", error); } let creatorNpub = "Unknown"; try { creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey); } catch (error) { this.log("Error converting pubkey to npub:", error); creatorNpub = video.pubkey; } this.videoTitle.textContent = video.title || "Untitled"; this.videoDescription.textContent = video.description || "No description available."; this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); this.creatorName.textContent = creatorProfile.name; this.creatorNpub.textContent = `${creatorNpub.slice( 0, 8 )}...${creatorNpub.slice(-4)}`; this.creatorAvatar.src = creatorProfile.picture; this.creatorAvatar.alt = creatorProfile.name; this.log("Starting video stream with:", finalMagnet); await torrentClient.streamVideo(finalMagnet, this.modalVideo); const updateInterval = setInterval(() => { if (!document.body.contains(this.modalVideo)) { clearInterval(updateInterval); 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.textContent = status.textContent; if (progress) this.modalProgress.style.width = progress.style.width; if (peers) this.modalPeers.textContent = peers.textContent; if (speed) this.modalSpeed.textContent = speed.textContent; if (downloaded) this.modalDownloaded.textContent = downloaded.textContent; }, 1000); } catch (error) { this.log("Error in playVideoByEventId:", error); this.showError(`Playback error: ${error.message}`); } } } export const app = new bitvidApp(); app.init(); window.app = app;