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:
npeter83 2026-07-05 22:57:18 +02:00
parent eed517b475
commit 690e17611c
10 changed files with 568 additions and 9 deletions

View file

@ -1 +1 @@
0.24.0
0.25.0

View file

@ -7,7 +7,9 @@ catalog sync.
"""
import logging
from datetime import datetime, timezone
from urllib.parse import quote
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_
@ -339,6 +341,82 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def _rich_meta(meta: dict) -> dict:
"""Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort any
missing field is just omitted."""
# Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
imdb_rating = None
for r in meta.get("Rating") or []:
if str(r.get("image") or "").startswith("imdb://"):
try:
imdb_rating = round(float(r.get("value")), 1)
except (TypeError, ValueError):
pass
break
if imdb_rating is None:
try:
imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1)
except (TypeError, ValueError):
imdb_rating = None
# IMDb id (tt…) from the Guid list → a clickable link.
imdb_id = None
for g in meta.get("Guid") or []:
gid = str(g.get("id") or "")
if gid.startswith("imdb://"):
imdb_id = gid.split("imdb://", 1)[1]
break
cast = []
for role in (meta.get("Role") or [])[:24]:
name = role.get("tag")
if not name:
continue
thumb = str(role.get("thumb") or "")
cast.append(
{
"name": name,
"role": role.get("role"),
"thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None,
}
)
return {
"imdb_rating": imdb_rating,
"imdb_id": imdb_id,
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
"content_rating": meta.get("contentRating"),
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"cast": cast,
}
@router.get("/person-image")
def person_image(u: str, _: User = Depends(current_user)) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed)."""
if not u.startswith(_PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
try:
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
r = c.get(u)
r.raise_for_status()
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Image fetch failed")
return Response(
content=r.content,
media_type=r.headers.get("content-type", "image/jpeg"),
headers={"Cache-Control": "public, max-age=604800"},
)
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
@ -367,14 +445,14 @@ def item_detail(
it = _item_or_404(db, rating_key)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
cast: list[str] = []
rich: dict = {}
markers: list[dict] = []
audio_streams: list[dict] = []
subtitle_streams: list[dict] = []
try:
with PlexClient(db) as plex:
meta = plex.metadata(it.rating_key, markers=True) or {}
cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
rich = _rich_meta(meta)
for m in meta.get("Marker") or []:
if m.get("type") in ("intro", "credits"):
markers.append(
@ -419,7 +497,15 @@ def item_detail(
"playable": it.playable,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"cast": cast,
"cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
"imdb_url": rich.get("imdb_url"),
"content_rating": rich.get("content_rating"),
"genres": rich.get("genres", []),
"directors": rich.get("directors", []),
"studio": rich.get("studio"),
"tagline": rich.get("tagline"),
"markers": markers,
"audio_streams": audio_streams,
"subtitle_streams": subtitle_streams,

View file

@ -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,

View file

@ -0,0 +1,296 @@
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>
);
}

View file

@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
@ -6,6 +6,7 @@ import {
ArrowLeft,
Clock,
Download,
Info,
Keyboard,
Maximize,
Minimize,
@ -22,6 +23,9 @@ import {
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
@ -77,6 +81,10 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
const [helpOpen, setHelpOpen] = useState(false);
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
const [infoOpen, setInfoOpen] = useState(false);
const infoOpenRef = useRef(false);
infoOpenRef.current = infoOpen;
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const iv = window.setInterval(() => setNow(new Date()), 15000);
@ -409,14 +417,20 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
case "H":
setHelpOpen((v) => !v);
break;
case "i":
case "I":
setInfoOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
e.preventDefault();
if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else onClose();
break;
case "Escape":
if (helpOpenRef.current) setHelpOpen(false);
else if (infoOpenRef.current) setInfoOpen(false);
else if (!document.fullscreenElement) onClose();
break;
}
@ -637,6 +651,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
)}
</div>
)}
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
<Info className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Keyboard className="w-5 h-5" />
</Ctrl>
@ -686,6 +703,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["F"], "plex.player.keys.fullscreen"],
[["I"], "plex.player.keys.info"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
@ -708,6 +726,26 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
</div>
)}
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4"
onClick={(e) => {
e.stopPropagation();
setInfoOpen(false);
}}
>
<div
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Suspense fallback={null}>
<PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
</Suspense>
</div>
</div>
)}
</div>
);
}

View file

@ -53,6 +53,7 @@
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.",
"stop": "Stopp",
"help": "Tastenkürzel",
"infoBtn": "Medieninfo",
"keys": {
"playPause": "Wiedergabe / Pause",
"seek": "10 Sekunden spulen",
@ -60,10 +61,23 @@
"volume": "Lautstärke lauter / leiser",
"mute": "Stumm",
"fullscreen": "Vollbild",
"info": "Medieninfo",
"back": "Stopp & zurück zum Feed",
"help": "Diese Hilfe anzeigen"
}
},
"info": {
"title": "Medieninfo",
"customize": "Ansicht anpassen",
"prefArtBg": "Dezenter Hintergrund",
"prefCast": "Besetzung",
"openImdb": "Bei IMDb öffnen",
"director": "Regie",
"cast": "Besetzung & Crew",
"markWatched": "Als gesehen markieren",
"markUnwatched": "Gesehen zurücknehmen",
"clearResume": "Fortsetzungsposition löschen"
},
"playable": {
"direct": "Spielt direkt im Browser",
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",

View file

@ -53,6 +53,7 @@
"errGeneric": "Playback couldn't start. Please try again.",
"stop": "Stop",
"help": "Keyboard shortcuts",
"infoBtn": "Media info",
"keys": {
"playPause": "Play / pause",
"seek": "Seek 10 seconds",
@ -60,10 +61,23 @@
"volume": "Volume up / down",
"mute": "Mute",
"fullscreen": "Fullscreen",
"info": "Media info",
"back": "Stop & back to feed",
"help": "Show this help"
}
},
"info": {
"title": "Media info",
"customize": "Customize view",
"prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew",
"openImdb": "Open on IMDb",
"director": "Director",
"cast": "Cast & crew",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"clearResume": "Clear resume position"
},
"playable": {
"direct": "Plays directly in the browser",
"remux": "Needs a light remux (no video re-encode)",

View file

@ -53,6 +53,7 @@
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
"stop": "Leállítás",
"help": "Billentyűparancsok",
"infoBtn": "Média infó",
"keys": {
"playPause": "Lejátszás / szünet",
"seek": "Tekerés 10 másodperc",
@ -60,10 +61,23 @@
"volume": "Hangerő fel / le",
"mute": "Némítás",
"fullscreen": "Teljes képernyő",
"info": "Média infó",
"back": "Leállítás és vissza a feedre",
"help": "Súgó megjelenítése"
}
},
"info": {
"title": "Média infó",
"customize": "Nézet testreszabása",
"prefArtBg": "Halvány háttér",
"prefCast": "Szereplők",
"openImdb": "Megnyitás az IMDb-n",
"director": "Rendező",
"cast": "Szereplők & stáb",
"markWatched": "Megjelölés megnézettként",
"markUnwatched": "Megnézett visszavonása",
"clearResume": "Folytatási pozíció törlése"
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",

View file

@ -695,7 +695,15 @@ export interface PlexItemDetail {
playable: string; // direct | remux | transcode
thumb: string;
art: string;
cast: string[];
cast: PlexCastMember[];
imdb_rating?: number | null;
imdb_id?: string | null;
imdb_url?: string | null;
content_rating?: string | null;
genres: string[];
directors: string[];
studio?: string | null;
tagline?: string | null;
markers: PlexMarker[];
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
subtitle_streams: { ord: number; label: string; language?: string | null }[];
@ -707,6 +715,11 @@ export interface PlexItemDetail {
prev_id?: string | null;
next_id?: string | null;
}
export interface PlexCastMember {
name: string;
role?: string | null;
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;

View file

@ -14,6 +14,17 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.25.0",
date: "2026-07-05",
summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
features: [
"Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.",
"Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.",
"Plex info: two view preferences you can toggle and that stick — a faint art backdrop and the cast & crew row.",
"Plex: you can now mark a title unwatched or clear its resume position from the info page.",
],
},
{
version: "0.24.0",
date: "2026-07-05",