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
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.24.0
|
0.25.0
|
||||||
|
|
@ -7,7 +7,9 @@ catalog sync.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import and_, func, or_
|
||||||
|
|
@ -339,6 +341,82 @@ _PROGRESS_MIN_S = 5
|
||||||
_FINISH_MARGIN_S = 30
|
_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]:
|
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||||
if it.kind != "episode" or it.show_id is None:
|
if it.kind != "episode" or it.show_id is None:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -367,14 +445,14 @@ def item_detail(
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||||
|
|
||||||
cast: list[str] = []
|
rich: dict = {}
|
||||||
markers: list[dict] = []
|
markers: list[dict] = []
|
||||||
audio_streams: list[dict] = []
|
audio_streams: list[dict] = []
|
||||||
subtitle_streams: list[dict] = []
|
subtitle_streams: list[dict] = []
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
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 []:
|
for m in meta.get("Marker") or []:
|
||||||
if m.get("type") in ("intro", "credits"):
|
if m.get("type") in ("intro", "credits"):
|
||||||
markers.append(
|
markers.append(
|
||||||
|
|
@ -419,7 +497,15 @@ def item_detail(
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"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,
|
"markers": markers,
|
||||||
"audio_streams": audio_streams,
|
"audio_streams": audio_streams,
|
||||||
"subtitle_streams": subtitle_streams,
|
"subtitle_streams": subtitle_streams,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
|
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, type PlexCard } from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
import { useHistorySubview } from "../lib/history";
|
||||||
|
|
||||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
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
|
// 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
|
// 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 });
|
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||||
else sub.open({ kind: "player", 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) {
|
async function toggleWatched(card: PlexCard) {
|
||||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||||
|
|
@ -95,6 +104,16 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||||
</Suspense>
|
</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") {
|
if (sub.view.kind === "show") {
|
||||||
return (
|
return (
|
||||||
<PlexShowView
|
<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">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||||
{items.map((c) => (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -138,10 +163,12 @@ const PLAYABLE_TINT: Record<string, string> = {
|
||||||
function PlexPosterCard({
|
function PlexPosterCard({
|
||||||
card,
|
card,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onInfo,
|
||||||
onToggleWatched,
|
onToggleWatched,
|
||||||
}: {
|
}: {
|
||||||
card: PlexCard;
|
card: PlexCard;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
onInfo: () => void;
|
||||||
onToggleWatched: (c: PlexCard) => void;
|
onToggleWatched: (c: PlexCard) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -200,6 +227,19 @@ function PlexPosterCard({
|
||||||
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
|
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
|
||||||
</button>
|
</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). */}
|
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||||
{card.status === "watched" && (
|
{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">
|
<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({
|
function PlexShowView({
|
||||||
showId,
|
showId,
|
||||||
onBack,
|
onBack,
|
||||||
|
|
|
||||||
296
frontend/src/components/PlexInfo.tsx
Normal file
296
frontend/src/components/PlexInfo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
|
|
@ -22,6 +23,9 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexMarker } from "../lib/api";
|
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
|
// 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
|
// (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
|
// 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 [helpOpen, setHelpOpen] = useState(false);
|
||||||
const helpOpenRef = useRef(false);
|
const helpOpenRef = useRef(false);
|
||||||
helpOpenRef.current = helpOpen;
|
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());
|
const [now, setNow] = useState(() => new Date());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iv = window.setInterval(() => setNow(new Date()), 15000);
|
const iv = window.setInterval(() => setNow(new Date()), 15000);
|
||||||
|
|
@ -409,14 +417,20 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
case "H":
|
case "H":
|
||||||
setHelpOpen((v) => !v);
|
setHelpOpen((v) => !v);
|
||||||
break;
|
break;
|
||||||
|
case "i":
|
||||||
|
case "I":
|
||||||
|
setInfoOpen((v) => !v);
|
||||||
|
break;
|
||||||
case "Backspace":
|
case "Backspace":
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (helpOpenRef.current) setHelpOpen(false);
|
if (helpOpenRef.current) setHelpOpen(false);
|
||||||
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
else onClose();
|
else onClose();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
if (helpOpenRef.current) setHelpOpen(false);
|
if (helpOpenRef.current) setHelpOpen(false);
|
||||||
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
else if (!document.fullscreenElement) onClose();
|
else if (!document.fullscreenElement) onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -637,6 +651,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)}>
|
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
||||||
<Keyboard className="w-5 h-5" />
|
<Keyboard className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
|
|
@ -686,6 +703,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
[["↑", "↓"], "plex.player.keys.volume"],
|
[["↑", "↓"], "plex.player.keys.volume"],
|
||||||
[["M"], "plex.player.keys.mute"],
|
[["M"], "plex.player.keys.mute"],
|
||||||
[["F"], "plex.player.keys.fullscreen"],
|
[["F"], "plex.player.keys.fullscreen"],
|
||||||
|
[["I"], "plex.player.keys.info"],
|
||||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||||
[["H"], "plex.player.keys.help"],
|
[["H"], "plex.player.keys.help"],
|
||||||
] as [string[], string][]
|
] as [string[], string][]
|
||||||
|
|
@ -708,6 +726,26 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.",
|
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||||
"stop": "Stopp",
|
"stop": "Stopp",
|
||||||
"help": "Tastenkürzel",
|
"help": "Tastenkürzel",
|
||||||
|
"infoBtn": "Medieninfo",
|
||||||
"keys": {
|
"keys": {
|
||||||
"playPause": "Wiedergabe / Pause",
|
"playPause": "Wiedergabe / Pause",
|
||||||
"seek": "10 Sekunden spulen",
|
"seek": "10 Sekunden spulen",
|
||||||
|
|
@ -60,10 +61,23 @@
|
||||||
"volume": "Lautstärke lauter / leiser",
|
"volume": "Lautstärke lauter / leiser",
|
||||||
"mute": "Stumm",
|
"mute": "Stumm",
|
||||||
"fullscreen": "Vollbild",
|
"fullscreen": "Vollbild",
|
||||||
|
"info": "Medieninfo",
|
||||||
"back": "Stopp & zurück zum Feed",
|
"back": "Stopp & zurück zum Feed",
|
||||||
"help": "Diese Hilfe anzeigen"
|
"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": {
|
"playable": {
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"errGeneric": "Playback couldn't start. Please try again.",
|
"errGeneric": "Playback couldn't start. Please try again.",
|
||||||
"stop": "Stop",
|
"stop": "Stop",
|
||||||
"help": "Keyboard shortcuts",
|
"help": "Keyboard shortcuts",
|
||||||
|
"infoBtn": "Media info",
|
||||||
"keys": {
|
"keys": {
|
||||||
"playPause": "Play / pause",
|
"playPause": "Play / pause",
|
||||||
"seek": "Seek 10 seconds",
|
"seek": "Seek 10 seconds",
|
||||||
|
|
@ -60,10 +61,23 @@
|
||||||
"volume": "Volume up / down",
|
"volume": "Volume up / down",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
|
"info": "Media info",
|
||||||
"back": "Stop & back to feed",
|
"back": "Stop & back to feed",
|
||||||
"help": "Show this help"
|
"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": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
"remux": "Needs a light remux (no video re-encode)",
|
"remux": "Needs a light remux (no video re-encode)",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
|
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
|
||||||
"stop": "Leállítás",
|
"stop": "Leállítás",
|
||||||
"help": "Billentyűparancsok",
|
"help": "Billentyűparancsok",
|
||||||
|
"infoBtn": "Média infó",
|
||||||
"keys": {
|
"keys": {
|
||||||
"playPause": "Lejátszás / szünet",
|
"playPause": "Lejátszás / szünet",
|
||||||
"seek": "Tekerés 10 másodperc",
|
"seek": "Tekerés 10 másodperc",
|
||||||
|
|
@ -60,10 +61,23 @@
|
||||||
"volume": "Hangerő fel / le",
|
"volume": "Hangerő fel / le",
|
||||||
"mute": "Némítás",
|
"mute": "Némítás",
|
||||||
"fullscreen": "Teljes képernyő",
|
"fullscreen": "Teljes képernyő",
|
||||||
|
"info": "Média infó",
|
||||||
"back": "Leállítás és vissza a feedre",
|
"back": "Leállítás és vissza a feedre",
|
||||||
"help": "Súgó megjelenítése"
|
"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": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||||
|
|
|
||||||
|
|
@ -695,7 +695,15 @@ export interface PlexItemDetail {
|
||||||
playable: string; // direct | remux | transcode
|
playable: string; // direct | remux | transcode
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: 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[];
|
markers: PlexMarker[];
|
||||||
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||||
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
||||||
|
|
@ -707,6 +715,11 @@ export interface PlexItemDetail {
|
||||||
prev_id?: string | null;
|
prev_id?: string | null;
|
||||||
next_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 {
|
export interface PlexPlaySession {
|
||||||
mode: "direct" | "hls";
|
mode: "direct" | "hls";
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.24.0",
|
||||||
date: "2026-07-05",
|
date: "2026-07-05",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue