diff --git a/src/assets/jpg/default-profile.jpg b/src/assets/jpg/default-profile.jpg new file mode 100644 index 0000000..2ff8307 Binary files /dev/null and b/src/assets/jpg/default-profile.jpg differ diff --git a/src/js/app.js b/src/js/app.js index d55fa90..af67171 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -1,32 +1,31 @@ // js/app.js -// -// DO NOT REMOVE OR EDIT. This context is for internal build tooling. -// File: js/app.js -// Project: bitvid -// - import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode } from "./config.js"; import { disclaimerModal } from "./disclaimer.js"; +/** + * Dummy "decryption" for private videos + */ +function fakeDecrypt(str) { + return str.split("").reverse().join(""); +} + class bitvidApp { constructor() { - // Authentication Elements + // Basic elements this.loginButton = document.getElementById("loginButton"); this.logoutButton = document.getElementById("logoutButton"); this.userStatus = document.getElementById("userStatus"); this.userPubKey = document.getElementById("userPubKey"); - // Form Elements + // Form elements this.submitForm = document.getElementById("submitForm"); this.videoFormContainer = document.getElementById("videoFormContainer"); - // Video List Element + // Listing + small player this.videoList = document.getElementById("videoList"); - - // Video Player Elements this.playerSection = document.getElementById("playerSection"); this.videoElement = document.getElementById("video"); this.status = document.getElementById("status"); @@ -35,7 +34,7 @@ class bitvidApp { this.speed = document.getElementById("speed"); this.downloaded = document.getElementById("downloaded"); - // Initialize these as null - they'll be set after modal loads + // Modal references (populated after initModal) this.playerModal = null; this.modalVideo = null; this.modalStatus = null; @@ -51,49 +50,64 @@ class bitvidApp { this.creatorName = null; this.creatorNpub = null; - // New buttons for magnet copy and share + // Buttons for magnet copy/share in modal this.copyMagnetBtn = null; this.shareBtn = null; - // Notification Containers + // Notification containers this.errorContainer = document.getElementById("errorContainer"); this.successContainer = document.getElementById("successContainer"); + // Auth state this.pubkey = null; + // Currently playing magnet this.currentMagnetUri = null; - // Private Video Checkbox + // Private checkbox this.isPrivateCheckbox = document.getElementById("isPrivate"); - // NEW: Store the currently loaded/playing video object + // The active video object this.currentVideo = null; + + // Subscription reference + this.videoSubscription = null; + + /** + * Replaces the old `this.videos = []` with a Map, + * keyed by `video.id` for O(1) lookups. + */ + this.videosMap = new Map(); + + // A simple cache for user profiles + this.profileCache = new Map(); } async init() { try { - // Hide and reset player states + // Hide any small player at first if (this.playerSection) { this.playerSection.style.display = "none"; } - // Initialize modal first + // Initialize the modal await this.initModal(); - - // Then update modal element references this.updateModalElements(); - // Initialize Nostr and check login + // Connect to Nostr await nostrClient.init(); const savedPubKey = localStorage.getItem("userPubKey"); if (savedPubKey) { this.login(savedPubKey, false); } + // Setup event listeners, disclaimers this.setupEventListeners(); disclaimerModal.show(); + + // Subscribe for videos await this.loadVideos(); - // NEW: Parse ?v=nevent after videos are loaded + // If there's a ?v= param, handle it this.checkUrlParams(); } catch (error) { console.error("Init failed:", error); @@ -104,13 +118,11 @@ class bitvidApp { async initModal() { try { console.log("Starting modal initialization..."); - const response = await fetch("components/video-modal.html"); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const resp = await fetch("components/video-modal.html"); + if (!resp.ok) { + throw new Error(`HTTP error! status: ${resp.status}`); } - - const html = await response.text(); + const html = await resp.text(); console.log("Modal HTML loaded successfully"); const modalContainer = document.getElementById("modalContainer"); @@ -121,25 +133,23 @@ class bitvidApp { modalContainer.innerHTML = html; console.log("Modal HTML inserted into DOM"); - // Set up modal close handler + // Navigation const closeButton = document.getElementById("closeModal"); if (!closeButton) { - throw new Error("Close button element not found!"); + throw new Error("Close button not found!"); } - closeButton.addEventListener("click", () => { this.hideModal(); }); - // Set up scroll handler for nav show/hide - let lastScrollY = 0; + // Scroll-based nav hide const modalNav = document.getElementById("modalNav"); const playerModal = document.getElementById("playerModal"); - if (!modalNav || !playerModal) { - throw new Error("Modal navigation elements not found!"); + throw new Error("Modal nav elements not found!"); } + let lastScrollY = 0; playerModal.addEventListener("scroll", (e) => { const currentScrollY = e.target.scrollTop; const shouldShowNav = @@ -160,7 +170,6 @@ class bitvidApp { } updateModalElements() { - // Update Modal Elements this.playerModal = document.getElementById("playerModal"); this.modalVideo = document.getElementById("modalVideo"); this.modalStatus = document.getElementById("modalStatus"); @@ -170,86 +179,46 @@ class bitvidApp { this.modalDownloaded = document.getElementById("modalDownloaded"); this.closePlayerBtn = document.getElementById("closeModal"); - // Update Video Info Elements this.videoTitle = document.getElementById("videoTitle"); this.videoDescription = document.getElementById("videoDescription"); this.videoTimestamp = document.getElementById("videoTimestamp"); - // Update Creator Info Elements this.creatorAvatar = document.getElementById("creatorAvatar"); this.creatorName = document.getElementById("creatorName"); this.creatorNpub = document.getElementById("creatorNpub"); - // New icons for magnet copy and share this.copyMagnetBtn = document.getElementById("copyMagnetBtn"); this.shareBtn = document.getElementById("shareBtn"); - - // Add scroll behavior for nav - let lastScrollY = 0; - const modalNav = document.getElementById("modalNav"); - - if (this.playerModal && modalNav) { - this.playerModal.addEventListener("scroll", (e) => { - const currentScrollY = e.target.scrollTop; - const shouldShowNav = - currentScrollY <= lastScrollY || currentScrollY < 50; - modalNav.style.transform = shouldShowNav - ? "translateY(0)" - : "translateY(-100%)"; - lastScrollY = currentScrollY; - }); - } - } - - 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, secondsInUnit] of Object.entries(intervals)) { - const interval = Math.floor(seconds / secondsInUnit); - if (interval >= 1) { - return `${interval} ${unit}${interval === 1 ? "" : "s"} ago`; - } - } - - return "just now"; } setupEventListeners() { - // Login Button + // Login this.loginButton.addEventListener("click", async () => { try { const pubkey = await nostrClient.login(); this.login(pubkey, true); - } catch (error) { - this.log("Login failed:", error); + } catch (err) { + this.log("Login failed:", err); this.showError("Failed to login. Please try again."); } }); - // Logout Button + // Logout this.logoutButton.addEventListener("click", () => { this.logout(); }); - // Form submission + // Submit new video form this.submitForm.addEventListener("submit", (e) => this.handleSubmit(e)); - // Close Modal Button + // Close modal by X if (this.closePlayerBtn) { this.closePlayerBtn.addEventListener("click", async () => { await this.hideModal(); }); } - // Close Modal by clicking outside content + // Close modal by clicking outside container if (this.playerModal) { this.playerModal.addEventListener("click", async (e) => { if (e.target === this.playerModal) { @@ -258,73 +227,63 @@ class bitvidApp { }); } - // Video error handling - this.videoElement.addEventListener("error", (e) => { - const error = e.target.error; - this.log("Video error:", error); - if (error) { - this.showError( - `Video playback error: ${error.message || "Unknown error"}` - ); - } - }); - - // Detailed Modal Video Event Listeners - if (this.modalVideo) { - this.modalVideo.addEventListener("error", (e) => { + // Error handling for the small inline player (if used) + if (this.videoElement) { + this.videoElement.addEventListener("error", (e) => { const error = e.target.error; - this.log("Modal video error:", error); if (error) { - this.log("Error code:", error.code); - this.log("Error message:", error.message); this.showError( `Video playback error: ${error.message || "Unknown error"}` ); } }); + } + // Modal video error + if (this.modalVideo) { + this.modalVideo.addEventListener("error", (e) => { + const error = e.target.error; + if (error) { + this.showError( + `Video playback error: ${error.message || "Unknown error"}` + ); + } + }); this.modalVideo.addEventListener("loadstart", () => { this.log("Video loadstart event fired"); }); - this.modalVideo.addEventListener("loadedmetadata", () => { this.log("Video loadedmetadata event fired"); }); - this.modalVideo.addEventListener("canplay", () => { this.log("Video canplay event fired"); }); } - // Copy magnet link + // Copy magnet if (this.copyMagnetBtn) { this.copyMagnetBtn.addEventListener("click", () => { if (this.currentMagnetUri) { navigator.clipboard .writeText(this.currentMagnetUri) - .then(() => this.showSuccess("Magnet link copied to clipboard!")) + .then(() => this.showSuccess("Magnet link copied!")) .catch(() => this.showError("Failed to copy magnet link.")); } }); } - // SHARE BUTTON + // Share button if (this.shareBtn) { this.shareBtn.addEventListener("click", () => { if (!this.currentVideo) { this.showError("No video is loaded to share."); return; } - try { - // Encode the raw hex event ID into 'nevent' const nevent = window.NostrTools.nip19.neventEncode({ id: this.currentVideo.id, }); - - // Build a URL that includes ?v= const shareUrl = `${window.location.origin}${window.location.pathname}?v=${nevent}`; - navigator.clipboard .writeText(shareUrl) .then(() => this.showSuccess("Video link copied to clipboard!")) @@ -341,9 +300,9 @@ class bitvidApp { await this.cleanup(); }); - // Popstate event for back/forward navigation - window.addEventListener("popstate", async (event) => { - console.log("[popstate] Back or forward button detected. Cleaning up..."); + // Back/forward navigation + window.addEventListener("popstate", async () => { + console.log("[popstate] user navigated back/forward; cleaning modal..."); await this.hideModal(); }); } @@ -355,7 +314,6 @@ class bitvidApp { this.userStatus.classList.remove("hidden"); this.userPubKey.textContent = pubkey; this.videoFormContainer.classList.remove("hidden"); - this.log(`User logged in as: ${pubkey}`); if (saveToStorage) { localStorage.setItem("userPubKey", pubkey); @@ -371,11 +329,11 @@ class bitvidApp { this.userPubKey.textContent = ""; this.videoFormContainer.classList.add("hidden"); localStorage.removeItem("userPubKey"); - this.log("User logged out."); } async cleanup() { try { + // Stop playing any small player or modal video if (this.videoElement) { this.videoElement.pause(); this.videoElement.src = ""; @@ -386,436 +344,324 @@ class bitvidApp { this.modalVideo.src = ""; this.modalVideo.load(); } + // Cleanup torrent client await torrentClient.cleanup(); - } catch (error) { - this.log("Cleanup error:", error); - } finally { - try { - await fetch("./webtorrent/cancel/"); - } catch (err) { - console.error("Failed to cancel old WebTorrent request:", err); - } + } catch (err) { + console.error("Cleanup error:", err); } } async hideVideoPlayer() { await this.cleanup(); - this.playerSection.classList.add("hidden"); + if (this.playerSection) { + this.playerSection.classList.add("hidden"); + } } - /** - * OPTIONAL: Reset the URL after hiding the modal so that ?v=nevent - * disappears. Remove this if you’d prefer the URL to remain set. - */ async hideModal() { await this.cleanup(); - this.playerModal.style.display = "none"; - this.playerModal.classList.add("hidden"); - // Clear out the old magnet so "same video requested" doesn't block re-loading + if (this.playerModal) { + this.playerModal.style.display = "none"; + this.playerModal.classList.add("hidden"); + } this.currentMagnetUri = null; - // Optionally revert the URL if you want to remove ?v=... + // Optionally revert ?v=... from the URL window.history.replaceState({}, "", window.location.pathname); - - // Cancel any lingering torrent requests again - try { - await fetch("./webtorrent/cancel/"); - } catch (err) { - console.error("Failed to cancel old WebTorrent request:", err); - } } async handleSubmit(e) { e.preventDefault(); - if (!this.pubkey) { this.showError("Please login to post a video."); return; } - const descriptionElement = document.getElementById("description"); - - // If you have a checkbox with id="isPrivate" in HTML + const descEl = document.getElementById("description"); const isPrivate = this.isPrivateCheckbox ? this.isPrivateCheckbox.checked : false; const formData = { - version: 2, // We set the version to 2 for new posts + version: 2, title: document.getElementById("title")?.value.trim() || "", magnet: document.getElementById("magnet")?.value.trim() || "", thumbnail: document.getElementById("thumbnail")?.value.trim() || "", - description: descriptionElement?.value.trim() || "", + description: descEl?.value.trim() || "", mode: isDevMode ? "dev" : "live", - isPrivate, // new field to handle private listings + isPrivate, }; - this.log("Form Data Collected:", formData); - if (!formData.title || !formData.magnet) { - this.showError("Title and Magnet URI are required."); + this.showError("Title and Magnet are required."); return; } try { await nostrClient.publishVideo(formData, this.pubkey); this.submitForm.reset(); - - // If the private checkbox was checked, reset it if (this.isPrivateCheckbox) { this.isPrivateCheckbox.checked = false; } - await this.loadVideos(); this.showSuccess("Video shared successfully!"); - } catch (error) { - this.log("Failed to publish video:", error.message); + } catch (err) { + console.error("Failed to publish video:", err); this.showError("Failed to share video. Please try again later."); } } + /** + * Subscribe to new videos & re-render the list. + * Now we store them in `this.videosMap`, keyed by `video.id`. + */ async loadVideos() { - console.log("Starting loadVideos..."); - try { - const videos = await nostrClient.fetchVideos(); - console.log("Raw videos from nostrClient:", videos); + console.log("Starting loadVideos (subscription approach)..."); - if (!videos) { - this.log("No videos received"); - throw new Error("No videos received from relays"); - } + if (this.videoSubscription) { + // unsub old sub if present + this.videoSubscription.unsub(); + this.videoSubscription = null; + } - const videosArray = Array.isArray(videos) ? videos : [videos]; - - // Filter so we only show: - // - isPrivate === false (public videos) - // - or isPrivate === true but pubkey === this.pubkey - const displayedVideos = videosArray.filter((video) => { - if (!video.isPrivate) { - return true; // public - } - // It's private; only show if user is the owner - return this.pubkey && video.pubkey === this.pubkey; - }); - - if (displayedVideos.length === 0) { - this.log("No valid videos found after filtering."); - this.videoList.innerHTML = ` -

- 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 - ? ` -
- - -
- ` - : ""; - - // Build the card HTML - return ` -
- - - -
- ${this.escapeHTML(video.title)} -
-
- -
-

- ${this.escapeHTML(video.title)} -

-
-
-
- ${profile.name} -
-
-

- ${this.escapeHTML(profile.name)} -

-
- ${timeAgo} -
-
-
- ${gearMenu} -
-
-
- `; - } catch (err) { - console.error(`Error processing video ${index}:`, err); - return ""; - } - }) - .filter((html) => html.length > 0); - - if (renderedVideos.length === 0) { - this.videoList.innerHTML = `

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 + ? ` +
+ + +
+ ` + : ""; + + // Build the card + const cardHtml = ` +
+ +
+ ${this.escapeHTML(video.title)} +
+
+
+

+ ${this.escapeHTML(video.title)} +

+
+
+
+ Placeholder +
+
+

+ Loading name... +

+
+ ${timeAgo} +
+
+
+ ${gearMenu} +
+
+
+ `; + + // Kick off background fetch for profile + this.fetchAndRenderProfile(video.pubkey); + + return cardHtml; + }); + + const valid = htmlList.filter((x) => x.length > 0); + if (valid.length === 0) { + this.videoList.innerHTML = ` +

+ 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 []; } }