perf(plex): a card fetches its picture at a card's size, not the detail page's

Cards fetched the full cuts: a 400x600 poster (~38 KB) for a ~144px picture and the 1280x720
backdrop (~162 KB) for a ~212px shelf picture. The image proxy now also cuts thumb_sm (240x360)
and art_sm (480x270), cached apart; card pictures (grid, shelves, collection tiles, Up next) carry
both in srcset with sizes=auto, so the browser picks by laid-out width and DPR. Localdev:
17 KB a poster, 56 KB a backdrop. The detail page keeps the full cuts.
This commit is contained in:
2026-10-02 21:27:13 +02:00
parent d928d50686
commit fb2e3d8490
9 changed files with 178 additions and 8 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
import { MEDIA_IMG } from "./ui/mediaCardStyles";
import { Pressable } from "./ui/Pressable";
import { cardImage } from "../lib/plexImage";
// 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
@@ -50,7 +51,7 @@ export default function PosterTile({
posters[i] ? (
<img
key={i}
src={`/api/plex/image/${posters[i]}`}
{...cardImage(`/api/plex/image/${posters[i]}`)}
alt=""
loading="lazy"
className={MEDIA_IMG}
+3 -1
View File
@@ -5,6 +5,7 @@ import { Play, X } from "lucide-react";
import { api } from "../lib/api";
import { plexLang } from "../i18n";
import { qk } from "../lib/queryKeys";
import { cardImage } from "../lib/plexImage";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
@@ -72,8 +73,9 @@ export function UpNextCard({
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
{next && (
<img
src={api.plexImageUrl(nextId, "thumb")}
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
alt=""
loading="lazy"
className="h-full w-full object-cover"
draggable={false}
/>
@@ -363,3 +363,19 @@ describe("PlexPosterCard on a shelf", () => {
}
});
});
// E31 S4e: a card fetched the detail page's 400px poster for its ~144px picture. It offers the
// proxy's small cut too and lets the browser pick by its width.
describe("a Plex card's picture", () => {
it("offers the small cut beside the full one, lazily", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), thumb: "/api/plex/image/1" }} onOpen={vi.fn()} />,
);
const img = container.querySelector("img")!;
expect(img.getAttribute("srcset")).toBe(
"/api/plex/image/1?variant=thumb_sm 240w, /api/plex/image/1 400w",
);
expect(img.getAttribute("sizes")).toBe("auto, 200px");
expect(img.getAttribute("loading")).toBe("lazy");
});
});
+11 -1
View File
@@ -27,6 +27,7 @@ import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
import { MediaPreview } from "./ui/MediaPreview";
import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
import { cardImage } from "../lib/plexImage";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -211,10 +212,19 @@ function PlexPicture({
// Not read off `pct`: that is rounded, so 20 s into a 3 h film was 0 % and "not started" here while
// the menu offered "from the start" (E28 review D4). One rule, `plexStarted`, for both.
const resumable = plays && started && !!onRestart;
const pic = cardImage(thumb);
return (
<div className={mediaPictureBox(aspect)}>
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
<img
srcSet={pic.srcSet}
sizes={pic.sizes}
src={pic.src}
alt=""
loading="lazy"
decoding="async"
className={MEDIA_IMG}
/>
{children}
<div className={MEDIA_HOVER_LAYER}>
{plays && !resumable && <MediaPlayGlyph small={small} />}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { cardImage } from "./plexImage";
describe("cardImage", () => {
it("offers a poster's small cut beside the full one", () => {
expect(cardImage("/api/plex/image/123")).toEqual({
src: "/api/plex/image/123",
srcSet: "/api/plex/image/123?variant=thumb_sm 240w, /api/plex/image/123 400w",
sizes: "auto, 200px",
});
});
it("offers a backdrop's small cut beside the full one", () => {
expect(cardImage("/api/plex/image/123?variant=art").srcSet).toBe(
"/api/plex/image/123?variant=art_sm 480w, /api/plex/image/123?variant=art 1280w",
);
});
it("leaves any other picture alone", () => {
expect(cardImage("https://image.tmdb.org/x.jpg")).toEqual({
src: "https://image.tmdb.org/x.jpg",
});
expect(cardImage("")).toEqual({ src: "" });
});
});
+25
View File
@@ -0,0 +1,25 @@
// A Plex picture at a card's size (E31 S4e). The image proxy cuts each picture twice (backend
// `app/plex/imgcache.py` SIZES): the full one a detail page shows and a card's `_sm` one. Cards fetched
// the full cut — a 400px poster for a ~144px picture, the 1280px backdrop for a ~212px shelf picture.
// A card now hands the browser both and lets it pick by the laid-out width: `sizes="auto"`, which the
// browser honours on a LAZY image only; the 200px after it is the guess for a browser without it.
// The cut widths, as imgcache.SIZES has them: [small, full].
const WIDTHS = { thumb: [240, 400], art: [480, 1280] } as const;
const PROXY = /^\/api\/plex\/image\/([^?]+)(?:\?variant=(art))?$/;
export type CardImage = { src: string; srcSet?: string; sizes?: string };
/** `src` + `srcSet` + `sizes` for a lazy card picture; a URL that is not the Plex proxy's is left as it is. */
export function cardImage(url: string): CardImage {
const m = PROXY.exec(url);
if (!m) return { src: url };
const kind = m[2] === "art" ? "art" : "thumb";
const [small, full] = WIDTHS[kind];
// srcSet and sizes ahead of src: spread onto an <img>, the attributes land in this order.
return {
srcSet: `/api/plex/image/${m[1]}?variant=${kind}_sm ${small}w, ${url} ${full}w`,
sizes: "auto, 200px",
src: url,
};
}