feat(plex): poster collages on the genre + collection browse tiles

The Browse-by-genre tiles and the Collections browse tiles were flat color-hash
gradients. Give each a 2×2 collage of its top films' poster art, with the color hash
kept as the fallback so a group with no matching art (a show-only or empty collection)
never renders blank.

Backend: a shared `top_art_keys` windowed query returns, per key (a genre value or a
collection rating_key), the top few items' rating_keys — best-rated then newest, in one
query. Wired into the Discover genre tiles and the collections union endpoint; the client
renders the posters from the existing /api/plex/image/{key} endpoint, so no new image
plumbing. Frontend: a shared PosterTile component (collage + scrim + label, color-hash
fallback) replaces the two ad-hoc colored-tile implementations.

Verified live on localdev: genre tiles and collection tiles render real poster collages,
with the color fallback correctly kicking in for groups without art.

Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 11 discover tests
(incl. a new genre-art assertion).
This commit is contained in:
2026-08-23 03:10:36 +02:00
parent 1e5232f47a
commit da3cb5e694
9 changed files with 153 additions and 59 deletions
+16 -37
View File
@@ -1,45 +1,19 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Layers, ListMusic } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./QueryState";
import PosterTile from "./PosterTile";
// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles,
// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the
// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections
// union), so this is a pure front-end surface. Tiles are coloured by a title hash (no per-item art to
// fetch/proxy), matching the Discover genre tiles.
function Tile({
title,
count,
icon,
onClick,
}: {
title: string;
count: ReactNode;
icon: ReactNode;
onClick: () => void;
}) {
const hue = hueOf(title);
return (
<button
type="button"
onClick={onClick}
title={title}
className="glass-hover group relative flex h-28 flex-col justify-end overflow-hidden rounded-xl p-3 text-left transition"
style={{ background: hueTileGradient(hue) }}
>
<span className="absolute right-2 top-2 text-white/70">{icon}</span>
<span className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</span>
<span className="text-2xs text-white/70">{count}</span>
</button>
);
}
// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top
// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they
// take the colour fallback.
const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
@@ -63,12 +37,13 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
return (
<div className={GRID}>
{playlists.map((p) => (
<Tile
<PosterTile
key={p.id}
title={p.title}
count={t("plex.browse.items", { count: p.item_count })}
icon={<ListMusic className="h-4 w-4" />}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
onClick={() => onOpen(p.id)}
className="h-28"
/>
))}
</div>
@@ -108,12 +83,16 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
) : (
<div className={GRID}>
{collections.map((c) => (
<Tile
<PosterTile
key={c.id}
title={c.title}
count={c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : ""}
icon={<Layers className="h-4 w-4" />}
art={c.art}
subtitle={
c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : undefined
}
topRight={<Layers className="h-4 w-4 text-white/80 drop-shadow" />}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>
))}
</div>