Merge branch 'main' into feature/e39b-machine-access
This commit is contained in:
@@ -7,6 +7,12 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.106.2 — 2026-10-14
|
||||
|
||||
### 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.
|
||||
|
||||
## 0.106.1 — 2026-10-14
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -114,7 +114,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
||||
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
||||
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
|
||||
(`SegmentedToggle` / `FilterSelect`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
|
||||
+7
-1
@@ -55,8 +55,14 @@ RUN adduser --disabled-password --gecos "" appuser \
|
||||
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
|
||||
# `apt-get upgrade`: the base image is a snapshot, and the Debian security fixes released since it
|
||||
# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
|
||||
# APT_REFRESH: the upgrade only runs when this layer is rebuilt, and a build cache keeps it for as long
|
||||
# as nothing above it changes — a publish shipped an image whose curl and librsvg were a release behind
|
||||
# fixes already in the archive. The release build passes the day here, so the layer is rebuilt at
|
||||
# least daily; empty (the default, a self-hosted build) leaves the cache alone.
|
||||
ARG DENO_VERSION=v2.9.1
|
||||
RUN apt-get update \
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-cached}" \
|
||||
&& apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
|
||||
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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