fix(plex): player + card polish from UAT

- Player: control tooltips now open ABOVE the button (no downward viewport clip);
  keyboard use also reveals the auto-hidden controls (wake() on keydown); added a
  Download button that downloads the ORIGINAL physical file (no re-encode/repackage,
  Content-Disposition attachment via GET /stream/{rk}/file?download=1).
- Plex cards: hover affordance shows what a click does (Play / Resume / Open show)
  + a quick watched/unwatched toggle (stopPropagation, no playback) — mirrors the
  YT-feed card quick-actions. New plex i18n keys en/hu/de.
- Verified in a real browser: card hover Play overlay + watched toggle (marks watched
  without opening), player download button present, tooltip-above, keyboard reveals
  controls, correct durations (Enola Holmes 3 = 1:48:04).
- NOTE: 'The Three Diablos' showing 13:05 was NOT a bug — it is a 13-min short (file
  ffprobe = 785s); the 1h42m film is 'The Last Wish'.
This commit is contained in:
npeter83 2026-07-05 05:26:16 +02:00
parent 86b86cb260
commit 29d306441a
7 changed files with 162 additions and 51 deletions

View file

@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
@ -30,6 +30,7 @@ function dur(n?: number | null): string {
export default function PlexBrowse({ q, library, show, sort }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" });
@ -68,6 +69,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 });
}
async function toggleWatched(card: PlexCard) {
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
qc.invalidateQueries({ queryKey: ["plex-browse"] });
}
if (sub.view.kind === "player") {
return (
@ -99,7 +104,7 @@ 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} onClick={() => onCard(c)} />
<PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
))}
</div>
)}
@ -116,21 +121,40 @@ const PLAYABLE_TINT: Record<string, string> = {
transcode: "bg-orange-600",
};
function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void }) {
function PlexPosterCard({
card,
onOpen,
onToggleWatched,
}: {
card: PlexCard;
onOpen: () => void;
onToggleWatched: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
return (
<button
onClick={onClick}
<div
className="group text-left"
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
>
<img
src={card.thumb}
alt=""
@ -138,8 +162,33 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Hover affordance: what a click does (play / resume / open show). */}
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
{isPlayable ? (
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
</div>
) : (
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
)}
</div>
{/* Quick watched toggle (movies/episodes), on hover. */}
{isPlayable && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "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">
<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">
{t("plex.watched")}
</span>
)}
@ -162,7 +211,7 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</button>
</div>
);
}