Files
bitvid/js/app.js
2025-02-08 11:05:20 -05:00

1808 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// js/app.js
import { loadView } from "./viewManager.js";
import { nostrClient } from "./nostr.js";
import { torrentClient } from "./webtorrent.js";
import { isDevMode } from "./config.js";
import disclaimerModal from "./disclaimer.js";
import { isWhitelistEnabled } from "./config.js";
import {
initialWhitelist,
initialBlacklist,
initialEventBlacklist,
} from "./lists.js";
/**
* Simple "decryption" placeholder for private videos.
*/
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
this.loginButton = document.getElementById("loginButton") || null;
this.logoutButton = document.getElementById("logoutButton") || null;
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;
// Profile modal references (if used in profile-modal.html)
this.profileModal = null;
this.closeProfileModal = null;
this.profileLogoutBtn = null;
this.profileModalAvatar = null;
this.profileModalName = null;
// Upload modal elements
this.uploadButton = document.getElementById("uploadButton") || null;
this.uploadModal = document.getElementById("uploadModal") || null;
this.closeUploadModalBtn =
document.getElementById("closeUploadModal") || null;
this.uploadForm = document.getElementById("uploadForm") || null;
// Optional small inline player stats
this.status = document.getElementById("status") || null;
this.progressBar = document.getElementById("progress") || null;
this.peers = document.getElementById("peers") || null;
this.speed = document.getElementById("speed") || null;
this.downloaded = document.getElementById("downloaded") || null;
// Video player modal references (loaded via video-modal.html)
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;
this.copyMagnetBtn = null;
this.shareBtn = null;
// Hide/Show Subscriptions Link
this.subscriptionsLink = null;
// Notification containers
this.errorContainer = document.getElementById("errorContainer") || null;
this.successContainer = document.getElementById("successContainer") || null;
// Auth state
this.pubkey = null;
this.currentMagnetUri = null;
this.currentVideo = null;
this.videoSubscription = null;
// Videos stored as a Map (key=event.id)
this.videosMap = new Map();
// Simple cache for user profiles
this.profileCache = new Map();
// NEW: reference to the login modal's close button
this.closeLoginModalBtn =
document.getElementById("closeLoginModal") || null;
// Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties
this.blacklistedEventIds = new Set();
for (const neventStr of initialEventBlacklist) {
// Skip any empty or obviously invalid strings
if (!neventStr || neventStr.trim().length < 8) {
continue;
}
try {
const decoded = window.NostrTools.nip19.decode(neventStr);
if (decoded.type === "nevent" && decoded.data.id) {
this.blacklistedEventIds.add(decoded.data.id);
}
} catch (err) {
console.error(
"[bitvidApp] Invalid nevent in blacklist:",
neventStr,
err
);
}
}
}
forceRefreshAllProfiles() {
// 1) Grab the newest set of videos from nostrClient
const activeVideos = nostrClient.getActiveVideos();
// 2) Build a unique set of pubkeys
const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey));
// 3) For each author, fetchAndRenderProfile with forceRefresh = true
for (const authorPubkey of uniqueAuthors) {
this.fetchAndRenderProfile(authorPubkey, true);
}
}
async init() {
try {
// Force update of any registered service workers to ensure latest code is used.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.update());
});
}
// 1. Initialize the video modal (components/video-modal.html)
await this.initModal();
this.updateModalElements();
// 2. Initialize the upload modal (components/upload-modal.html)
await this.initUploadModal();
// 3. (Optional) Initialize the profile modal (components/profile-modal.html)
await this.initProfileModal();
// 4. Connect to Nostr
await nostrClient.init();
// Grab the "Subscriptions" link by its id in the sidebar
this.subscriptionsLink = document.getElementById("subscriptionsLink");
const savedPubKey = localStorage.getItem("userPubKey");
if (savedPubKey) {
// Auto-login if a pubkey was saved
this.login(savedPubKey, false);
// If the user was already logged in, show the Subscriptions link
if (this.subscriptionsLink) {
this.subscriptionsLink.classList.remove("hidden");
}
}
// 5. Setup general event listeners, show disclaimers
this.setupEventListeners();
disclaimerModal.show();
// 6) Load the default view ONLY if there's no #view= already
if (!window.location.hash || !window.location.hash.startsWith("#view=")) {
console.log(
"[app.init()] No #view= in the URL, loading default home view"
);
await loadView("views/most-recent-videos.html");
} else {
console.log(
"[app.init()] Found hash:",
window.location.hash,
"so skipping default load"
);
}
// 7. Once loaded, get a reference to #videoList
this.videoList = document.getElementById("videoList");
// 8. Subscribe or fetch videos
await this.loadVideos();
// 9. Check URL ?v= param
this.checkUrlParams();
// Keep an array of active interval IDs so we can clear them on modal close
this.activeIntervals = [];
} catch (error) {
console.error("Init failed:", error);
this.showError("Failed to connect to Nostr relay");
}
}
/**
* Initialize the main video modal (video-modal.html).
*/
async initModal() {
try {
const resp = await fetch("components/video-modal.html");
if (!resp.ok) {
throw new Error(`HTTP error! status: ${resp.status}`);
}
const html = await resp.text();
const modalContainer = document.getElementById("modalContainer");
if (!modalContainer) {
throw new Error("Modal container element not found!");
}
// Instead of overwriting, we append a new DIV with the fetched HTML
const wrapper = document.createElement("div");
wrapper.innerHTML = html; // set the markup
modalContainer.appendChild(wrapper); // append the markup
// Now we can safely find elements inside:
const closeButton = document.getElementById("closeModal");
if (!closeButton) {
throw new Error("Close button not found in video-modal!");
}
closeButton.addEventListener("click", () => {
this.hideModal();
});
// Setup scroll-based nav hide
const modalNav = document.getElementById("modalNav");
const playerModal = document.getElementById("playerModal");
if (!modalNav || !playerModal) {
throw new Error("Modal nav (#modalNav) or #playerModal not found!");
}
let lastScrollY = 0;
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("Video modal initialization successful");
return true;
} catch (error) {
console.error("initModal failed:", error);
this.showError(`Failed to initialize video modal: ${error.message}`);
return false;
}
}
/**
* After we load the video modal, store references in `this.*`.
*/
updateModalElements() {
this.playerModal = document.getElementById("playerModal") || null;
this.modalVideo = document.getElementById("modalVideo") || null;
this.modalStatus = document.getElementById("modalStatus") || null;
this.modalProgress = document.getElementById("modalProgress") || null;
this.modalPeers = document.getElementById("modalPeers") || null;
this.modalSpeed = document.getElementById("modalSpeed") || null;
this.modalDownloaded = document.getElementById("modalDownloaded") || null;
this.closePlayerBtn = document.getElementById("closeModal") || null;
this.videoTitle = document.getElementById("videoTitle") || null;
this.videoDescription = document.getElementById("videoDescription") || null;
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
this.creatorName = document.getElementById("creatorName") || null;
this.creatorNpub = document.getElementById("creatorNpub") || null;
this.copyMagnetBtn = document.getElementById("copyMagnetBtn") || null;
this.shareBtn = document.getElementById("shareBtn") || null;
// Attach the event listeners for the copy/share buttons
if (this.copyMagnetBtn) {
this.copyMagnetBtn.addEventListener("click", () => {
this.handleCopyMagnet();
});
}
// UPDATED: This share button just copies the ?v= URL to the clipboard:
if (this.shareBtn) {
this.shareBtn.addEventListener("click", () => {
if (!this.currentVideo) {
this.showError("No video is loaded to share.");
return;
}
try {
const nevent = window.NostrTools.nip19.neventEncode({
id: this.currentVideo.id,
});
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.");
}
});
}
}
/**
* Show the modal and set the "Please stand by" poster on the video.
*/
showModalWithPoster() {
if (this.playerModal) {
this.playerModal.style.display = "flex";
this.playerModal.classList.remove("hidden");
}
if (this.modalVideo) {
this.modalVideo.poster = "assets/gif/please-stand-by.gif";
}
}
/**
* Initialize the upload modal (upload-modal.html).
*/
async initUploadModal() {
try {
const resp = await fetch("components/upload-modal.html");
if (!resp.ok) {
throw new Error(`HTTP error! status: ${resp.status}`);
}
const html = await resp.text();
const modalContainer = document.getElementById("modalContainer");
if (!modalContainer) {
throw new Error("Modal container element not found!");
}
// Append the upload modal markup
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
modalContainer.appendChild(wrapper);
// Grab references
this.uploadModal = document.getElementById("uploadModal") || null;
this.closeUploadModalBtn =
document.getElementById("closeUploadModal") || null;
this.uploadForm = document.getElementById("uploadForm") || null;
// Optional: if close button found, wire up
if (this.closeUploadModalBtn) {
this.closeUploadModalBtn.addEventListener("click", () => {
if (this.uploadModal) {
this.uploadModal.classList.add("hidden");
}
});
}
// If the form is found, wire up
if (this.uploadForm) {
this.uploadForm.addEventListener("submit", (e) => {
e.preventDefault();
this.handleUploadSubmit();
});
}
console.log("Upload modal initialization successful");
return true;
} catch (error) {
console.error("initUploadModal failed:", error);
this.showError(`Failed to initialize upload modal: ${error.message}`);
return false;
}
}
/**
* (Optional) Initialize a separate profile modal (profile-modal.html).
*/
async initProfileModal() {
try {
console.log("Starting profile modal initialization...");
const resp = await fetch("components/profile-modal.html");
if (!resp.ok) {
// If you don't have a profile modal, comment this entire method out.
throw new Error(`HTTP error! status: ${resp.status}`);
}
const html = await resp.text();
const modalContainer = document.getElementById("modalContainer");
if (!modalContainer) {
throw new Error("Modal container element not found!");
}
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
modalContainer.appendChild(wrapper);
// Now references
this.profileModal = document.getElementById("profileModal") || null;
this.closeProfileModal =
document.getElementById("closeProfileModal") || null;
this.profileLogoutBtn =
document.getElementById("profileLogoutBtn") || null;
this.profileModalAvatar =
document.getElementById("profileModalAvatar") || null;
this.profileModalName =
document.getElementById("profileModalName") || null;
// Wire up
if (this.closeProfileModal) {
this.closeProfileModal.addEventListener("click", () => {
this.profileModal.classList.add("hidden");
});
}
if (this.profileLogoutBtn) {
this.profileLogoutBtn.addEventListener("click", () => {
// On "Logout" inside the profile modal
this.logout();
this.profileModal.classList.add("hidden");
});
}
console.log("Profile modal initialization successful");
return true;
} catch (error) {
console.error("initProfileModal failed:", error);
// Not critical if missing
return false;
}
}
/**
* Setup general event listeners for logout, modals, etc.
*/
setupEventListeners() {
// 1) Logout button
if (this.logoutButton) {
this.logoutButton.addEventListener("click", () => {
this.logout();
});
}
// 2) Profile button
if (this.profileButton) {
this.profileButton.addEventListener("click", () => {
if (this.profileModal) {
this.profileModal.classList.remove("hidden");
}
});
}
// 3) Upload button => show upload modal
if (this.uploadButton) {
this.uploadButton.addEventListener("click", () => {
if (this.uploadModal) {
this.uploadModal.classList.remove("hidden");
}
});
}
// 4) Login button => show the login modal
if (this.loginButton) {
this.loginButton.addEventListener("click", () => {
console.log("Login button clicked!");
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.remove("hidden");
}
});
}
// 5) Close login modal button => hide modal
if (this.closeLoginModalBtn) {
this.closeLoginModalBtn.addEventListener("click", () => {
console.log("[app.js] closeLoginModal button clicked!");
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
});
}
// 6) NIP-07 button inside the login modal => call the extension & login
const nip07Button = document.getElementById("loginNIP07");
if (nip07Button) {
nip07Button.addEventListener("click", async () => {
console.log(
"[app.js] loginNIP07 clicked! Attempting extension login..."
);
try {
const pubkey = await nostrClient.login(); // call the extension
console.log("[NIP-07] login returned pubkey:", pubkey);
this.login(pubkey, true);
// Hide the login modal
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
} catch (err) {
console.error("[NIP-07 login error]", err);
this.showError("Failed to login with NIP-07. Please try again.");
}
});
}
// 7) Cleanup on page unload
window.addEventListener("beforeunload", async () => {
await this.cleanup();
});
// 8) Handle back/forward navigation => hide video modal
window.addEventListener("popstate", async () => {
console.log("[popstate] user navigated back/forward; cleaning modal...");
await this.hideModal();
});
// 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") {
// Hide the login modal
const loginModal = document.getElementById("loginModal");
if (loginModal) {
loginModal.classList.add("hidden");
}
// Show the application modal
const appModal = document.getElementById("nostrFormModal");
if (appModal) {
appModal.classList.remove("hidden");
}
}
});
}
/**
* Attempt to load the user's own profile from Nostr (kind:0).
*/
async loadOwnProfile(pubkey) {
try {
const events = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [pubkey], limit: 1 },
]);
let displayName = "User";
let picture = "assets/svg/default-profile.svg";
if (events.length && events[0].content) {
const data = JSON.parse(events[0].content);
displayName = data.name || data.display_name || "User";
picture = data.picture || "assets/svg/default-profile.svg";
}
// If you have a top-bar avatar (profileAvatar)
if (this.profileAvatar) {
this.profileAvatar.src = picture;
}
// If you want to show name somewhere
if (this.profileModalName) {
this.profileModalName.textContent = displayName;
}
if (this.profileModalAvatar) {
this.profileModalAvatar.src = picture;
}
} catch (error) {
console.error("loadOwnProfile error:", error);
}
}
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.
*/
async handleUploadSubmit() {
if (!this.pubkey) {
this.showError("Please login to post a video.");
return;
}
const titleEl = document.getElementById("uploadTitle");
const magnetEl = document.getElementById("uploadMagnet");
const thumbEl = document.getElementById("uploadThumbnail");
const descEl = document.getElementById("uploadDescription");
const privEl = document.getElementById("uploadIsPrivate");
const formData = {
version: 2,
title: titleEl?.value.trim() || "",
magnet: magnetEl?.value.trim() || "",
thumbnail: thumbEl?.value.trim() || "",
description: descEl?.value.trim() || "",
mode: isDevMode ? "dev" : "live",
isPrivate: privEl?.checked || false,
};
if (!formData.title || !formData.magnet) {
this.showError("Title and Magnet are required.");
return;
}
try {
await nostrClient.publishVideo(formData, this.pubkey);
// Clear fields
if (titleEl) titleEl.value = "";
if (magnetEl) magnetEl.value = "";
if (thumbEl) thumbEl.value = "";
if (descEl) descEl.value = "";
if (privEl) privEl.checked = false;
// Hide the modal
if (this.uploadModal) {
this.uploadModal.classList.add("hidden");
}
// *** Refresh to show the newly uploaded video in the grid ***
await this.loadVideos();
this.showSuccess("Video shared successfully!");
} catch (err) {
console.error("Failed to publish video:", err);
this.showError("Failed to share video. Please try again later.");
}
}
/**
* Called upon successful login.
*/
async login(pubkey, saveToStorage = true) {
console.log("[app.js] login() called with pubkey =", pubkey);
this.pubkey = pubkey;
// Hide login button if present
if (this.loginButton) {
this.loginButton.classList.add("hidden");
}
// Optionally hide logout or userStatus
if (this.logoutButton) {
this.logoutButton.classList.add("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
// Show the upload button, profile button, etc.
if (this.uploadButton) {
this.uploadButton.classList.remove("hidden");
}
if (this.profileButton) {
this.profileButton.classList.remove("hidden");
}
// Show the "Subscriptions" link if it exists
if (this.subscriptionsLink) {
this.subscriptionsLink.classList.remove("hidden");
}
// (Optional) load the user's own Nostr profile
this.loadOwnProfile(pubkey);
// Save pubkey locally if requested
if (saveToStorage) {
localStorage.setItem("userPubKey", pubkey);
}
// Refresh the video list so the user sees any private videos, etc.
await this.loadVideos();
// Force a fresh fetch of all profile pictures/names
this.forceRefreshAllProfiles();
}
/**
* Logout logic
*/
async logout() {
nostrClient.logout();
this.pubkey = null;
// Show the login button again
if (this.loginButton) {
this.loginButton.classList.remove("hidden");
}
// Hide logout or userStatus
if (this.logoutButton) {
this.logoutButton.classList.add("hidden");
}
if (this.userStatus) {
this.userStatus.classList.add("hidden");
}
if (this.userPubKey) {
this.userPubKey.textContent = "";
}
// Hide upload & profile
if (this.uploadButton) {
this.uploadButton.classList.add("hidden");
}
if (this.profileButton) {
this.profileButton.classList.add("hidden");
}
// Hide the Subscriptions link
if (this.subscriptionsLink) {
this.subscriptionsLink.classList.add("hidden");
}
// Clear localStorage
localStorage.removeItem("userPubKey");
// Refresh the video list so user sees only public videos again
await this.loadVideos();
// Force a fresh fetch of all profile pictures/names (public ones in this case)
this.forceRefreshAllProfiles();
}
/**
* Cleanup resources on unload or modal close.
*/
async cleanup() {
try {
// If there's a small inline player
if (this.videoElement) {
this.videoElement.pause();
this.videoElement.src = "";
this.videoElement.load();
}
// If there's a modal video
if (this.modalVideo) {
this.modalVideo.pause();
this.modalVideo.src = "";
this.modalVideo.load();
}
// Tell webtorrent to cleanup
await torrentClient.cleanup();
} catch (err) {
console.error("Cleanup error:", err);
}
}
/**
* Hide the video modal.
*/
async hideModal() {
// 1) Clear intervals, cleanup, etc. (unchanged)
if (this.activeIntervals && this.activeIntervals.length) {
this.activeIntervals.forEach((id) => clearInterval(id));
this.activeIntervals = [];
}
try {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
} catch (err) {
// ignore
}
await this.cleanup();
// 2) Hide the modal
if (this.playerModal) {
this.playerModal.style.display = "none";
this.playerModal.classList.add("hidden");
}
this.currentMagnetUri = null;
// 3) Remove only `?v=` but **keep** the hash
const url = new URL(window.location.href);
url.searchParams.delete("v"); // remove ?v= param
const newUrl = url.pathname + url.search + url.hash;
window.history.replaceState({}, "", newUrl);
}
/**
* Subscribe to videos (older + new) and render them as they come in.
*/
async loadVideos(forceFetch = false) {
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
// If forceFetch is true, unsubscribe from the old subscription to start fresh
if (forceFetch && this.videoSubscription) {
// Call unsubscribe on the subscription object directly.
this.videoSubscription.unsub();
this.videoSubscription = null;
}
// The rest of your existing logic:
if (!this.videoSubscription) {
if (this.videoList) {
this.videoList.innerHTML = `
<p class="text-center text-gray-500">
Loading videos as they arrive...
</p>`;
}
// Create a new subscription
this.videoSubscription = nostrClient.subscribeVideos(() => {
const updatedAll = nostrClient.getActiveVideos();
// Filter out blacklisted authors & blacklisted event IDs
const filteredVideos = updatedAll.filter((video) => {
// 1) If the event ID is in our blacklistedEventIds set, skip
if (this.blacklistedEventIds.has(video.id)) {
return false;
}
// 2) Check if the author's npub is in initialBlacklist
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
return false;
}
// 3) If whitelist mode is enabled, only keep authors in initialWhitelist
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
return false;
}
return true;
});
this.renderVideoList(filteredVideos);
});
// *** IMPORTANT ***: Unsubscribe once we get the historical EOSE
// so that we do not hold an open subscription forever:
if (this.videoSubscription) {
this.videoSubscription.on("eose", () => {
this.videoSubscription.unsub();
console.log("[loadVideos] unsubscribed after EOSE");
});
}
} else {
// Already subscribed: just show what's cached
const allCached = nostrClient.getActiveVideos();
const filteredCached = allCached.filter((video) => {
if (this.blacklistedEventIds.has(video.id)) {
return false;
}
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
return false;
}
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
return false;
}
return true;
});
this.renderVideoList(filteredCached);
}
}
/**
* Returns true if there's at least one strictly older version
* (same videoRootId, created_at < current) which is NOT deleted.
*/
hasOlderVersion(video, allEvents) {
if (!video || !video.videoRootId) return false;
const rootId = video.videoRootId;
const currentTs = video.created_at;
// among ALL known events (including overshadowed), find older, not deleted
const olderMatches = allEvents.filter(
(v) => v.videoRootId === rootId && v.created_at < currentTs && !v.deleted
);
return olderMatches.length > 0;
}
async renderVideoList(videos) {
if (!this.videoList) return;
// Check if there's anything to show
if (!videos || videos.length === 0) {
this.videoList.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No public videos available yet. Be the first to upload one!
</p>`;
return;
}
// Sort newest first
videos.sort((a, b) => b.created_at - a.created_at);
// Convert allEvents to an array for checking older overshadowed events
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment();
videos.forEach((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);
// Check if there's an older version (for revert button)
let hasOlder = false;
if (canEdit && video.videoRootId) {
hasOlder = this.hasOlderVersion(video, fullAllEventsArray);
}
const revertButton = hasOlder
? `
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-revert-index="${index}"
>
Revert
</button>
`
: "";
// Gear menu (only shown if canEdit)
const gearMenu = canEdit
? `
<div class="relative inline-block ml-3 overflow-visible">
<button
type="button"
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-settings-dropdown="${index}"
>
<img
src="assets/svg/video-settings-gear.svg"
alt="Settings"
class="w-5 h-5"
/>
</button>
<div
id="settingsDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-edit-index="${index}"
>
Edit
</button>
${revertButton}
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-delete-all-index="${index}"
>
Delete All
</button>
</div>
</div>
</div>
`
: "";
// Card markup
const cardHtml = `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<a
href="${shareUrl}"
data-play-magnet="${encodeURIComponent(video.magnet)}"
class="block cursor-pointer relative group"
>
<div class="ratio-16-9">
<img
src="assets/jpg/video-thumbnail-fallback.jpg"
data-lazy="${this.escapeHTML(video.thumbnail)}"
alt="${this.escapeHTML(video.title)}"
/>
</div>
</a>
<div class="p-4">
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
data-play-magnet="${encodeURIComponent(video.magnet)}"
>
${this.escapeHTML(video.title)}
</h3>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden flex items-center justify-center">
<img
class="author-pic"
data-pubkey="${video.pubkey}"
src="assets/svg/default-profile.svg"
alt="Placeholder"
/>
</div>
<div class="min-w-0">
<p
class="text-sm text-gray-400 author-name"
data-pubkey="${video.pubkey}"
>
Loading name...
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div>
</div>
${gearMenu}
</div>
</div>
</div>
`;
// 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);
// Add the finished card to our fragment
fragment.appendChild(cardEl);
});
// Clear the list and add our fragment
this.videoList.innerHTML = "";
this.videoList.appendChild(fragment);
// Lazy-load images
const lazyEls = this.videoList.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => this.mediaLoader.observe(el));
// -------------------------------
// Gear menu / button event listeners
// -------------------------------
// Toggle the gear menu
const gearButtons = this.videoList.querySelectorAll(
"[data-settings-dropdown]"
);
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");
}
});
});
// 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]"
);
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);
});
});
// 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);
});
});
}
/**
* Updates the modal to reflect current torrent stats.
* We remove the unused torrent.status references,
* and do not re-trigger recursion here (no setTimeout).
*/
updateTorrentStatus(torrent) {
console.log("[DEBUG] updateTorrentStatus called with torrent:", torrent);
if (!torrent) {
console.log("[DEBUG] torrent is null/undefined!");
return;
}
// Log only fields that actually exist on the torrent:
console.log("[DEBUG] torrent.progress =", torrent.progress);
console.log("[DEBUG] torrent.numPeers =", torrent.numPeers);
console.log("[DEBUG] torrent.downloadSpeed =", torrent.downloadSpeed);
console.log("[DEBUG] torrent.downloaded =", torrent.downloaded);
console.log("[DEBUG] torrent.length =", torrent.length);
console.log("[DEBUG] torrent.ready =", torrent.ready);
// Use "Complete" vs. "Downloading" as the textual status.
if (this.modalStatus) {
const fullyDownloaded = torrent.progress >= 1;
this.modalStatus.textContent = fullyDownloaded
? "Complete"
: "Downloading";
}
// Update the progress bar
if (this.modalProgress) {
const percent = (torrent.progress * 100).toFixed(2);
this.modalProgress.style.width = `${percent}%`;
}
// Update peers count
if (this.modalPeers) {
this.modalPeers.textContent = `Peers: ${torrent.numPeers}`;
}
// Update speed in KB/s
if (this.modalSpeed) {
const kb = (torrent.downloadSpeed / 1024).toFixed(2);
this.modalSpeed.textContent = `${kb} KB/s`;
}
// Update downloaded / total
if (this.modalDownloaded) {
const downloadedMb = (torrent.downloaded / (1024 * 1024)).toFixed(2);
const lengthMb = (torrent.length / (1024 * 1024)).toFixed(2);
this.modalDownloaded.textContent = `${downloadedMb} MB / ${lengthMb} MB`;
}
// If you want to show a different text at 100% or if "ready"
// you can do it here:
if (torrent.ready && this.modalStatus) {
this.modalStatus.textContent = "Ready to play";
}
}
/**
* Handle "Edit Video" from gear menu.
*/
async handleEditVideo(index) {
try {
// 1) Fetch the current list of videos (the newest versions)
const all = await nostrClient.fetchVideos();
const video = all[index];
// 2) Basic ownership checks
if (!this.pubkey) {
this.showError("Please login to edit videos.");
return;
}
if (!video || video.pubkey !== this.pubkey) {
this.showError("You do not own this video.");
return;
}
// 3) Prompt the user for updated fields
const newTitle = prompt("New Title? (blank=keep existing)", video.title);
const newMagnet = prompt(
"New Magnet? (blank=keep existing)",
video.magnet
);
const newThumb = prompt(
"New Thumbnail? (blank=keep existing)",
video.thumbnail
);
const newDesc = prompt(
"New Description? (blank=keep existing)",
video.description
);
const wantPrivate = confirm("Make this video private? OK=Yes, Cancel=No");
// 4) Build final updated fields (or fallback to existing)
const title =
!newTitle || !newTitle.trim() ? video.title : newTitle.trim();
const magnet =
!newMagnet || !newMagnet.trim() ? video.magnet : newMagnet.trim();
const thumbnail =
!newThumb || !newThumb.trim() ? video.thumbnail : newThumb.trim();
const description =
!newDesc || !newDesc.trim() ? video.description : newDesc.trim();
// 5) Create an object with the new data
const updatedData = {
version: video.version || 2,
isPrivate: wantPrivate,
title,
magnet,
thumbnail,
description,
mode: isDevMode ? "dev" : "live",
};
// 6) Build the originalEvent stub, now including videoRootId to avoid extra fetch
const originalEvent = {
id: video.id,
pubkey: video.pubkey,
videoRootId: video.videoRootId, // <-- pass this if it exists
};
// 7) Call the editVideo method
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
// 8) Refresh local UI
await this.loadVideos();
// 8.1) Purge the outdated cache
this.videosMap.clear();
this.showSuccess("Video updated successfully!");
// 9) Also refresh all profile caches so any new name/pic changes are reflected
this.forceRefreshAllProfiles();
} catch (err) {
console.error("Failed to edit video:", err);
this.showError("Failed to edit video. Please try again.");
}
}
async handleRevertVideo(index) {
try {
// 1) Still use fetchVideos to get the video in question
const activeVideos = await nostrClient.fetchVideos();
const video = activeVideos[index];
if (!this.pubkey) {
this.showError("Please login to revert.");
return;
}
if (!video || video.pubkey !== this.pubkey) {
this.showError("You do not own this video.");
return;
}
// 2) Grab all known events so older overshadowed ones are included
const allEvents = Array.from(nostrClient.allEvents.values());
// 3) Check for older versions among *all* events, not just the active ones
if (!this.hasOlderVersion(video, allEvents)) {
this.showError("No older version exists to revert to.");
return;
}
if (!confirm(`Revert current version of "${video.title}"?`)) {
return;
}
const originalEvent = {
id: video.id,
pubkey: video.pubkey,
tags: video.tags,
};
await nostrClient.revertVideo(originalEvent, this.pubkey);
await this.loadVideos();
this.showSuccess("Current version reverted successfully!");
this.forceRefreshAllProfiles();
} catch (err) {
console.error("Failed to revert video:", err);
this.showError("Failed to revert video. Please try again.");
}
}
/**
* Handle "Delete Video" from gear menu.
*/
async handleFullDeleteVideo(index) {
try {
const all = await nostrClient.fetchVideos();
const video = all[index];
if (!this.pubkey) {
this.showError("Please login to delete videos.");
return;
}
if (!video || video.pubkey !== this.pubkey) {
this.showError("You do not own this video.");
return;
}
// Make sure the user is absolutely sure:
if (
!confirm(
`Delete ALL versions of "${video.title}"? This action is permanent.`
)
) {
return;
}
// We assume video.videoRootId is not empty, or fallback to video.id if needed
const rootId = video.videoRootId || video.id;
await nostrClient.deleteAllVersions(rootId, this.pubkey);
// Reload
await this.loadVideos();
this.showSuccess("All versions deleted successfully!");
this.forceRefreshAllProfiles();
} catch (err) {
console.error("Failed to delete all versions:", err);
this.showError("Failed to delete all versions. Please try again.");
}
}
/**
* If there's a ?v= param in the URL, auto-open that video.
*/
checkUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const maybeNevent = urlParams.get("v");
if (!maybeNevent) return; // no link param
try {
const decoded = window.NostrTools.nip19.decode(maybeNevent);
if (decoded.type === "nevent" && decoded.data.id) {
const eventId = decoded.data.id;
// 1) check local map
let localMatch = this.videosMap.get(eventId);
if (localMatch) {
this.playVideoByEventId(eventId);
} else {
// 2) fallback => getOldEventById
this.getOldEventById(eventId)
.then((video) => {
if (video) {
this.playVideoByEventId(eventId);
} else {
this.showError("No matching video found for that link.");
}
})
.catch((err) => {
console.error("Error fetching older event by ID:", err);
this.showError("Could not load videos for the share link.");
});
}
}
} catch (err) {
console.error("Error decoding nevent:", err);
this.showError("Invalid share link.");
}
}
/**
* Helper to open a video by event ID (like ?v=...).
*/
async playVideoByEventId(eventId) {
// 1) Event-level blacklist check
if (this.blacklistedEventIds.has(eventId)) {
this.showError("This content has been removed or is not allowed.");
return;
}
try {
// Attempt to get the video from local cache or fetch
let video = this.videosMap.get(eventId);
if (!video) {
video = await this.getOldEventById(eventId);
}
if (!video) {
this.showError("Video not found.");
return;
}
// 2) Author-level blacklist check
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
return;
}
// 3) Whitelist check if enabled
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
this.showError("This content is not from a whitelisted author.");
return;
}
// Handle private videos (decrypt if owner is the current user)
if (
video.isPrivate &&
video.pubkey === this.pubkey &&
!video.alreadyDecrypted
) {
video.magnet = fakeDecrypt(video.magnet);
video.alreadyDecrypted = true;
}
this.currentVideo = video;
this.currentMagnetUri = video.magnet;
this.showModalWithPoster();
// Update ?v= param in the URL
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
const newUrl = `${window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
window.history.pushState({}, "", newUrl);
// Fetch 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 },
]);
if (userEvents.length > 0 && userEvents[0]?.content) {
const data = JSON.parse(userEvents[0].content);
creatorProfile = {
name: data.name || data.display_name || "Unknown",
picture: data.picture || `https://robohash.org/${video.pubkey}`,
};
}
} catch (error) {
this.log("Error fetching creator profile:", error);
}
// Update UI fields
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoTitle) {
this.videoTitle.textContent = video.title || "Untitled";
}
if (this.videoDescription) {
this.videoDescription.textContent =
video.description || "No description available.";
}
if (this.videoTimestamp) {
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
}
if (this.creatorName) {
this.creatorName.textContent = creatorProfile.name;
}
if (this.creatorNpub) {
this.creatorNpub.textContent = `${creatorNpub.slice(
0,
8
)}...${creatorNpub.slice(-4)}`;
}
if (this.creatorAvatar) {
this.creatorAvatar.src = creatorProfile.picture;
this.creatorAvatar.alt = creatorProfile.name;
}
// Cleanup any previous WebTorrent streams, then start a fresh one
await torrentClient.cleanup();
const cacheBustedMagnet = video.magnet + "&ts=" + Date.now();
this.log("Starting video stream with:", cacheBustedMagnet);
// Autoplay preferences
const storedUnmuted = localStorage.getItem("unmutedAutoplay");
const userWantsUnmuted = storedUnmuted === "true";
this.modalVideo.muted = !userWantsUnmuted;
this.modalVideo.addEventListener("volumechange", () => {
localStorage.setItem(
"unmutedAutoplay",
(!this.modalVideo.muted).toString()
);
});
const realTorrent = await torrentClient.streamVideo(
cacheBustedMagnet,
this.modalVideo
);
// Try playing; if autoplay fails, fallback to muted
this.modalVideo.play().catch((err) => {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
this.log("Falling back to muted autoplay.");
this.modalVideo.muted = true;
this.modalVideo.play().catch((err2) => {
this.log("Muted autoplay also failed:", err2);
});
}
});
// Update torrent stats every 3s
const updateInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(updateInterval);
return;
}
this.updateTorrentStatus(realTorrent);
}, 3000);
this.activeIntervals.push(updateInterval);
// Mirror stats into the modal if needed
const mirrorInterval = setInterval(() => {
if (!document.body.contains(this.modalVideo)) {
clearInterval(mirrorInterval);
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) {
this.modalStatus.textContent = status.textContent;
}
if (progress && this.modalProgress) {
this.modalProgress.style.width = progress.style.width;
}
if (peers && this.modalPeers) {
this.modalPeers.textContent = peers.textContent;
}
if (speed && this.modalSpeed) {
this.modalSpeed.textContent = speed.textContent;
}
if (downloaded && this.modalDownloaded) {
this.modalDownloaded.textContent = downloaded.textContent;
}
}, 3000);
this.activeIntervals.push(mirrorInterval);
} catch (error) {
this.log("Error in playVideoByEventId:", error);
this.showError(`Playback error: ${error.message}`);
}
}
/**
* Simple helper to safely encode an npub.
*/
safeEncodeNpub(pubkey) {
try {
return window.NostrTools.nip19.npubEncode(pubkey);
} catch (err) {
return null;
}
}
/**
* Attempts to fetch an older event by its ID if we can't find it in
* this.videosMap or from a bulk fetch. Uses nostrClient.getEventById.
*/
async getOldEventById(eventId) {
// 1) Already in our local videosMap?
let video = this.videosMap.get(eventId);
if (video) {
return video;
}
// 2) Already in nostrClient.allEvents?
// (assuming nostrClient.allEvents is a Map of id => video)
const fromAll = nostrClient.allEvents.get(eventId);
if (fromAll && !fromAll.deleted) {
this.videosMap.set(eventId, fromAll);
return fromAll;
}
// 3) Direct single-event fetch (fewer resources than full fetchVideos)
const single = await nostrClient.getEventById(eventId);
if (single && !single.deleted) {
this.videosMap.set(single.id, single);
return single;
}
// 4) If you wanted a final fallback, you could do it here:
// But it's typically better to avoid repeated full fetches
// console.log("Falling back to full fetchVideos...");
// const allFetched = await nostrClient.fetchVideos();
// video = allFetched.find(v => v.id === eventId && !v.deleted);
// if (video) {
// this.videosMap.set(video.id, video);
// return video;
// }
// Not found or was deleted
return null;
}
/**
* Format "time ago" for a given timestamp (in seconds).
*/
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
showError(msg) {
if (!msg) {
// Remove any content, then hide
this.errorContainer.textContent = "";
this.errorContainer.classList.add("hidden");
return;
}
// If there's a message, show it
this.errorContainer.textContent = msg;
this.errorContainer.classList.remove("hidden");
// Optional auto-hide after 5 seconds
setTimeout(() => {
this.errorContainer.textContent = "";
this.errorContainer.classList.add("hidden");
}, 5000);
}
showSuccess(msg) {
if (!msg) {
this.successContainer.textContent = "";
this.successContainer.classList.add("hidden");
return;
}
this.successContainer.textContent = msg;
this.successContainer.classList.remove("hidden");
setTimeout(() => {
this.successContainer.textContent = "";
this.successContainer.classList.add("hidden");
}, 5000);
}
log(msg) {
console.log(msg);
}
/**
* Copies the current video's magnet link to the clipboard.
*/
handleCopyMagnet() {
if (!this.currentVideo || !this.currentVideo.magnet) {
this.showError("No magnet link to copy.");
return;
}
try {
navigator.clipboard.writeText(this.currentVideo.magnet);
this.showSuccess("Magnet link copied to clipboard!");
} catch (err) {
console.error("Failed to copy magnet link:", err);
this.showError("Could not copy magnet link. Please copy it manually.");
}
}
}
/**
* Given an array of video objects,
* return only the newest (by created_at) for each videoRootId.
* If no videoRootId is present, treat the videos own ID as its root.
*/
function dedupeToNewestByRoot(videos) {
const map = new Map(); // key = rootId, value = newest video for that root
for (const vid of videos) {
// If there's no videoRootId, fall back to vid.id (treat it as its own "root")
const rootId = vid.videoRootId || vid.id;
const existing = map.get(rootId);
if (!existing || vid.created_at > existing.created_at) {
map.set(rootId, vid);
}
}
// Return just the newest from each group
return Array.from(map.values());
}
export const app = new bitvidApp();
app.init();
window.app = app;