From 96bbeef040784fbe5a5f58a3abef64082817d149 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:17:06 -0400 Subject: [PATCH 1/2] Fix lazy thumbnail load completion --- js/utils/mediaLoader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/utils/mediaLoader.js b/js/utils/mediaLoader.js index b2d65181..0955b09c 100644 --- a/js/utils/mediaLoader.js +++ b/js/utils/mediaLoader.js @@ -49,7 +49,7 @@ export class MediaLoader { el.style.backgroundImage = `url('${lazySrc}')`; } - el.dataset.thumbnailLoaded = "true"; + delete el.dataset.lazy; this.observer.unobserve(el); } }, { From e2935c23fda63da4ea122c43f1e60caa30f71c12 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 9 Oct 2025 20:31:40 -0400 Subject: [PATCH 2/2] Cache health badge state to avoid re-flashing --- js/app.js | 71 +++++++- js/gridHealth.js | 41 ++++- js/subscriptions.js | 16 ++ js/ui/components/VideoCard.js | 321 ++++++++++++++++++++++++++++++++-- js/ui/views/VideoListView.js | 108 +++++++++++- 5 files changed, 536 insertions(+), 21 deletions(-) diff --git a/js/app.js b/js/app.js index 80bf9828..a6b4bd32 100644 --- a/js/app.js +++ b/js/app.js @@ -162,6 +162,15 @@ class Application { ? helpers.mediaLoader : mediaLoaderFactory(); this.loadedThumbnails = new Map(); + this.urlHealthSnapshots = new Map(); + this.streamHealthSnapshots = new Map(); + this.boundStreamHealthBadgeHandler = (detail) => + this.handleStreamHealthBadgeUpdate(detail); + this.attachHealthBadgesWithCache = (container) => { + attachHealthBadges(container, { + onUpdate: this.boundStreamHealthBadgeHandler, + }); + }; this.relayManager = relayManager; this.activeIntervals = []; this.watchHistoryTelemetry = null; @@ -787,7 +796,11 @@ class Application { const videoListViewConfig = { document, mediaLoader: this.mediaLoader, - badgeHelpers: { attachHealthBadges, attachUrlHealthBadges }, + badgeHelpers: { + attachHealthBadges: this.attachHealthBadgesWithCache, + attachUrlHealthBadges: (container, onCheck) => + attachUrlHealthBadges(container, onCheck), + }, formatters: { formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), formatViewCountLabel: (total) => this.formatViewCountLabel(total), @@ -805,6 +818,8 @@ class Application { state: { loadedThumbnails: this.loadedThumbnails, videosMap: this.videosMap, + urlHealthByVideoId: this.urlHealthSnapshots, + streamHealthByVideoId: this.streamHealthSnapshots, }, utils: { dedupeVideos: (videos) => this.dedupeVideosByRoot(videos), @@ -4104,6 +4119,20 @@ class Application { "text-gray-300" ); } + + if ( + videoId && + this.videoListView && + typeof this.videoListView.cacheUrlHealth === "function" + ) { + this.videoListView.cacheUrlHealth(videoId, { + status, + message, + lastCheckedAt: Number.isFinite(state?.lastCheckedAt) + ? state.lastCheckedAt + : undefined, + }); + } } handleUrlHealthBadge({ video, url, badgeEl }) { @@ -4201,6 +4230,46 @@ class Application { }); } + handleStreamHealthBadgeUpdate(detail) { + if (!detail || typeof detail !== "object") { + return; + } + + const card = detail.card; + if (!(card instanceof HTMLElement)) { + return; + } + + let videoId = + (card.dataset && card.dataset.videoId) || + (typeof card.getAttribute === "function" ? card.getAttribute("data-video-id") : "") || + ""; + + if (!videoId && typeof card.querySelector === "function") { + const fallback = card.querySelector("[data-video-id]"); + if (fallback instanceof HTMLElement && fallback.dataset.videoId) { + videoId = fallback.dataset.videoId; + } + } + + if (!videoId) { + return; + } + + if (this.videoListView && typeof this.videoListView.cacheStreamHealth === "function") { + this.videoListView.cacheStreamHealth(videoId, { + state: detail.state, + peers: detail.peers, + reason: detail.reason, + checkedAt: detail.checkedAt, + text: detail.text, + tooltip: detail.tooltip, + role: detail.role, + ariaLive: detail.ariaLive, + }); + } + } + async renderVideoList(payload) { if (!this.videoListView) { return; diff --git a/js/gridHealth.js b/js/gridHealth.js index 5c6c969c..2066e1a5 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -2,6 +2,25 @@ import { createCardObserver } from "./dom/cardObserver.js"; import { infoHashFromMagnet } from "./magnets.js"; import { TorrentClient, torrentClient } from "./webtorrent.js"; +const badgeUpdateListeners = new Set(); + +function notifyBadgeUpdate(payload) { + if (!payload) { + return; + } + + badgeUpdateListeners.forEach((listener) => { + if (typeof listener !== "function") { + return; + } + try { + listener(payload); + } catch (err) { + console.warn("[gridHealth] badge listener failed", err); + } + }); +} + const ROOT_MARGIN = "200px 0px"; function now() { @@ -479,7 +498,8 @@ function setBadge(card, state, details) { }); badge.setAttribute("aria-label", tooltip); badge.setAttribute("title", tooltip); - badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite"); + const ariaLive = entry.role === "alert" ? "assertive" : "polite"; + badge.setAttribute("aria-live", ariaLive); badge.setAttribute("role", entry.role); badge.dataset.streamHealthState = normalizedState; if (hasPeerCount) { @@ -487,6 +507,20 @@ function setBadge(card, state, details) { } else if (badge.dataset.streamHealthPeers) { delete badge.dataset.streamHealthPeers; } + + const payload = { + card, + state: normalizedState, + peers: hasPeerCount ? peersValue : null, + reason: details && typeof details.reason === "string" ? details.reason : null, + checkedAt: + details && Number.isFinite(details.checkedAt) ? Number(details.checkedAt) : null, + text: badge.textContent, + tooltip, + role: entry.role, + ariaLive, + }; + notifyBadgeUpdate(payload); } function handleCardVisible({ card, pendingByCard, priority = 0 }) { @@ -597,10 +631,13 @@ const gridCardObserver = createCardObserver({ }, }); -export function attachHealthBadges(container) { +export function attachHealthBadges(container, options = {}) { if (!(container instanceof HTMLElement)) { return; } + if (options && typeof options.onUpdate === "function") { + badgeUpdateListeners.add(options.onUpdate); + } gridCardObserver.observe(container); } diff --git a/js/subscriptions.js b/js/subscriptions.js index 8b07c1e7..110d8fd2 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -518,6 +518,20 @@ class SubscriptionsManager { ? baseView.state.videosMap : new Map(); + const urlHealthCache = + app?.urlHealthSnapshots instanceof Map + ? app.urlHealthSnapshots + : baseView?.state?.urlHealthByVideoId instanceof Map + ? baseView.state.urlHealthByVideoId + : new Map(); + + const streamHealthCache = + app?.streamHealthSnapshots instanceof Map + ? app.streamHealthSnapshots + : baseView?.state?.streamHealthByVideoId instanceof Map + ? baseView.state.streamHealthByVideoId + : new Map(); + const listViewConfig = { document: doc, container, @@ -538,6 +552,8 @@ class SubscriptionsManager { state: { loadedThumbnails, videosMap, + urlHealthByVideoId: urlHealthCache, + streamHealthByVideoId: streamHealthCache, }, utils: { dedupeVideos: (videos) => (Array.isArray(videos) ? [...videos] : []), diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index c0cc9cec..d741d224 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -56,6 +56,12 @@ export class VideoCard { this.state = { loadedThumbnails: state.loadedThumbnails instanceof Map ? state.loadedThumbnails : null, + urlHealthByVideoId: + state.urlHealthByVideoId instanceof Map ? state.urlHealthByVideoId : null, + streamHealthByVideoId: + state.streamHealthByVideoId instanceof Map + ? state.streamHealthByVideoId + : null, }; this.ensureGlobalMoreMenuHandlers = typeof ensureGlobalMoreMenuHandlers === "function" @@ -210,6 +216,10 @@ export class VideoCard { this.root = root; + if (this.video?.id) { + root.dataset.videoId = this.video.id; + } + this.applyPointerDataset(); this.applyOwnerDataset(); this.applySourceDatasets(); @@ -887,17 +897,13 @@ export class VideoCard { "px-2", "py-1", "rounded", - "inline-flex", - "items-center", - "gap-1", - "bg-gray-800", - "text-gray-300", + "transition-colors", + "duration-200", ], - textContent: "Checking hosted URL…", }); - badge.dataset.urlHealthState = "checking"; badge.setAttribute("aria-live", "polite"); badge.setAttribute("role", "status"); + this.applyUrlBadgeVisualState(badge, this.getCachedUrlHealthEntry()); this.urlHealthBadgeEl = badge; pieces.push(badge); } @@ -911,19 +917,13 @@ export class VideoCard { "px-2", "py-1", "rounded", - "inline-flex", - "items-center", - "gap-1", - "bg-gray-800", - "text-gray-300", "transition-colors", "duration-200", ], - textContent: "⏳ Torrent", }); - badge.dataset.streamHealthState = "checking"; badge.setAttribute("aria-live", "polite"); badge.setAttribute("role", "status"); + this.applyStreamBadgeVisualState(badge, this.getCachedStreamHealthEntry()); this.torrentHealthBadgeEl = badge; pieces.push(badge); } @@ -939,6 +939,269 @@ export class VideoCard { return container; } + getCachedUrlHealthEntry() { + if (!this.video?.id || !(this.state.urlHealthByVideoId instanceof Map)) { + return null; + } + + const entry = this.state.urlHealthByVideoId.get(this.video.id); + if (!entry || typeof entry !== "object") { + return null; + } + + const normalized = {}; + if (typeof entry.status === "string" && entry.status) { + normalized.status = entry.status; + } + if (typeof entry.message === "string" && entry.message) { + normalized.message = entry.message; + } + if (Number.isFinite(entry.lastCheckedAt)) { + normalized.lastCheckedAt = Math.floor(entry.lastCheckedAt); + } + + return Object.keys(normalized).length ? normalized : null; + } + + getCachedStreamHealthEntry() { + if (!this.video?.id || !(this.state.streamHealthByVideoId instanceof Map)) { + return null; + } + + const entry = this.state.streamHealthByVideoId.get(this.video.id); + if (!entry || typeof entry !== "object") { + return null; + } + + const normalized = {}; + if (typeof entry.state === "string" && entry.state) { + normalized.state = entry.state; + } + if (Number.isFinite(entry.peers)) { + normalized.peers = Math.max(0, Number(entry.peers)); + } + if (typeof entry.reason === "string" && entry.reason) { + normalized.reason = entry.reason; + } + if (Number.isFinite(entry.checkedAt)) { + normalized.checkedAt = Math.floor(entry.checkedAt); + } + if (typeof entry.text === "string" && entry.text) { + normalized.text = entry.text; + } + if (typeof entry.tooltip === "string" && entry.tooltip) { + normalized.tooltip = entry.tooltip; + } + if (entry.role === "alert" || entry.role === "status") { + normalized.role = entry.role; + } + if (entry.ariaLive === "assertive" || entry.ariaLive === "polite") { + normalized.ariaLive = entry.ariaLive; + } + + return Object.keys(normalized).length ? normalized : null; + } + + formatTorrentCheckedTime(timestamp) { + if (!Number.isFinite(timestamp)) { + return ""; + } + + try { + return new Date(timestamp).toLocaleTimeString([], { hour12: false }); + } catch (error) { + try { + return new Date(timestamp).toLocaleTimeString(); + } catch (err) { + return ""; + } + } + } + + buildTorrentTooltip({ peers = null, checkedAt = null, reason = null } = {}) { + const parts = []; + if (Number.isFinite(peers)) { + parts.push(`Peers: ${Math.max(0, Number(peers))}`); + } + if (Number.isFinite(checkedAt)) { + const formatted = this.formatTorrentCheckedTime(checkedAt); + if (formatted) { + parts.push(`Checked ${formatted}`); + } + } + if (typeof reason === "string" && reason && reason !== "peer") { + let normalizedReason; + if (reason === "timeout") { + normalizedReason = "Timed out"; + } else if (reason === "no-trackers") { + normalizedReason = "No WSS trackers"; + } else if (reason === "invalid") { + normalizedReason = "Invalid magnet"; + } else { + normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1); + } + parts.push(normalizedReason); + } + if (!parts.length) { + return "WebTorrent status unknown"; + } + return `WebTorrent • ${parts.join(" • ")}`; + } + + applyUrlBadgeVisualState(badge, entry) { + if (!(badge instanceof HTMLElement)) { + return; + } + + const status = + typeof entry?.status === "string" && entry.status ? entry.status : "checking"; + const message = + typeof entry?.message === "string" && entry.message + ? entry.message + : "Checking hosted URL…"; + + const hadMargin = badge.classList.contains("mt-3"); + const baseClasses = [ + "url-health-badge", + "text-xs", + "font-semibold", + "px-2", + "py-1", + "rounded", + "transition-colors", + "duration-200", + ]; + if (hadMargin) { + baseClasses.unshift("mt-3"); + } + badge.className = baseClasses.join(" "); + + const common = ["inline-flex", "items-center", "gap-1"]; + const addClasses = (classes) => { + classes.forEach((cls) => badge.classList.add(cls)); + }; + + if (status === "healthy") { + addClasses([...common, "bg-green-900", "text-green-200"]); + } else if (status === "offline") { + addClasses([...common, "bg-red-900", "text-red-200"]); + } else if (status === "unknown" || status === "timeout") { + addClasses([...common, "bg-amber-900", "text-amber-200"]); + } else { + addClasses([...common, "bg-gray-800", "text-gray-300"]); + } + + badge.dataset.urlHealthState = status; + badge.textContent = message; + badge.setAttribute("aria-live", "polite"); + badge.setAttribute("role", status === "offline" ? "alert" : "status"); + } + + applyStreamBadgeVisualState(badge, entry) { + if (!(badge instanceof HTMLElement)) { + return; + } + + const state = + typeof entry?.state === "string" && entry.state ? entry.state : "checking"; + const peersValue = Number.isFinite(entry?.peers) + ? Math.max(0, Number(entry.peers)) + : null; + const reason = typeof entry?.reason === "string" && entry.reason ? entry.reason : null; + const text = typeof entry?.text === "string" && entry.text ? entry.text : null; + const tooltip = + typeof entry?.tooltip === "string" && entry.tooltip ? entry.tooltip : null; + const role = entry?.role === "alert" || entry?.role === "status" ? entry.role : null; + const ariaLive = + entry?.ariaLive === "assertive" || entry?.ariaLive === "polite" + ? entry.ariaLive + : role === "alert" + ? "assertive" + : "polite"; + + const hadMargin = badge.classList.contains("mt-3"); + const baseClasses = [ + "torrent-health-badge", + "text-xs", + "font-semibold", + "px-2", + "py-1", + "rounded", + "transition-colors", + "duration-200", + ]; + if (hadMargin) { + baseClasses.unshift("mt-3"); + } + badge.className = baseClasses.join(" "); + + const common = ["inline-flex", "items-center", "gap-1"]; + const addClasses = (classes) => { + classes.forEach((cls) => badge.classList.add(cls)); + }; + + if (state === "healthy") { + addClasses([...common, "bg-green-900", "text-green-200"]); + } else if (state === "unhealthy") { + addClasses([...common, "bg-red-900", "text-red-200"]); + } else { + addClasses([...common, "bg-gray-800", "text-gray-300"]); + } + + const map = { + healthy: { + icon: "🟢", + aria: "WebTorrent peers available", + }, + unhealthy: { + icon: "🔴", + aria: "WebTorrent peers unavailable", + }, + checking: { + icon: "⏳", + aria: "Checking WebTorrent peers", + }, + unknown: { + icon: "⚪", + aria: "WebTorrent status unknown", + }, + }; + + const descriptor = map[state] || map.unknown; + const peersText = state === "healthy" && peersValue > 0 ? ` (${peersValue})` : ""; + const iconPrefix = descriptor.icon ? `${descriptor.icon} ` : ""; + const computedText = `${iconPrefix}WebTorrent${peersText}`; + badge.textContent = text || computedText; + + const tooltipValue = + tooltip || + (state === "checking" || state === "unknown" + ? descriptor.aria + : this.buildTorrentTooltip({ + peers: peersValue, + checkedAt: Number.isFinite(entry?.checkedAt) ? entry.checkedAt : null, + reason, + })); + + badge.setAttribute("aria-label", tooltipValue); + badge.setAttribute("title", tooltipValue); + badge.setAttribute("aria-live", ariaLive); + badge.setAttribute("role", role || (state === "unhealthy" ? "alert" : "status")); + + badge.dataset.streamHealthState = state; + if (reason) { + badge.dataset.streamHealthReason = reason; + } else if (badge.dataset.streamHealthReason) { + delete badge.dataset.streamHealthReason; + } + + if (Number.isFinite(peersValue) && peersValue !== null) { + badge.dataset.streamHealthPeers = String(peersValue); + } else if (badge.dataset.streamHealthPeers) { + delete badge.dataset.streamHealthPeers; + } + } + buildDiscussionCount() { if (this.video.enableComments === false) { return null; @@ -1033,8 +1296,13 @@ export class VideoCard { return; } + const cachedUrlHealth = this.getCachedUrlHealthEntry(); if (this.playbackUrl) { - this.root.dataset.urlHealthState = "checking"; + const status = + typeof cachedUrlHealth?.status === "string" && cachedUrlHealth.status + ? cachedUrlHealth.status + : "checking"; + this.root.dataset.urlHealthState = status; delete this.root.dataset.urlHealthReason; this.root.dataset.urlHealthEventId = this.video.id || ""; this.root.dataset.urlHealthUrl = encodeURIComponent(this.playbackUrl); @@ -1045,14 +1313,33 @@ export class VideoCard { delete this.root.dataset.urlHealthUrl; } + const cachedStreamHealth = this.getCachedStreamHealthEntry(); if (this.magnetProvided && this.magnetSupported) { - this.root.dataset.streamHealthState = "checking"; - delete this.root.dataset.streamHealthReason; + const state = + typeof cachedStreamHealth?.state === "string" && cachedStreamHealth.state + ? cachedStreamHealth.state + : "checking"; + this.root.dataset.streamHealthState = state; + if (cachedStreamHealth?.reason) { + this.root.dataset.streamHealthReason = cachedStreamHealth.reason; + } else if (this.root.dataset.streamHealthReason) { + delete this.root.dataset.streamHealthReason; + } + if (Number.isFinite(cachedStreamHealth?.peers)) { + this.root.dataset.streamHealthPeers = String( + Math.max(0, Number(cachedStreamHealth.peers)) + ); + } else if (this.root.dataset.streamHealthPeers) { + delete this.root.dataset.streamHealthPeers; + } } else { this.root.dataset.streamHealthState = "unhealthy"; this.root.dataset.streamHealthReason = this.magnetProvided ? "unsupported" : "missing-source"; + if (this.root.dataset.streamHealthPeers) { + delete this.root.dataset.streamHealthPeers; + } } if (this.magnetProvided) { diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index 68afa1f3..3bee2039 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -54,6 +54,14 @@ export class VideoListView { loadedThumbnails: state.loadedThumbnails instanceof Map ? state.loadedThumbnails : new Map(), videosMap: state.videosMap instanceof Map ? state.videosMap : new Map(), + urlHealthByVideoId: + state.urlHealthByVideoId instanceof Map + ? state.urlHealthByVideoId + : new Map(), + streamHealthByVideoId: + state.streamHealthByVideoId instanceof Map + ? state.streamHealthByVideoId + : new Map(), feedMetadata: state.feedMetadata && typeof state.feedMetadata === "object" ? { ...state.feedMetadata } @@ -268,6 +276,7 @@ export class VideoListView { this.syncViewCountSubscriptions(displayVideos); this.cleanupThumbnailCache(displayVideos); + this.cleanupHealthCache(displayVideos); if (!displayVideos.length) { this.renderedVideoIds.clear(); @@ -383,7 +392,11 @@ export class VideoListView { fallbackThumbnailSrc: this.assets.fallbackThumbnailSrc, unsupportedBtihMessage: this.assets.unsupportedBtihMessage, }, - state: { loadedThumbnails: this.state.loadedThumbnails }, + state: { + loadedThumbnails: this.state.loadedThumbnails, + urlHealthByVideoId: this.state.urlHealthByVideoId, + streamHealthByVideoId: this.state.streamHealthByVideoId, + }, ensureGlobalMoreMenuHandlers: () => this.utils.ensureGlobalMoreMenuHandlers(), onRequestCloseAllMenus: () => this.closeAllMenus(), @@ -517,6 +530,99 @@ export class VideoListView { return displayVideos; } + cacheUrlHealth(videoId, entry) { + if (!videoId || !(this.state.urlHealthByVideoId instanceof Map)) { + return; + } + + if (!entry || typeof entry !== "object") { + this.state.urlHealthByVideoId.delete(videoId); + return; + } + + const normalized = { + status: + typeof entry.status === "string" && entry.status ? entry.status : "checking", + message: + typeof entry.message === "string" && entry.message + ? entry.message + : "Checking hosted URL…", + }; + + if (Number.isFinite(entry.lastCheckedAt)) { + normalized.lastCheckedAt = Math.floor(entry.lastCheckedAt); + } + + this.state.urlHealthByVideoId.set(videoId, normalized); + } + + cacheStreamHealth(videoId, entry) { + if (!videoId || !(this.state.streamHealthByVideoId instanceof Map)) { + return; + } + + if (!entry || typeof entry !== "object") { + this.state.streamHealthByVideoId.delete(videoId); + return; + } + + const normalized = { + state: + typeof entry.state === "string" && entry.state ? entry.state : "unknown", + }; + + if (Number.isFinite(entry.peers)) { + normalized.peers = Math.max(0, Number(entry.peers)); + } + if (typeof entry.reason === "string" && entry.reason) { + normalized.reason = entry.reason; + } + if (Number.isFinite(entry.checkedAt)) { + normalized.checkedAt = Math.floor(entry.checkedAt); + } + if (typeof entry.text === "string" && entry.text) { + normalized.text = entry.text; + } + if (typeof entry.tooltip === "string" && entry.tooltip) { + normalized.tooltip = entry.tooltip; + } + if (entry.role === "alert" || entry.role === "status") { + normalized.role = entry.role; + } + if (entry.ariaLive === "assertive" || entry.ariaLive === "polite") { + normalized.ariaLive = entry.ariaLive; + } + + this.state.streamHealthByVideoId.set(videoId, normalized); + } + + cleanupHealthCache(videos) { + const activeIds = new Set(); + if (Array.isArray(videos)) { + videos.forEach((video) => { + if (video && typeof video.id === "string" && video.id) { + activeIds.add(video.id); + } + }); + } + + if (this.state.urlHealthByVideoId instanceof Map) { + Array.from(this.state.urlHealthByVideoId.keys()).forEach((videoId) => { + if (!activeIds.has(videoId)) { + this.state.urlHealthByVideoId.delete(videoId); + } + }); + } + + if (this.state.streamHealthByVideoId instanceof Map) { + Array.from(this.state.streamHealthByVideoId.keys()).forEach((videoId) => { + if (!activeIds.has(videoId)) { + this.state.streamHealthByVideoId.delete(videoId); + } + }); + } + } + updateVideo(videoId, patch) { if (!videoId) { return null;