feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
This commit is contained in:
parent
eed517b475
commit
690e17611c
10 changed files with 568 additions and 9 deletions
|
|
@ -1,15 +1,20 @@
|
|||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
|
||||
type Sub =
|
||||
| { kind: "grid" }
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "player"; id: string }
|
||||
| { kind: "info"; id: string };
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
|
|
@ -83,6 +88,10 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
async function toggleWatched(card: PlexCard) {
|
||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
|
|
@ -95,6 +104,16 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "info") {
|
||||
const infoId = sub.view.id;
|
||||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "show") {
|
||||
return (
|
||||
<PlexShowView
|
||||
|
|
@ -118,7 +137,13 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
<PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -138,10 +163,12 @@ const PLAYABLE_TINT: Record<string, string> = {
|
|||
function PlexPosterCard({
|
||||
card,
|
||||
onOpen,
|
||||
onInfo,
|
||||
onToggleWatched,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
onInfo: () => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -200,6 +227,19 @@ function PlexPosterCard({
|
|||
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Media info page (movies/episodes). */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||
>
|
||||
<Info className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||
{card.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
|
|
@ -229,6 +269,39 @@ function PlexPosterCard({
|
|||
);
|
||||
}
|
||||
|
||||
function PlexInfoView({
|
||||
id,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||
return (
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
{q.isLoading || !q.data ? (
|
||||
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
|
||||
) : (
|
||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||
<PlexInfo detail={q.data} variant="page" onPlay={onPlay} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue