fix(ui): a picture that fails to load shows the card's own frame, then retries

A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
This commit is contained in:
2026-10-14 20:51:08 +02:00
parent 06e8291896
commit 383d394e32
31 changed files with 435 additions and 235 deletions
+16 -58
View File
@@ -47,6 +47,7 @@ import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import { useTags } from "../lib/useTags";
import RemoteImage from "./ui/RemoteImage";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -94,17 +95,6 @@ export default function ChannelPage({
// is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row.
const foldable = useRef<HTMLDivElement>(null);
const folded = useScrollFold(foldable);
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
// scheduler tops up any tail on its ~10-minute ticks — then stop polling once the back-catalogue is
@@ -113,13 +103,6 @@ export default function ChannelPage({
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
// new banner was committed with the previous one's exhausted attempt count still in place.
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
if (bannerFor !== ch?.banner_url) {
setBannerFor(ch?.banner_url);
setBannerAttempt(0);
}
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
@@ -348,9 +331,7 @@ export default function ChannelPage({
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707` : null;
return (
<>
<PageToolbar>
@@ -359,44 +340,21 @@ export default function ChannelPage({
<div ref={foldable}>
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
<div className="px-4 sm:px-6 pt-3">
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<img
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
() => setBannerAttempt((a) => a + 1),
500 * (bannerAttempt + 1),
);
}
}}
/>
</div>
) : (
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
// overlapping avatar + the Back button have the same room and don't collide (a short bar
// let the -mt-8 avatar ride up into the Back button).
<div
className="w-full rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
{/* Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
low default size, so request a crisp wide version (=w1707) and object-cover the
desktop "safe area" — the centre 2560×423 (~6:1) band. With no banner (or a dead one)
the bar keeps the SAME height, so the overlapping avatar and the Back button keep
their room (a short bar let the -mt-8 avatar ride up into the Back button). */}
<div
className="w-full overflow-hidden rounded-2xl bg-surface"
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
>
<RemoteImage
src={bannerSrc}
alt=""
className="w-full h-full object-cover object-center"
/>
)}
</div>
</div>
<div className="px-4 sm:px-6">