From c0617891d21a418ef7b8957755a9ec426492499d Mon Sep 17 00:00:00 2001 From: Keep Creating Online <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 25 Jan 2025 16:37:54 -0500 Subject: [PATCH] added unique URL based on note nevent ID and also working share button to video player module I also added middle mouse click on video to new tab because I do that a lot on YouTube. --- src/js/app.js | 487 +++++++++++++++++++++++++++++++----------------- src/js/nostr.js | 1 + 2 files changed, 318 insertions(+), 170 deletions(-) diff --git a/src/js/app.js b/src/js/app.js index 6e3d3ba..7fd8fee 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -58,6 +58,9 @@ class bitvidApp { // Private Video Checkbox this.isPrivateCheckbox = document.getElementById("isPrivate"); + + // NEW: Store the currently loaded/playing video object + this.currentVideo = null; } async init() { @@ -83,6 +86,9 @@ class bitvidApp { 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"); @@ -142,7 +148,6 @@ class bitvidApp { return true; } catch (error) { console.error("Modal initialization failed:", error); - // You might want to show this error to the user this.showError(`Failed to initialize video player: ${error.message}`); return false; } @@ -190,9 +195,6 @@ class bitvidApp { } } - /** - * Formats a timestamp into a "time ago" format. - */ formatTimeAgo(timestamp) { const seconds = Math.floor(Date.now() / 1000 - timestamp); const intervals = { @@ -214,9 +216,6 @@ class bitvidApp { return "just now"; } - /** - * Sets up event listeners for various UI interactions. - */ setupEventListeners() { // Login Button this.loginButton.addEventListener("click", async () => { @@ -303,10 +302,31 @@ class bitvidApp { }); } - // Share button (no action for now) + // SHARE BUTTON if (this.shareBtn) { this.shareBtn.addEventListener("click", () => { - this.log("Share button clicked (not implemented)."); + 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."); + } }); } @@ -316,9 +336,6 @@ class bitvidApp { }); } - /** - * Handles user login. - */ login(pubkey, saveToStorage = true) { this.pubkey = pubkey; this.loginButton.classList.add("hidden"); @@ -333,9 +350,6 @@ class bitvidApp { } } - /** - * Handles user logout. - */ logout() { nostrClient.logout(); this.pubkey = null; @@ -348,9 +362,6 @@ class bitvidApp { this.log("User logged out."); } - /** - * Cleans up video player and torrents. - */ async cleanup() { try { if (this.videoElement) { @@ -369,26 +380,24 @@ class bitvidApp { } } - /** - * Hides the video player section. - */ async hideVideoPlayer() { await this.cleanup(); this.playerSection.classList.add("hidden"); } /** - * Hides the video modal. + * 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"); + + // Reset back to original path (no query param) + window.history.replaceState({}, "", window.location.pathname); } - /** - * Handles video submission (with version, private listing). - */ async handleSubmit(e) { e.preventDefault(); @@ -399,7 +408,6 @@ class bitvidApp { const descriptionElement = document.getElementById("description"); - // ADDED FOR VERSIONING/PRIVATE/DELETE: // If you have a checkbox with id="isPrivate" in HTML const isPrivate = this.isPrivateCheckbox ? this.isPrivateCheckbox.checked @@ -439,9 +447,6 @@ class bitvidApp { } } - /** - * Loads and displays videos from Nostr. - */ async loadVideos() { console.log("Starting loadVideos..."); try { @@ -453,18 +458,16 @@ class bitvidApp { throw new Error("No videos received from relays"); } - // Convert to array if not already const videosArray = Array.isArray(videos) ? videos : [videos]; - // **Filter** so we only show: - // - isPrivate === false (public videos) - // - or isPrivate === true but pubkey === this.pubkey + // Filter so we only show: + // - isPrivate === false (public videos) + // - or isPrivate === true but pubkey === this.pubkey const displayedVideos = videosArray.filter((video) => { if (!video.isPrivate) { - // Public video => show it - return true; + return true; // public } - // Else it's private; only show if it's owned by the logged-in user + // It's private; only show if user is the owner return this.pubkey && video.pubkey === this.pubkey; }); @@ -490,7 +493,6 @@ class bitvidApp { }); }); - // Now render only the displayedVideos await this.renderVideoList(displayedVideos); this.log(`Rendered ${displayedVideos.length} videos successfully`); } catch (error) { @@ -505,10 +507,6 @@ class bitvidApp { } } - /** - * Renders the given list of videos. If a video is private and belongs to the user, - * highlight with a special border (e.g. border-yellow-500). - */ async renderVideoList(videos) { try { console.log("RENDER VIDEO LIST - Start", { @@ -578,6 +576,14 @@ class bitvidApp { return ""; } + // First, create a ?v=... link for middle-click / ctrl+click + const nevent = window.NostrTools.nip19.neventEncode({ + id: video.id, + }); + const shareUrl = `${ + window.location.pathname + }?v=${encodeURIComponent(nevent)}`; + const profile = userProfiles.get(video.pubkey) || { name: "Unknown", picture: `https://robohash.org/${video.pubkey}`, @@ -593,114 +599,120 @@ class bitvidApp { ? "border-2 border-yellow-500" : "border-none"; // normal case - // Gear menu (unchanged) + // Gear menu if canEdit const gearMenu = canEdit ? ` -
- - - -
- ` +
+ + + +
+ ` : ""; + // Instead of a
for the thumbnail, we use + // This allows middle-click or ctrl+click to open shareUrl in a new tab, + // while left-click is prevented => opens modal return ` -
- - -
- ${ - video.thumbnail - ? `${this.escapeHTML(video.title)} + + + + ${ + video.thumbnail + ? `${this.escapeHTML(video.title)}` + : `
+ + + + +
` + } +
+
+ + +
+ +

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

+ + +
+ +
+
+ ${profile.name}` - : `
- - - - -
` - } -
-
- - -
- -

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

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

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

-
- ${timeAgo} -
-
-
- - ${gearMenu} -
-
-
- `; + > +
+
+

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

+
+ ${timeAgo} +
+
+
+ + ${gearMenu} +
+
+
+ `; } catch (error) { console.error(`Error processing video ${index}:`, error); return ""; @@ -723,9 +735,6 @@ class bitvidApp { } } - /** - * Validates a video object - */ validateVideo(video, index) { const validationResults = { hasId: Boolean(video?.id), @@ -752,9 +761,6 @@ class bitvidApp { return passed; } - /** - * Gets a user-friendly error message. - */ getErrorMessage(error) { if (error.message.includes("404")) { return "Service worker not found. Please check server configuration."; @@ -767,9 +773,6 @@ class bitvidApp { } } - /** - * Shows an error message to the user. - */ showError(message) { if (this.errorContainer) { this.errorContainer.textContent = message; @@ -783,9 +786,6 @@ class bitvidApp { } } - /** - * Shows a success message to the user. - */ showSuccess(message) { if (this.successContainer) { this.successContainer.textContent = message; @@ -799,9 +799,6 @@ class bitvidApp { } } - /** - * Escapes HTML to prevent XSS. - */ escapeHTML(unsafe) { return unsafe .replace(/&/g, "&") @@ -811,9 +808,6 @@ class bitvidApp { .replace(/'/g, "'"); } - /** - * Logs messages to console. - */ log(message) { console.log(message); } @@ -844,12 +838,14 @@ class bitvidApp { // Re-fetch the latest from relays const videos = await nostrClient.fetchVideos(); const video = videos.find((v) => v.magnet === decodedMagnet); - if (!video) { this.showError("Video data not found."); return; } + // store the full video object so we can reference it in share + this.currentVideo = video; + // Decrypt only once if user owns it if ( video.isPrivate && @@ -863,6 +859,18 @@ class bitvidApp { const finalMagnet = video.magnet; + // Generate the nevent from video.id + // - We keep the same PATH (window.location.pathname), + // just adding ?v=... so the service worker scope is consistent + 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); + } + let creatorProfile = { name: "Unknown", picture: `https://robohash.org/${video.pubkey}`, @@ -958,7 +966,6 @@ class bitvidApp { /** * Allows the user to edit a video note (only if they are the owner). - * We reuse the note's existing d tag via nostrClient.editVideo. */ async handleEditVideo(index) { try { @@ -974,7 +981,6 @@ class bitvidApp { return; } - // Prompt for new fields or keep old const newTitle = prompt( "New Title? (Leave blank to keep existing)", video.title @@ -992,10 +998,8 @@ class bitvidApp { video.description ); - // Ask user if they want the note private or public const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No"); - // Fallback to old if user typed nothing const title = newTitle === null || newTitle.trim() === "" ? video.title @@ -1013,9 +1017,8 @@ class bitvidApp { ? video.description : newDescription.trim(); - // Build final updated data const updatedData = { - version: video.version || 2, // keep old version or set 2 + version: video.version || 2, isPrivate: wantPrivate, title, magnet, @@ -1024,7 +1027,6 @@ class bitvidApp { mode: isDevMode ? "dev" : "live", }; - // Edit const originalEvent = { id: video.id, pubkey: video.pubkey, @@ -1040,7 +1042,6 @@ class bitvidApp { } /** - * ADDED FOR VERSIONING/PRIVATE/DELETE: * Allows the user to delete (soft-delete) a video by marking it as deleted. */ async handleDeleteVideo(index) { @@ -1079,6 +1080,152 @@ class bitvidApp { 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(); diff --git a/src/js/nostr.js b/src/js/nostr.js index 71b24c4..f8249dc 100644 --- a/src/js/nostr.js +++ b/src/js/nostr.js @@ -8,6 +8,7 @@ const RELAY_URLS = [ "wss://nos.lol", "wss://relay.snort.social", "wss://nostr.wine", + "wss://relay.nostr.band", ]; // Rate limiting for error logs