Merge branch 'main' into feature/e39b-machine-access

This commit is contained in:
2026-10-14 21:19:14 +02:00
36 changed files with 465 additions and 237 deletions
+7
View File
@@ -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");
});
});
+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">
+7 -8
View File
@@ -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>
);
+3 -2
View File
@@ -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.
+3 -1
View File
@@ -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>
+2 -1
View File
@@ -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"
+6 -1
View File
@@ -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">
+7 -2
View File
@@ -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"
+12 -12
View File
@@ -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}
+8 -1
View File
@@ -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"
+9 -3
View File
@@ -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=""
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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=""
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+13 -13
View File
@@ -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"
+45 -49
View File
@@ -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>
);
+2 -1
View File
@@ -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). */}
+15 -10
View File
@@ -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. */}
+5 -1
View File
@@ -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">
+11 -7
View File
@@ -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>
+19 -47
View File
@@ -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>
+1 -1
View File
@@ -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 })}
/>
);
}
@@ -1,4 +1,12 @@
[
{
"version": "0.106.2",
"date": "2026-10-14",
"new": [],
"fixed": [
"Pictures: a thumbnail, poster or channel picture that fails to load is retried, and meanwhile the card shows its empty frame instead of a broken-image mark."
]
},
{
"version": "0.106.1",
"date": "2026-10-14",
@@ -1,4 +1,12 @@
[
{
"version": "0.106.2",
"date": "2026-10-14",
"new": [],
"fixed": [
"Képek: a be nem töltődő bélyegképet, posztert vagy csatornaképet az app újra lekéri, addig pedig a kártya üres kerete látszik a törött kép jele helyett."
]
},
{
"version": "0.106.1",
"date": "2026-10-14",
@@ -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", () => {
+4
View File
@@ -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\\]/]"