- 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 = ` + // Clear the listing + this.videoList.innerHTML = `- No videos available at the moment. Please try again later. + Loading videos...
`; - } - } - async renderVideoList(videos) { + // Clear the Map so we start fresh + this.videosMap.clear(); + 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}`, - }); + // Subscribe to new events + this.videoSubscription = nostrClient.subscribeVideos((video) => { + // Skip private videos not owned + if (video.isPrivate && video.pubkey !== this.pubkey) { + return; } - } - - // 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 - ? ` -- ${this.escapeHTML(profile.name)} -
-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.
`; + + // Only store if we haven’t seen this event ID yet + if (!this.videosMap.has(video.id)) { + this.videosMap.set(video.id, video); + // Then re-render from the map + const allVideos = Array.from(this.videosMap.values()); + this.renderVideoList(allVideos); + } + }); + } catch (err) { + console.error("Subscription error:", err); + this.showError("Could not load videos via subscription."); + this.videoList.innerHTML = ` ++ No videos available at this time. +
`; } } - 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. + * Convert the values of our videosMap to an array & render them. + */ + async renderVideoList(videos) { + console.log("RENDER VIDEO LIST - Start", { + videosReceived: videos, + videosCount: videos.length, + }); + + if (!videos || videos.length === 0) { + this.videoList.innerHTML = ` ++ No public videos available yet. Be the first to upload one! +
`; + return; + } + + // Sort newest first + videos.sort((a, b) => b.created_at - a.created_at); + + const htmlList = videos.map((video, index) => { + if (!video.id || !video.title) { + console.error("Video missing ID/title:", video); + return ""; + } + + const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); + const shareUrl = `${window.location.pathname}?v=${encodeURIComponent( + nevent + )}`; + + const canEdit = video.pubkey === this.pubkey; + const highlightClass = + video.isPrivate && canEdit + ? "border-2 border-yellow-500" + : "border-none"; + const timeAgo = this.formatTimeAgo(video.created_at); + + // Gear menu + const gearMenu = canEdit + ? ` ++ No valid videos to display. +
`; + return; + } + + this.videoList.innerHTML = valid.join(""); + console.log("Videos rendered successfully (subscription approach)."); + } + + async fetchAndRenderProfile(pubkey) { + if (this.profileCache.has(pubkey)) { + this.updateProfileInDOM(pubkey, this.profileCache.get(pubkey)); + return; + } + try { + const userEvents = await nostrClient.pool.list(nostrClient.relays, [ + { kinds: [0], authors: [pubkey], limit: 1 }, + ]); + if (userEvents.length > 0 && userEvents[0].content) { + const data = JSON.parse(userEvents[0].content); + const profile = { + name: data.name || data.display_name || "Unknown", + picture: data.picture || "assets/png/default-avatar.png", + }; + this.profileCache.set(pubkey, profile); + this.updateProfileInDOM(pubkey, profile); + } + } catch (err) { + console.error("Profile fetch error:", err); + } + } + + updateProfileInDOM(pubkey, profile) { + const picEls = document.querySelectorAll( + `.author-pic[data-pubkey="${pubkey}"]` + ); + for (const el of picEls) { + el.src = profile.picture; + } + const nameEls = document.querySelectorAll( + `.author-name[data-pubkey="${pubkey}"]` + ); + for (const el of nameEls) { + el.textContent = profile.name; + } + } + + /** + * Actually plays a video, using magnet lookups. + * We search our Map’s values by magnet. If not found, fallback fetch. */ async playVideo(magnetURI) { try { @@ -823,49 +669,49 @@ class bitvidApp { 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 + // "Please stand by" poster this.modalVideo.poster = "assets/gif/please-stand-by.gif"; - - // Show the modal + // Show 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); + // 1) Convert the map’s values to an array and find by magnet + let video = Array.from(this.videosMap.values()).find( + (v) => v.magnet === decodedMagnet + ); + + // 2) Fallback fetch if not found + if (!video) { + const allVideos = await nostrClient.fetchVideos(); + video = allVideos.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..."); + this.log("Decrypting private 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 + // Update URL try { const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const newUrl = @@ -875,7 +721,7 @@ class bitvidApp { console.error("Error pushing new URL state:", err); } - // Fetch creator profile + // optional: fetch a single author profile let creatorProfile = { name: "Unknown", picture: `https://robohash.org/${video.pubkey}`, @@ -885,17 +731,16 @@ class bitvidApp { { kinds: [0], authors: [video.pubkey], limit: 1 }, ]); if (userEvents.length > 0 && userEvents[0]?.content) { - const profile = JSON.parse(userEvents[0].content); + const data = JSON.parse(userEvents[0].content); creatorProfile = { - name: profile.name || profile.display_name || "Unknown", - picture: profile.picture || `https://robohash.org/${video.pubkey}`, + name: data.name || data.display_name || "Unknown", + picture: data.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); @@ -904,7 +749,7 @@ class bitvidApp { creatorNpub = video.pubkey; } - // Populate modal fields + // Fill modal this.videoTitle.textContent = video.title || "Untitled"; this.videoDescription.textContent = video.description || "No description available."; @@ -918,22 +763,19 @@ class bitvidApp { 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 + // Mirror stats from the small player 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"); @@ -956,7 +798,6 @@ class bitvidApp { 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}`; @@ -967,7 +808,6 @@ class bitvidApp { torrent.downloaded / (1024 * 1024) ).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`; - if (torrent.ready) { this.modalStatus.textContent = "Ready to play"; } else { @@ -975,58 +815,43 @@ class bitvidApp { } } - /** - * 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]; - + // We do a fallback fetch to get a list of videos by index + const all = await nostrClient.fetchVideos(); + const video = all[index]; if (!this.pubkey) { this.showError("Please login to edit videos."); return; } - if (video.pubkey !== this.pubkey) { + if (!video || 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 newTitle = prompt("New Title? (blank=keep existing)", video.title); const newMagnet = prompt( - "New Magnet? (Leave blank to keep existing)", + "New Magnet? (blank=keep existing)", video.magnet ); - const newThumbnail = prompt( - "New Thumbnail? (Leave blank to keep existing)", + const newThumb = prompt( + "New Thumbnail? (blank=keep existing)", video.thumbnail ); - const newDescription = prompt( - "New Description? (Leave blank to keep existing)", + const newDesc = prompt( + "New Description? (blank=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(); + !newTitle || !newTitle.trim() ? video.title : newTitle.trim(); const magnet = - newMagnet === null || newMagnet.trim() === "" - ? video.magnet - : newMagnet.trim(); + !newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim(); const thumbnail = - newThumbnail === null || newThumbnail.trim() === "" - ? video.thumbnail - : newThumbnail.trim(); + !newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim(); const description = - newDescription === null || newDescription.trim() === "" - ? video.description - : newDescription.trim(); + !newDesc || !newDesc.trim() ? video.description : newDesc.trim(); const updatedData = { version: video.version || 2, @@ -1045,35 +870,26 @@ class bitvidApp { }; await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); this.showSuccess("Video updated successfully!"); - await this.loadVideos(); + await this.loadVideos(); // re-subscribe and re-render } catch (err) { this.log("Failed to edit video:", err.message); - this.showError("Failed to edit video. Please try again later."); + this.showError("Failed to edit video. Please try again."); } } - /** - * 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]; - + const all = await nostrClient.fetchVideos(); + const video = all[index]; if (!this.pubkey) { this.showError("Please login to delete videos."); return; } - if (video.pubkey !== this.pubkey) { + if (!video || 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.` - ) - ) { + if (!confirm(`Delete "${video.title}"? This can't be undone.`)) { return; } @@ -1082,45 +898,48 @@ class bitvidApp { pubkey: video.pubkey, tags: video.tags, }; - await nostrClient.deleteVideo(originalEvent, this.pubkey); - this.showSuccess("Video deleted (hidden) successfully!"); + this.showSuccess("Video deleted successfully!"); await this.loadVideos(); } catch (err) { this.log("Failed to delete video:", err.message); - this.showError("Failed to delete video. Please try again later."); + this.showError("Failed to delete video. Please try again."); } } - // NEW: Parse ?v=nevent after videos are loaded + /** + * Checks URL params for ?v=... and tries to open the video by ID. + */ 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."); - }); + // 1) Check local Map first + let localMatch = this.videosMap.get(eventId); + if (localMatch) { + this.playVideoByEventId(eventId); + } else { + // 2) fallback fetch + nostrClient + .fetchVideos() + .then((all) => { + const matched = all.find((v) => v.id === eventId); + if (matched) { + 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); @@ -1129,26 +948,32 @@ class bitvidApp { } } - // NEW: A helper to play by event ID so we don't rely on magnet string + /** + * Plays a video given an event ID. Looks up in the Map if possible, else fallback. + */ async playVideoByEventId(eventId) { try { - const videos = await nostrClient.fetchVideos(); - const video = videos.find((v) => v.id === eventId); + // 1) Check local subscription map first + let video = this.videosMap.get(eventId); + + // 2) Fallback fetch if not found + if (!video) { + const all = await nostrClient.fetchVideos(); + video = all.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..."); + this.log("Decrypting private magnet link..."); video.magnet = fakeDecrypt(video.magnet); video.alreadyDecrypted = true; } @@ -1159,30 +984,25 @@ class bitvidApp { 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 + // optional: fetch single 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, - }, + { kinds: [0], authors: [video.pubkey], limit: 1 }, ]); if (userEvents.length > 0 && userEvents[0]?.content) { - const profile = JSON.parse(userEvents[0].content); + const data = JSON.parse(userEvents[0].content); creatorProfile = { - name: profile.name || profile.display_name || "Unknown", - picture: profile.picture || `https://robohash.org/${video.pubkey}`, + name: data.name || data.display_name || "Unknown", + picture: data.picture || `https://robohash.org/${video.pubkey}`, }; } } catch (error) { @@ -1218,7 +1038,6 @@ class bitvidApp { clearInterval(updateInterval); return; } - const status = document.getElementById("status"); const progress = document.getElementById("progress"); const peers = document.getElementById("peers"); @@ -1229,14 +1048,76 @@ class bitvidApp { 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) + if (downloaded) { this.modalDownloaded.textContent = downloaded.textContent; + } }, 1000); } catch (error) { this.log("Error in playVideoByEventId:", error); this.showError(`Playback error: ${error.message}`); } } + + // Utility helpers + formatTimeAgo(timestamp) { + const seconds = Math.floor(Date.now() / 1000 - timestamp); + const intervals = { + year: 31536000, + month: 2592000, + week: 604800, + day: 86400, + hour: 3600, + minute: 60, + }; + for (const [unit, secInUnit] of Object.entries(intervals)) { + const int = Math.floor(seconds / secInUnit); + if (int >= 1) { + return `${int} ${unit}${int === 1 ? "" : "s"} ago`; + } + } + return "just now"; + } + + escapeHTML(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + showError(msg) { + console.error(msg); + if (this.errorContainer) { + this.errorContainer.textContent = msg; + this.errorContainer.classList.remove("hidden"); + setTimeout(() => { + this.errorContainer.classList.add("hidden"); + this.errorContainer.textContent = ""; + }, 5000); + } else { + alert(msg); + } + } + + showSuccess(msg) { + console.log(msg); + if (this.successContainer) { + this.successContainer.textContent = msg; + this.successContainer.classList.remove("hidden"); + setTimeout(() => { + this.successContainer.classList.add("hidden"); + this.successContainer.textContent = ""; + }, 5000); + } else { + alert(msg); + } + } + + log(msg) { + console.log(msg); + } } export const app = new bitvidApp(); diff --git a/src/js/nostr.js b/src/js/nostr.js index f8249dc..48fccf6 100644 --- a/src/js/nostr.js +++ b/src/js/nostr.js @@ -46,6 +46,9 @@ class NostrClient { this.pool = null; this.pubkey = null; this.relays = RELAY_URLS; + + // We keep a Map of subscribed videos for quick lookups by event.id + this.subscribedVideos = new Map(); } /** @@ -207,7 +210,7 @@ class NostrClient { const event = { kind: 30078, pubkey, - created_at: Math.floor(Date.now() / 1000), + created_at: Math.floor(Date.now() / 100), tags: [ ["t", "video"], ["d", uniqueD], @@ -254,7 +257,6 @@ class NostrClient { * Edits an existing video event by reusing the same "d" tag. * Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet. */ - // Minimal fix: ensures we only ever encrypt once per edit operation async editVideo(originalEvent, updatedVideoData, pubkey) { if (!pubkey) { throw new Error("User is not logged in."); @@ -382,7 +384,7 @@ class NostrClient { /** * Soft-delete or hide an existing video by marking content as "deleted: true" - * and republishing with same (kind=30078, pubkey, d) address. + * and republishing with the same (kind=30078, pubkey, d) address. */ async deleteVideo(originalEvent, pubkey) { if (!pubkey) { @@ -407,6 +409,7 @@ class NostrClient { const oldContent = JSON.parse(originalEvent.content || "{}"); const oldVersion = oldContent.version ?? 1; + // Mark it "deleted" and clear out magnet, thumbnail, etc. const contentObject = { version: oldVersion, deleted: true, @@ -418,6 +421,7 @@ class NostrClient { isPrivate: oldContent.isPrivate || false, }; + // Reuse the same d-tag for an addressable edit const event = { kind: 30078, pubkey, @@ -451,10 +455,7 @@ class NostrClient { } } catch (err) { if (isDevMode) { - console.error( - `Failed to publish deleted event to ${url}:`, - err.message - ); + console.error(`Failed to publish deleted event to ${url}:`, err); } } }) @@ -463,117 +464,141 @@ class NostrClient { return signedEvent; } catch (error) { if (isDevMode) { - console.error("Failed to sign deleted event:", error.message); + console.error("Failed to sign deleted event:", error); } throw new Error("Failed to sign deleted event."); } } /** - * Fetches videos from all configured relays. + * Subscribes to video events from all configured relays, storing them in a Map. + * + * @param {Function} onVideo - Callback fired for each new/updated video + */ + subscribeVideos(onVideo) { + const filter = { + kinds: [30078], + "#t": ["video"], + limit: 500, // Adjust as needed + since: 0, + }; + + if (isDevMode) { + console.log("[subscribeVideos] Subscribing with filter:", filter); + } + + // Create subscription across all relays + const sub = this.pool.sub(this.relays, [filter]); + + sub.on("event", (event) => { + try { + const content = JSON.parse(event.content); + + // If marked deleted + if (content.deleted === true) { + // Remove it from our Map if we had it + if (this.subscribedVideos.has(event.id)) { + this.subscribedVideos.delete(event.id); + // Optionally notify the callback so UI can remove it + // onVideo(null, { deletedId: event.id }); + } + return; + } + + // Construct a video object + const video = { + id: event.id, + version: content.version ?? 1, + isPrivate: content.isPrivate ?? false, + title: content.title || "", + magnet: content.magnet || "", + thumbnail: content.thumbnail || "", + description: content.description || "", + mode: content.mode || "live", + pubkey: event.pubkey, + created_at: event.created_at, + tags: event.tags, + }; + + // Check if we already have it in our Map + if (!this.subscribedVideos.has(event.id)) { + // It's new, so store it + this.subscribedVideos.set(event.id, video); + // Then notify the callback that a new video arrived + onVideo(video); + } else { + // Optional: if you want to detect edits, compare the new vs. old and update + // this.subscribedVideos.set(event.id, video); + // onVideo(video) to re-render, etc. + } + } catch (err) { + if (isDevMode) { + console.error("[subscribeVideos] Error parsing event:", err); + } + } + }); + + sub.on("eose", () => { + if (isDevMode) { + console.log("[subscribeVideos] Reached EOSE for all relays"); + } + // Optionally: onVideo(null, { eose: true }) to signal initial load done + }); + + return sub; // so you can unsub later if needed + } + + /** + * A one-time, bulk fetch of videos from all configured relays. + * (Limit has been reduced to 300 for better performance.) */ async fetchVideos() { const filter = { kinds: [30078], "#t": ["video"], - limit: 1000, + limit: 300, // Reduced from 1000 for quicker fetches since: 0, }; - const videoEvents = new Map(); - if (isDevMode) { - console.log("[fetchVideos] Starting fetch from all relays..."); - console.log("[fetchVideos] Filter:", filter); - } - try { + // Query each relay in parallel await Promise.all( this.relays.map(async (url) => { - if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`); - - try { - const events = await this.pool.list([url], [filter]); - - if (isDevMode) { - console.log(`Events from ${url}:`, events.length); - if (events.length > 0) { - events.forEach((evt, idx) => { - console.log( - `[fetchVideos] [${url}] Event[${idx}] ID: ${evt.id} | pubkey: ${evt.pubkey} | created_at: ${evt.created_at}` - ); + const events = await this.pool.list([url], [filter]); + for (const evt of events) { + try { + const content = JSON.parse(evt.content); + if (content.deleted) { + videoEvents.delete(evt.id); + } else { + videoEvents.set(evt.id, { + id: evt.id, + pubkey: evt.pubkey, + created_at: evt.created_at, + title: content.title || "", + magnet: content.magnet || "", + thumbnail: content.thumbnail || "", + description: content.description || "", + mode: content.mode || "live", + isPrivate: content.isPrivate || false, + tags: evt.tags, }); } - } - - events.forEach((event) => { - try { - const content = JSON.parse(event.content); - - // If deleted == true, it overrides older notes - if (content.deleted === true) { - videoEvents.delete(event.id); - return; - } - - // If we haven't seen this event.id before, store it - if (!videoEvents.has(event.id)) { - videoEvents.set(event.id, { - id: event.id, - version: content.version ?? 1, - isPrivate: content.isPrivate ?? false, - title: content.title || "", - magnet: content.magnet || "", - thumbnail: content.thumbnail || "", - description: content.description || "", - mode: content.mode || "live", - pubkey: event.pubkey, - created_at: event.created_at, - tags: event.tags, - }); - } - } catch (parseError) { - if (isDevMode) { - console.error( - "[fetchVideos] Event parsing error:", - parseError - ); - } - } - }); - } catch (relayError) { - if (isDevMode) { - console.error( - `[fetchVideos] Error fetching from ${url}:`, - relayError - ); + } catch (e) { + console.error("Error parsing event content:", e); } } }) ); - const videos = Array.from(videoEvents.values()).sort( + // Turn the Map into a sorted array + const allVideos = Array.from(videoEvents.values()).sort( (a, b) => b.created_at - a.created_at ); - - // Apply access control filtering - const filteredVideos = accessControl.filterVideos(videos); - - if (isDevMode) { - console.log("[fetchVideos] All relays have responded."); - console.log( - `[fetchVideos] Total unique video events: ${videoEvents.size}` - ); - console.log( - `[fetchVideos] Videos after filtering: ${filteredVideos.length}` - ); - } - - return filteredVideos; - } catch (error) { - if (isDevMode) { - console.error("FETCH VIDEOS ERROR:", error); - } + return allVideos; + } catch (err) { + console.error("fetchVideos error:", err); return []; } }