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.
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
ExternalLink,
|
|
Play,
|
|
RotateCcw,
|
|
SlidersHorizontal,
|
|
Star,
|
|
X,
|
|
} from "lucide-react";
|
|
import { api, type PlexItemDetail } from "../lib/api";
|
|
|
|
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
|
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
|
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
|
|
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
|
|
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
|
|
|
|
type Props = {
|
|
detail: PlexItemDetail;
|
|
variant: "page" | "overlay";
|
|
onPlay?: () => void; // page: start/resume playback
|
|
onClose?: () => void; // overlay: dismiss
|
|
onStateChange?: () => void; // after a watch-state change, let the opener refresh
|
|
};
|
|
|
|
function fmtDur(s?: number | null): string {
|
|
if (!s) return "";
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
return h ? `${h}h ${m}m` : `${m}m`;
|
|
}
|
|
|
|
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange }: Props) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
|
|
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
|
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
|
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean };
|
|
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
|
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
|
const [customizing, setCustomizing] = useState(false);
|
|
const savePref = (patch: Record<string, boolean>) => {
|
|
api.savePrefs(patch).catch(() => {});
|
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
|
);
|
|
};
|
|
|
|
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
|
const setState = async (status: "new" | "watched") => {
|
|
await api.plexSetState(detail.id, status).catch(() => {});
|
|
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
|
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
|
onStateChange?.();
|
|
};
|
|
|
|
const meta = [
|
|
detail.year,
|
|
detail.content_rating,
|
|
fmtDur(detail.duration_seconds),
|
|
detail.studio,
|
|
].filter(Boolean);
|
|
|
|
const cast = showCast ? detail.cast : [];
|
|
const overlay = variant === "overlay";
|
|
|
|
return (
|
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
|
{/* Faint art backdrop (page only, toggleable). */}
|
|
{!overlay && artBg && detail.art && (
|
|
<div className="absolute inset-0 -z-10 overflow-hidden rounded-2xl">
|
|
<img src={detail.art} alt="" className="w-full h-full object-cover opacity-20" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-bg via-bg/85 to-bg/60" />
|
|
</div>
|
|
)}
|
|
|
|
<div className={overlay ? "p-5" : "p-4 sm:p-6"}>
|
|
{/* Header: close (overlay) / customize */}
|
|
<div className="mb-4 flex items-start gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
|
|
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
|
|
</h1>
|
|
{detail.kind === "episode" && (
|
|
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
|
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="relative shrink-0">
|
|
<button
|
|
onClick={() => setCustomizing((v) => !v)}
|
|
title={t("plex.info.customize")}
|
|
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
|
>
|
|
<SlidersHorizontal className="w-4 h-4" />
|
|
</button>
|
|
{customizing && (
|
|
<div className="absolute right-0 top-full z-10 mt-1 w-52 rounded-xl border border-border bg-card p-1.5 text-sm shadow-xl">
|
|
<PrefToggle
|
|
label={t("plex.info.prefArtBg")}
|
|
on={artBg}
|
|
onClick={() => {
|
|
setArtBg((v) => !v);
|
|
savePref({ plexInfoArtBg: !artBg });
|
|
}}
|
|
/>
|
|
<PrefToggle
|
|
label={t("plex.info.prefCast")}
|
|
on={showCast}
|
|
onClick={() => {
|
|
setShowCast((v) => !v);
|
|
savePref({ plexInfoCast: !showCast });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{overlay && onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
|
aria-label={t("plex.player.back")}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-4 sm:gap-6">
|
|
{/* Poster */}
|
|
<img
|
|
src={detail.thumb}
|
|
alt=""
|
|
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
|
|
/>
|
|
|
|
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
|
{/* Meta line + IMDb rating/link */}
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
{meta.map((m, i) => (
|
|
<span key={i} className={overlay ? "text-white/70" : "text-muted"}>
|
|
{m}
|
|
</span>
|
|
))}
|
|
{detail.imdb_rating != null && (
|
|
<a
|
|
href={detail.imdb_url ?? undefined}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
|
detail.imdb_url ? "bg-amber-400/15 text-amber-500 hover:bg-amber-400/25" : ""
|
|
}`}
|
|
title={detail.imdb_url ? t("plex.info.openImdb") : undefined}
|
|
>
|
|
<Star className="w-3.5 h-3.5 fill-current" />
|
|
{detail.imdb_rating.toFixed(1)}
|
|
{detail.imdb_url && <ExternalLink className="w-3 h-3 opacity-70" />}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{detail.tagline && (
|
|
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
|
{detail.tagline}
|
|
</p>
|
|
)}
|
|
|
|
{detail.genres.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{detail.genres.map((g) => (
|
|
<span
|
|
key={g}
|
|
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
|
|
>
|
|
{g}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{detail.directors.length > 0 && (
|
|
<p className={`mt-2 text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
|
{t("plex.info.director")}: <span className="font-medium">{detail.directors.join(", ")}</span>
|
|
</p>
|
|
)}
|
|
|
|
{detail.summary && (
|
|
<p className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}>
|
|
{detail.summary}
|
|
</p>
|
|
)}
|
|
|
|
{/* Page actions: Play/Resume + watch controls */}
|
|
{!overlay && (
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
{onPlay && (
|
|
<button
|
|
onClick={onPlay}
|
|
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
|
|
>
|
|
<Play className="w-5 h-5 fill-current" />
|
|
{inProgress ? t("plex.resume") : t("plex.play")}
|
|
</button>
|
|
)}
|
|
{detail.status === "watched" ? (
|
|
<button
|
|
onClick={() => setState("new")}
|
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
{t("plex.info.markUnwatched")}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setState("watched")}
|
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
{t("plex.info.markWatched")}
|
|
</button>
|
|
)}
|
|
{inProgress && (
|
|
<button
|
|
onClick={() => setState("new")}
|
|
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
{t("plex.info.clearResume")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cast row (toggleable), circular photos. */}
|
|
{cast.length > 0 && (
|
|
<div className="mt-5">
|
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
|
{t("plex.info.cast")}
|
|
</h2>
|
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
|
{cast.map((c, i) => (
|
|
<div key={i} className="w-20 shrink-0 text-center">
|
|
<div
|
|
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"}`}
|
|
>
|
|
{c.thumb ? (
|
|
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="grid h-full w-full place-items-center text-lg opacity-40">
|
|
{c.name.charAt(0)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
|
|
{c.name}
|
|
</div>
|
|
{c.role && (
|
|
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
|
|
{c.role}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
|
>
|
|
<span>{label}</span>
|
|
<span
|
|
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
|
/>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|