feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)

The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
This commit is contained in:
2026-09-29 02:12:32 +02:00
parent 13d95ef70e
commit 632e39d458
37 changed files with 673 additions and 203 deletions
+29 -10
View File
@@ -5,6 +5,7 @@ import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { seasonTitle } from "../lib/plexDetail";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
@@ -288,7 +289,12 @@ export function PlexPosterCard({
}) {
const { t } = useTranslation();
const preview = useMediaPreview();
const landscape = wide || card.type === "episode";
// An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster
// and name, the episode on the line under it (E30, option A). Its own 16:9 still in a poster slot
// was a cropped frame titled only "Countdown". Needs the show (`show_id`, from a leaf card).
const asShow = card.type === "episode" && !wide && !!card.show_id && !!card.show_title;
const landscape = wide || (card.type === "episode" && !asShow);
const name = asShow ? `${card.show_title} · ${card.title}` : card.title;
const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep;
@@ -320,7 +326,7 @@ export function PlexPosterCard({
dense
rootProps={preview.rootProps({
...menuProps?.(card),
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
...(navIndex !== undefined && navItemAttrs(navIndex, name)),
})}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`).
@@ -328,8 +334,12 @@ export function PlexPosterCard({
picture={
<PlexPicture
aspect={landscape ? "wide" : "poster"}
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
label={card.title}
thumb={
asShow
? `/api/plex/image/${encodeURIComponent(card.show_id ?? "")}`
: (wide && card.type !== "episode" && card.art) || card.thumb
}
label={name}
onOpen={onOpen}
onRestart={onRestart}
plays={isPlayable}
@@ -362,11 +372,19 @@ export function PlexPosterCard({
}
title={
<CardTitle onClick={onOpen} shelf={shelf}>
{card.title}
{asShow ? card.show_title : card.title}
</CardTitle>
}
meta={
epCode ? (
asShow ? (
<div className="truncate text-xs leading-6 text-muted">
{t("plex.episodeLine", {
season: card.season_number ?? "?",
episode: card.episode_number ?? "?",
title: card.title,
})}
</div>
) : epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
@@ -383,7 +401,7 @@ export function PlexPosterCard({
/>
<MediaPreview
preview={preview}
title={card.title}
title={name}
facts={
<span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")}
@@ -443,6 +461,7 @@ export function SeasonCard({
navIndex?: number;
}) {
const { t } = useTranslation();
const title = seasonTitle(se, t);
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
@@ -450,14 +469,14 @@ export function SeasonCard({
dense
rootProps={{
...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
...(navIndex !== undefined && navItemAttrs(navIndex, title)),
}}
picture={
// A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture
aspect="poster"
thumb={se.thumb}
label={se.title}
label={title}
onOpen={onOpen}
plays={false}
bar={
@@ -481,7 +500,7 @@ export function SeasonCard({
</CornerBadge>
</PlexPicture>
}
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>}
title={<CardTitle onClick={onOpen}>{title}</CardTitle>}
more={m}
moreLabel={t("card.menu.more")}
/>