From 5b5e76b73d5f172ec4c5a950d6fcdf40bbbfe3f0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 20:30:30 +0200 Subject: [PATCH] =?UTF-8?q?E5=20S3:=20unified=20Plex=20card=20=E2=80=94=20?= =?UTF-8?q?scrim-reveal=20hover=20+=20rest=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poster card (grid + person page) gets the Seerr-style hover: on hover a bottom scrim slides up with the title, a "year · runtime · ★rating" meta line, and a 3-line synopsis — the info you'd otherwise click through for — plus a less-flat play ring (radial-gradient + ring shadow) and a top-right quick-action cluster (Info + watched-toggle, revealed on hover / always on touch). Rest-state badges (season / type tag) yield to the scrim on hover; the playable-cost tint stays. (§1 A + C from the enrichment study.) Backend (the data the scrim needs, mirroring S1's `art`): the grid (browse.unified_library) now emits a lang-coalesced, truncated `summary` (new query.summary_col) + the Plex `rating`; movie_card/show_card do the same (EN) for the detail/related/person cards. PlexCard.rating type added (summary already existed for episodes). Gate: tsc x3 / eslint / prettier / knip / vitest 326 green; ruff green; backend lane full suite green. Self-E2E on the live dev server (rebuilt on this branch, S2-enriched): the grid serves rating+summary (Shawshank 9.3, Godfather 9.2, real synopses) and the card renders the play ring + scrim (The Expanse: ring, synopsis, ★8.1) — verified via the DOM (the pane wouldn't composite for a screenshot). --- backend/app/plex/cards.py | 11 ++ backend/app/plex/query.py | 5 + backend/app/routes/plex/browse.py | 8 + .../src/components/plexCards.dom.test.tsx | 20 +++ frontend/src/components/plexCards.tsx | 145 +++++++++++------- frontend/src/lib/api/plex.ts | 3 +- 6 files changed, 134 insertions(+), 58 deletions(-) diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index 7e13a595..da24097e 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -14,12 +14,21 @@ def _art_url(rating_key: str, art_key: str | None) -> str | None: return f"/api/plex/image/{rating_key}?variant=art" if art_key else None +# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards +# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve +# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.) +def _card_summary(summary: str | None) -> str | None: + return (summary or "")[:300] or None + + def movie_card(it: PlexItem, st: PlexState | None) -> dict: return { "id": it.rating_key, "type": "movie", "title": it.title, "year": it.year, + "rating": it.rating, + "summary": _card_summary(it.summary), "duration_seconds": it.duration_s, "thumb": f"/api/plex/image/{it.rating_key}", "art": _art_url(it.rating_key, it.art_key), @@ -35,6 +44,8 @@ def show_card(sh: PlexShow, status: str = "new") -> dict: "type": "show", "title": sh.title, "year": sh.year, + "rating": sh.rating, + "summary": _card_summary(sh.summary), "thumb": f"/api/plex/image/{sh.rating_key}", "art": _art_url(sh.rating_key, sh.art_key), "season_count": sh.child_count, diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index ca3b31ce..56d0efe6 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -29,6 +29,11 @@ def title_col(model, lang: str): return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title +def summary_col(model, lang: str): + """DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this.""" + return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary + + def genres_col(model, lang: str): # type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce. if lang == "hu": diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index f333ffb9..5fa702c2 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -38,6 +38,7 @@ from app.plex.query import ( movie_status_case, show_status_case, split_csv, + summary_col, title_col, vector_col, vector_match, @@ -130,6 +131,8 @@ def unified_library( title_col(PlexItem, lang).label("title"), PlexItem.year.label("year"), PlexItem.rating.label("rating"), + # Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis. + func.left(summary_col(PlexItem, lang), 300).label("summary"), PlexItem.added_at.label("added_at"), PlexItem.originally_available_at.label("rel"), PlexItem.duration_s.label("duration_s"), @@ -170,6 +173,7 @@ def unified_library( title_col(PlexShow, lang).label("title"), PlexShow.year.label("year"), PlexShow.rating.label("rating"), + func.left(summary_col(PlexShow, lang), 300).label("summary"), PlexShow.added_at.label("added_at"), PlexShow.originally_available_at.label("rel"), cast(null(), Integer).label("duration_s"), @@ -227,6 +231,8 @@ def unified_library( "type": "movie", "title": r.title, "year": r.year, + "rating": r.rating, + "summary": r.summary, "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", "playable": r.playable, @@ -241,6 +247,8 @@ def unified_library( "type": "show", "title": r.title, "year": r.year, + "rating": r.rating, + "summary": r.summary, "thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status, diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index 20261d36..f631aba8 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -61,6 +61,26 @@ describe("PlexPosterCard a11y", () => { ); expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); }); + + it("reveals the synopsis + rating on the hover scrim when the card carries them", () => { + render( + , + ); + expect(screen.getByText("A quiet synopsis.")).toBeTruthy(); + expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1) + }); + + it("omits the scrim when the card has no synopsis or rating", () => { + render( + , + ); + expect(screen.queryByText(/synopsis/i)).toBeNull(); + }); }); describe("SeasonCard a11y", () => { diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index f3a02dcb..2829a620 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; +import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; import { Carousel } from "./Carousel"; @@ -52,6 +52,7 @@ export function PlexPosterCard({ const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); + const runtime = formatRuntime(card.duration_seconds); const pct = inProgress && card.duration_seconds ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) @@ -81,50 +82,15 @@ export function PlexPosterCard({ decoding="async" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" /> - {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} -
-
- - - {card.type === "show" - ? t("plex.openShow") - : inProgress - ? t("plex.resume") - : t("plex.play")} - -
-
- {/* Quick watched toggle (movies/episodes), on hover. */} - {isPlayable && ( - + {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */} + {card.playable && ( + )} - {/* Media info page (movies/episodes). */} - {isPlayable && ( - - )} - {/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card - has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch, - where the toggle is always shown); a watched show keeps the badge as its sole marker. */} + {/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card + (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */} {card.status === "watched" && ( )} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && ( {t("plex.inProgress")} )} - {card.playable && ( - + {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} + {card.type === "show" && card.season_count != null && ( + + {t("plex.seasons", { count: card.season_count })} + )} - {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} {card.type === "show" ? : } + + {/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the + synopsis + rating (the info you'd otherwise click through for). */} +
+
+
+
+ +
+
+ {(card.summary || card.rating != null) && ( +
+
+ {card.title} +
+
+ {(card.year || runtime) && ( + {[card.year, runtime].filter(Boolean).join(" · ")} + )} + {card.rating != null && ( + + + {card.rating.toFixed(1)} + + )} +
+ {card.summary && ( +

+ {card.summary} +

+ )} +
+ )} +
+ + {/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */} + {isPlayable && ( +
+ + +
+ )} + {pct > 0 && ( -
+
)} diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 5a601578..442a9ed5 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -43,6 +43,7 @@ export interface PlexCard { duration_seconds?: number | null; thumb: string; art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key + rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview playable?: string; // direct | remux | transcode status?: string; // new | watched | hidden position_seconds?: number; @@ -51,7 +52,7 @@ export interface PlexCard { episode_number?: number | null; // episode show_title?: string | null; // episode (in a mixed playlist) show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) - summary?: string | null; // episode + summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary } // Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that // also matches episodes (the grouped "Episodes" section).