diff --git a/js/utils/cardSourceVisibility.js b/js/utils/cardSourceVisibility.js index bc64fd58..10ae9763 100644 --- a/js/utils/cardSourceVisibility.js +++ b/js/utils/cardSourceVisibility.js @@ -13,13 +13,16 @@ export function cardNeedsEagerLivenessProbe(cardLike) { return false; } const policy = getCardLivenessPolicy(); - if (policy === "hide-all") { - return true; + const gated = + policy === "hide-all" || + (policy === "hide-foreign" && card.dataset.foreign === "true"); + if (!gated) { + return false; } - if (policy === "hide-foreign") { - return card.dataset.foreign === "true"; - } - return false; + // Only cards backed by a fast CDN probe are hidden-until-verified (so they need + // eager probing while hidden). P2P-only cards fall back to show-pending — they + // start visible and are probed normally on scroll, so no eager kick is needed. + return Boolean(card.dataset.urlHealthUrl); } function resolveCardElement(cardLike) { @@ -99,12 +102,28 @@ export function updateVideoCardSourceVisibility(cardLike) { const hideUntilVerified = policy === "hide-all" || (policy === "hide-foreign" && isForeign); - const shouldHide = hideUntilVerified - ? // Hidden until at least one source proves playable (covers the pending - // window too, so a dead foreign stranger never flashes in). - !cdnHealthy && !streamHealthy - : // Default: only hide once every source has come back dead. - !cdnHealthy && !streamHealthy && !cdnPending && !streamPending; + // Default policy: hide only once every source has come back dead. + const confirmedDead = + !cdnHealthy && !streamHealthy && !cdnPending && !streamPending; + + let shouldHide; + if (!hideUntilVerified) { + shouldHide = confirmedDead; + } else if (cdnHealthy || streamHealthy) { + shouldHide = false; // proven playable → show + } else if (cdnPending) { + // Still waiting on the FAST, reliable CDN probe (~4s) — keep it hidden so a + // dead foreign card never flashes in. + shouldHide = true; + } else { + // CDN is resolved dead (or absent) and the only remaining signal is the slow, + // unreliable WebTorrent swarm probe (20s + 5min cache). Do NOT bury the card + // behind it — a live-but-slow P2P video is indistinguishable from a dead one + // until that probe finishes, and burying live content is worse than a brief + // flash. Fall back to show-pending for the stream: show while it's checked, + // hide only once it too is confirmed dead. + shouldHide = !streamHealthy && !streamPending; + } if (shouldHide) { if (!card.hidden) { diff --git a/tests/video-card-source-visibility.test.mjs b/tests/video-card-source-visibility.test.mjs index b33c1d6f..8b75bf25 100644 --- a/tests/video-card-source-visibility.test.mjs +++ b/tests/video-card-source-visibility.test.mjs @@ -160,13 +160,25 @@ test("VideoCard hides cards without playable sources until a healthy CDN update assert.equal(root.dataset.sourceVisibility, "visible", "visibility dataset should reflect the restored state"); }); -function makeCard(document, { foreign = "false", owner = "false", url = "checking", stream = "checking" } = {}) { +function makeCard( + document, + { + foreign = "false", + owner = "false", + url = "checking", + stream = "checking", + cdnUrl = "https://cdn.example/v.mp4", // pass "" for a WebTorrent-only card + } = {} +) { const card = document.createElement("article"); card.classList.add("card"); card.dataset.ownerIsViewer = owner; card.dataset.foreign = foreign; card.dataset.urlHealthState = url; card.dataset.streamHealthState = stream; + if (cdnUrl) { + card.dataset.urlHealthUrl = encodeURIComponent(cdnUrl); + } document.body.appendChild(card); return card; } @@ -225,6 +237,41 @@ test("CARD_LIVENESS_POLICY=hide-all hides every pending non-owner card but never assert.equal(nativeCard.hidden, false, "card appears once a source verifies"); }); +// WebTorrent-only foreign content must NOT get buried under hide-foreign: its +// only signal is the slow/unreliable swarm probe (20s + 5min cache), so a +// live-but-slow P2P video would otherwise stay hidden. It falls back to +// show-pending instead (shown while checked, hidden only once confirmed dead). +test("hide-foreign does not bury WebTorrent-only foreign cards (no CDN to verify fast)", (t) => { + const { document } = setupDom(t); + setCardLivenessPolicy("hide-foreign"); + t.after(() => setCardLivenessPolicy("show-pending")); + + // No CDN url → urlHealthState resolves to "offline"; stream probe still running. + const p2pCard = makeCard(document, { + foreign: "true", + cdnUrl: "", + url: "offline", + stream: "checking", + }); + updateVideoCardSourceVisibility(p2pCard); + assert.equal(p2pCard.hidden, false, "P2P-only foreign card is shown while its swarm is checked, not buried"); + assert.equal( + cardNeedsEagerLivenessProbe(p2pCard), + false, + "P2P-only card is show-pending, so it is not eager-probed", + ); + + // Only once the swarm probe also confirms it dead does it hide. + p2pCard.dataset.streamHealthState = "unhealthy"; + updateVideoCardSourceVisibility(p2pCard); + assert.equal(p2pCard.hidden, true, "confirmed-dead P2P card finally hides"); + + // A CDN-backed foreign card, by contrast, IS hidden while its fast probe runs. + const cdnCard = makeCard(document, { foreign: "true", url: "checking", stream: "checking" }); + updateVideoCardSourceVisibility(cdnCard); + assert.equal(cdnCard.hidden, true, "CDN-backed foreign card stays hidden during its fast probe"); +}); + // Hide-until-verified cards start display:none, so the viewport observer can't // fire for them — they must be eager-probed. This predicate gates that, and it // must mirror the visibility policy exactly so the right cards get probed.