From 6cf1d53624467701cbdee21f42f3210a396208d6 Mon Sep 17 00:00:00 2001 From: Keep Creating Online <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:56:13 -0500 Subject: [PATCH] massive CPU efficiency boost. --- js/app.js | 397 +++++++++++++++++++++++++---------------------- js/sidebar.js | 2 + js/webtorrent.js | 80 ++++++---- 3 files changed, 263 insertions(+), 216 deletions(-) diff --git a/js/app.js b/js/app.js index 417c357..aac4c3b 100644 --- a/js/app.js +++ b/js/app.js @@ -14,6 +14,48 @@ function fakeDecrypt(str) { return str.split("").reverse().join(""); } +/** + * Simple IntersectionObserver-based lazy loader for images (or videos). + * + * Usage: + * const mediaLoader = new MediaLoader(); + * mediaLoader.observe(imgElement); + * + * This will load the real image source from `imgElement.dataset.lazy` + * once the image enters the viewport. + */ +class MediaLoader { + constructor(rootMargin = "50px") { + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const el = entry.target; + const lazySrc = el.dataset.lazy; + if (lazySrc) { + el.src = lazySrc; + delete el.dataset.lazy; + } + // Stop observing once loaded + this.observer.unobserve(el); + } + } + }, + { rootMargin } + ); + } + + observe(el) { + if (el.dataset.lazy) { + this.observer.observe(el); + } + } + + disconnect() { + this.observer.disconnect(); + } +} + class bitvidApp { constructor() { // Basic auth/display elements @@ -22,6 +64,9 @@ class bitvidApp { this.userStatus = document.getElementById("userStatus") || null; this.userPubKey = document.getElementById("userPubKey") || null; + // Lazy-loading helper for images + this.mediaLoader = new MediaLoader(); + // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; this.profileAvatar = document.getElementById("profileAvatar") || null; @@ -478,22 +523,36 @@ class bitvidApp { await this.cleanup(); }); - // 8) Handle back/forward nav => hide video modal + // 8) Handle back/forward navigation => hide video modal window.addEventListener("popstate", async () => { console.log("[popstate] user navigated back/forward; cleaning modal..."); await this.hideModal(); }); - // Event delegation for the “Application Form” button inside the login modal + // 9) Event delegation on the video list container for playing videos + if (this.videoList) { + this.videoList.addEventListener("click", (event) => { + const magnetTrigger = event.target.closest("[data-play-magnet]"); + if (magnetTrigger) { + // For a normal left-click (button 0, no Ctrl/Cmd), prevent navigation: + if (event.button === 0 && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); // Stop browser from following the href + const magnet = magnetTrigger.dataset.playMagnet; + this.playVideo(magnet); + } + } + }); + } + + // 10) Event delegation for the “Application Form” button inside the login modal document.addEventListener("click", (event) => { if (event.target && event.target.id === "openApplicationModal") { - // 1) Hide the login modal + // Hide the login modal const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.add("hidden"); } - - // 2) Show the application modal + // Show the application modal const appModal = document.getElementById("nostrFormModal"); if (appModal) { appModal.classList.remove("hidden"); @@ -535,6 +594,56 @@ class bitvidApp { } } + async fetchAndRenderProfile(pubkey, forceRefresh = false) { + const now = Date.now(); + + // 1) Check if we have a cached entry + const cacheEntry = this.profileCache.get(pubkey); + if (!forceRefresh && cacheEntry && now - cacheEntry.timestamp < 60000) { + // If it's less than 60 seconds old, just update DOM with it + this.updateProfileInDOM(pubkey, cacheEntry.profile); + return; + } + + // 2) Otherwise, fetch from Nostr + 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/svg/default-profile.svg", + }; + + // Cache it + this.profileCache.set(pubkey, { profile, timestamp: now }); + // Update DOM + this.updateProfileInDOM(pubkey, profile); + } + } catch (err) { + console.error("Profile fetch error:", err); + } + } + + updateProfileInDOM(pubkey, profile) { + // For any .author-pic[data-pubkey=...] + const picEls = document.querySelectorAll( + `.author-pic[data-pubkey="${pubkey}"]` + ); + picEls.forEach((el) => { + el.src = profile.picture; + }); + // For any .author-name[data-pubkey=...] + const nameEls = document.querySelectorAll( + `.author-name[data-pubkey="${pubkey}"]` + ); + nameEls.forEach((el) => { + el.textContent = profile.name; + }); + } + /** * Actually handle the upload form submission. */ @@ -815,10 +924,10 @@ class bitvidApp { return olderMatches.length > 0; } - // 4) Build the DOM for each video in newestActive async renderVideoList(videos) { if (!this.videoList) return; + // Check if there's anything to show if (!videos || videos.length === 0) { this.videoList.innerHTML = `

@@ -830,13 +939,14 @@ class bitvidApp { // Sort newest first videos.sort((a, b) => b.created_at - a.created_at); - // <-- NEW: Convert allEvents map => array to check older overshadowed events + // Convert allEvents to an array for checking older overshadowed events const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); + const fragment = document.createDocumentFragment(); - const htmlList = videos.map((video, index) => { + videos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Video missing ID/title:", video); - return ""; + return; } const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); @@ -850,32 +960,31 @@ class bitvidApp { : "border-none"; const timeAgo = this.formatTimeAgo(video.created_at); - // 1) Do we have an older version? + // Check if there's an older version (for revert button) let hasOlder = false; if (canEdit && video.videoRootId) { hasOlder = this.hasOlderVersion(video, fullAllEventsArray); } - // 2) If we do => show revert button const revertButton = hasOlder ? ` - - ` + + ` : ""; - // 3) Gear menu + // Gear menu (only shown if canEdit) const gearMenu = canEdit ? `

${revertButton} @@ -907,42 +1016,26 @@ class bitvidApp { ` : ""; - // 4) Build the card markup... + // Card markup const cardHtml = `
${this.escapeHTML(video.title)}

${this.escapeHTML(video.title)}

@@ -974,132 +1067,83 @@ class bitvidApp {
`; - // Fire off a background fetch for the author's profile + // Turn the HTML into an element + const template = document.createElement("template"); + template.innerHTML = cardHtml.trim(); + const cardEl = template.content.firstElementChild; + + // Fetch the author's profile info in the background this.fetchAndRenderProfile(video.pubkey); - return cardHtml; + // Add the finished card to our fragment + fragment.appendChild(cardEl); }); - // Filter out any empty strings - const valid = htmlList.filter((x) => x.length > 0); - if (valid.length === 0) { - this.videoList.innerHTML = ` -

- No valid videos to display. -

`; - return; - } + // Clear the list and add our fragment + this.videoList.innerHTML = ""; + this.videoList.appendChild(fragment); - // Finally inject into DOM - this.videoList.innerHTML = valid.join(""); - } + // Lazy-load images + const lazyEls = this.videoList.querySelectorAll("[data-lazy]"); + lazyEls.forEach((el) => this.mediaLoader.observe(el)); - /** - * Retrieve the profile for a given pubkey (kind:0) and update the DOM. - */ - async fetchAndRenderProfile(pubkey, forceRefresh = false) { - const now = Date.now(); + // ------------------------------- + // Gear menu / button event listeners + // ------------------------------- - // Check if we already have a cached entry for this pubkey: - const cacheEntry = this.profileCache.get(pubkey); - - // If not forcing refresh, and we have a cache entry less than 60 sec old, use it: - if (!forceRefresh && cacheEntry && now - cacheEntry.timestamp < 60000) { - this.updateProfileInDOM(pubkey, cacheEntry.profile); - return; - } - - // Otherwise, go fetch from the relay - 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/svg/default-profile.svg", - }; - - // Store into the cache with a timestamp - this.profileCache.set(pubkey, { - profile, - timestamp: now, - }); - - // Now update the DOM elements - this.updateProfileInDOM(pubkey, profile); - } - } catch (err) { - console.error("Profile fetch error for pubkey:", pubkey, err); - } - } - - /** - * Update all DOM elements that match this pubkey, e.g. .author-pic[data-pubkey=...] - */ - updateProfileInDOM(pubkey, profile) { - const picEls = document.querySelectorAll( - `.author-pic[data-pubkey="${pubkey}"]` + // Toggle the gear menu + const gearButtons = this.videoList.querySelectorAll( + "[data-settings-dropdown]" ); - picEls.forEach((el) => { - el.src = profile.picture; + gearButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-settings-dropdown"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) { + dropdown.classList.toggle("hidden"); + } + }); }); - const nameEls = document.querySelectorAll( - `.author-name[data-pubkey="${pubkey}"]` + + // Edit button + const editButtons = this.videoList.querySelectorAll("[data-edit-index]"); + editButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-edit-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + // Assuming you have a method like this in your code: + this.handleEditVideo(index); + }); + }); + + // Revert button + const revertButtons = this.videoList.querySelectorAll( + "[data-revert-index]" ); - nameEls.forEach((el) => { - el.textContent = profile.name; + revertButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-revert-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + // Assuming you have a method like this in your code: + this.handleRevertVideo(index); + }); }); - } - /** - * Plays a video given its magnet URI. - * We simply look up which event has this magnet - * and then delegate to playVideoByEventId for - * consistent modal and metadata handling. - */ - async playVideo(magnetURI) { - try { - if (!magnetURI) { - this.showError("Invalid Magnet URI."); - return; - } - - const decodedMagnet = decodeURIComponent(magnetURI); - - // If we are already playing this exact magnet, do nothing. - if (this.currentMagnetUri === decodedMagnet) { - this.log("Same video requested - already playing"); - return; - } - - // 1) Check local 'videosMap' or 'nostrClient.getActiveVideos()' - let matchedVideo = Array.from(this.videosMap.values()).find( - (v) => v.magnet === decodedMagnet - ); - if (!matchedVideo) { - // Instead of forcing a full `fetchVideos()`, - // try looking in the activeVideos from local cache: - const activeVideos = nostrClient.getActiveVideos(); - matchedVideo = activeVideos.find((v) => v.magnet === decodedMagnet); - } - - // If still not found, you can do a single event-based approach or just show an error: - if (!matchedVideo) { - this.showError("No matching video found in local cache."); - return; - } - - // Update tracking - this.currentMagnetUri = decodedMagnet; - - // Delegate to the main method - await this.playVideoByEventId(matchedVideo.id); - } catch (error) { - console.error("Error in playVideo:", error); - this.showError(`Playback error: ${error.message}`); - } + // Delete All button + const deleteAllButtons = this.videoList.querySelectorAll( + "[data-delete-all-index]" + ); + deleteAllButtons.forEach((button) => { + button.addEventListener("click", () => { + const index = button.getAttribute("data-delete-all-index"); + const dropdown = document.getElementById(`settingsDropdown-${index}`); + if (dropdown) dropdown.classList.add("hidden"); + // Assuming you have a method like this in your code: + this.handleFullDeleteVideo(index); + }); + }); } /** @@ -1371,55 +1415,48 @@ class bitvidApp { * Helper to open a video by event ID (like ?v=...). */ async playVideoByEventId(eventId) { - // First, check if this event is blacklisted by event ID if (this.blacklistedEventIds.has(eventId)) { this.showError("This content has been removed or is not allowed."); return; } try { - // 1) Check local subscription map let video = this.videosMap.get(eventId); - // 2) If not in local map, attempt fallback fetch from getOldEventById if (!video) { video = await this.getOldEventById(eventId); } - // 3) If still not found, show error and return if (!video) { this.showError("Video not found."); return; } - // **Check if video’s author is blacklisted** const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (initialBlacklist.includes(authorNpub)) { this.showError("This content has been removed or is not allowed."); return; } - // 4) Decrypt magnet if private & owned if ( video.isPrivate && video.pubkey === this.pubkey && !video.alreadyDecrypted ) { - this.log("Decrypting private magnet link..."); video.magnet = fakeDecrypt(video.magnet); video.alreadyDecrypted = true; } - // 5) Show the modal and set the "please stand by" poster this.currentVideo = video; this.currentMagnetUri = video.magnet; this.showModalWithPoster(); - // 6) Update ?v= param in the URL + // Update ?v= param in the URL const nevent = window.NostrTools.nip19.neventEncode({ id: eventId }); - const newUrl = - window.location.pathname + `?v=${encodeURIComponent(nevent)}`; + const newUrl = `${window.location.pathname}?v=${encodeURIComponent( + nevent + )}`; window.history.pushState({}, "", newUrl); - // 7) Optionally fetch the author profile + // Fetch author profile let creatorProfile = { name: "Unknown", picture: `https://robohash.org/${video.pubkey}`, @@ -1439,7 +1476,6 @@ class bitvidApp { this.log("Error fetching creator profile:", error); } - // 8) Render video details in modal const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (this.videoTitle) { this.videoTitle.textContent = video.title || "Untitled"; @@ -1465,43 +1501,27 @@ class bitvidApp { this.creatorAvatar.alt = creatorProfile.name; } - // 9) Clean up any existing torrent instance before starting a new stream await torrentClient.cleanup(); - // 10) Append a cache-busting parameter to the magnet URI const cacheBustedMagnet = video.magnet + "&ts=" + Date.now(); this.log("Starting video stream with:", cacheBustedMagnet); - // 11) Set autoplay preferences: - // Read user preference from localStorage (if not set, default to muted) + // Autoplay preferences const storedUnmuted = localStorage.getItem("unmutedAutoplay"); const userWantsUnmuted = storedUnmuted === "true"; this.modalVideo.muted = !userWantsUnmuted; - this.log( - "Autoplay preference - unmuted:", - userWantsUnmuted, - "=> muted:", - this.modalVideo.muted - ); - // Attach a volumechange listener to update the stored preference this.modalVideo.addEventListener("volumechange", () => { localStorage.setItem( "unmutedAutoplay", (!this.modalVideo.muted).toString() ); - this.log( - "Volume changed, new unmuted preference:", - !this.modalVideo.muted - ); }); - // 12) Start torrent streaming const realTorrent = await torrentClient.streamVideo( cacheBustedMagnet, this.modalVideo ); - // 13) Attempt to autoplay; if unmuted autoplay fails, fall back to muted this.modalVideo.play().catch((err) => { this.log("Autoplay failed:", err); if (!this.modalVideo.muted) { @@ -1513,7 +1533,7 @@ class bitvidApp { } }); - // 14) Start intervals to update torrent stats (every 3 seconds) + // Update torrent stats every 3s const updateInterval = setInterval(() => { if (!document.body.contains(this.modalVideo)) { clearInterval(updateInterval); @@ -1523,7 +1543,7 @@ class bitvidApp { }, 3000); this.activeIntervals.push(updateInterval); - // 15) (Optional) Mirror small inline stats into the modal + // Mirror stats into the modal if needed const mirrorInterval = setInterval(() => { if (!document.body.contains(this.modalVideo)) { clearInterval(mirrorInterval); @@ -1534,7 +1554,6 @@ class bitvidApp { const peers = document.getElementById("peers"); const speed = document.getElementById("speed"); const downloaded = document.getElementById("downloaded"); - if (status && this.modalStatus) { this.modalStatus.textContent = status.textContent; } diff --git a/js/sidebar.js b/js/sidebar.js index e2eacf9..c5c7808 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -1,3 +1,5 @@ +//js/sidebar.js + import { loadView } from "./viewManager.js"; import { viewInitRegistry } from "./viewManager.js"; diff --git a/js/webtorrent.js b/js/webtorrent.js index 0e82b2e..65b8ea8 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -1,10 +1,19 @@ +//js/webtorrent.js + import WebTorrent from "./webtorrent.min.js"; export class TorrentClient { constructor() { - this.client = null; // Do NOT instantiate right away + // Reusable objects and flags + this.client = null; this.currentTorrent = null; - this.TIMEOUT_DURATION = 60000; // 60 seconds + + // Service worker registration is cached + this.swRegistration = null; + this.serverCreated = false; // Indicates if we've called createServer on this.client + + // Timeout for SW operations + this.TIMEOUT_DURATION = 60000; } log(msg) { @@ -21,6 +30,22 @@ export class TorrentClient { return /firefox/i.test(window.navigator.userAgent); } + /** + * Makes sure we have exactly one WebTorrent client instance and one SW registration. + * Called once from streamVideo. + */ + async init() { + // 1) If the client doesn't exist, create it + if (!this.client) { + this.client = new WebTorrent(); + } + + // 2) If we haven’t registered the service worker yet, do it now + if (!this.swRegistration) { + this.swRegistration = await this.setupServiceWorker(); + } + } + async waitForServiceWorkerActivation(registration) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -67,6 +92,7 @@ export class TorrentClient { throw new Error("Service Worker not supported or disabled"); } + // Brave-specific logic if (isBraveBrowser) { this.log("Checking Brave configuration..."); if (!navigator.serviceWorker) { @@ -78,6 +104,7 @@ export class TorrentClient { throw new Error("Please enable WebRTC in Brave Shield settings"); } + // Unregister all existing service workers before installing a fresh one const registrations = await navigator.serviceWorker.getRegistrations(); for (const reg of registrations) { await reg.unregister(); @@ -135,8 +162,8 @@ export class TorrentClient { // Force the SW to check for updates registration.update(); - this.log("Service worker ready"); + return registration; } catch (error) { this.log("Service worker setup error:", error); @@ -144,7 +171,7 @@ export class TorrentClient { } } - // Minimal handleChromeTorrent + // Handle Chrome-based browsers handleChromeTorrent(torrent, videoElement, resolve, reject) { torrent.on("warning", (err) => { if (err && typeof err.message === "string") { @@ -204,7 +231,7 @@ export class TorrentClient { }); } - // Minimal handleFirefoxTorrent + // Handle Firefox-based browsers handleFirefoxTorrent(torrent, videoElement, resolve, reject) { const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/.test(f.name.toLowerCase()) @@ -227,7 +254,7 @@ export class TorrentClient { }); try { - file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); + file.streamTo(videoElement, { highWaterMark: 256 * 1024 }); this.currentTorrent = torrent; resolve(torrent); } catch (err) { @@ -243,32 +270,27 @@ export class TorrentClient { /** * Initiates streaming of a torrent magnet to a