// 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"; class bitvidApp { constructor() { // Authentication Elements this.loginButton = document.getElementById("loginButton"); this.logoutButton = document.getElementById("logoutButton"); this.userStatus = document.getElementById("userStatus"); this.userPubKey = document.getElementById("userPubKey"); // Form Elements this.submitForm = document.getElementById("submitForm"); this.videoFormContainer = document.getElementById("videoFormContainer"); // Video List Element this.videoList = document.getElementById("videoList"); // Video Player Elements this.playerSection = document.getElementById("playerSection"); this.videoElement = document.getElementById("video"); this.status = document.getElementById("status"); this.progressBar = document.getElementById("progress"); this.peers = document.getElementById("peers"); this.speed = document.getElementById("speed"); this.downloaded = document.getElementById("downloaded"); // Initialize these as null - they'll be set after modal loads this.playerModal = null; this.modalVideo = null; this.modalStatus = null; this.modalProgress = null; this.modalPeers = null; this.modalSpeed = null; this.modalDownloaded = null; this.closePlayerBtn = null; this.videoTitle = null; this.videoDescription = null; this.videoTimestamp = null; this.creatorAvatar = null; this.creatorName = null; this.creatorNpub = null; // New buttons for magnet copy and share this.copyMagnetBtn = null; this.shareBtn = null; // Notification Containers this.errorContainer = document.getElementById("errorContainer"); this.successContainer = document.getElementById("successContainer"); this.pubkey = null; this.currentMagnetUri = null; // Private Video Checkbox this.isPrivateCheckbox = document.getElementById("isPrivate"); // NEW: Store the currently loaded/playing video object this.currentVideo = null; } async init() { try { // Hide and reset player states if (this.playerSection) { this.playerSection.style.display = "none"; } // Initialize modal first await this.initModal(); // Then update modal element references this.updateModalElements(); // Initialize Nostr and check login await nostrClient.init(); const savedPubKey = localStorage.getItem("userPubKey"); if (savedPubKey) { this.login(savedPubKey, false); } this.setupEventListeners(); disclaimerModal.show(); await this.loadVideos(); // NEW: Parse ?v=nevent after videos are loaded this.checkUrlParams(); } catch (error) { console.error("Init failed:", error); this.showError("Failed to connect to Nostr relay"); } } 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 html = await response.text(); console.log("Modal HTML loaded successfully"); const modalContainer = document.getElementById("modalContainer"); if (!modalContainer) { throw new Error("Modal container element not found!"); } modalContainer.innerHTML = html; console.log("Modal HTML inserted into DOM"); // Set up modal close handler const closeButton = document.getElementById("closeModal"); if (!closeButton) { throw new Error("Close button element not found!"); } closeButton.addEventListener("click", () => { this.hideModal(); }); // Set up scroll handler for nav show/hide let lastScrollY = 0; const modalNav = document.getElementById("modalNav"); const playerModal = document.getElementById("playerModal"); if (!modalNav || !playerModal) { throw new Error("Modal navigation elements not found!"); } 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; }); console.log("Modal initialization completed successfully"); return true; } catch (error) { console.error("Modal initialization failed:", error); this.showError(`Failed to initialize video player: ${error.message}`); return false; } } updateModalElements() { // Update Modal Elements this.playerModal = document.getElementById("playerModal"); this.modalVideo = document.getElementById("modalVideo"); this.modalStatus = document.getElementById("modalStatus"); this.modalProgress = document.getElementById("modalProgress"); this.modalPeers = document.getElementById("modalPeers"); this.modalSpeed = document.getElementById("modalSpeed"); 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 this.loginButton.addEventListener("click", async () => { try { const pubkey = await nostrClient.login(); this.login(pubkey, true); } catch (error) { this.log("Login failed:", error); this.showError("Failed to login. Please try again."); } }); // Logout Button this.logoutButton.addEventListener("click", () => { this.logout(); }); // Form submission this.submitForm.addEventListener("submit", (e) => this.handleSubmit(e)); // Close Modal Button if (this.closePlayerBtn) { this.closePlayerBtn.addEventListener("click", async () => { await this.hideModal(); }); } // Close Modal by clicking outside content if (this.playerModal) { this.playerModal.addEventListener("click", async (e) => { if (e.target === this.playerModal) { await this.hideModal(); } }); } // 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) => { 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"}` ); } }); 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 if (this.copyMagnetBtn) { this.copyMagnetBtn.addEventListener("click", () => { if (this.currentMagnetUri) { navigator.clipboard .writeText(this.currentMagnetUri) .then(() => this.showSuccess("Magnet link copied to clipboard!")) .catch(() => this.showError("Failed to copy magnet link.")); } }); } // 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!")) .catch(() => this.showError("Failed to copy the link.")); } catch (err) { console.error("Error generating share link:", err); this.showError("Could not generate link."); } }); } // Cleanup on page unload window.addEventListener("beforeunload", async () => { await this.cleanup(); }); // Popstate event for back/forward navigation window.addEventListener("popstate", async (event) => { console.log("[popstate] Back or forward button detected. Cleaning up..."); await this.hideModal(); }); } login(pubkey, saveToStorage = true) { this.pubkey = pubkey; this.loginButton.classList.add("hidden"); this.logoutButton.classList.remove("hidden"); 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); } } logout() { nostrClient.logout(); this.pubkey = null; this.loginButton.classList.remove("hidden"); this.logoutButton.classList.add("hidden"); this.userStatus.classList.add("hidden"); this.userPubKey.textContent = ""; this.videoFormContainer.classList.add("hidden"); localStorage.removeItem("userPubKey"); this.log("User logged out."); } async cleanup() { try { if (this.videoElement) { this.videoElement.pause(); this.videoElement.src = ""; this.videoElement.load(); } if (this.modalVideo) { this.modalVideo.pause(); this.modalVideo.src = ""; this.modalVideo.load(); } 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); } } } async hideVideoPlayer() { await this.cleanup(); 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 this.currentMagnetUri = null; // Optionally revert the URL if you want to remove ?v=... 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 isPrivate = this.isPrivateCheckbox ? this.isPrivateCheckbox.checked : false; const formData = { version: 2, // We set the version to 2 for new posts title: document.getElementById("title")?.value.trim() || "", magnet: document.getElementById("magnet")?.value.trim() || "", thumbnail: document.getElementById("thumbnail")?.value.trim() || "", description: descriptionElement?.value.trim() || "", mode: isDevMode ? "dev" : "live", isPrivate, // new field to handle private listings }; this.log("Form Data Collected:", formData); if (!formData.title || !formData.magnet) { this.showError("Title and Magnet URI 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); this.showError("Failed to share video. Please try again later."); } } async loadVideos() { console.log("Starting loadVideos..."); try { const videos = await nostrClient.fetchVideos(); console.log("Raw videos from nostrClient:", videos); if (!videos) { this.log("No videos received"); throw new Error("No videos received from relays"); } 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 = `

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 ? `
` : ""; // 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.

`; } } 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;