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:
@@ -78,7 +78,7 @@ const fullPayload = {
|
||||
scope: "both",
|
||||
continue_watching: [card("cw1", "Resume Me")],
|
||||
recently_added: [card("ra1", "Just Added")],
|
||||
genres: [{ value: "Drama", count: 12 }],
|
||||
genres: [{ value: "Drama", count: 12, art: [] }],
|
||||
because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] },
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
import PosterTile from "./PosterTile";
|
||||
import { plexLang } from "../i18n";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { usePlex } from "./PlexProvider";
|
||||
@@ -199,21 +199,16 @@ export default function PlexDiscover({
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g) => {
|
||||
const hue = hueOf(g.value);
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{ background: hueTileGradient(hue) }}
|
||||
>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{d.genres.map((g) => (
|
||||
<PosterTile
|
||||
key={g.value}
|
||||
title={g.value}
|
||||
art={g.art}
|
||||
topRight={<span className="text-2xs text-white/80 drop-shadow">{g.count}</span>}
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="h-20"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
|
||||
// A browse tile: a 2×2 collage of the group's top poster art (served from /api/plex/image/{key}) under
|
||||
// a dark scrim with the label, falling back to a color hash when the group has no art — so a tile never
|
||||
// renders blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
|
||||
export default function PosterTile({
|
||||
title,
|
||||
subtitle,
|
||||
topRight,
|
||||
art,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items")
|
||||
topRight?: ReactNode; // top-right corner content (a count, or an icon)
|
||||
art?: string[]; // up to 4 poster rating_keys; fewer/none falls back to the color hash
|
||||
onClick: () => void;
|
||||
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
||||
}) {
|
||||
const posters = (art ?? []).slice(0, 4);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`glass-hover group relative flex flex-col justify-end overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||
style={posters.length === 0 ? { background: hueTileGradient(hueOf(title)) } : undefined}
|
||||
>
|
||||
{posters.length > 0 && (
|
||||
<div className="absolute inset-0 grid grid-cols-2 grid-rows-2">
|
||||
{Array.from({ length: 4 }).map((_, i) =>
|
||||
posters[i] ? (
|
||||
<img
|
||||
key={i}
|
||||
src={`/api/plex/image/${posters[i]}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||
{/* After the scrim in DOM order → paints above it without a z-index token. */}
|
||||
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
|
||||
<div className="relative p-3">
|
||||
<div className="line-clamp-2 text-sm font-semibold text-white drop-shadow">{title}</div>
|
||||
{subtitle != null && <div className="text-2xs text-white/80">{subtitle}</div>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user