From 2e6e7012a6ceeec265fcef8c85bf1f055b08803a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:33:56 -0400 Subject: [PATCH 001/195] Refactor shared helpers into utility modules --- js/app.js | 147 +++++------------------------------------ js/channelProfile.js | 12 +--- js/utils/domUtils.js | 35 ++++++++++ js/utils/formatters.js | 70 ++++++++++++++++++++ js/utils/storage.js | 56 ++++++++++++++++ 5 files changed, 179 insertions(+), 141 deletions(-) create mode 100644 js/utils/domUtils.js create mode 100644 js/utils/formatters.js create mode 100644 js/utils/storage.js diff --git a/js/app.js b/js/app.js index f33c9653..13c9b564 100644 --- a/js/app.js +++ b/js/app.js @@ -52,22 +52,20 @@ import { formatViewCount, ingestLocalViewEvent, } from "./viewCounter.js"; - -function truncateMiddle(text, maxLength = 72) { - if (!text || typeof text !== "string") { - return ""; - } - - if (text.length <= maxLength) { - return text; - } - - const ellipsis = "…"; - const charsToShow = maxLength - ellipsis.length; - const front = Math.ceil(charsToShow / 2); - const back = Math.floor(charsToShow / 2); - return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; -} +import { + formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, + formatTimeAgo as formatTimeAgoUtil, + truncateMiddle, +} from "./utils/formatters.js"; +import { + escapeHTML as escapeHtml, + removeTrackingScripts, +} from "./utils/domUtils.js"; +import { + readUrlHealthFromStorage, + removeUrlHealthFromStorage, + writeUrlHealthToStorage, +} from "./utils/storage.js"; function pointerArrayToKey(pointer) { if (!Array.isArray(pointer) || pointer.length < 2) { @@ -109,7 +107,6 @@ const ADMIN_DM_IMAGE_URL = const BITVID_WEBSITE_URL = "https://bitvid.network/"; const MAX_DISCUSSION_COUNT_VIDEOS = 24; const VIDEO_EVENT_KIND = 30078; -const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; /** * Local storage keys for cached profile metadata and saved authentication @@ -134,76 +131,9 @@ const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds -const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:"; const urlHealthCache = new Map(); const urlHealthInFlight = new Map(); -function removeTrackingScripts(root) { - if (!root || typeof root.querySelectorAll !== "function") { - return; - } - - root.querySelectorAll("script[src]").forEach((script) => { - const src = script.getAttribute("src") || ""; - if (TRACKING_SCRIPT_PATTERN.test(src)) { - script.remove(); - } - }); -} - -function getUrlHealthStorageKey(eventId) { - return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`; -} - -function readUrlHealthFromStorage(eventId) { - if (!eventId || typeof localStorage === "undefined") { - return null; - } - - try { - const raw = localStorage.getItem(getUrlHealthStorageKey(eventId)); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") { - return parsed; - } - } catch (err) { - console.warn(`Failed to parse stored URL health for ${eventId}:`, err); - } - - removeUrlHealthFromStorage(eventId); - return null; -} - -function writeUrlHealthToStorage(eventId, entry) { - if (!eventId || typeof localStorage === "undefined") { - return; - } - - try { - localStorage.setItem( - getUrlHealthStorageKey(eventId), - JSON.stringify(entry) - ); - } catch (err) { - console.warn(`Failed to persist URL health for ${eventId}:`, err); - } -} - -function removeUrlHealthFromStorage(eventId) { - if (!eventId || typeof localStorage === "undefined") { - return; - } - - try { - localStorage.removeItem(getUrlHealthStorageKey(eventId)); - } catch (err) { - console.warn(`Failed to remove URL health for ${eventId}:`, err); - } -} - function isUrlHealthEntryFresh(entry, url) { if (!entry || typeof entry !== "object") { return false; @@ -11528,58 +11458,15 @@ class bitvidApp { * Format "time ago" for a given timestamp (in seconds). */ formatAbsoluteTimestamp(timestamp) { - if (!Number.isFinite(timestamp)) { - return "Unknown date"; - } - - const date = new Date(timestamp * 1000); - if (Number.isNaN(date.getTime())) { - return "Unknown date"; - } - - try { - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - } catch (err) { - return date.toISOString(); - } + return formatAbsoluteTimestampUtil(timestamp); } 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"; + return formatTimeAgoUtil(timestamp); } escapeHTML(unsafe) { - if (unsafe === null || typeof unsafe === "undefined") { - return ""; - } - - return String(unsafe) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return escapeHtml(unsafe); } showError(msg) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 8e0b6dc1..d0111f0e 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -9,6 +9,7 @@ import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; +import { escapeHTML } from "./utils/domUtils.js"; let currentChannelHex = null; let currentChannelNpub = null; @@ -912,14 +913,3 @@ function dedupeToNewestByRoot(videos) { return Array.from(map.values()); } -/** - * Basic escaping to avoid XSS. - */ -function escapeHTML(unsafe = "") { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} diff --git a/js/utils/domUtils.js b/js/utils/domUtils.js new file mode 100644 index 00000000..c41d0099 --- /dev/null +++ b/js/utils/domUtils.js @@ -0,0 +1,35 @@ +// js/utils/domUtils.js + +const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; + +/** + * Removes script tags that match known tracking script patterns. + */ +export function removeTrackingScripts(root) { + if (!root || typeof root.querySelectorAll !== "function") { + return; + } + + root.querySelectorAll("script[src]").forEach((script) => { + const src = script.getAttribute("src") || ""; + if (TRACKING_SCRIPT_PATTERN.test(src)) { + script.remove(); + } + }); +} + +/** + * Escapes HTML entities in a string to prevent XSS when injecting content. + */ +export function escapeHTML(unsafe) { + if (unsafe === null || typeof unsafe === "undefined") { + return ""; + } + + return String(unsafe) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/js/utils/formatters.js b/js/utils/formatters.js new file mode 100644 index 00000000..020e0150 --- /dev/null +++ b/js/utils/formatters.js @@ -0,0 +1,70 @@ +// js/utils/formatters.js + +/** + * Shortens a string by replacing the middle with an ellipsis when it exceeds + * the desired length. Ensures the returned string never exceeds `maxLength`. + */ +export function truncateMiddle(text, maxLength = 72) { + if (!text || typeof text !== "string") { + return ""; + } + + if (text.length <= maxLength) { + return text; + } + + const ellipsis = "…"; + const charsToShow = maxLength - ellipsis.length; + const front = Math.ceil(charsToShow / 2); + const back = Math.floor(charsToShow / 2); + return `${text.slice(0, front)}${ellipsis}${text.slice(text.length - back)}`; +} + +/** + * Formats a timestamp (in seconds) to a localized string. Falls back to + * ISO-8601 formatting if locale formatting fails. + */ +export function formatAbsoluteTimestamp(timestamp) { + if (!Number.isFinite(timestamp)) { + return "Unknown date"; + } + + const date = new Date(timestamp * 1000); + if (Number.isNaN(date.getTime())) { + return "Unknown date"; + } + + try { + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch (err) { + return date.toISOString(); + } +} + +/** + * Returns a human readable "time ago" string for a timestamp (in seconds). + */ +export function 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"; +} diff --git a/js/utils/storage.js b/js/utils/storage.js new file mode 100644 index 00000000..7bd5766f --- /dev/null +++ b/js/utils/storage.js @@ -0,0 +1,56 @@ +// js/utils/storage.js + +const URL_HEALTH_STORAGE_PREFIX = "bitvid:urlHealth:"; + +export function getUrlHealthStorageKey(eventId) { + return `${URL_HEALTH_STORAGE_PREFIX}${eventId}`; +} + +export function readUrlHealthFromStorage(eventId) { + if (!eventId || typeof localStorage === "undefined") { + return null; + } + + try { + const raw = localStorage.getItem(getUrlHealthStorageKey(eventId)); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch (err) { + console.warn(`Failed to parse stored URL health for ${eventId}:`, err); + } + + removeUrlHealthFromStorage(eventId); + return null; +} + +export function writeUrlHealthToStorage(eventId, entry) { + if (!eventId || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem( + getUrlHealthStorageKey(eventId), + JSON.stringify(entry) + ); + } catch (err) { + console.warn(`Failed to persist URL health for ${eventId}:`, err); + } +} + +export function removeUrlHealthFromStorage(eventId) { + if (!eventId || typeof localStorage === "undefined") { + return; + } + + try { + localStorage.removeItem(getUrlHealthStorageKey(eventId)); + } catch (err) { + console.warn(`Failed to remove URL health for ${eventId}:`, err); + } +} From 2e2624215362a5d8c0c86c50b34f1a0aac2788c4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:03:12 -0400 Subject: [PATCH 002/195] Fix duplicate url health reader in cache module --- js/app.js | 689 +++++++++---------------------------------- js/state/appState.js | 190 ++++++++++++ js/state/cache.js | 657 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 994 insertions(+), 542 deletions(-) create mode 100644 js/state/appState.js create mode 100644 js/state/cache.js diff --git a/js/app.js b/js/app.js index 13c9b564..b5c7dbca 100644 --- a/js/app.js +++ b/js/app.js @@ -62,10 +62,35 @@ import { removeTrackingScripts, } from "./utils/domUtils.js"; import { - readUrlHealthFromStorage, - removeUrlHealthFromStorage, - writeUrlHealthToStorage, -} from "./utils/storage.js"; + getPubkey as getStoredPubkey, + setPubkey as setStoredPubkey, + getCurrentUserNpub as getStoredCurrentUserNpub, + setCurrentUserNpub as setStoredCurrentUserNpub, + getCurrentVideo as getStoredCurrentVideo, + setCurrentVideo as setStoredCurrentVideo, + setModalState as setGlobalModalState, + subscribeToAppStateKey, +} from "./state/appState.js"; +import { + getSavedProfiles, + getActiveProfilePubkey, + setActiveProfilePubkey as setStoredActiveProfilePubkey, + setSavedProfiles, + persistSavedProfiles, + loadSavedProfilesFromStorage as hydrateSavedProfilesFromStorage, + syncSavedProfileFromCache as syncSavedProfileFromCacheState, + loadProfileCacheFromStorage as hydrateProfileCacheFromStorage, + persistProfileCacheToStorage as persistProfileCacheState, + getProfileCacheEntry as getCachedProfileEntry, + setProfileCacheEntry as setCachedProfileEntry, + getProfileCacheMap, + getCachedUrlHealth as readCachedUrlHealth, + storeUrlHealth as persistUrlHealth, + setInFlightUrlProbe, + getInFlightUrlProbe, + URL_PROBE_TIMEOUT_MS, + urlHealthConstants, +} from "./state/cache.js"; function pointerArrayToKey(pointer) { if (!Array.isArray(pointer) || pointer.length < 2) { @@ -108,147 +133,7 @@ const BITVID_WEBSITE_URL = "https://bitvid.network/"; const MAX_DISCUSSION_COUNT_VIDEOS = 24; const VIDEO_EVENT_KIND = 30078; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; -/** - * Local storage keys for cached profile metadata and saved authentication - * sessions. `SAVED_PROFILES_STORAGE_KEY` stores objects shaped like - * `{ version: 1, entries: [{ pubkey, npub, name, picture, authType }], - * activePubkey?: string | null }`. `authType` currently supports `"nip07"` - * and reserves `"nsec"` for future direct key flows. See docs/nostr-auth.md - * for extension guidance. - */ -const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1"; -const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1"; -const SAVED_PROFILES_STORAGE_VERSION = 1; const HEX64_REGEX = /^[0-9a-f]{64}$/i; -const PROFILE_CACHE_VERSION = 1; -const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -// We probe hosted URLs often enough that a naive implementation would spam -// remote CDNs. A medium-lived cache (roughly 45 minutes) keeps us from -// hammering dead hosts while still giving the UI timely updates when a CDN -// recovers. Results are stored both in-memory and in localStorage so other -// views can reuse them without re-probing. -const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes -const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes -const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds -const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds -const urlHealthCache = new Map(); -const urlHealthInFlight = new Map(); - -function isUrlHealthEntryFresh(entry, url) { - if (!entry || typeof entry !== "object") { - return false; - } - - const now = Date.now(); - if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) { - return false; - } - - if (url && entry.url && entry.url !== url) { - return false; - } - - return true; -} - -/** - * Returns the cached health result for a given event ID when the entry is - * still "fresh". Entries are evicted eagerly once their TTL expires or when - * the associated URL changes (e.g. a user edits the post to point elsewhere). - */ -function readUrlHealthFromCache(eventId, url) { - if (!eventId) { - return null; - } - - const entry = urlHealthCache.get(eventId); - if (isUrlHealthEntryFresh(entry, url)) { - return entry; - } - - if (entry) { - urlHealthCache.delete(eventId); - } - - const stored = readUrlHealthFromStorage(eventId); - if (!isUrlHealthEntryFresh(stored, url)) { - if (stored) { - removeUrlHealthFromStorage(eventId); - } - return null; - } - - urlHealthCache.set(eventId, stored); - return stored; -} - -/** - * Stores a health result and calculates the expiry moment. The TTL lasts for - * roughly three quarters of an hour so we recover quickly when a CDN returns - * while still avoiding redundant probes during render thrash. -*/ -function writeUrlHealthToCache(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) { - if (!eventId) { - return null; - } - - const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS; - const now = Date.now(); - const entry = { - status: result?.status || "checking", - message: result?.message || "Checking hosted URL…", - url: url || result?.url || "", - expiresAt: now + ttl, - lastCheckedAt: now, - }; - urlHealthCache.set(eventId, entry); - writeUrlHealthToStorage(eventId, entry); - return entry; -} - -/** - * Tracks in-flight probe promises so that multiple cards rendering the same - * event simultaneously do not all fire off duplicate requests. The stored - * metadata includes the URL so edits invalidate the old request immediately. - */ -function buildUrlProbeKey(url, options = {}) { - const trimmed = typeof url === "string" ? url : ""; - const mode = options?.confirmPlayable ? "playable" : "basic"; - return `${trimmed}::${mode}`; -} - -function setInFlightUrlProbe(eventId, url, promise, options = {}) { - if (!eventId || !promise) { - return; - } - - const key = buildUrlProbeKey(url, options); - urlHealthInFlight.set(eventId, { promise, key }); - promise.finally(() => { - const current = urlHealthInFlight.get(eventId); - if (current && current.promise === promise) { - urlHealthInFlight.delete(eventId); - } - }); -} - -function getInFlightUrlProbe(eventId, url, options = {}) { - if (!eventId) { - return null; - } - - const entry = urlHealthInFlight.get(eventId); - if (!entry) { - return null; - } - - const key = buildUrlProbeKey(url, options); - if (entry.key && entry.key !== key) { - return null; - } - - return entry.promise; -} // NOTE: The modal uses a "please stand by" animated poster while the torrent // or direct URL boots up. We've regressed multiple times by forgetting to clear // that poster once playback starts, which leaves the loading GIF covering the @@ -457,6 +342,30 @@ class MediaLoader { } class bitvidApp { + get pubkey() { + return getStoredPubkey(); + } + + set pubkey(value) { + setStoredPubkey(value ?? null); + } + + get currentUserNpub() { + return getStoredCurrentUserNpub(); + } + + set currentUserNpub(value) { + setStoredCurrentUserNpub(value ?? null); + } + + get currentVideo() { + return getStoredCurrentVideo(); + } + + set currentVideo(value) { + setStoredCurrentVideo(value ?? null); + } + constructor() { // Basic auth/display elements this.loginButton = document.getElementById("loginButton") || null; @@ -668,123 +577,39 @@ class bitvidApp { this.moreMenuGlobalHandlerBound = false; this.boundMoreMenuDocumentClick = null; this.boundMoreMenuDocumentKeydown = null; - // Simple cache for user profiles - this.profileCache = new Map(); - this.savedProfiles = []; - this.activeProfilePubkey = null; - this.readSavedProfilesPayloadFromStorage = () => { - if (typeof localStorage === "undefined") { - return null; - } + Object.defineProperty(this, "savedProfiles", { + configurable: false, + enumerable: false, + get() { + return getSavedProfiles(); + }, + set(next) { + setSavedProfiles(Array.isArray(next) ? next : [], { + persist: false, + persistActive: false, + }); + }, + }); - const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY); - if (!raw) { - return null; - } + Object.defineProperty(this, "activeProfilePubkey", { + configurable: false, + enumerable: false, + get() { + return getActiveProfilePubkey(); + }, + set(value) { + setStoredActiveProfilePubkey(value, { persist: false }); + }, + }); - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return null; - } - return parsed; - } catch (err) { - console.warn("[readSavedProfilesPayloadFromStorage] Failed to parse payload:", err); - return null; - } - }; - this.writeSavedProfilesPayloadToStorage = (payload) => { - if (typeof localStorage === "undefined") { - return; - } + Object.defineProperty(this, "profileCache", { + configurable: false, + enumerable: false, + get() { + return getProfileCacheMap(); + }, + }); - try { - localStorage.setItem( - SAVED_PROFILES_STORAGE_KEY, - JSON.stringify(payload) - ); - } catch (err) { - const isQuotaError = - err && - (err.name === "QuotaExceededError" || - err.name === "NS_ERROR_DOM_QUOTA_REACHED" || - err.code === 22 || - err.code === 1014); - if (isQuotaError) { - console.warn( - "[writeSavedProfilesPayloadToStorage] Storage quota exceeded while saving profiles; keeping in-memory copy only.", - err - ); - } else { - console.warn( - "[writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:", - err - ); - } - } - }; - this.persistSavedProfiles = ({ persistActive = true } = {}) => { - if (typeof localStorage === "undefined") { - return; - } - - if (!this.savedProfiles.length && !this.activeProfilePubkey) { - try { - localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY); - } catch (err) { - console.warn("[persistSavedProfiles] Failed to remove empty payload:", err); - } - return; - } - - let activePubkeyToPersist = this.activeProfilePubkey || null; - if (!persistActive) { - const storedPayload = this.readSavedProfilesPayloadFromStorage(); - if (storedPayload && typeof storedPayload === "object") { - const candidate = - typeof storedPayload.activePubkey === "string" - ? storedPayload.activePubkey - : typeof storedPayload.activePubKey === "string" - ? storedPayload.activePubKey - : null; - const normalizedStored = this.normalizeHexPubkey(candidate); - if (normalizedStored) { - activePubkeyToPersist = normalizedStored; - } else if (candidate === null) { - activePubkeyToPersist = null; - } - } - } - - const payload = { - version: SAVED_PROFILES_STORAGE_VERSION, - entries: this.savedProfiles.map((entry) => ({ - pubkey: entry.pubkey, - npub: - typeof entry.npub === "string" && entry.npub.trim() - ? entry.npub.trim() - : null, - name: typeof entry.name === "string" ? entry.name : "", - picture: - typeof entry.picture === "string" ? entry.picture : "", - authType: entry.authType === "nsec" ? "nsec" : "nip07", - })), - activePubkey: activePubkeyToPersist, - }; - - this.writeSavedProfilesPayloadToStorage(payload); - }; - this.persistActiveProfileSelection = ( - pubkey, - { persist = true } = {} - ) => { - const normalized = this.normalizeHexPubkey(pubkey); - this.activeProfilePubkey = normalized; - if (persist) { - this.persistSavedProfiles({ persistActive: true }); - } - this.renderSavedProfiles(); - }; this.lastRenderedVideoSignature = null; this._lastRenderedVideoListElement = null; this.renderedVideoIds = new Set(); @@ -813,303 +638,61 @@ class bitvidApp { ); } } + + this.unsubscribeFromPubkeyState = subscribeToAppStateKey( + "pubkey", + (next, previous) => { + if (next !== previous) { + this.renderSavedProfiles(); + } + } + ); + + this.unsubscribeFromCurrentUserState = subscribeToAppStateKey( + "currentUserNpub", + (next) => { + if (this.userPubKey) { + this.userPubKey.textContent = next || ""; + } + } + ); } loadSavedProfilesFromStorage() { - this.savedProfiles = []; - this.activeProfilePubkey = null; - - if (typeof localStorage === "undefined") { - return; + const { profiles } = hydrateSavedProfilesFromStorage(); + if (!getActiveProfilePubkey() && Array.isArray(profiles) && profiles.length) { + setStoredActiveProfilePubkey(profiles[0].pubkey, { persist: true }); } - - const seenPubkeys = new Set(); - let needsRewrite = false; - let parsed = null; - const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY); - if (raw) { - try { - parsed = JSON.parse(raw); - } catch (err) { - console.warn("Failed to parse saved profiles payload:", err); - needsRewrite = true; - } - } - - if ( - parsed && - typeof parsed === "object" && - parsed.version === SAVED_PROFILES_STORAGE_VERSION && - Array.isArray(parsed.entries) - ) { - for (const candidate of parsed.entries) { - if (!candidate || typeof candidate !== "object") { - needsRewrite = true; - continue; - } - - const normalizedPubkey = this.normalizeHexPubkey(candidate.pubkey); - if (!normalizedPubkey) { - needsRewrite = true; - continue; - } - - if (seenPubkeys.has(normalizedPubkey)) { - needsRewrite = true; - continue; - } - - seenPubkeys.add(normalizedPubkey); - - const storedAuthType = - candidate.authType === "nsec" ? "nsec" : "nip07"; - if (candidate.authType !== storedAuthType) { - needsRewrite = true; - } - - const entry = { - pubkey: normalizedPubkey, - npub: - typeof candidate.npub === "string" && candidate.npub.trim() - ? candidate.npub.trim() - : this.safeEncodeNpub(normalizedPubkey), - name: - typeof candidate.name === "string" ? candidate.name : "", - picture: - typeof candidate.picture === "string" ? candidate.picture : "", - authType: storedAuthType, - }; - - if ( - typeof candidate.npub === "string" && - candidate.npub.trim() !== candidate.npub - ) { - needsRewrite = true; - } - - if ( - entry.npub && - typeof candidate.npub !== "string" && - entry.npub !== candidate.npub - ) { - needsRewrite = true; - } - - this.savedProfiles.push(entry); - } - - const activeCandidate = - typeof parsed.activePubkey === "string" - ? parsed.activePubkey - : typeof parsed.activePubKey === "string" - ? parsed.activePubKey - : null; - const normalizedActive = this.normalizeHexPubkey(activeCandidate); - if (normalizedActive && seenPubkeys.has(normalizedActive)) { - this.activeProfilePubkey = normalizedActive; - } else if (activeCandidate) { - needsRewrite = true; - } - } else if (raw) { - needsRewrite = true; - } - - if (!this.activeProfilePubkey && this.savedProfiles.length) { - this.activeProfilePubkey = this.savedProfiles[0].pubkey; - needsRewrite = true; - } - - if (needsRewrite) { - this.persistSavedProfiles(); - } - this.renderSavedProfiles(); } syncSavedProfileFromCache(pubkey, { persist = false } = {}) { - const normalized = this.normalizeHexPubkey(pubkey); - if (!normalized) { - return false; + const updated = syncSavedProfileFromCacheState(pubkey, { persist }); + if (updated) { + this.renderSavedProfiles(); } - - const cacheEntry = this.profileCache.get(normalized); - if (!cacheEntry || typeof cacheEntry !== "object") { - return false; - } - - const index = this.savedProfiles.findIndex( - (entry) => entry && entry.pubkey === normalized - ); - if (index < 0) { - return false; - } - - const existing = this.savedProfiles[index] || {}; - const profile = cacheEntry.profile || {}; - const nextEntry = { - ...existing, - name: profile.name || existing.name || "", - picture: profile.picture || existing.picture || "", - }; - - const changed = - existing.name !== nextEntry.name || existing.picture !== nextEntry.picture; - - if (!changed) { - return false; - } - - this.savedProfiles[index] = nextEntry; - - if (persist) { - this.persistSavedProfiles({ persistActive: false }); - } - - this.renderSavedProfiles(); - return true; + return updated; } loadProfileCacheFromStorage() { - if (typeof localStorage === "undefined") { - return; - } - - const now = Date.now(); - const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY); - if (!raw) { - return; - } - - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object") { - return; - } - - if (parsed.version !== PROFILE_CACHE_VERSION) { - return; - } - - const entries = parsed.entries; - if (!entries || typeof entries !== "object") { - return; - } - - for (const [pubkey, entry] of Object.entries(entries)) { - if (!pubkey || !entry || typeof entry !== "object") { - continue; - } - - const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; - if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) { - continue; - } - - const profile = entry.profile; - if (!profile || typeof profile !== "object") { - continue; - } - - const normalized = { - name: profile.name || profile.display_name || "Unknown", - picture: profile.picture || "assets/svg/default-profile.svg", - }; - - this.profileCache.set(pubkey, { - profile: normalized, - timestamp, - }); - } - } catch (err) { - console.warn("Failed to parse stored profile cache:", err); - } + hydrateProfileCacheFromStorage(); } persistProfileCacheToStorage() { - if (typeof localStorage === "undefined") { - return; - } - - const now = Date.now(); - const entries = {}; - - for (const [pubkey, entry] of this.profileCache.entries()) { - if (!entry || typeof entry !== "object") { - continue; - } - const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; - if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) { - this.profileCache.delete(pubkey); - continue; - } - - entries[pubkey] = { - profile: entry.profile, - timestamp, - }; - } - - const payload = { - version: PROFILE_CACHE_VERSION, - savedAt: now, - entries, - }; - - if (Object.keys(entries).length === 0) { - try { - localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY); - } catch (err) { - console.warn("Failed to clear profile cache storage:", err); - } - return; - } - - try { - localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload)); - } catch (err) { - console.warn("Failed to persist profile cache:", err); - } + persistProfileCacheState(); } getProfileCacheEntry(pubkey) { - const normalized = this.normalizeHexPubkey(pubkey); - if (!normalized) { - return null; - } + return getCachedProfileEntry(pubkey); + } - const entry = this.profileCache.get(normalized); + setProfileCacheEntry(pubkey, profile) { + const entry = setCachedProfileEntry(pubkey, profile); if (!entry) { return null; } - const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; - if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) { - this.profileCache.delete(normalized); - this.persistProfileCacheToStorage(); - return null; - } - - return entry; - } - - setProfileCacheEntry(pubkey, profile) { const normalizedPubkey = this.normalizeHexPubkey(pubkey); - if (!normalizedPubkey || !profile) { - return; - } - - const normalized = { - name: profile.name || profile.display_name || "Unknown", - picture: profile.picture || "assets/svg/default-profile.svg", - }; - - const entry = { - profile: normalized, - timestamp: Date.now(), - }; - - this.profileCache.set(normalizedPubkey, entry); - this.persistProfileCacheToStorage(); const didUpdateSavedProfile = this.syncSavedProfileFromCache(normalizedPubkey, { persist: true, }); @@ -1119,6 +702,11 @@ class bitvidApp { return entry; } + persistActiveProfileSelection(pubkey, { persist = true } = {}) { + setStoredActiveProfilePubkey(pubkey, { persist }); + this.renderSavedProfiles(); + } + prepareForViewLoad() { if (this._videoListElement && this._videoListClickHandler) { try { @@ -1465,6 +1053,7 @@ class bitvidApp { this.playerModal; scrollRegion.scrollTop = 0; } + setGlobalModalState("player", true); this.applyModalLoadingPoster(); } @@ -1638,6 +1227,7 @@ class bitvidApp { this.closeUploadModalBtn.addEventListener("click", () => { if (this.uploadModal) { this.uploadModal.classList.add("hidden"); + setGlobalModalState("upload", false); } }); } @@ -1831,6 +1421,7 @@ class bitvidApp { showEditVideoModal() { if (this.editVideoModal) { this.editVideoModal.classList.remove("hidden"); + setGlobalModalState("editVideo", true); } } @@ -1838,6 +1429,7 @@ class bitvidApp { if (this.editVideoModal) { this.editVideoModal.classList.add("hidden"); } + setGlobalModalState("editVideo", false); this.resetEditVideoForm(); } @@ -2006,6 +1598,7 @@ class bitvidApp { showRevertVideoModal() { if (this.revertVideoModal) { this.revertVideoModal.classList.remove("hidden"); + setGlobalModalState("revertVideo", true); } } @@ -2013,6 +1606,7 @@ class bitvidApp { if (this.revertVideoModal) { this.revertVideoModal.classList.add("hidden"); } + setGlobalModalState("revertVideo", false); this.resetRevertVideoModal(); } @@ -4493,6 +4087,7 @@ class bitvidApp { this.profileModal.classList.remove("hidden"); this.profileModal.setAttribute("aria-hidden", "false"); + setGlobalModalState("profile", true); this.lastFocusedBeforeProfileModal = document.activeElement instanceof HTMLElement @@ -4591,6 +4186,7 @@ class bitvidApp { this.profileModal.classList.add("hidden"); } this.profileModal.setAttribute("aria-hidden", "true"); + setGlobalModalState("profile", false); if (this.boundProfileModalKeydown) { this.profileModal.removeEventListener( @@ -5137,7 +4733,7 @@ class bitvidApp { authType: "nip07", }); - this.persistSavedProfiles({ persistActive: false }); + persistSavedProfiles({ persistActive: false }); this.renderSavedProfiles(); this.showSuccess("Profile added. Select it when you're ready to switch."); @@ -5895,6 +5491,7 @@ class bitvidApp { this.uploadButton.addEventListener("click", () => { if (this.uploadModal) { this.uploadModal.classList.remove("hidden"); + setGlobalModalState("upload", true); } }); } @@ -5906,6 +5503,7 @@ class bitvidApp { const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.remove("hidden"); + setGlobalModalState("login", true); } }); } @@ -5917,6 +5515,7 @@ class bitvidApp { const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.add("hidden"); + setGlobalModalState("login", false); } }); } @@ -5952,6 +5551,7 @@ class bitvidApp { const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.add("hidden"); + setGlobalModalState("login", false); } } catch (err) { console.error("[NIP-07 login error]", err); @@ -6006,6 +5606,7 @@ class bitvidApp { const loginModal = document.getElementById("loginModal"); if (loginModal) { loginModal.classList.add("hidden"); + setGlobalModalState("login", false); } // Show the application modal const appModal = document.getElementById("nostrFormModal"); @@ -6311,6 +5912,7 @@ class bitvidApp { } if (suppressModalClose !== true && this.uploadModal) { this.uploadModal.classList.add("hidden"); + setGlobalModalState("upload", false); } await this.loadVideos(); this.showSuccess("Video shared successfully!"); @@ -6528,7 +6130,7 @@ class bitvidApp { this.activeProfilePubkey = null; } - this.persistSavedProfiles(); + persistSavedProfiles(); this.renderSavedProfiles(); } @@ -6584,7 +6186,7 @@ class bitvidApp { } if (reordered) { - this.persistSavedProfiles({ persistActive: true }); + persistSavedProfiles({ persistActive: true }); } this.renderSavedProfiles(); @@ -6749,7 +6351,7 @@ class bitvidApp { }); if (!persistActive && savedProfilesMutated) { - this.persistSavedProfiles({ persistActive: false }); + persistSavedProfiles({ persistActive: false }); } } else { if (persistActive) { @@ -6760,7 +6362,7 @@ class bitvidApp { } if (!normalizedPubkey && savedProfilesMutated) { - this.persistSavedProfiles({ persistActive: false }); + persistSavedProfiles({ persistActive: false }); } await this.applyPostLoginState(); @@ -8117,6 +7719,7 @@ class bitvidApp { } document.body.classList.remove("modal-open"); document.documentElement.classList.remove("modal-open"); + setGlobalModalState("player", false); if (typeof this.modalPosterCleanup === "function") { this.modalPosterCleanup(); this.modalPosterCleanup = null; @@ -8332,11 +7935,11 @@ class bitvidApp { } getCachedUrlHealth(eventId, url) { - return readUrlHealthFromCache(eventId, url); + return readCachedUrlHealth(eventId, url); } storeUrlHealth(eventId, url, result, ttlMs) { - return writeUrlHealthToCache(eventId, url, result, ttlMs); + return persistUrlHealth(eventId, url, result, ttlMs); } updateUrlHealthBadge(badgeEl, state, videoId) { @@ -8498,7 +8101,7 @@ class bitvidApp { const ttlOverride = entry.status === "timeout" || entry.status === "unknown" - ? URL_HEALTH_TIMEOUT_RETRY_MS + ? urlHealthConstants.URL_HEALTH_TIMEOUT_RETRY_MS : undefined; return this.storeUrlHealth(eventId, trimmedUrl, entry, ttlOverride); @@ -10397,10 +10000,12 @@ class bitvidApp { if ( result && result.outcome === "timeout" && - Number.isFinite(URL_PROBE_TIMEOUT_RETRY_MS) && - URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout + Number.isFinite(urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS) && + urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS > initialTimeout ) { - const retryResult = await attemptWithTimeout(URL_PROBE_TIMEOUT_RETRY_MS); + const retryResult = await attemptWithTimeout( + urlHealthConstants.URL_PROBE_TIMEOUT_RETRY_MS + ); if (retryResult) { result = { ...retryResult, retriedAfterTimeout: true }; } diff --git a/js/state/appState.js b/js/state/appState.js new file mode 100644 index 00000000..03d4a86f --- /dev/null +++ b/js/state/appState.js @@ -0,0 +1,190 @@ +const state = { + pubkey: null, + currentUserNpub: null, + currentVideo: null, + modals: Object.create(null), +}; + +const globalSubscribers = new Set(); +const keySubscribers = new Map(); +const modalSubscribers = new Map(); + +function createSnapshot() { + return { + pubkey: state.pubkey, + currentUserNpub: state.currentUserNpub, + currentVideo: state.currentVideo, + modals: { ...state.modals }, + }; +} + +function notifyKey(key, value, previousValue) { + const subscribers = keySubscribers.get(key); + if (subscribers) { + for (const callback of subscribers) { + try { + callback(value, previousValue, createSnapshot()); + } catch (error) { + console.warn(`[appState] subscriber for key "${key}" threw:`, error); + } + } + } + + if (globalSubscribers.size) { + const snapshot = createSnapshot(); + for (const callback of globalSubscribers) { + try { + callback(snapshot, { key, value, previousValue }); + } catch (error) { + console.warn("[appState] global subscriber threw:", error); + } + } + } +} + +function subscribeToKey(key, callback) { + if (typeof callback !== "function") { + return () => {}; + } + + if (!keySubscribers.has(key)) { + keySubscribers.set(key, new Set()); + } + const subscribers = keySubscribers.get(key); + subscribers.add(callback); + + return () => { + subscribers.delete(callback); + if (subscribers.size === 0) { + keySubscribers.delete(key); + } + }; +} + +export function subscribeToAppState(callback) { + if (typeof callback !== "function") { + return () => {}; + } + + globalSubscribers.add(callback); + return () => { + globalSubscribers.delete(callback); + }; +} + +export function subscribeToAppStateKey(key, callback) { + return subscribeToKey(key, callback); +} + +function updateKey(key, value) { + const previousValue = state[key]; + if (previousValue === value) { + return value; + } + + state[key] = value; + notifyKey(key, value, previousValue); + return value; +} + +export function getAppState() { + return createSnapshot(); +} + +export function getPubkey() { + return state.pubkey; +} + +export function setPubkey(value) { + return updateKey("pubkey", typeof value === "string" ? value : value ?? null); +} + +export function getCurrentUserNpub() { + return state.currentUserNpub; +} + +export function setCurrentUserNpub(value) { + return updateKey( + "currentUserNpub", + typeof value === "string" && value ? value : value ?? null + ); +} + +export function getCurrentVideo() { + return state.currentVideo; +} + +export function setCurrentVideo(value) { + return updateKey("currentVideo", value ?? null); +} + +export function getModalState(name) { + if (typeof name !== "string" || !name) { + return false; + } + return Boolean(state.modals[name]); +} + +function notifyModal(name, value, previousValue) { + const subscribers = modalSubscribers.get(name); + if (!subscribers || subscribers.size === 0) { + return; + } + + for (const callback of subscribers) { + try { + callback(value, previousValue, createSnapshot()); + } catch (error) { + console.warn(`[appState] modal subscriber for "${name}" threw:`, error); + } + } +} + +export function setModalState(name, isOpen) { + if (typeof name !== "string" || !name) { + return false; + } + + const normalized = Boolean(isOpen); + const previous = Boolean(state.modals[name]); + if (previous === normalized) { + return normalized; + } + + const previousModals = { ...state.modals }; + state.modals[name] = normalized; + notifyModal(name, normalized, previous); + notifyKey("modals", { ...state.modals }, previousModals); + return normalized; +} + +export function subscribeToModalState(name, callback) { + if (typeof name !== "string" || !name || typeof callback !== "function") { + return () => {}; + } + + if (!modalSubscribers.has(name)) { + modalSubscribers.set(name, new Set()); + } + const subscribers = modalSubscribers.get(name); + subscribers.add(callback); + + return () => { + subscribers.delete(callback); + if (subscribers.size === 0) { + modalSubscribers.delete(name); + } + }; +} + +export function resetAppState() { + const previous = createSnapshot(); + state.pubkey = null; + state.currentUserNpub = null; + state.currentVideo = null; + state.modals = Object.create(null); + notifyKey("pubkey", state.pubkey, previous.pubkey); + notifyKey("currentUserNpub", state.currentUserNpub, previous.currentUserNpub); + notifyKey("currentVideo", state.currentVideo, previous.currentVideo); + notifyKey("modals", { ...state.modals }, previous.modals); +} diff --git a/js/state/cache.js b/js/state/cache.js new file mode 100644 index 00000000..785da11b --- /dev/null +++ b/js/state/cache.js @@ -0,0 +1,657 @@ +import { + readUrlHealthFromStorage, + removeUrlHealthFromStorage, + writeUrlHealthToStorage, +} from "../utils/storage.js"; + +const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1"; +const PROFILE_CACHE_VERSION = 1; +const PROFILE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +const SAVED_PROFILES_STORAGE_KEY = "bitvid:savedProfiles:v1"; +const SAVED_PROFILES_STORAGE_VERSION = 1; + +const URL_HEALTH_TTL_MS = 45 * 60 * 1000; // 45 minutes +const URL_HEALTH_TIMEOUT_RETRY_MS = 5 * 60 * 1000; // 5 minutes +export const URL_PROBE_TIMEOUT_MS = 8 * 1000; // 8 seconds +const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +let savedProfiles = []; +let activeProfilePubkey = null; +const profileCache = new Map(); +const urlHealthCache = new Map(); +const urlHealthInFlight = new Map(); + +function hasSavedProfilesChanged(previousProfiles, nextProfiles) { + if (previousProfiles === nextProfiles) { + return false; + } + if (!Array.isArray(previousProfiles) || !Array.isArray(nextProfiles)) { + return true; + } + if (previousProfiles.length !== nextProfiles.length) { + return true; + } + + for (let index = 0; index < previousProfiles.length; index += 1) { + const prev = previousProfiles[index]; + const next = nextProfiles[index]; + if (prev === next) { + continue; + } + if (!prev || !next || typeof prev !== "object" || typeof next !== "object") { + return true; + } + + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + for (const key of keys) { + if (prev[key] !== next[key]) { + return true; + } + } + } + + return false; +} + +function safeDecodeNpub(npub) { + if (typeof npub !== "string") { + return null; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + + try { + const decoded = window?.NostrTools?.nip19?.decode(trimmed); + if (decoded && decoded.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } catch (error) { + return null; + } + + return null; +} + +function normalizeHexPubkey(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (trimmed.startsWith("npub1")) { + const decoded = safeDecodeNpub(trimmed); + if (decoded && HEX64_REGEX.test(decoded)) { + return decoded.toLowerCase(); + } + } + + return null; +} + +export function getSavedProfiles() { + return savedProfiles; +} + +export function getProfileCacheMap() { + return profileCache; +} + +export function getActiveProfilePubkey() { + return activeProfilePubkey; +} + +export function setActiveProfilePubkey(pubkey, { persist = true } = {}) { + const normalized = normalizeHexPubkey(pubkey); + const nextValue = + normalized || (typeof pubkey === "string" && pubkey.trim() ? pubkey.trim() : null); + if (activeProfilePubkey === nextValue) { + if (persist) { + persistSavedProfiles({ persistActive: true }); + } + return activeProfilePubkey; + } + + activeProfilePubkey = nextValue; + if (persist) { + persistSavedProfiles({ persistActive: true }); + } + return activeProfilePubkey; +} + +export function mutateSavedProfiles(mutator, { persist = true, persistActive = true } = {}) { + if (typeof mutator !== "function") { + return { changed: false, profiles: getSavedProfiles() }; + } + + const draft = savedProfiles.slice(); + const result = mutator(draft); + const nextProfiles = Array.isArray(result) ? result : draft; + const changed = hasSavedProfilesChanged(savedProfiles, nextProfiles); + + if (changed) { + savedProfiles = nextProfiles; + if (persist) { + persistSavedProfiles({ persistActive }); + } + } else if (persist && persistActive === false) { + persistSavedProfiles({ persistActive: false }); + } + + return { changed, profiles: getSavedProfiles() }; +} + +export function setSavedProfiles(nextProfiles, options) { + return mutateSavedProfiles( + () => (Array.isArray(nextProfiles) ? nextProfiles.slice() : []), + options + ); +} + +export function readSavedProfilesPayloadFromStorage() { + if (typeof localStorage === "undefined") { + return null; + } + + const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return null; + } + return parsed; + } catch (error) { + console.warn( + "[cache.readSavedProfilesPayloadFromStorage] Failed to parse payload:", + error + ); + return null; + } +} + +function writeSavedProfilesPayloadToStorage(payload) { + if (typeof localStorage === "undefined") { + return; + } + + try { + localStorage.setItem(SAVED_PROFILES_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + const isQuotaError = + error && + (error.name === "QuotaExceededError" || + error.name === "NS_ERROR_DOM_QUOTA_REACHED" || + error.code === 22 || + error.code === 1014); + if (isQuotaError) { + console.warn( + "[cache.writeSavedProfilesPayloadToStorage] Storage quota exceeded; keeping in-memory copy only.", + error + ); + } else { + console.warn( + "[cache.writeSavedProfilesPayloadToStorage] Failed to persist saved profiles:", + error + ); + } + } +} + +export function persistSavedProfiles({ persistActive = true } = {}) { + if (typeof localStorage === "undefined") { + return; + } + + if (!savedProfiles.length && !activeProfilePubkey) { + try { + localStorage.removeItem(SAVED_PROFILES_STORAGE_KEY); + } catch (error) { + console.warn( + "[cache.persistSavedProfiles] Failed to remove empty payload:", + error + ); + } + return; + } + + let activePubkeyToPersist = activeProfilePubkey || null; + if (!persistActive) { + const storedPayload = readSavedProfilesPayloadFromStorage(); + if (storedPayload && typeof storedPayload === "object") { + const candidate = + typeof storedPayload.activePubkey === "string" + ? storedPayload.activePubkey + : typeof storedPayload.activePubKey === "string" + ? storedPayload.activePubKey + : null; + const normalizedStored = normalizeHexPubkey(candidate); + if (normalizedStored) { + activePubkeyToPersist = normalizedStored; + } else if (candidate === null) { + activePubkeyToPersist = null; + } + } + } + + const payload = { + version: SAVED_PROFILES_STORAGE_VERSION, + entries: savedProfiles.map((entry) => ({ + pubkey: entry.pubkey, + npub: + typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null, + name: typeof entry.name === "string" ? entry.name : "", + picture: typeof entry.picture === "string" ? entry.picture : "", + authType: entry.authType === "nsec" ? "nsec" : "nip07", + })), + activePubkey: activePubkeyToPersist, + }; + + writeSavedProfilesPayloadToStorage(payload); +} + +export function loadSavedProfilesFromStorage() { + savedProfiles = []; + activeProfilePubkey = null; + + if (typeof localStorage === "undefined") { + return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey }; + } + + const raw = localStorage.getItem(SAVED_PROFILES_STORAGE_KEY); + if (!raw) { + return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey }; + } + + let needsRewrite = false; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) { + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + const seenPubkeys = new Set(); + for (const candidate of entries) { + if (!candidate || typeof candidate !== "object") { + continue; + } + + const normalizedPubkey = normalizeHexPubkey(candidate.pubkey); + if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) { + needsRewrite = true; + continue; + } + + seenPubkeys.add(normalizedPubkey); + const storedAuthType = candidate.authType === "nsec" ? "nsec" : "nip07"; + const npub = + typeof candidate.npub === "string" && candidate.npub.trim() + ? candidate.npub.trim() + : null; + + const entry = { + pubkey: normalizedPubkey, + npub, + name: typeof candidate.name === "string" ? candidate.name : "", + picture: typeof candidate.picture === "string" ? candidate.picture : "", + authType: storedAuthType, + }; + + if ( + typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub + ) { + needsRewrite = true; + } + + if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) { + needsRewrite = true; + } + + savedProfiles.push(entry); + } + + const activeCandidate = + typeof parsed.activePubkey === "string" + ? parsed.activePubkey + : typeof parsed.activePubKey === "string" + ? parsed.activePubKey + : null; + const normalizedActive = normalizeHexPubkey(activeCandidate); + if (normalizedActive && seenPubkeys.has(normalizedActive)) { + activeProfilePubkey = normalizedActive; + } else if (activeCandidate) { + needsRewrite = true; + } + } else if (raw) { + needsRewrite = true; + } + } catch (error) { + console.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error); + needsRewrite = true; + } + + if (!activeProfilePubkey && savedProfiles.length) { + activeProfilePubkey = savedProfiles[0].pubkey; + needsRewrite = true; + } + + if (needsRewrite) { + persistSavedProfiles(); + } + + return { profiles: getSavedProfiles(), activePubkey: activeProfilePubkey }; +} + +export function syncSavedProfileFromCache(pubkey, { persist = false } = {}) { + const normalized = normalizeHexPubkey(pubkey); + if (!normalized) { + return false; + } + + const cacheEntry = profileCache.get(normalized); + if (!cacheEntry || typeof cacheEntry !== "object") { + return false; + } + + let updated = false; + mutateSavedProfiles((profiles) => { + const next = profiles.slice(); + const index = next.findIndex((entry) => entry && entry.pubkey === normalized); + if (index < 0) { + return profiles; + } + + const existing = next[index] || {}; + const profile = cacheEntry.profile || {}; + const nextEntry = { + ...existing, + name: profile.name || existing.name || "", + picture: profile.picture || existing.picture || "", + }; + + if ( + existing.name === nextEntry.name && + existing.picture === nextEntry.picture + ) { + return profiles; + } + + next[index] = nextEntry; + updated = true; + return next; + }, { persist, persistActive: false }); + + return updated; +} + +export function loadProfileCacheFromStorage() { + if (typeof localStorage === "undefined") { + return; + } + + const now = Date.now(); + const raw = localStorage.getItem(PROFILE_CACHE_STORAGE_KEY); + if (!raw) { + return; + } + + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return; + } + + if (parsed.version !== PROFILE_CACHE_VERSION) { + return; + } + + const entries = parsed.entries; + if (!entries || typeof entries !== "object") { + return; + } + + for (const [pubkey, entry] of Object.entries(entries)) { + if (!pubkey || !entry || typeof entry !== "object") { + continue; + } + + const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; + if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) { + continue; + } + + const profile = entry.profile; + if (!profile || typeof profile !== "object") { + continue; + } + + const normalized = { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || "assets/svg/default-profile.svg", + }; + + profileCache.set(pubkey, { + profile: normalized, + timestamp, + }); + } + } catch (error) { + console.warn("[cache.loadProfileCacheFromStorage] Failed to parse payload:", error); + } +} + +export function persistProfileCacheToStorage() { + if (typeof localStorage === "undefined") { + return; + } + + const now = Date.now(); + const entries = {}; + + for (const [pubkey, entry] of profileCache.entries()) { + if (!entry || typeof entry !== "object") { + continue; + } + + const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; + if (!timestamp || now - timestamp > PROFILE_CACHE_TTL_MS) { + profileCache.delete(pubkey); + continue; + } + + entries[pubkey] = { + profile: entry.profile, + timestamp, + }; + } + + const payload = { + version: PROFILE_CACHE_VERSION, + savedAt: now, + entries, + }; + + if (Object.keys(entries).length === 0) { + try { + localStorage.removeItem(PROFILE_CACHE_STORAGE_KEY); + } catch (error) { + console.warn( + "[cache.persistProfileCacheToStorage] Failed to clear storage:", + error + ); + } + return; + } + + try { + localStorage.setItem(PROFILE_CACHE_STORAGE_KEY, JSON.stringify(payload)); + } catch (error) { + console.warn("[cache.persistProfileCacheToStorage] Failed to persist cache:", error); + } +} + +export function getProfileCacheEntry(pubkey) { + const normalized = normalizeHexPubkey(pubkey); + if (!normalized) { + return null; + } + + const entry = profileCache.get(normalized); + if (!entry) { + return null; + } + + const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0; + if (!timestamp || Date.now() - timestamp > PROFILE_CACHE_TTL_MS) { + profileCache.delete(normalized); + persistProfileCacheToStorage(); + return null; + } + + return entry; +} + +export function setProfileCacheEntry(pubkey, profile, { persist = true } = {}) { + const normalizedPubkey = normalizeHexPubkey(pubkey); + if (!normalizedPubkey || !profile) { + return null; + } + + const normalized = { + name: profile.name || profile.display_name || "Unknown", + picture: profile.picture || "assets/svg/default-profile.svg", + }; + + const entry = { + profile: normalized, + timestamp: Date.now(), + }; + + profileCache.set(normalizedPubkey, entry); + if (persist) { + persistProfileCacheToStorage(); + } + + return entry; +} + +function buildUrlProbeKey(url, options = {}) { + const trimmed = typeof url === "string" ? url : ""; + const mode = options?.confirmPlayable ? "playable" : "basic"; + return `${trimmed}::${mode}`; +} + +function isUrlHealthEntryFresh(entry, url) { + if (!entry || typeof entry !== "object") { + return false; + } + + const now = Date.now(); + if (typeof entry.expiresAt !== "number" || entry.expiresAt <= now) { + return false; + } + + if (url && entry.url && entry.url !== url) { + return false; + } + + return true; +} + +export function getCachedUrlHealth(eventId, url) { + if (!eventId) { + return null; + } + + const entry = urlHealthCache.get(eventId); + if (isUrlHealthEntryFresh(entry, url)) { + return entry; + } + + if (entry) { + urlHealthCache.delete(eventId); + } + + const stored = readUrlHealthFromStorage(eventId); + if (!isUrlHealthEntryFresh(stored, url)) { + if (stored) { + removeUrlHealthFromStorage(eventId); + } + return null; + } + + urlHealthCache.set(eventId, stored); + return stored; +} + +export function storeUrlHealth(eventId, url, result, ttlMs = URL_HEALTH_TTL_MS) { + if (!eventId) { + return null; + } + + const ttl = typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : URL_HEALTH_TTL_MS; + const now = Date.now(); + const entry = { + status: result?.status || "checking", + message: result?.message || "Checking hosted URL…", + url: url || result?.url || "", + expiresAt: now + ttl, + lastCheckedAt: now, + }; + urlHealthCache.set(eventId, entry); + writeUrlHealthToStorage(eventId, entry); + return entry; +} + +export function setInFlightUrlProbe(eventId, url, promise, options = {}) { + if (!eventId || !promise) { + return; + } + + const key = buildUrlProbeKey(url, options); + urlHealthInFlight.set(eventId, { promise, key }); + promise.finally(() => { + const current = urlHealthInFlight.get(eventId); + if (current && current.promise === promise) { + urlHealthInFlight.delete(eventId); + } + }); +} + +export function getInFlightUrlProbe(eventId, url, options = {}) { + if (!eventId) { + return null; + } + + const entry = urlHealthInFlight.get(eventId); + if (!entry) { + return null; + } + + const key = buildUrlProbeKey(url, options); + if (entry.key && entry.key !== key) { + return null; + } + + return entry.promise; +} + +export const urlHealthConstants = { + URL_HEALTH_TTL_MS, + URL_HEALTH_TIMEOUT_RETRY_MS, + URL_PROBE_TIMEOUT_RETRY_MS, +}; From 06c979569fb3bd14b969b56e380eb3abace1bfcf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:29:54 -0400 Subject: [PATCH 003/195] Refactor Cloudflare R2 handling into dedicated service --- js/app.js | 941 ++++++++++----------------------------- js/services/r2Service.js | 746 +++++++++++++++++++++++++++++++ 2 files changed, 980 insertions(+), 707 deletions(-) create mode 100644 js/services/r2Service.js diff --git a/js/app.js b/js/app.js index b5c7dbca..99a8f4d3 100644 --- a/js/app.js +++ b/js/app.js @@ -20,28 +20,7 @@ import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; import watchHistoryService from "./watchHistoryService.js"; -import { - loadR2Settings, - saveR2Settings, - clearR2Settings, - buildR2Key, - buildPublicUrl, - mergeBucketEntry, - sanitizeBaseDomain, -} from "./r2.js"; -import { - sanitizeBucketName, - ensureBucket, - putCors, - attachCustomDomainAndWait, - setManagedDomain, - deriveShortSubdomain, -} from "./storage/r2-mgmt.js"; -import { - makeR2Client, - multipartUpload, - ensureBucketCors, -} from "./storage/r2-s3.js"; +import r2Service from "./services/r2Service.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; @@ -455,7 +434,8 @@ class bitvidApp { this.customUploadSection = null; this.cloudflareUploadSection = null; this.activeUploadMode = "custom"; - this.cloudflareSettings = null; + this.r2Service = r2Service; + this.cloudflareSettings = this.r2Service.getSettings(); this.cloudflareSettingsForm = null; this.cloudflareClearSettingsButton = null; this.cloudflareSettingsStatus = null; @@ -477,13 +457,14 @@ class bitvidApp { this.cloudflareAdvancedToggleLabel = null; this.cloudflareAdvancedToggleIcon = null; this.cloudflareAdvancedFields = null; - this.cloudflareAdvancedVisible = false; + this.cloudflareAdvancedVisible = this.r2Service.getCloudflareAdvancedVisibility(); this.r2AccountIdInput = null; this.r2AccessKeyIdInput = null; this.r2SecretAccessKeyInput = null; this.r2ApiTokenInput = null; this.r2ZoneIdInput = null; this.r2BaseDomainInput = null; + this.r2ServiceUnsubscribes = []; // Edit video modal elements this.editVideoModal = null; @@ -1239,23 +1220,144 @@ class bitvidApp { }); } + if (Array.isArray(this.r2ServiceUnsubscribes)) { + this.r2ServiceUnsubscribes.forEach((fn) => { + try { + if (typeof fn === "function") { + fn(); + } + } catch (err) { + console.warn("Failed to unsubscribe from R2 service event:", err); + } + }); + } + this.r2ServiceUnsubscribes = []; + + const registerSubscription = (unsubscribe) => { + if (typeof unsubscribe === "function") { + this.r2ServiceUnsubscribes.push(unsubscribe); + } + }; + + registerSubscription( + this.r2Service.on("advancedVisibilityChange", ({ visible } = {}) => { + this.renderCloudflareAdvancedVisibility(visible); + }) + ); + + registerSubscription( + this.r2Service.on("settingsStatus", ({ message, variant } = {}) => { + this.applyCloudflareStatus( + this.cloudflareSettingsStatus, + message, + variant + ); + }) + ); + + registerSubscription( + this.r2Service.on("uploadStatus", ({ message, variant } = {}) => { + this.applyCloudflareStatus( + this.cloudflareUploadStatus, + message, + variant + ); + }) + ); + + registerSubscription( + this.r2Service.on("uploadStateChange", ({ isUploading } = {}) => { + this.renderCloudflareUploadingState(isUploading); + }) + ); + + registerSubscription( + this.r2Service.on("uploadProgress", ({ fraction } = {}) => { + this.updateCloudflareProgress(fraction); + }) + ); + + registerSubscription( + this.r2Service.on("settingsPopulated", ({ settings } = {}) => { + this.fillCloudflareSettingsInputs(settings); + }) + ); + + registerSubscription( + this.r2Service.on("settingsChanged", ({ settings } = {}) => { + this.cloudflareSettings = settings || this.cloudflareSettings; + }) + ); + + registerSubscription( + this.r2Service.on("bucketPreview", (detail = {}) => { + this.renderCloudflareBucketPreview(detail); + }) + ); + if (this.cloudflareSettingsForm) { - this.cloudflareSettingsForm.addEventListener("submit", (e) => { + this.cloudflareSettingsForm.addEventListener("submit", async (e) => { e.preventDefault(); - this.handleCloudflareSettingsSubmit(); + try { + const formValues = this.collectCloudflareSettingsFormValues(); + const saved = await this.r2Service.saveSettings(formValues); + if (saved) { + await this.refreshCloudflareBucketPreview(); + } + } catch (err) { + console.error("Failed to save Cloudflare settings:", err); + } }); } if (this.cloudflareClearSettingsButton) { - this.cloudflareClearSettingsButton.addEventListener("click", () => { - this.handleCloudflareClearSettings(); + this.cloudflareClearSettingsButton.addEventListener("click", async () => { + try { + const cleared = await this.r2Service.clearSettings(); + if (cleared) { + await this.refreshCloudflareBucketPreview(); + } + } catch (err) { + console.error("Failed to clear Cloudflare settings:", err); + } }); } if (this.cloudflareUploadForm) { - this.cloudflareUploadForm.addEventListener("submit", (e) => { - e.preventDefault(); - this.handleCloudflareUploadSubmit(); + this.cloudflareUploadForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!this.pubkey) { + this.showError("Please login to post a video."); + return; + } + + const npub = this.safeEncodeNpub(this.pubkey) || ""; + const file = this.cloudflareFileInput?.files?.[0] || null; + const metadata = { + title: (this.cloudflareTitleInput?.value || "").trim(), + description: (this.cloudflareDescriptionInput?.value || "").trim(), + thumbnail: (this.cloudflareThumbnailInput?.value || "").trim(), + magnet: (this.cloudflareMagnetInput?.value || "").trim(), + ws: (this.cloudflareWsInput?.value || "").trim(), + xs: (this.cloudflareXsInput?.value || "").trim(), + enableComments: this.cloudflareEnableCommentsInput + ? this.cloudflareEnableCommentsInput.checked + : true, + }; + + try { + await this.r2Service.uploadVideo({ + npub, + file, + metadata, + settingsInput: this.collectCloudflareSettingsFormValues(), + publishVideoNote: (payload, options) => + this.publishVideoNote(payload, options), + onReset: () => this.resetCloudflareUploadForm(), + }); + } catch (err) { + console.error("Cloudflare upload failed:", err); + } }); } @@ -1270,16 +1372,24 @@ class bitvidApp { if (this.cloudflareAdvancedToggle) { this.cloudflareAdvancedToggle.addEventListener("click", () => { - this.setCloudflareAdvancedVisibility( - !this.cloudflareAdvancedVisible + this.r2Service.setCloudflareAdvancedVisibility( + !this.r2Service.getCloudflareAdvancedVisibility() ); }); } - this.setCloudflareAdvancedVisibility(this.cloudflareAdvancedVisible); + this.cloudflareSettings = this.r2Service.getSettings(); + this.renderCloudflareAdvancedVisibility( + this.r2Service.getCloudflareAdvancedVisibility() + ); - await this.loadCloudflareSettingsFromStorage(); - await this.updateCloudflareBucketPreview(); + try { + await this.r2Service.loadSettings(); + } catch (err) { + // errors already reported by the service + } + + await this.refreshCloudflareBucketPreview(); this.setUploadMode(this.activeUploadMode); @@ -2632,11 +2742,50 @@ class bitvidApp { } if (normalized === "cloudflare") { - this.updateCloudflareBucketPreview(); + this.refreshCloudflareBucketPreview(); } } - setCloudflareAdvancedVisibility(visible) { + collectCloudflareSettingsFormValues() { + return { + accountId: (this.r2AccountIdInput?.value || "").trim(), + accessKeyId: (this.r2AccessKeyIdInput?.value || "").trim(), + secretAccessKey: (this.r2SecretAccessKeyInput?.value || "").trim(), + apiToken: (this.r2ApiTokenInput?.value || "").trim(), + zoneId: (this.r2ZoneIdInput?.value || "").trim(), + baseDomain: this.r2BaseDomainInput?.value || "", + }; + } + + applyCloudflareStatus(element, message = "", variant = "info") { + if (!element) { + return; + } + + element.textContent = message || ""; + element.classList.remove( + "text-green-400", + "text-red-400", + "text-yellow-400", + "text-gray-400" + ); + if (!message) { + element.classList.add("text-gray-400"); + return; + } + + let cls = "text-gray-400"; + if (variant === "success") { + cls = "text-green-400"; + } else if (variant === "error") { + cls = "text-red-400"; + } else if (variant === "warning") { + cls = "text-yellow-400"; + } + element.classList.add(cls); + } + + renderCloudflareAdvancedVisibility(visible) { const isVisible = Boolean(visible); this.cloudflareAdvancedVisible = isVisible; @@ -2666,65 +2815,7 @@ class bitvidApp { } } - setCloudflareSettingsStatus(message = "", variant = "info") { - if (!this.cloudflareSettingsStatus) { - return; - } - - const el = this.cloudflareSettingsStatus; - el.textContent = message || ""; - el.classList.remove( - "text-green-400", - "text-red-400", - "text-yellow-400", - "text-gray-400" - ); - if (!message) { - el.classList.add("text-gray-400"); - return; - } - - let cls = "text-gray-400"; - if (variant === "success") { - cls = "text-green-400"; - } else if (variant === "error") { - cls = "text-red-400"; - } else if (variant === "warning") { - cls = "text-yellow-400"; - } - el.classList.add(cls); - } - - setCloudflareUploadStatus(message = "", variant = "info") { - if (!this.cloudflareUploadStatus) { - return; - } - - const el = this.cloudflareUploadStatus; - el.textContent = message || ""; - el.classList.remove( - "text-green-400", - "text-red-400", - "text-yellow-400", - "text-gray-400" - ); - if (!message) { - el.classList.add("text-gray-400"); - return; - } - - let cls = "text-gray-400"; - if (variant === "success") { - cls = "text-green-400"; - } else if (variant === "error") { - cls = "text-red-400"; - } else if (variant === "warning") { - cls = "text-yellow-400"; - } - el.classList.add(cls); - } - - setCloudflareUploading(isUploading) { + renderCloudflareUploadingState(isUploading) { if (this.cloudflareUploadButton) { this.cloudflareUploadButton.disabled = Boolean(isUploading); this.cloudflareUploadButton.textContent = isUploading @@ -2741,71 +2832,8 @@ class bitvidApp { } } - updateCloudflareProgress(fraction) { - if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) { - return; - } - - if (typeof fraction !== "number" || Number.isNaN(fraction)) { - this.cloudflareProgressBar.classList.add("hidden"); - this.cloudflareProgressFill.style.width = "0%"; - return; - } - - const clamped = Math.max(0, Math.min(1, fraction)); - this.cloudflareProgressBar.classList.remove("hidden"); - this.cloudflareProgressFill.style.width = `${(clamped * 100).toFixed(1)}%`; - } - - resetCloudflareUploadForm() { - if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = ""; - if (this.cloudflareDescriptionInput) - this.cloudflareDescriptionInput.value = ""; - if (this.cloudflareThumbnailInput) - this.cloudflareThumbnailInput.value = ""; - if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = ""; - if (this.cloudflareWsInput) this.cloudflareWsInput.value = ""; - if (this.cloudflareXsInput) this.cloudflareXsInput.value = ""; - if (this.cloudflareEnableCommentsInput) - this.cloudflareEnableCommentsInput.checked = true; - if (this.cloudflareFileInput) this.cloudflareFileInput.value = ""; - this.updateCloudflareProgress(Number.NaN); - } - - async loadCloudflareSettingsFromStorage() { - try { - const settings = await loadR2Settings(); - this.cloudflareSettings = settings; - this.populateCloudflareSettingsInputs(settings); - } catch (err) { - console.error("Failed to load Cloudflare settings:", err); - this.setCloudflareSettingsStatus( - "Failed to load saved settings.", - "error" - ); - this.cloudflareSettings = { - accountId: "", - accessKeyId: "", - secretAccessKey: "", - apiToken: "", - zoneId: "", - baseDomain: "", - buckets: {}, - }; - this.populateCloudflareSettingsInputs(this.cloudflareSettings); - } - } - - populateCloudflareSettingsInputs(settings) { - const data = - settings || { - accountId: "", - accessKeyId: "", - secretAccessKey: "", - apiToken: "", - zoneId: "", - baseDomain: "", - }; + fillCloudflareSettingsInputs(settings) { + const data = settings || {}; if (this.r2AccountIdInput) { this.r2AccountIdInput.value = data.accountId || ""; @@ -2825,566 +2853,65 @@ class bitvidApp { if (this.r2BaseDomainInput) { this.r2BaseDomainInput.value = data.baseDomain || ""; } - - const hasAdvancedValues = Boolean( - (data.apiToken && data.apiToken.length > 0) || - (data.zoneId && data.zoneId.length > 0) || - (data.baseDomain && data.baseDomain.length > 0) - ); - if (hasAdvancedValues) { - this.setCloudflareAdvancedVisibility(true); - } else if (!this.cloudflareAdvancedVisible) { - this.setCloudflareAdvancedVisibility(false); - } - - this.setCloudflareSettingsStatus(""); } - async handleCloudflareSettingsSubmit({ quiet = false } = {}) { - const accountId = (this.r2AccountIdInput?.value || "").trim(); - const accessKeyId = (this.r2AccessKeyIdInput?.value || "").trim(); - const secretAccessKey = (this.r2SecretAccessKeyInput?.value || "").trim(); - const apiToken = (this.r2ApiTokenInput?.value || "").trim(); - const zoneId = (this.r2ZoneIdInput?.value || "").trim(); - const baseDomain = sanitizeBaseDomain( - this.r2BaseDomainInput?.value || "" - ); - - if (!accountId || !accessKeyId || !secretAccessKey) { - if (!quiet) { - this.setCloudflareSettingsStatus( - "Account ID, Access Key ID, and Secret are required.", - "error" - ); - } - return false; - } - - let buckets = { ...(this.cloudflareSettings?.buckets || {}) }; - const previousAccount = this.cloudflareSettings?.accountId || ""; - const previousBaseDomain = this.cloudflareSettings?.baseDomain || ""; - const previousZoneId = this.cloudflareSettings?.zoneId || ""; - if ( - previousAccount !== accountId || - previousBaseDomain !== baseDomain || - previousZoneId !== zoneId - ) { - buckets = {}; - } - - const updatedSettings = { - accountId, - accessKeyId, - secretAccessKey, - apiToken, - zoneId, - baseDomain, - buckets, - }; - - try { - this.cloudflareSettings = await saveR2Settings(updatedSettings); - this.populateCloudflareSettingsInputs(this.cloudflareSettings); - if (!quiet) { - this.setCloudflareSettingsStatus("Settings saved locally.", "success"); - } - await this.updateCloudflareBucketPreview(); - return true; - } catch (err) { - console.error("Failed to save Cloudflare settings:", err); - if (!quiet) { - this.setCloudflareSettingsStatus( - "Failed to save settings. Check console for details.", - "error" - ); - } - } - - return false; - } - - async handleCloudflareClearSettings() { - try { - await clearR2Settings(); - this.cloudflareSettings = await loadR2Settings(); - this.populateCloudflareSettingsInputs(this.cloudflareSettings); - this.setCloudflareAdvancedVisibility(false); - this.setCloudflareSettingsStatus("Settings cleared.", "success"); - await this.updateCloudflareBucketPreview(); - } catch (err) { - console.error("Failed to clear Cloudflare settings:", err); - this.setCloudflareSettingsStatus( - "Failed to clear settings.", - "error" - ); - } - } - - getCorsOrigins() { - const origins = new Set(); - if (typeof window !== "undefined" && window.location) { - const origin = window.location.origin; - if (origin && origin !== "null") { - origins.add(origin); - } - if (origin && origin.startsWith("http://localhost")) { - origins.add(origin.replace("http://", "https://")); - } - } - return Array.from(origins); - } - - deriveSubdomainForNpub(npub) { - try { - return deriveShortSubdomain(npub); - } catch (err) { - console.warn("Failed to derive short subdomain, falling back:", err); - } - - const base = String(npub || "user") - .toLowerCase() - .replace(/[^a-z0-9-]/g, "") - .replace(/^-+|[-]+$/g, ""); - return base.slice(0, 32) || "user"; - } - - async ensureBucketConfigForNpub(npub) { - if (!npub || !this.cloudflareSettings) { - return null; - } - - const accountId = (this.cloudflareSettings.accountId || "").trim(); - const apiToken = (this.cloudflareSettings.apiToken || "").trim(); - const zoneId = (this.cloudflareSettings.zoneId || "").trim(); - const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim(); - const secretAccessKey = - (this.cloudflareSettings.secretAccessKey || "").trim(); - const corsOrigins = this.getCorsOrigins(); - const baseDomain = this.cloudflareSettings.baseDomain || ""; - - if (!accountId) { - throw new Error("Cloudflare account ID is missing."); - } - - let entry = this.cloudflareSettings.buckets?.[npub] || null; - - if (entry && entry.publicBaseUrl) { - if (apiToken) { - try { - await ensureBucket({ - accountId, - bucket: entry.bucket, - token: apiToken, - }); - await putCors({ - accountId, - bucket: entry.bucket, - token: apiToken, - origins: corsOrigins, - }); - } catch (err) { - console.warn("Failed to refresh bucket configuration:", err); - } - } else if ( - accessKeyId && - secretAccessKey && - corsOrigins.length > 0 - ) { - try { - const s3 = makeR2Client({ - accountId, - accessKeyId, - secretAccessKey, - }); - await ensureBucketCors({ - s3, - bucket: entry.bucket, - origins: corsOrigins, - }); - } catch (err) { - console.warn( - "Failed to refresh bucket CORS via access keys:", - err - ); - } - } - return { - entry, - usedManagedFallback: entry.domainType !== "custom", - customDomainStatus: entry.domainType === "custom" ? "active" : "skipped", - }; - } - - if (!apiToken) { - const bucketName = entry?.bucket || sanitizeBucketName(npub); - const manualCustomDomain = baseDomain - ? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}` - : ""; - - let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain; - if (!publicBaseUrl) { - publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`; - } - - if (!publicBaseUrl) { - throw new Error( - "No public bucket domain configured. Add an API token or configure the domain manually." - ); - } - - const manualEntry = { - bucket: bucketName, - publicBaseUrl, - domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom", - lastUpdated: Date.now(), - }; - - if (accessKeyId && secretAccessKey && corsOrigins.length > 0) { - try { - const s3 = makeR2Client({ - accountId, - accessKeyId, - secretAccessKey, - }); - await ensureBucketCors({ - s3, - bucket: bucketName, - origins: corsOrigins, - }); - } catch (corsErr) { - console.warn( - "Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.", - corsErr - ); - } - } - - let savedEntry = entry; - if ( - !entry || - entry.bucket !== manualEntry.bucket || - entry.publicBaseUrl !== manualEntry.publicBaseUrl || - entry.domainType !== manualEntry.domainType - ) { - const updatedSettings = await saveR2Settings( - mergeBucketEntry(this.cloudflareSettings, npub, manualEntry) - ); - this.cloudflareSettings = updatedSettings; - savedEntry = updatedSettings.buckets?.[npub] || manualEntry; - } - - return { - entry: savedEntry, - usedManagedFallback: manualEntry.domainType !== "custom", - customDomainStatus: - manualEntry.domainType === "custom" ? "manual" : "managed", - }; - } - - const bucketName = entry?.bucket || sanitizeBucketName(npub); - - await ensureBucket({ accountId, bucket: bucketName, token: apiToken }); - - try { - await putCors({ - accountId, - bucket: bucketName, - token: apiToken, - origins: corsOrigins, - }); - } catch (err) { - console.warn("Failed to apply R2 CORS rules:", err); - } - - let publicBaseUrl = entry?.publicBaseUrl || ""; - let domainType = entry?.domainType || "managed"; - let usedManagedFallback = false; - let customDomainStatus = "skipped"; - - if (baseDomain && zoneId) { - const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`; - try { - const custom = await attachCustomDomainAndWait({ - accountId, - bucket: bucketName, - token: apiToken, - zoneId, - domain, - pollInterval: 2500, - timeoutMs: 120000, - }); - customDomainStatus = custom?.status || "unknown"; - if (custom?.active && custom?.url) { - publicBaseUrl = custom.url; - domainType = "custom"; - try { - await setManagedDomain({ - accountId, - bucket: bucketName, - token: apiToken, - enabled: false, - }); - } catch (disableErr) { - console.warn("Failed to disable managed domain:", disableErr); - } - } else { - usedManagedFallback = true; - } - } catch (err) { - if (/already exists/i.test(err.message || "")) { - publicBaseUrl = `https://${domain}`; - domainType = "custom"; - customDomainStatus = "active"; - try { - await setManagedDomain({ - accountId, - bucket: bucketName, - token: apiToken, - enabled: false, - }); - } catch (disableErr) { - console.warn("Failed to disable managed domain:", disableErr); - } - } else { - console.warn("Failed to attach custom domain, falling back:", err); - usedManagedFallback = true; - customDomainStatus = "error"; - } - } - } - - if (!publicBaseUrl) { - const managed = await setManagedDomain({ - accountId, - bucket: bucketName, - token: apiToken, - enabled: true, - }); - publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`; - domainType = "managed"; - usedManagedFallback = true; - customDomainStatus = customDomainStatus === "skipped" ? "managed" : customDomainStatus; - } - - const mergedEntry = { - bucket: bucketName, - publicBaseUrl, - domainType, - lastUpdated: Date.now(), - }; - const updatedSettings = await saveR2Settings( - mergeBucketEntry(this.cloudflareSettings, npub, mergedEntry) - ); - this.cloudflareSettings = updatedSettings; - return { entry: mergedEntry, usedManagedFallback, customDomainStatus }; - } - - async updateCloudflareBucketPreview() { + renderCloudflareBucketPreview({ text = "", title = "" } = {}) { if (!this.cloudflareBucketPreview) { return; } - const el = this.cloudflareBucketPreview; - if (!this.cloudflareSettings) { - el.textContent = "Save your credentials to configure R2."; - return; + this.cloudflareBucketPreview.textContent = text || ""; + if (title) { + this.cloudflareBucketPreview.setAttribute("title", title); + } else { + this.cloudflareBucketPreview.removeAttribute("title"); } - - if (!this.pubkey) { - el.textContent = "Login to preview your R2 bucket."; - return; - } - - const npub = this.safeEncodeNpub(this.pubkey); - if (!npub) { - el.textContent = "Unable to encode npub."; - return; - } - - const entry = this.cloudflareSettings.buckets?.[npub]; - if (!entry || !entry.publicBaseUrl) { - el.textContent = "Bucket will be auto-created on your next upload."; - return; - } - - const sampleKey = buildR2Key(npub, { name: "sample.mp4" }); - const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey); - const fullPreview = `${entry.bucket} • ${publicUrl}`; - - let displayHostAndPath = truncateMiddle(publicUrl, 72); - try { - const parsed = new URL(publicUrl); - const cleanPath = parsed.pathname.replace(/^\//, ""); - const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32); - displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`; - } catch (err) { - // ignore URL parse issues and fall back to the raw string - } - - const truncatedBucket = truncateMiddle(entry.bucket, 28); - el.textContent = `${truncatedBucket} • ${displayHostAndPath}`; - el.setAttribute("title", fullPreview); } - async handleCloudflareUploadSubmit() { - if (!this.pubkey) { - this.showError("Please login to post a video."); + async refreshCloudflareBucketPreview() { + if (!this.r2Service) { + return; + } + const hasPubkey = Boolean(this.pubkey); + const npub = hasPubkey ? this.safeEncodeNpub(this.pubkey) : ""; + await this.r2Service.updateCloudflareBucketPreview({ hasPubkey, npub }); + } + + updateCloudflareProgress(fraction) { + if (!this.cloudflareProgressBar || !this.cloudflareProgressFill) { return; } - const saved = await this.handleCloudflareSettingsSubmit({ quiet: true }); - if (!saved) { - this.setCloudflareUploadStatus( - "Fix your R2 settings before uploading.", - "error" - ); + if (!Number.isFinite(fraction) || fraction < 0) { + this.cloudflareProgressBar.classList.add("hidden"); + this.cloudflareProgressBar.setAttribute("aria-hidden", "true"); + this.cloudflareProgressFill.style.width = "0%"; + this.cloudflareProgressFill.setAttribute("aria-valuenow", "0"); return; } - const title = (this.cloudflareTitleInput?.value || "").trim(); - if (!title) { - this.setCloudflareUploadStatus("Title is required.", "error"); - return; - } + const clamped = Math.max(0, Math.min(1, fraction)); + const percent = Math.round(clamped * 100); - const file = this.cloudflareFileInput?.files?.[0] || null; - if (!file) { - this.setCloudflareUploadStatus( - "Select a video or HLS file to upload.", - "error" - ); - return; - } + this.cloudflareProgressBar.classList.remove("hidden"); + this.cloudflareProgressBar.setAttribute("aria-hidden", "false"); + this.cloudflareProgressFill.style.width = `${percent}%`; + this.cloudflareProgressFill.setAttribute("aria-valuenow", `${percent}`); + } - const description = (this.cloudflareDescriptionInput?.value || "").trim(); - const thumbnail = (this.cloudflareThumbnailInput?.value || "").trim(); - const magnet = (this.cloudflareMagnetInput?.value || "").trim(); - const ws = (this.cloudflareWsInput?.value || "").trim(); - const xs = (this.cloudflareXsInput?.value || "").trim(); - const enableComments = this.cloudflareEnableCommentsInput - ? this.cloudflareEnableCommentsInput.checked - : true; - - const accountId = (this.cloudflareSettings?.accountId || "").trim(); - const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim(); - const secretAccessKey = ( - this.cloudflareSettings?.secretAccessKey || "" - ).trim(); - - if (!accountId || !accessKeyId || !secretAccessKey) { - this.setCloudflareUploadStatus( - "Missing R2 credentials. Save them before uploading.", - "error" - ); - return; - } - - const npub = this.safeEncodeNpub(this.pubkey); - if (!npub) { - this.setCloudflareUploadStatus("Unable to encode npub.", "error"); - return; - } - - this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info"); - this.updateCloudflareProgress(0); - this.setCloudflareUploading(true); - - let bucketResult = null; - try { - bucketResult = await this.ensureBucketConfigForNpub(npub); - } catch (err) { - console.error("Failed to prepare R2 bucket:", err); - this.setCloudflareUploadStatus( - err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.", - "error" - ); - this.setCloudflareUploading(false); - this.updateCloudflareProgress(Number.NaN); - return; - } - - const bucketEntry = - bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub]; - - if (!bucketEntry || !bucketEntry.publicBaseUrl) { - this.setCloudflareUploadStatus( - "Bucket is missing a public domain. Check your Cloudflare settings.", - "error" - ); - this.setCloudflareUploading(false); - this.updateCloudflareProgress(Number.NaN); - return; - } - - let statusMessage = `Uploading to ${bucketEntry.bucket}…`; - if (bucketResult?.usedManagedFallback) { - const baseDomain = this.cloudflareSettings?.baseDomain || ""; - if (baseDomain) { - const customStatus = bucketResult?.customDomainStatus - ? ` (custom domain status: ${bucketResult.customDomainStatus})` - : ""; - statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`; - } else { - statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`; - } - } - - this.setCloudflareUploadStatus( - statusMessage, - bucketResult?.usedManagedFallback ? "warning" : "info" - ); - - const key = buildR2Key(npub, file); - const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key); - - try { - const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey }); - - await multipartUpload({ - s3, - bucket: bucketEntry.bucket, - key, - file, - contentType: file.type, - onProgress: (fraction) => { - this.updateCloudflareProgress(fraction); - }, - }); - - const payload = { - title, - url: publicUrl, - magnet, - thumbnail, - description, - ws, - xs, - enableComments, - }; - - const published = await this.publishVideoNote(payload, { - onSuccess: () => { - this.resetCloudflareUploadForm(); - }, - }); - - if (published) { - this.setCloudflareUploadStatus( - `Published ${publicUrl}`, - "success" - ); - } - } catch (err) { - console.error("Cloudflare upload failed:", err); - this.setCloudflareUploadStatus( - err?.message ? `Upload failed: ${err.message}` : "Upload failed.", - "error" - ); - } finally { - this.setCloudflareUploading(false); - this.updateCloudflareProgress(Number.NaN); - await this.updateCloudflareBucketPreview(); - } + resetCloudflareUploadForm() { + if (this.cloudflareTitleInput) this.cloudflareTitleInput.value = ""; + if (this.cloudflareDescriptionInput) + this.cloudflareDescriptionInput.value = ""; + if (this.cloudflareThumbnailInput) + this.cloudflareThumbnailInput.value = ""; + if (this.cloudflareMagnetInput) this.cloudflareMagnetInput.value = ""; + if (this.cloudflareWsInput) this.cloudflareWsInput.value = ""; + if (this.cloudflareXsInput) this.cloudflareXsInput.value = ""; + if (this.cloudflareEnableCommentsInput) + this.cloudflareEnableCommentsInput.checked = true; + if (this.cloudflareFileInput) this.cloudflareFileInput.value = ""; + this.updateCloudflareProgress(Number.NaN); } renderSavedProfiles() { @@ -6250,7 +5777,7 @@ class bitvidApp { this.forceRefreshAllProfiles(); - await this.updateCloudflareBucketPreview(); + await this.refreshCloudflareBucketPreview(); } /** @@ -6451,7 +5978,7 @@ class bitvidApp { // Force a fresh fetch of all profile pictures/names (public ones in this case) this.forceRefreshAllProfiles(); - await this.updateCloudflareBucketPreview(); + await this.refreshCloudflareBucketPreview(); } /** diff --git a/js/services/r2Service.js b/js/services/r2Service.js new file mode 100644 index 00000000..3ca2d143 --- /dev/null +++ b/js/services/r2Service.js @@ -0,0 +1,746 @@ +import { + loadR2Settings, + saveR2Settings, + clearR2Settings, + buildR2Key, + buildPublicUrl, + mergeBucketEntry, + sanitizeBaseDomain, +} from "../r2.js"; +import { + sanitizeBucketName, + ensureBucket, + putCors, + attachCustomDomainAndWait, + setManagedDomain, + deriveShortSubdomain, +} from "../storage/r2-mgmt.js"; +import { + makeR2Client, + multipartUpload, + ensureBucketCors, +} from "../storage/r2-s3.js"; +import { truncateMiddle } from "../utils/formatters.js"; + +const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); + +function createDefaultSettings() { + return { + accountId: "", + accessKeyId: "", + secretAccessKey: "", + apiToken: "", + zoneId: "", + baseDomain: "", + buckets: {}, + }; +} + +class R2Service { + constructor() { + this.listeners = new Map(); + this.cloudflareSettings = null; + this.cloudflareAdvancedVisible = false; + } + + on(event, handler) { + if (typeof handler !== "function") { + return () => {}; + } + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + const handlers = this.listeners.get(event); + handlers.add(handler); + return () => { + handlers.delete(handler); + if (!handlers.size) { + this.listeners.delete(event); + } + }; + } + + emit(event, detail) { + const handlers = this.listeners.get(event); + if (!handlers) { + return; + } + for (const handler of handlers) { + try { + handler(detail); + } catch (err) { + console.error("[r2Service] Listener error for", event, err); + } + } + } + + getSettings() { + return this.cloudflareSettings || createDefaultSettings(); + } + + setSettings(settings) { + this.cloudflareSettings = settings ? { ...settings } : null; + this.emit("settingsChanged", { settings: this.getSettings() }); + } + + getCloudflareAdvancedVisibility() { + return Boolean(this.cloudflareAdvancedVisible); + } + + setCloudflareAdvancedVisibility(visible) { + const isVisible = Boolean(visible); + if (this.cloudflareAdvancedVisible === isVisible) { + return; + } + this.cloudflareAdvancedVisible = isVisible; + this.emit("advancedVisibilityChange", { visible: isVisible }); + } + + normalizeStatusVariant(variant) { + if (STATUS_VARIANTS.has(variant)) { + return variant; + } + return "info"; + } + + setCloudflareSettingsStatus(message = "", variant = "info") { + this.emit("settingsStatus", { + message: message || "", + variant: this.normalizeStatusVariant(variant), + }); + } + + setCloudflareUploadStatus(message = "", variant = "info") { + this.emit("uploadStatus", { + message: message || "", + variant: this.normalizeStatusVariant(variant), + }); + } + + setCloudflareUploading(isUploading) { + this.emit("uploadStateChange", { isUploading: Boolean(isUploading) }); + } + + updateCloudflareProgress(fraction) { + this.emit("uploadProgress", { fraction }); + } + + populateCloudflareSettingsInputs(settings) { + const data = { + accountId: settings?.accountId || "", + accessKeyId: settings?.accessKeyId || "", + secretAccessKey: settings?.secretAccessKey || "", + apiToken: settings?.apiToken || "", + zoneId: settings?.zoneId || "", + baseDomain: settings?.baseDomain || "", + }; + + const hasAdvancedValues = Boolean( + (data.apiToken && data.apiToken.length > 0) || + (data.zoneId && data.zoneId.length > 0) || + (data.baseDomain && data.baseDomain.length > 0) + ); + + if (hasAdvancedValues) { + this.setCloudflareAdvancedVisibility(true); + } else if (!this.cloudflareAdvancedVisible) { + this.setCloudflareAdvancedVisibility(false); + } + + this.setCloudflareSettingsStatus(""); + this.emit("settingsPopulated", { settings: data }); + } + + async loadSettings() { + try { + const settings = await loadR2Settings(); + this.setSettings(settings); + this.populateCloudflareSettingsInputs(settings); + return settings; + } catch (err) { + console.error("Failed to load Cloudflare settings:", err); + this.setSettings(createDefaultSettings()); + this.populateCloudflareSettingsInputs(this.getSettings()); + this.setCloudflareSettingsStatus( + "Failed to load saved settings.", + "error" + ); + throw err; + } + } + + async handleCloudflareSettingsSubmit(formValues = {}, { quiet = false } = {}) { + const accountId = String(formValues.accountId || "").trim(); + const accessKeyId = String(formValues.accessKeyId || "").trim(); + const secretAccessKey = String(formValues.secretAccessKey || "").trim(); + const apiToken = String(formValues.apiToken || "").trim(); + const zoneId = String(formValues.zoneId || "").trim(); + const baseDomain = sanitizeBaseDomain(formValues.baseDomain || ""); + + if (!accountId || !accessKeyId || !secretAccessKey) { + if (!quiet) { + this.setCloudflareSettingsStatus( + "Account ID, Access Key ID, and Secret are required.", + "error" + ); + } + return false; + } + + let buckets = { ...(this.getSettings().buckets || {}) }; + const previousAccount = this.getSettings().accountId || ""; + const previousBaseDomain = this.getSettings().baseDomain || ""; + const previousZoneId = this.getSettings().zoneId || ""; + if ( + previousAccount !== accountId || + previousBaseDomain !== baseDomain || + previousZoneId !== zoneId + ) { + buckets = {}; + } + + const updatedSettings = { + accountId, + accessKeyId, + secretAccessKey, + apiToken, + zoneId, + baseDomain, + buckets, + }; + + try { + const saved = await saveR2Settings(updatedSettings); + this.setSettings(saved); + this.populateCloudflareSettingsInputs(saved); + if (!quiet) { + this.setCloudflareSettingsStatus("Settings saved locally.", "success"); + } + return true; + } catch (err) { + console.error("Failed to save Cloudflare settings:", err); + if (!quiet) { + this.setCloudflareSettingsStatus( + "Failed to save settings. Check console for details.", + "error" + ); + } + return false; + } + } + + async saveSettings(formValues = {}, options = {}) { + return this.handleCloudflareSettingsSubmit(formValues, options); + } + + async handleCloudflareClearSettings() { + try { + await clearR2Settings(); + const refreshed = await loadR2Settings(); + this.setSettings(refreshed); + this.populateCloudflareSettingsInputs(refreshed); + this.setCloudflareAdvancedVisibility(false); + this.setCloudflareSettingsStatus("Settings cleared.", "success"); + return true; + } catch (err) { + console.error("Failed to clear Cloudflare settings:", err); + this.setCloudflareSettingsStatus("Failed to clear settings.", "error"); + return false; + } + } + + async clearSettings() { + return this.handleCloudflareClearSettings(); + } + + getCorsOrigins() { + const origins = new Set(); + if (typeof window !== "undefined" && window.location) { + const origin = window.location.origin; + if (origin && origin !== "null") { + origins.add(origin); + } + if (origin && origin.startsWith("http://localhost")) { + origins.add(origin.replace("http://", "https://")); + } + } + return Array.from(origins); + } + + deriveSubdomainForNpub(npub) { + try { + return deriveShortSubdomain(npub); + } catch (err) { + console.warn("Failed to derive short subdomain, falling back:", err); + } + + const base = String(npub || "user") + .toLowerCase() + .replace(/[^a-z0-9-]/g, "") + .replace(/^-+|[-]+$/g, ""); + return base.slice(0, 32) || "user"; + } + + async ensureBucketConfigForNpub(npub) { + if (!npub || !this.cloudflareSettings) { + return null; + } + + const accountId = (this.cloudflareSettings.accountId || "").trim(); + const apiToken = (this.cloudflareSettings.apiToken || "").trim(); + const zoneId = (this.cloudflareSettings.zoneId || "").trim(); + const accessKeyId = (this.cloudflareSettings.accessKeyId || "").trim(); + const secretAccessKey = (this.cloudflareSettings.secretAccessKey || "").trim(); + const corsOrigins = this.getCorsOrigins(); + const baseDomain = this.cloudflareSettings.baseDomain || ""; + + if (!accountId) { + throw new Error("Cloudflare account ID is missing."); + } + + let entry = this.cloudflareSettings.buckets?.[npub] || null; + + if (entry && entry.publicBaseUrl) { + if (apiToken) { + try { + await ensureBucket({ + accountId, + bucket: entry.bucket, + token: apiToken, + }); + await putCors({ + accountId, + bucket: entry.bucket, + token: apiToken, + origins: corsOrigins, + }); + } catch (err) { + console.warn("Failed to refresh bucket configuration:", err); + } + } else if (accessKeyId && secretAccessKey && corsOrigins.length > 0) { + try { + const s3 = makeR2Client({ + accountId, + accessKeyId, + secretAccessKey, + }); + await ensureBucketCors({ + s3, + bucket: entry.bucket, + origins: corsOrigins, + }); + } catch (err) { + console.warn("Failed to refresh bucket CORS via access keys:", err); + } + } + return { + entry, + usedManagedFallback: entry.domainType !== "custom", + customDomainStatus: entry.domainType === "custom" ? "active" : "skipped", + }; + } + + if (!apiToken) { + const bucketName = entry?.bucket || sanitizeBucketName(npub); + const manualCustomDomain = baseDomain + ? `https://${this.deriveSubdomainForNpub(npub)}.${baseDomain}` + : ""; + + let publicBaseUrl = entry?.publicBaseUrl || manualCustomDomain; + if (!publicBaseUrl) { + publicBaseUrl = `https://${bucketName}.${accountId}.r2.dev`; + } + + if (!publicBaseUrl) { + throw new Error( + "No public bucket domain configured. Add an API token or configure the domain manually." + ); + } + + const manualEntry = { + bucket: bucketName, + publicBaseUrl, + domainType: publicBaseUrl.includes(".r2.dev") ? "managed" : "custom", + lastUpdated: Date.now(), + }; + + if (accessKeyId && secretAccessKey && corsOrigins.length > 0) { + try { + const s3 = makeR2Client({ + accountId, + accessKeyId, + secretAccessKey, + }); + await ensureBucketCors({ + s3, + bucket: bucketName, + origins: corsOrigins, + }); + } catch (corsErr) { + console.warn( + "Failed to ensure R2 CORS rules via access keys. Configure the bucket's CORS policy manually if uploads continue to fail.", + corsErr + ); + } + } + + let savedEntry = entry; + if ( + !entry || + entry.bucket !== manualEntry.bucket || + entry.publicBaseUrl !== manualEntry.publicBaseUrl || + entry.domainType !== manualEntry.domainType + ) { + const updatedSettings = await saveR2Settings( + mergeBucketEntry(this.getSettings(), npub, manualEntry) + ); + this.setSettings(updatedSettings); + savedEntry = updatedSettings.buckets?.[npub] || manualEntry; + } + + return { + entry: savedEntry, + usedManagedFallback: manualEntry.domainType !== "custom", + customDomainStatus: + manualEntry.domainType === "custom" ? "manual" : "managed", + }; + } + + const bucketName = entry?.bucket || sanitizeBucketName(npub); + + await ensureBucket({ accountId, bucket: bucketName, token: apiToken }); + + try { + await putCors({ + accountId, + bucket: bucketName, + token: apiToken, + origins: corsOrigins, + }); + } catch (err) { + console.warn("Failed to apply R2 CORS rules:", err); + } + + let publicBaseUrl = entry?.publicBaseUrl || ""; + let domainType = entry?.domainType || "managed"; + let usedManagedFallback = false; + let customDomainStatus = "skipped"; + + if (baseDomain && zoneId) { + const domain = `${this.deriveSubdomainForNpub(npub)}.${baseDomain}`; + try { + const custom = await attachCustomDomainAndWait({ + accountId, + bucket: bucketName, + token: apiToken, + zoneId, + domain, + pollInterval: 2500, + timeoutMs: 120000, + }); + customDomainStatus = custom?.status || "unknown"; + if (custom?.active && custom?.url) { + publicBaseUrl = custom.url; + domainType = "custom"; + try { + await setManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + enabled: false, + }); + } catch (disableErr) { + console.warn("Failed to disable managed domain:", disableErr); + } + } else { + usedManagedFallback = true; + } + } catch (err) { + if (/already exists/i.test(err.message || "")) { + publicBaseUrl = `https://${domain}`; + domainType = "custom"; + customDomainStatus = "active"; + try { + await setManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + enabled: false, + }); + } catch (disableErr) { + console.warn("Failed to disable managed domain:", disableErr); + } + } else { + console.warn("Failed to attach custom domain, falling back:", err); + usedManagedFallback = true; + customDomainStatus = "error"; + } + } + } + + if (!publicBaseUrl) { + const managed = await setManagedDomain({ + accountId, + bucket: bucketName, + token: apiToken, + enabled: true, + }); + publicBaseUrl = managed?.url || `https://${bucketName}.${accountId}.r2.dev`; + domainType = "managed"; + usedManagedFallback = true; + customDomainStatus = + customDomainStatus === "skipped" ? "managed" : customDomainStatus; + } + + const mergedEntry = { + bucket: bucketName, + publicBaseUrl, + domainType, + lastUpdated: Date.now(), + }; + const updatedSettings = await saveR2Settings( + mergeBucketEntry(this.getSettings(), npub, mergedEntry) + ); + this.setSettings(updatedSettings); + return { entry: mergedEntry, usedManagedFallback, customDomainStatus }; + } + + async updateCloudflareBucketPreview({ hasPubkey = false, npub = "" } = {}) { + if (!this.cloudflareSettings) { + const detail = { + text: "Save your credentials to configure R2.", + title: "", + }; + this.emit("bucketPreview", detail); + return detail; + } + + if (!hasPubkey) { + const detail = { text: "Login to preview your R2 bucket.", title: "" }; + this.emit("bucketPreview", detail); + return detail; + } + + if (!npub) { + const detail = { text: "Unable to encode npub.", title: "" }; + this.emit("bucketPreview", detail); + return detail; + } + + const entry = this.cloudflareSettings.buckets?.[npub]; + if (!entry || !entry.publicBaseUrl) { + const detail = { + text: "Bucket will be auto-created on your next upload.", + title: "", + }; + this.emit("bucketPreview", detail); + return detail; + } + + const sampleKey = buildR2Key(npub, { name: "sample.mp4" }); + const publicUrl = buildPublicUrl(entry.publicBaseUrl, sampleKey); + const fullPreview = `${entry.bucket} • ${publicUrl}`; + + let displayHostAndPath = truncateMiddle(publicUrl, 72); + try { + const parsed = new URL(publicUrl); + const cleanPath = parsed.pathname.replace(/^\//, ""); + const truncatedPath = truncateMiddle(cleanPath || sampleKey, 32); + displayHostAndPath = `${truncateMiddle(parsed.host, 32)}/${truncatedPath}`; + } catch (err) { + // ignore URL parse issues and fall back to the raw string + } + + const truncatedBucket = truncateMiddle(entry.bucket, 28); + const detail = { + text: `${truncatedBucket} • ${displayHostAndPath}`, + title: fullPreview, + }; + this.emit("bucketPreview", detail); + return detail; + } + + async handleCloudflareUploadSubmit({ + npub = "", + file = null, + metadata = {}, + settingsInput = null, + publishVideoNote, + onReset, + } = {}) { + if (settingsInput) { + const saved = await this.handleCloudflareSettingsSubmit(settingsInput, { + quiet: true, + }); + if (!saved) { + this.setCloudflareUploadStatus( + "Fix your R2 settings before uploading.", + "error" + ); + return false; + } + } + + if (!npub) { + this.setCloudflareUploadStatus("Unable to encode npub.", "error"); + return false; + } + + const title = String(metadata.title || "").trim(); + if (!title) { + this.setCloudflareUploadStatus("Title is required.", "error"); + return false; + } + + if (!file) { + this.setCloudflareUploadStatus( + "Select a video or HLS file to upload.", + "error" + ); + return false; + } + + const description = String(metadata.description || "").trim(); + const thumbnail = String(metadata.thumbnail || "").trim(); + const magnet = String(metadata.magnet || "").trim(); + const ws = String(metadata.ws || "").trim(); + const xs = String(metadata.xs || "").trim(); + const enableComments = metadata.enableComments !== false; + + const accountId = (this.cloudflareSettings?.accountId || "").trim(); + const accessKeyId = (this.cloudflareSettings?.accessKeyId || "").trim(); + const secretAccessKey = (this.cloudflareSettings?.secretAccessKey || "").trim(); + + if (!accountId || !accessKeyId || !secretAccessKey) { + this.setCloudflareUploadStatus( + "Missing R2 credentials. Save them before uploading.", + "error" + ); + return false; + } + + this.setCloudflareUploadStatus("Preparing Cloudflare R2…", "info"); + this.updateCloudflareProgress(0); + this.setCloudflareUploading(true); + + let bucketResult = null; + try { + bucketResult = await this.ensureBucketConfigForNpub(npub); + } catch (err) { + console.error("Failed to prepare R2 bucket:", err); + this.setCloudflareUploadStatus( + err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.", + "error" + ); + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + return false; + } + + const bucketEntry = + bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub]; + + if (!bucketEntry || !bucketEntry.publicBaseUrl) { + this.setCloudflareUploadStatus( + "Bucket is missing a public domain. Check your Cloudflare settings.", + "error" + ); + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + return false; + } + + let statusMessage = `Uploading to ${bucketEntry.bucket}…`; + if (bucketResult?.usedManagedFallback) { + const baseDomain = this.cloudflareSettings?.baseDomain || ""; + if (baseDomain) { + const customStatus = bucketResult?.customDomainStatus + ? ` (custom domain status: ${bucketResult.customDomainStatus})` + : ""; + statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Verify your Cloudflare zone${customStatus}. Uploading…`; + } else { + statusMessage = `Using managed r2.dev domain for ${bucketEntry.bucket}. Uploading…`; + } + } + + this.setCloudflareUploadStatus( + statusMessage, + bucketResult?.usedManagedFallback ? "warning" : "info" + ); + + const key = buildR2Key(npub, file); + const publicUrl = buildPublicUrl(bucketEntry.publicBaseUrl, key); + + try { + const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey }); + + await multipartUpload({ + s3, + bucket: bucketEntry.bucket, + key, + file, + contentType: file.type, + onProgress: (fraction) => { + this.updateCloudflareProgress(fraction); + }, + }); + + let publishOutcome = true; + + if (typeof publishVideoNote !== "function") { + console.warn( + "publishVideoNote handler missing; skipping publish step." + ); + publishOutcome = false; + } else { + const payload = { + title, + url: publicUrl, + magnet, + thumbnail, + description, + ws, + xs, + enableComments, + }; + + const published = await publishVideoNote(payload, { + onSuccess: () => { + if (typeof onReset === "function") { + onReset(); + } + }, + }); + + publishOutcome = Boolean(published); + if (publishOutcome) { + this.setCloudflareUploadStatus( + `Published ${publicUrl}`, + "success" + ); + } + } + + return publishOutcome; + } catch (err) { + console.error("Cloudflare upload failed:", err); + this.setCloudflareUploadStatus( + err?.message ? `Upload failed: ${err.message}` : "Upload failed.", + "error" + ); + return false; + } finally { + this.setCloudflareUploading(false); + this.updateCloudflareProgress(Number.NaN); + await this.updateCloudflareBucketPreview({ hasPubkey: true, npub }); + } + } + + async uploadVideo(params = {}) { + return this.handleCloudflareUploadSubmit(params); + } +} + +const r2Service = new R2Service(); + +export default r2Service; From 2b42d0ada642ccedcba22ecccd40038594260950 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 22:59:12 -0400 Subject: [PATCH 004/195] Refactor playback into service --- js/app.js | 641 ++++++----------------------- js/services/playbackService.js | 731 +++++++++++++++++++++++++++++++++ 2 files changed, 860 insertions(+), 512 deletions(-) create mode 100644 js/services/playbackService.js diff --git a/js/app.js b/js/app.js index 99a8f4d3..087168e2 100644 --- a/js/app.js +++ b/js/app.js @@ -21,6 +21,7 @@ import { userBlocks } from "./userBlocks.js"; import { relayManager } from "./relayManager.js"; import watchHistoryService from "./watchHistoryService.js"; import r2Service from "./services/r2Service.js"; +import PlaybackService from "./services/playbackService.js"; import { initQuickR2Upload } from "./r2-quick.js"; import { createWatchHistoryRenderer } from "./historyView.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; @@ -358,10 +359,40 @@ class bitvidApp { this.videoDiscussionCountCache = new Map(); this.inFlightDiscussionCounts = new Map(); this.activeIntervals = []; - this.urlPlaybackWatchdogCleanup = null; this.watchHistoryMetadataEnabled = null; this.watchHistoryPreferenceUnsubscribe = null; + this.playbackService = new PlaybackService({ + logger: (message, ...args) => this.log(message, ...args), + torrentClient, + deriveTorrentPlaybackConfig, + isValidMagnetUri, + urlFirstEnabled: URL_FIRST_ENABLED, + analyticsCallbacks: { + "session-start": (detail) => { + const urlProvided = detail?.urlProvided ? "true" : "false"; + const magnetProvided = detail?.magnetProvided ? "true" : "false"; + const magnetUsable = detail?.magnetUsable ? "true" : "false"; + this.log( + `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}` + ); + }, + fallback: (detail) => { + if (detail?.reason) { + this.log( + `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).` + ); + } + }, + error: (detail) => { + if (detail?.error) { + this.log("[playVideoWithFallback] Playback error observed", detail.error); + } + }, + }, + }); + this.activePlaybackSession = null; + // Optional: a "profile" button or avatar (if used) this.profileButton = document.getElementById("profileButton") || null; this.profileAvatar = document.getElementById("profileAvatar") || null; @@ -6015,7 +6046,9 @@ class bitvidApp { } ); this.clearActiveIntervals(); - this.cleanupUrlPlaybackWatchdog(); + if (this.playbackService) { + this.playbackService.cleanupWatchdog(); + } this.teardownModalViewCountSubscription(); if (!preserveObservers && this.mediaLoader) { @@ -6106,18 +6139,6 @@ class bitvidApp { this.activeIntervals = []; } - cleanupUrlPlaybackWatchdog() { - if (typeof this.urlPlaybackWatchdogCleanup === "function") { - try { - this.urlPlaybackWatchdogCleanup(); - } catch (err) { - console.warn("[cleanupUrlPlaybackWatchdog]", err); - } finally { - this.urlPlaybackWatchdogCleanup = null; - } - } - } - cancelPendingViewLogging() { const state = this.playbackTelemetryState; if (!state) { @@ -6934,110 +6955,6 @@ class bitvidApp { return clone; } - registerUrlPlaybackWatchdogs( - videoElement, - { stallMs = 8000, onSuccess, onFallback } = {} - ) { - this.cleanupUrlPlaybackWatchdog(); - - if (!videoElement || typeof onFallback !== "function") { - return () => {}; - } - - const normalizedStallMs = Number.isFinite(stallMs) && stallMs > 0 ? stallMs : 0; - this.log( - `[registerUrlPlaybackWatchdogs] Installing watchdogs (stallMs=${normalizedStallMs}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}` - ); - let active = true; - let stallTimerId = null; - - const listeners = []; - - const cleanup = () => { - if (!active) { - return; - } - active = false; - if (stallTimerId) { - clearTimeout(stallTimerId); - stallTimerId = null; - } - for (const [eventName, handler] of listeners) { - videoElement.removeEventListener(eventName, handler); - } - this.urlPlaybackWatchdogCleanup = null; - }; - - const triggerFallback = (reason) => { - if (!active) { - return; - } - this.log( - `[registerUrlPlaybackWatchdogs] Triggering fallback (${reason}) readyState=${videoElement.readyState} networkState=${videoElement.networkState}` - ); - cleanup(); - onFallback(reason); - }; - - const handleSuccess = () => { - if (!active) { - return; - } - this.log( - "[registerUrlPlaybackWatchdogs] Hosted playback signaled success; clearing watchdogs." - ); - cleanup(); - if (typeof onSuccess === "function") { - onSuccess(); - } - }; - - const resetTimer = () => { - if (!active || !normalizedStallMs) { - return; - } - if (stallTimerId) { - clearTimeout(stallTimerId); - } - stallTimerId = setTimeout(() => triggerFallback("stall"), normalizedStallMs); - }; - - const addListener = (eventName, handler) => { - videoElement.addEventListener(eventName, handler); - listeners.push([eventName, handler]); - }; - - addListener("error", () => triggerFallback("error")); - addListener("abort", () => triggerFallback("abort")); - addListener("stalled", () => triggerFallback("stalled")); - addListener("playing", handleSuccess); - addListener("ended", handleSuccess); - - const timerEvents = [ - "timeupdate", - "progress", - "loadeddata", - "canplay", - "canplaythrough", - "suspend", - "waiting", - ]; - for (const eventName of timerEvents) { - addListener(eventName, resetTimer); - } - - if (normalizedStallMs) { - resetTimer(); - } - - this.urlPlaybackWatchdogCleanup = () => { - this.log("[registerUrlPlaybackWatchdogs] Manual watchdog cleanup invoked."); - cleanup(); - }; - - return this.urlPlaybackWatchdogCleanup; - } - resetTorrentStats() { if (this.modalPeers) { this.modalPeers.textContent = ""; @@ -7143,26 +7060,6 @@ class bitvidApp { return dedupeToNewestByRoot(videos); } - prepareModalVideoForPlayback() { - if (!this.modalVideo) { - return; - } - - const storedUnmuted = localStorage.getItem("unmutedAutoplay"); - const userWantsUnmuted = storedUnmuted === "true"; - this.modalVideo.muted = !userWantsUnmuted; - - if (!this.modalVideo.dataset.autoplayBound) { - this.modalVideo.addEventListener("volumechange", () => { - localStorage.setItem( - "unmutedAutoplay", - (!this.modalVideo.muted).toString() - ); - }); - this.modalVideo.dataset.autoplayBound = "true"; - } - } - autoplayModalVideo() { if (!this.modalVideo) return; this.modalVideo.play().catch((err) => { @@ -9762,40 +9659,65 @@ class bitvidApp { * and falls back to WebTorrent when needed. */ async playVideoWithFallback({ url = "", magnet = "" } = {}) { - // When the modal closes we run an asynchronous cleanup that tears down the - // previous MediaSource attachment. If the user immediately selects another - // video we must wait for that teardown to finish or the stale cleanup will - // wipe the freshly attached `src`/`srcObject`, leaving playback stuck on a - // blank frame. Guard every playback entry point with waitForCleanup() so the - // race never reappears. await this.waitForCleanup(); this.cancelPendingViewLogging(); const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; - const playbackConfig = deriveTorrentPlaybackConfig({ - magnet: trimmedMagnet, + if (!this.modalVideo) { + throw new Error("Video element is not ready for playback."); + } + + if (typeof this.modalPosterCleanup === "function") { + try { + this.modalPosterCleanup(); + } catch (err) { + console.warn("[playVideoWithFallback] modal poster cleanup threw:", err); + } finally { + this.modalPosterCleanup = null; + } + } + + const refreshedModal = this.teardownVideoElement(this.modalVideo, { + replaceNode: true, + }); + if (refreshedModal) { + this.modalVideo = refreshedModal; + } + + const session = this.playbackService.createSession({ url: sanitizedUrl, - logger: (message) => this.log(message), + magnet: trimmedMagnet, + videoElement: this.modalVideo, + waitForCleanup: () => this.waitForCleanup(), + cancelPendingViewLogging: () => this.cancelPendingViewLogging(), + clearActiveIntervals: () => this.clearActiveIntervals(), + showModalWithPoster: () => this.showModalWithPoster(), + teardownVideoElement: (videoEl, options) => + this.teardownVideoElement(videoEl, options), + probeUrl: (candidateUrl) => this.probeUrl(candidateUrl), + playViaWebTorrent: (magnetUri, options) => + this.playViaWebTorrent(magnetUri, options), + autoplay: () => this.autoplayModalVideo(), + unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE, }); - const magnetIsUsable = isValidMagnetUri(playbackConfig.magnet); - const magnetForPlayback = magnetIsUsable ? playbackConfig.magnet : ""; - const fallbackMagnet = magnetIsUsable - ? playbackConfig.fallbackMagnet - : ""; - const magnetProvided = playbackConfig.provided; + this.activePlaybackSession = session; - this.log( - `[playVideoWithFallback] Session start urlProvided=${!!sanitizedUrl} magnetProvided=${magnetProvided} magnetUsable=${!!magnetForPlayback}` - ); + this.resetTorrentStats(); + this.playSource = null; + + const playbackConfig = session.getPlaybackConfig(); + const magnetForPlayback = session.getMagnetForPlayback(); + const fallbackMagnet = session.getFallbackMagnet(); + const magnetProvided = session.getMagnetProvided(); if (this.currentVideo) { this.currentVideo.magnet = magnetForPlayback; this.currentVideo.normalizedMagnet = magnetForPlayback; this.currentVideo.normalizedMagnetFallback = fallbackMagnet; - if (playbackConfig.infoHash && !this.currentVideo.legacyInfoHash) { + if (playbackConfig?.infoHash && !this.currentVideo.legacyInfoHash) { this.currentVideo.legacyInfoHash = playbackConfig.infoHash; } this.currentVideo.torrentSupported = !!magnetForPlayback; @@ -9803,383 +9725,78 @@ class bitvidApp { this.currentMagnetUri = magnetForPlayback || null; this.setCopyMagnetState(!!magnetForPlayback); - let ensureDebugListenersRemoved = () => {}; - - try { - if (!this.modalVideo) { - throw new Error("Video element is not ready for playback."); - } - - if (typeof this.modalPosterCleanup === "function") { - try { - this.modalPosterCleanup(); - } catch (err) { - console.warn("[playVideoWithFallback] modal poster cleanup threw:", err); - } finally { - this.modalPosterCleanup = null; - } - } - - const refreshedModal = this.teardownVideoElement(this.modalVideo, { - replaceNode: true, - }); - if (refreshedModal) { - this.modalVideo = refreshedModal; - } - - this.showModalWithPoster(); - const HOSTED_URL_SUCCESS_MESSAGE = "✅ Streaming from hosted URL"; + const unsubscribers = []; + const subscribe = (eventName, handler) => { + const off = session.on(eventName, handler); + unsubscribers.push(off); + }; + subscribe("status", ({ message } = {}) => { if (this.modalStatus) { - this.modalStatus.textContent = "Preparing video..."; + this.modalStatus.textContent = + typeof message === "string" ? message : ""; } + }); - this.clearActiveIntervals(); - this.prepareModalVideoForPlayback(); - - await torrentClient.cleanup(); - - const activeVideoEl = this.teardownVideoElement(this.modalVideo); - if (activeVideoEl) { - this.modalVideo = activeVideoEl; - this.log( - `[playVideoWithFallback] Modal video prepared (readyState=${activeVideoEl.readyState} networkState=${activeVideoEl.networkState}).` - ); + subscribe("video-prepared", ({ videoElement } = {}) => { + if (videoElement && videoElement !== this.modalVideo) { + this.modalVideo = videoElement; } + }); - if (activeVideoEl) { - const debugEvents = [ - "loadedmetadata", - "loadeddata", - "canplay", - "canplaythrough", - "play", - "playing", - "pause", - "stalled", - "suspend", - "waiting", - "ended", - "error", - ]; - const debugHandlers = []; - ensureDebugListenersRemoved = () => { - if (!debugHandlers.length) { - return; - } - for (const [eventName, handler] of debugHandlers) { - activeVideoEl.removeEventListener(eventName, handler); - } - debugHandlers.length = 0; - ensureDebugListenersRemoved = () => {}; - }; - for (const eventName of debugEvents) { - const handler = () => { - const { readyState, networkState, currentTime, paused, error } = - activeVideoEl; - let suffix = `readyState=${readyState} networkState=${networkState} currentTime=${Number.isFinite(currentTime) ? currentTime.toFixed(2) : currentTime} paused=${paused}`; - if (eventName === "error" && error) { - const errorMessage = error.message || ""; - suffix += ` code=${error.code || ""} message=${errorMessage}`; - } - this.log( - `[playVideoWithFallback]