diff --git a/css/style.css b/css/style.css index 208e5552..b523fd42 100644 --- a/css/style.css +++ b/css/style.css @@ -3,6 +3,7 @@ --color-card: #1e293b; --color-primary: #fe0032; --color-secondary: #ff93a5; + --color-card-critical: #3f0b13; --color-text: #f8fafc; --color-muted: #94a3b8; --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); @@ -74,6 +75,19 @@ header img { box-shadow: var(--shadow-md); } +.video-card[data-owner-is-viewer="false"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] { + display: none; +} + +.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] { + background-color: var(--color-card-critical); + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35), var(--shadow-md); +} + +.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"]:hover { + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.45), var(--shadow-lg); +} + .video-card--enter { opacity: 0; animation: video-card-fade-in 220ms ease-out forwards; diff --git a/js/app.js b/js/app.js index 7d693751..e379a8d6 100644 --- a/js/app.js +++ b/js/app.js @@ -3243,6 +3243,10 @@ class bitvidApp { const hadMargin = badgeEl.classList.contains("mt-3"); badgeEl.dataset.urlHealthState = status; + const cardEl = badgeEl.closest(".video-card"); + if (cardEl) { + cardEl.dataset.urlHealthState = status; + } badgeEl.setAttribute("aria-live", "polite"); badgeEl.setAttribute("role", status === "offline" ? "alert" : "status"); badgeEl.textContent = message; @@ -3677,6 +3681,34 @@ class bitvidApp { template.innerHTML = cardHtml.trim(); const cardEl = template.content.firstElementChild; if (cardEl) { + cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; + if (typeof video.pubkey === "string" && video.pubkey) { + cardEl.dataset.ownerPubkey = video.pubkey; + } else if (cardEl.dataset.ownerPubkey) { + delete cardEl.dataset.ownerPubkey; + } + + if (trimmedUrl) { + cardEl.dataset.urlHealthState = "checking"; + if (cardEl.dataset.urlHealthReason) { + delete cardEl.dataset.urlHealthReason; + } + } else { + cardEl.dataset.urlHealthState = "offline"; + cardEl.dataset.urlHealthReason = "missing-source"; + } + if (magnetProvided && magnetSupported) { + cardEl.dataset.streamHealthState = "checking"; + if (cardEl.dataset.streamHealthReason) { + delete cardEl.dataset.streamHealthReason; + } + } else { + cardEl.dataset.streamHealthState = "unhealthy"; + cardEl.dataset.streamHealthReason = magnetProvided + ? "unsupported" + : "missing-source"; + } + const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]"); if (thumbnailEl) { const markThumbnailAsLoaded = () => { diff --git a/js/channelProfile.js b/js/channelProfile.js index 531d5ad4..c9a08660 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -360,6 +360,13 @@ async function loadUserVideos(pubkey) { "duration-300" ); + cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; + if (typeof video.pubkey === "string" && video.pubkey) { + cardEl.dataset.ownerPubkey = video.pubkey; + } else if (cardEl.dataset.ownerPubkey) { + delete cardEl.dataset.ownerPubkey; + } + const rawMagnet = typeof video.magnet === "string" ? video.magnet : ""; const trimmedMagnet = rawMagnet ? rawMagnet.trim() : ""; @@ -433,6 +440,27 @@ async function loadUserVideos(pubkey) { delete cardEl.dataset.torrentSupported; } + if (trimmedUrl) { + cardEl.dataset.urlHealthState = "checking"; + if (cardEl.dataset.urlHealthReason) { + delete cardEl.dataset.urlHealthReason; + } + } else { + cardEl.dataset.urlHealthState = "offline"; + cardEl.dataset.urlHealthReason = "missing-source"; + } + if (magnetProvided && magnetSupported) { + cardEl.dataset.streamHealthState = "checking"; + if (cardEl.dataset.streamHealthReason) { + delete cardEl.dataset.streamHealthReason; + } + } else { + cardEl.dataset.streamHealthState = "unhealthy"; + cardEl.dataset.streamHealthReason = magnetProvided + ? "unsupported" + : "missing-source"; + } + if (magnetProvided) { cardEl.dataset.magnet = playbackMagnet; } else if (cardEl.dataset.magnet) { diff --git a/js/gridHealth.js b/js/gridHealth.js index b2cf13ca..997ce041 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -358,10 +358,37 @@ function getIntersectionRect(entry) { } function setBadge(card, state, details) { + if (!(card instanceof HTMLElement)) { + return; + } + + const normalizedState = + typeof state === "string" && state ? state : "unknown"; + const peersValue = + details && Number.isFinite(details.peers) + ? Math.max(0, Number(details.peers)) + : 0; + const hasPeerCount = details ? Number.isFinite(details.peers) : false; + const peersTextValue = hasPeerCount ? String(peersValue) : ""; + + card.dataset.streamHealthState = normalizedState; + if (hasPeerCount) { + card.dataset.streamHealthPeers = peersTextValue; + } else if (card.dataset.streamHealthPeers) { + delete card.dataset.streamHealthPeers; + } + + if (details && typeof details.reason === "string" && details.reason) { + card.dataset.streamHealthReason = details.reason; + } else if (card.dataset.streamHealthReason) { + delete card.dataset.streamHealthReason; + } + const badge = card.querySelector(".torrent-health-badge"); if (!badge) { return; } + const hadMargin = badge.classList.contains("mt-3"); const baseClasses = [ @@ -409,22 +436,19 @@ function setBadge(card, state, details) { }, }; - const entry = map[state] || map.unknown; + const entry = map[normalizedState] || map.unknown; entry.classes.forEach((cls) => badge.classList.add(cls)); - const peers = - details && Number.isFinite(details.peers) - ? Math.max(0, Number(details.peers)) - : 0; - const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : ""; + const peersText = + normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : ""; const iconPrefix = entry.icon ? `${entry.icon} ` : ""; badge.textContent = `${iconPrefix}WebTorrent${peersText}`; const tooltip = - state === "checking" || state === "unknown" + normalizedState === "checking" || normalizedState === "unknown" ? entry.aria : buildTooltip({ - peers, + peers: peersValue, checkedAt: details?.checkedAt, reason: details?.reason, }); @@ -432,9 +456,9 @@ function setBadge(card, state, details) { badge.setAttribute("title", tooltip); badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite"); badge.setAttribute("role", entry.role); - badge.dataset.streamHealthState = state; - if (Number.isFinite(peers)) { - badge.dataset.streamHealthPeers = String(peers); + badge.dataset.streamHealthState = normalizedState; + if (hasPeerCount) { + badge.dataset.streamHealthPeers = peersTextValue; } else if (badge.dataset.streamHealthPeers) { delete badge.dataset.streamHealthPeers; } @@ -446,13 +470,13 @@ function handleCardVisible({ card, pendingByCard, priority = 0 }) { } const magnet = card.dataset.magnet || ""; if (!magnet) { - setBadge(card, "unknown"); + setBadge(card, "unhealthy", { reason: "missing-source" }); return; } const infoHash = infoHashFromMagnet(magnet); if (!infoHash) { - setBadge(card, "unknown"); + setBadge(card, "unhealthy", { reason: "invalid" }); return; } @@ -523,7 +547,7 @@ export function attachHealthBadges(container) { state.observedCards.add(card); state.observer.observe(card); if (!card.dataset.magnet) { - setBadge(card, "unknown"); + setBadge(card, "unhealthy", { reason: "missing-source" }); } }); processObserverEntries(state.observer.takeRecords(), state); diff --git a/js/subscriptions.js b/js/subscriptions.js index 68b42102..9a55fef9 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -467,6 +467,34 @@ class SubscriptionsManager { t.innerHTML = cardHtml.trim(); const cardEl = t.content.firstElementChild; if (cardEl) { + cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; + if (typeof video.pubkey === "string" && video.pubkey) { + cardEl.dataset.ownerPubkey = video.pubkey; + } else if (cardEl.dataset.ownerPubkey) { + delete cardEl.dataset.ownerPubkey; + } + + if (trimmedUrl) { + cardEl.dataset.urlHealthState = "checking"; + if (cardEl.dataset.urlHealthReason) { + delete cardEl.dataset.urlHealthReason; + } + } else { + cardEl.dataset.urlHealthState = "offline"; + cardEl.dataset.urlHealthReason = "missing-source"; + } + if (magnetProvided && magnetSupported) { + cardEl.dataset.streamHealthState = "checking"; + if (cardEl.dataset.streamHealthReason) { + delete cardEl.dataset.streamHealthReason; + } + } else { + cardEl.dataset.streamHealthState = "unhealthy"; + cardEl.dataset.streamHealthReason = magnetProvided + ? "unsupported" + : "missing-source"; + } + // Leave the data-play-* attributes empty in the literal markup so we can // assign the raw URL/magnet strings post-parsing without HTML entity // escaping, mirroring the approach in app.js. The URL is encoded so that