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:
@@ -627,6 +627,13 @@ export default tseslint.config(
|
||||
message:
|
||||
"No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
||||
},
|
||||
// A picture that failed to load left the browser's broken-image mark on a card: a bare <img>
|
||||
// sends a Referer Google's image CDNs refuse, and has no retry and no fallback.
|
||||
{
|
||||
selector: "JSXOpeningElement[name.name='img']",
|
||||
message:
|
||||
"No bare <img>: use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.",
|
||||
},
|
||||
// Keep the rem system from rotting. A px-frozen `text-[Npx]` font size does NOT
|
||||
// scale with the Settings --font-scale slider while its rem neighbours do; use
|
||||
// `text-2xs`/`text-3xs` or a `text-[Nrem]` arbitrary instead. Matches both plain strings and
|
||||
|
||||
@@ -99,10 +99,10 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
});
|
||||
|
||||
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
|
||||
// next waits, a visible <img> shows the browser's broken-image glyph (seen live). It stays
|
||||
// invisible until a source has actually loaded.
|
||||
// next waits, a visible <img> showed the browser's broken-image glyph (seen live). While a retry
|
||||
// waits there is no <img> at all — the bar's own ground — and the retry asks past the cache.
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
|
||||
it("leaves the bar empty while a failed attempt waits, then asks again past the cache", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -134,16 +134,14 @@ describe("ChannelPage — the banner", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const banner = () =>
|
||||
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
|
||||
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]');
|
||||
await waitFor(() => expect(banner()).toBeTruthy());
|
||||
expect(banner().classList.contains("opacity-0")).toBe(true);
|
||||
fireEvent.error(banner());
|
||||
expect(banner()!.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
fireEvent.error(banner()!);
|
||||
expect(banner()).toBeNull();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
expect(banner().getAttribute("src")).toContain("?r=1");
|
||||
expect(banner().classList.contains("opacity-0")).toBe(true);
|
||||
fireEvent.load(banner());
|
||||
expect(banner().classList.contains("opacity-0")).toBe(false);
|
||||
expect(banner()!.getAttribute("src")).toContain("?r=1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -23,6 +23,7 @@ import Modal from "./ui/Modal";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time,
|
||||
// in the shared dialog shell. What a file shows is the server's call (`asset.preview`,
|
||||
@@ -187,19 +188,17 @@ function Stage({
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (kind === "image")
|
||||
return failed ? (
|
||||
<StateMessage tone="error" className={`${STAGE} justify-center`}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : (
|
||||
return (
|
||||
<div className={`${STAGE} flex items-center justify-center`}>
|
||||
<img
|
||||
{/* A preview that fails is an answer (the file is not an image after all), not a hiccup:
|
||||
said at once, no retries. */}
|
||||
<RemoteImage
|
||||
src={api.previewUrl(file.id)}
|
||||
alt={title}
|
||||
onError={() => setFailed(true)}
|
||||
className="max-h-full max-w-full rounded-md object-contain"
|
||||
retries={0}
|
||||
fallback={<StateMessage tone="error">{t("common.loadError")}</StateMessage>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
mediaPictureBox,
|
||||
mediaTitleClass,
|
||||
} from "./ui/mediaCardStyles";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The Home surface — "Mit nézzek most?".
|
||||
//
|
||||
@@ -450,7 +451,7 @@ function ShelfTile({
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
|
||||
<RemoteImage src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
|
||||
) : null}
|
||||
{/* The Play only when there is something to play: the click plays the channel's newest
|
||||
unwatched video, the playlist's first. */}
|
||||
@@ -574,7 +575,7 @@ function Hero({
|
||||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||||
>
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
|
||||
<RemoteImage src={card.thumb} alt="" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
{card.duration_seconds != null && (
|
||||
// It steps aside while the hover row covers its corner, as a card's length does.
|
||||
|
||||
@@ -29,6 +29,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Textarea, Input } from "./ui/Input";
|
||||
import { Checkbox } from "./ui/NativeControls";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The issue composer: one dialog — the draft on the left (title, description, the
|
||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||
@@ -680,10 +681,11 @@ function Attachments({
|
||||
key={shot.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
src={api.debugScreenshotUrl(session.id, shot.id)}
|
||||
alt={t("recorder.composer.screenshot", { n: i + 1 })}
|
||||
className="h-10 w-16 shrink-0 rounded-md object-cover bg-surface"
|
||||
fallback={<div className="h-10 w-16 shrink-0 rounded-md bg-surface" />}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMediaLogRef } from "../lib/mediaLog";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
import { keptPosition, useResume } from "../lib/resume";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// In-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
||||
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
||||
@@ -94,7 +95,7 @@ export default function LibraryPlayer({
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
<img
|
||||
<RemoteImage
|
||||
src={poster}
|
||||
alt=""
|
||||
className="w-40 h-40 rounded-xl object-cover border border-border"
|
||||
|
||||
@@ -67,6 +67,7 @@ import { MoreButton } from "./ui/MoreButton";
|
||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The playlist detail is a HYBRID table: real sortable/filterable column headers over a
|
||||
// body that is still a drag-reorderable @dnd-kit list.
|
||||
@@ -847,7 +848,11 @@ export default function Playlists() {
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
|
||||
{items[0]?.thumbnail_url && (
|
||||
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
<RemoteImage
|
||||
src={items[0].thumbnail_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -29,6 +29,7 @@ import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -298,7 +299,11 @@ export default function PlaylistsRail({
|
||||
<div key={pl.id} className={`${ROW_BASE} bg-accent/10 border-accent`}>
|
||||
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
<RemoteImage
|
||||
src={pl.cover_thumbnail}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
@@ -349,7 +354,7 @@ export default function PlaylistsRail({
|
||||
>
|
||||
<div className="w-8.5 h-8.5 rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img
|
||||
<RemoteImage
|
||||
src={pl.cover_thumbnail}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
|
||||
@@ -55,6 +55,7 @@ import { Chip } from "./ui/Chip";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -672,18 +673,17 @@ export default function PlexBrowse() {
|
||||
title={t("plex.info.viewPerson", { name: p.name })}
|
||||
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg"
|
||||
>
|
||||
{p.photo ? (
|
||||
<img
|
||||
src={p.photo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<RemoteImage
|
||||
src={p.photo}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-10 h-10 rounded-full object-cover shrink-0"
|
||||
fallback={
|
||||
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate max-w-37.5 group-hover/person:text-accent">
|
||||
{p.name}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { vocab } from "../lib/vocab";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -403,10 +404,16 @@ export default function PlexInfo({
|
||||
>
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
{/* Poster */}
|
||||
<img
|
||||
<RemoteImage
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 self-start rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
|
||||
// The poster's own box, empty, so the text beside it does not jump into its place.
|
||||
fallback={
|
||||
<div
|
||||
className={`shrink-0 self-start rounded-xl border border-border bg-surface ${overlay ? "w-24 sm:w-28" : DETAIL_POSTER} aspect-2/3`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState } from "./ui/QueryState";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
@@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
<RemoteImage
|
||||
{...cardImage(c.thumb)}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
|
||||
@@ -56,6 +56,7 @@ import { PageHeading } from "./ui/Headings";
|
||||
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
|
||||
import { seasonTitle } from "../lib/plexDetail";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -158,7 +159,12 @@ function Poster({ src, className }: { src: string; className: string }) {
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
|
||||
>
|
||||
<img {...cardImage(src)} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
<RemoteImage
|
||||
{...cardImage(src)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,7 +207,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
{...cardImage(ep.thumb)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@@ -323,7 +329,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
|
||||
aria-hidden
|
||||
>
|
||||
{block.items.slice(0, 3).map((ep, i) => (
|
||||
<img
|
||||
<RemoteImage
|
||||
key={ep.id}
|
||||
{...cardImage(ep.thumb)}
|
||||
alt=""
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The Plex header search box: the shared SearchBar enriched with the search scope-pill (a
|
||||
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
|
||||
@@ -160,7 +161,7 @@ export default function PlexSearchBox() {
|
||||
data-active={hi === i ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
{...cardImage(c.thumb)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { navItemAttrs } from "../lib/listNav";
|
||||
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||
@@ -49,7 +50,7 @@ export default function PosterTile({
|
||||
{posters.length > 0 &&
|
||||
[0, 1].map((i) =>
|
||||
posters[i] ? (
|
||||
<img
|
||||
<RemoteImage
|
||||
key={i}
|
||||
{...cardImage(`/api/plex/image/${posters[i]}`)}
|
||||
alt=""
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cardImage } from "../lib/plexImage";
|
||||
import { episodeNumbers } from "../lib/plexDetail";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// Up-next / end-card. Shown at episode end when a next item exists and the pref is on, INSTEAD
|
||||
// of the old instant jump — so binge stays under the viewer's control. Counts down ~10s then
|
||||
@@ -76,7 +77,7 @@ export function UpNextCard({
|
||||
>
|
||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||
{next && (
|
||||
<img
|
||||
<RemoteImage
|
||||
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { MediaCard, MediaProgress } from "./ui/MediaCard";
|
||||
import { MediaResumeButtons } from "./MediaResumeButtons";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
/** The "…" that opens the video's action list, and whether its menu is the open one. */
|
||||
type More = { props: Record<string, unknown>; open: boolean };
|
||||
@@ -252,7 +253,7 @@ function Thumb({
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
<RemoteImage
|
||||
src={video.thumbnail_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { LinkButton } from "./ui/LinkButton";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
@@ -224,28 +225,18 @@ function Preview({
|
||||
className?: string;
|
||||
onOpen?: (src: string, label: string) => void;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`${SURFACE.card} overflow-hidden ${className ?? ""}`}>
|
||||
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Pressable
|
||||
onClick={() => onOpen?.(src, label)}
|
||||
aria-label={label}
|
||||
className={`group relative ${SURFACE.card} overflow-hidden block cursor-zoom-in ${className ?? ""}`}
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
src={src}
|
||||
alt={label}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className="w-full h-full object-cover object-top"
|
||||
fallback={<ShotLabel label={label} />}
|
||||
/>
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
|
||||
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
|
||||
@@ -254,6 +245,15 @@ function Preview({
|
||||
);
|
||||
}
|
||||
|
||||
// A screenshot that would not load: its caption on the card's own ground, not the broken-image mark.
|
||||
function ShotLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="w-full h-full grid place-items-center bg-linear-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
|
||||
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
|
||||
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
|
||||
@@ -320,7 +320,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
className="flex max-h-full max-w-full flex-col items-center gap-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
src={src}
|
||||
alt={label}
|
||||
className="max-h-[82dvh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { File, Film, Music } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
@@ -6,6 +6,7 @@ import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MediaProgress } from "./ui/MediaCard";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
|
||||
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
|
||||
@@ -65,73 +66,68 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
|
||||
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
|
||||
// now that the poster is the primary image, reads as a blank Library thumbnail.
|
||||
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
|
||||
const primary = poster || remote;
|
||||
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
|
||||
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
|
||||
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
|
||||
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
|
||||
// …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
|
||||
// that 404'd because its file was still being written should be re-requested when the row comes
|
||||
// back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
|
||||
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
|
||||
if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
|
||||
// The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same
|
||||
// way, so the row falls to the glyph below instead of the browser's broken-image mark.
|
||||
const [failedRemote, setFailedRemote] = useState<string | null>(null);
|
||||
if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null);
|
||||
// An image file's own picture failed (gone since, or not an image after all): the glyph instead.
|
||||
const [failedPicture, setFailedPicture] = useState<string | null>(null);
|
||||
const candidate = failedPrimary && failedPrimary === primary ? remote : primary;
|
||||
const src = candidate && candidate === failedRemote ? null : candidate;
|
||||
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
|
||||
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
|
||||
// a neutral file glyph instead of an (always-empty) thumbnail.
|
||||
// a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer
|
||||
// (gone since, or not an image after all), not a hiccup: the glyph at once, no retries.
|
||||
if (job.asset?.media_kind === "file") {
|
||||
const picture = imageThumbOf(job);
|
||||
const glyph = <File className="w-8 h-8" aria-hidden="true" />;
|
||||
return (
|
||||
<div className={`${box} flex items-center justify-center text-muted`}>
|
||||
{picture && picture !== failedPicture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setFailedPicture(picture)}
|
||||
/>
|
||||
) : (
|
||||
<File className="w-8 h-8" aria-hidden="true" />
|
||||
)}
|
||||
<RemoteImage
|
||||
src={imageThumbOf(job)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
retries={0}
|
||||
fallback={glyph}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// How far into it the viewer is, the media cards' bar.
|
||||
const duration = job.asset?.duration_s ?? 0;
|
||||
const pct = duration > 0 ? ((job.position_seconds ?? 0) / duration) * 100 : 0;
|
||||
// No picture at all (a link that failed before its details, a poster that never came): a quiet
|
||||
// glyph of what it is, like the file glyph above — an empty box read as "still loading".
|
||||
const glyph = (
|
||||
<div className="flex h-full items-center justify-center text-muted">
|
||||
{job.asset?.media_kind === "audio" ? (
|
||||
<Music className="w-7 h-7" aria-hidden="true" />
|
||||
) : (
|
||||
<Film className="w-7 h-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// The source's picture, the poster's fallback: retried like any remote picture, then the glyph.
|
||||
const remotePicture = (
|
||||
<RemoteImage
|
||||
src={remote}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
fallback={glyph}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className={`${box} relative`}>
|
||||
<MediaProgress pct={pct} />
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
{poster ? (
|
||||
// Our own poster failing is an answer (the file is missing), so straight to the source's
|
||||
// picture. RemoteImage remembers the failure by URL, so a recycled row never shows one job's
|
||||
// fallback over the next one's poster, and forgets it once the row moves on — a poster that
|
||||
// 404'd while its file was still being written is asked for again when the row comes back.
|
||||
<RemoteImage
|
||||
src={poster}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => {
|
||||
if (src !== remote) setFailedPrimary(primary);
|
||||
else setFailedRemote(remote);
|
||||
}}
|
||||
retries={0}
|
||||
fallback={remotePicture}
|
||||
/>
|
||||
) : (
|
||||
// No picture at all (a link that failed before its details, a poster that never came): a
|
||||
// quiet glyph of what it is, like the file glyph above — an empty box read as "still loading".
|
||||
<div className="flex h-full items-center justify-center text-muted">
|
||||
{job.asset?.media_kind === "audio" ? (
|
||||
<Music className="w-7 h-7" aria-hidden="true" />
|
||||
) : (
|
||||
<Film className="w-7 h-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
remotePicture
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "../lib/tableColumns";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
@@ -67,7 +68,7 @@ export function playlistColumns({
|
||||
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
{/* Narrow, the Length column is gone and its value rides on the picture, as on the
|
||||
media card (the owner's pick A). */}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MediaPreview } from "./ui/MediaPreview";
|
||||
import { useMediaPreview } from "./ui/useMediaPreview";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { cardImage } from "../lib/plexImage";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one
|
||||
// read is a show card asking which episode it would play, once its preview is up). Rendered by the
|
||||
@@ -224,7 +225,7 @@ function PlexPicture({
|
||||
return (
|
||||
<div className={mediaPictureBox(aspect)}>
|
||||
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
|
||||
<img
|
||||
<RemoteImage
|
||||
srcSet={pic.srcSet}
|
||||
sizes={pic.sizes}
|
||||
src={pic.src}
|
||||
@@ -599,15 +600,19 @@ export function CastRow({
|
||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
|
||||
>
|
||||
{c.thumb ? (
|
||||
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
||||
>
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<RemoteImage
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
fallback={
|
||||
<div
|
||||
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
|
||||
>
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
|
||||
same text height and the row of circles sits on one baseline regardless of name length. */}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
@@ -263,10 +264,13 @@ export function DetailHero({
|
||||
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
|
||||
title={posterTitle}
|
||||
>
|
||||
<img
|
||||
<RemoteImage
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="w-full aspect-2/3 object-cover rounded-xl border border-border"
|
||||
fallback={
|
||||
<div className="w-full aspect-2/3 rounded-xl border border-border bg-surface" />
|
||||
}
|
||||
/>
|
||||
{showPlayOverlay && (
|
||||
<div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
|
||||
|
||||
@@ -62,6 +62,7 @@ import { SURFACE } from "./ui/surfaces";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { vocab } from "../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../lib/format";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -292,13 +293,16 @@ export function PlexPersonView({
|
||||
<div className="measure min-w-0 text-sm">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
<RemoteImage
|
||||
src={d.photo}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
fallback={
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PageHeading className="text-xl font-semibold">{d.name}</PageHeading>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import RemoteImage from "./RemoteImage";
|
||||
|
||||
// Avatar image with a graceful fallback.
|
||||
//
|
||||
// Uses referrerPolicy="no-referrer" because Google's avatar CDNs
|
||||
// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests
|
||||
// from our origin — yt3 refuses even a single load, not only a burst (measured
|
||||
// 2026-09-26) — and the browser shows its broken-image icon. Dropping the
|
||||
// referrer makes those CDNs serve the images. The channel banner, from the same
|
||||
// CDN, does the same (ChannelPage).
|
||||
// If a load still fails we render a neutral placeholder (optionally an initial)
|
||||
// instead of the broken-image icon.
|
||||
// Avatar image with a graceful fallback: a neutral disc (optionally the name's initial) while there is
|
||||
// no picture. The loading itself — no Referer for Google's avatar CDNs, the retry past the browser's
|
||||
// negative cache, the failure remembered by URL so a recycled feed slot never paints one channel's
|
||||
// initial over the next one's picture — is ui/RemoteImage's.
|
||||
export default function Avatar({
|
||||
src,
|
||||
alt = "",
|
||||
@@ -21,43 +15,21 @@ export default function Avatar({
|
||||
fallback?: string; // a name/title; its first letter is shown when there's no image
|
||||
className?: string;
|
||||
}) {
|
||||
// The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes.
|
||||
// That distinction is visible: this component is recycled across items in the virtualized feed, so
|
||||
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
|
||||
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
|
||||
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
|
||||
//
|
||||
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
|
||||
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
|
||||
// swaps — the player's channel avatar stepping through a queue — would
|
||||
// show the initial for a throttled channel forever, never re-requesting even after the CDN
|
||||
// relented. Written in render because it is convergent (the write falsifies its own condition) and
|
||||
// because an effect would leave one committed frame in which the memory is stale, which is the
|
||||
// frame this whole component is about.
|
||||
const [failedSrc, setFailedSrc] = useState<string | null>(null);
|
||||
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
|
||||
const failed = !!src && failedSrc === src;
|
||||
|
||||
if (src && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailedSrc(src)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const initial = fallback?.trim()?.[0]?.toUpperCase() ?? "";
|
||||
return (
|
||||
<div
|
||||
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
|
||||
aria-hidden
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<RemoteImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className={className}
|
||||
fallback={
|
||||
<div
|
||||
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
|
||||
aria-hidden
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ function Page({ withMenu = true }: { withMenu?: boolean }) {
|
||||
>
|
||||
<span data-testid="row-inner" />
|
||||
</div>
|
||||
{/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
|
||||
<img data-testid="img" alt="" />
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
@@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
|
||||
import RemoteImage from "./RemoteImage";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const img = () => screen.queryByRole("img", { name: "pic" });
|
||||
const wait = (ms: number) =>
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
const fallback = <span>no picture</span>;
|
||||
|
||||
describe("RemoteImage", () => {
|
||||
// Google's image CDNs refuse a request carrying our origin's Referer — and the card then showed
|
||||
// the browser's broken-image mark (a Home shelf card, 2026-10-14).
|
||||
it("asks without a Referer", () => {
|
||||
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" />);
|
||||
expect(img()?.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
});
|
||||
|
||||
it("shows the fallback, never the broken image, while a retry waits — then asks again past the cache", async () => {
|
||||
render(<RemoteImage src="https://cdn.test/a.jpg?s=1" alt="pic" fallback={fallback} />);
|
||||
fireEvent.error(img()!);
|
||||
expect(img()).toBeNull();
|
||||
expect(screen.getByText("no picture")).toBeTruthy();
|
||||
|
||||
await wait(999);
|
||||
expect(img()).toBeNull();
|
||||
await wait(1);
|
||||
// A fresh request: the browser negative-caches the failed URL, so the same one would only replay it.
|
||||
expect(img()?.getAttribute("src")).toBe("https://cdn.test/a.jpg?s=1&r=1");
|
||||
});
|
||||
|
||||
it("waits longer each time, and keeps the fallback once every retry is spent", async () => {
|
||||
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" fallback={fallback} />);
|
||||
for (const [after, n] of [
|
||||
[1000, 1],
|
||||
[2000, 2],
|
||||
[4000, 3],
|
||||
] as const) {
|
||||
fireEvent.error(img()!);
|
||||
await wait(after - 1);
|
||||
expect(img()).toBeNull();
|
||||
await wait(1);
|
||||
expect(img()?.getAttribute("src")).toBe(`https://cdn.test/a.jpg?r=${n}`);
|
||||
}
|
||||
fireEvent.error(img()!);
|
||||
await wait(60_000);
|
||||
expect(img()).toBeNull();
|
||||
expect(screen.getByText("no picture")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a loaded retry stays", async () => {
|
||||
render(<RemoteImage src="https://cdn.test/a.jpg" alt="pic" />);
|
||||
fireEvent.error(img()!);
|
||||
await wait(1000);
|
||||
fireEvent.load(img()!);
|
||||
await wait(60_000);
|
||||
expect(img()?.getAttribute("src")).toBe("https://cdn.test/a.jpg?r=1");
|
||||
});
|
||||
|
||||
it("busts every candidate of a srcset", async () => {
|
||||
render(
|
||||
<RemoteImage
|
||||
src="/api/plex/image/7"
|
||||
srcSet="/api/plex/image/7?variant=thumb_sm 200w, /api/plex/image/7 400w"
|
||||
alt="pic"
|
||||
/>,
|
||||
);
|
||||
fireEvent.error(img()!);
|
||||
await wait(1000);
|
||||
expect(img()?.getAttribute("srcset")).toBe(
|
||||
"/api/plex/image/7?variant=thumb_sm&r=1 200w, /api/plex/image/7?r=1 400w",
|
||||
);
|
||||
});
|
||||
|
||||
it("with no retries, a failure is the fallback at once", () => {
|
||||
render(<RemoteImage src="/api/files/1/preview" alt="pic" retries={0} fallback={fallback} />);
|
||||
fireEvent.error(img()!);
|
||||
expect(screen.getByText("no picture")).toBeTruthy();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
expect(img()).toBeNull();
|
||||
});
|
||||
|
||||
it("starts over for a new src, and gives no src the fallback", () => {
|
||||
const { rerender } = render(
|
||||
<RemoteImage src="https://cdn.test/a.jpg" alt="pic" retries={0} fallback={fallback} />,
|
||||
);
|
||||
fireEvent.error(img()!);
|
||||
rerender(
|
||||
<RemoteImage src="https://cdn.test/b.jpg" alt="pic" retries={0} fallback={fallback} />,
|
||||
);
|
||||
expect(img()?.getAttribute("src")).toBe("https://cdn.test/b.jpg");
|
||||
rerender(<RemoteImage src={null} alt="pic" fallback={fallback} />);
|
||||
expect(img()).toBeNull();
|
||||
expect(screen.getByText("no picture")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not bust an inline picture", async () => {
|
||||
render(<RemoteImage src="data:image/png;base64,AAAA" alt="pic" />);
|
||||
fireEvent.error(img()!);
|
||||
await wait(1000);
|
||||
expect(img()?.getAttribute("src")).toBe("data:image/png;base64,AAAA");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
// Every picture the app loads over the network goes through here (lint-enforced: a bare <img> is
|
||||
// refused everywhere else), because a picture that fails left the browser's broken-image mark on a
|
||||
// card, and each component that noticed had hand-rolled its own cure. What they learned, in one place:
|
||||
//
|
||||
// - No Referer. Google's image CDNs (yt3.ggpht.com, lh3.googleusercontent.com) refuse a request that
|
||||
// carries one from our origin — not only in a burst (measured 2026-09-26). Our own endpoints never
|
||||
// read it, so it is dropped for every picture rather than guessed per host.
|
||||
// - A failed load is retried, with a cache-buster: the browser NEGATIVE-CACHES the miss, so asking for
|
||||
// the same URL again would only replay the failure. Both Google CDNs and our API ignore the extra
|
||||
// `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way.
|
||||
// - While there is no picture — none given, a retry still waiting, every retry spent — the caller's
|
||||
// `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no
|
||||
// thumbnail has always shown). Never the broken-image mark.
|
||||
// - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled,
|
||||
// and a failure stored as a flag an effect resets painted one item's fallback over the next one's
|
||||
// picture for a frame (ui/Avatar had that defect).
|
||||
|
||||
/** How many times a failed picture is asked for again, and the wait before the first retry (it
|
||||
* doubles each time): 1 s, 2 s, 4 s, then the fallback for good. */
|
||||
const IMAGE_RETRIES = 3;
|
||||
const FIRST_RETRY_MS = 1000;
|
||||
|
||||
/** `url` with a cache-buster for retry `attempt` (0 = the URL as given). An inline `data:`/`blob:` URL
|
||||
* is not fetched, so it has no cache to bust. */
|
||||
function bustImageUrl(url: string, attempt: number): string {
|
||||
if (attempt === 0 || /^(data|blob):/.test(url)) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}r=${attempt}`;
|
||||
}
|
||||
|
||||
/** The same for every candidate of a `srcset` ("url 200w, url2 400w"). */
|
||||
function bustSrcSet(srcSet: string, attempt: number): string {
|
||||
if (attempt === 0) return srcSet;
|
||||
return srcSet
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const [url = "", ...descriptor] = part.trim().split(/\s+/);
|
||||
return [bustImageUrl(url, attempt), ...descriptor].join(" ");
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
type Miss = { src: string; attempt: number; waiting: boolean };
|
||||
|
||||
export default function RemoteImage({
|
||||
src,
|
||||
srcSet,
|
||||
fallback = null,
|
||||
retries = IMAGE_RETRIES,
|
||||
...img
|
||||
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
|
||||
src: string | null | undefined;
|
||||
srcSet?: string;
|
||||
/** What stands in while there is no picture. */
|
||||
fallback?: ReactNode;
|
||||
/** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there
|
||||
* yet is better shown as its fallback now than after seven seconds of retries. */
|
||||
retries?: number;
|
||||
}) {
|
||||
const [miss, setMiss] = useState<Miss | null>(null);
|
||||
// Forgotten once `src` moves off it — convergent, so it belongs in render (see above).
|
||||
if (miss !== null && miss.src !== src) setMiss(null);
|
||||
const current = miss !== null && miss.src === src ? miss : null;
|
||||
const attempt = current?.attempt ?? 0;
|
||||
|
||||
const waiting = current?.waiting === true;
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const timer = window.setTimeout(
|
||||
() => setMiss((m) => (m !== null && m.src === src ? { ...m, waiting: false } : m)),
|
||||
FIRST_RETRY_MS * 2 ** (attempt - 1),
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [waiting, attempt, src]);
|
||||
|
||||
const spent = current !== null && !current.waiting && current.attempt > retries;
|
||||
if (!src || waiting || spent) return <>{fallback}</>;
|
||||
return (
|
||||
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
|
||||
<img
|
||||
{...img}
|
||||
// A fresh element per attempt: the failed one is done, and a new src on it would not refire.
|
||||
key={attempt}
|
||||
src={bustImageUrl(src, attempt)}
|
||||
srcSet={srcSet === undefined ? undefined : bustSrcSet(srcSet, attempt)}
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setMiss({ src, attempt: attempt + 1, waiting: attempt < retries })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,18 @@ describe("a native select", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A bare <img> left the broken-image mark on a card whose picture failed (ui/RemoteImage's header).
|
||||
describe("a bare img", () => {
|
||||
it("is refused; ui/RemoteImage is not", async () => {
|
||||
const lint = async (source: string) =>
|
||||
(
|
||||
await eslint.lintText(source, { filePath: "src/components/__probe.tsx" })
|
||||
)[0]?.messages.filter((m) => m.message.startsWith("No bare <img>")).length;
|
||||
expect(await lint('export const A = () => <img src="x" alt="" />;\n')).toBe(1);
|
||||
expect(await lint('export const B = () => <RemoteImage src="x" alt="" />;\n')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute; `act:` is not),
|
||||
// which then lit up on hover as if a click would work.
|
||||
describe("a hover gated on `enabled:` alone", () => {
|
||||
|
||||
@@ -756,6 +756,10 @@
|
||||
"message": "No native <select>: use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.",
|
||||
"selector": "JSXOpeningElement[name.name='select']"
|
||||
},
|
||||
{
|
||||
"message": "No bare <img>: use ui/RemoteImage — no Referer, a retry past the browser's negative cache, and a fallback instead of the broken-image mark.",
|
||||
"selector": "JSXOpeningElement[name.name='img']"
|
||||
},
|
||||
{
|
||||
"message": "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx].",
|
||||
"selector": "Literal[value=/text-\\[[\\d.]+px\\]/]"
|
||||
|
||||
Reference in New Issue
Block a user