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

@ -473,14 +473,22 @@ def stream_session(
@router.get("/stream/{rating_key}/file") @router.get("/stream/{rating_key}/file")
def stream_file( def stream_file(
rating_key: str, rating_key: str,
download: bool = False,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> FileResponse: ) -> FileResponse:
"""Serve the raw local media file with HTTP range support (direct-playable files).""" """Serve the raw local media file with HTTP range support. Used both for direct-play (inline)
and for the player's "download original" button (`?download=1` → attachment). The download is
the untouched physical file no re-encode/repackage."""
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
src = plex_paths.local_media_path(db, it.file_path) src = plex_paths.local_media_path(db, it.file_path)
if src is None: if src is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise HTTPException(status_code=404, detail="Media file not found on disk")
if download:
# A clean, safe download name from the title + the real file extension.
ext = src.suffix or (f".{it.container}" if it.container else "")
base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video"
return FileResponse(str(src), filename=f"{base}{ext}")
return FileResponse(str(src)) # Starlette honours the Range header (206) return FileResponse(str(src)) # Starlette honours the Range header (206)

View file

@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react"; import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft, CheckCircle2, 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";
@ -30,6 +30,7 @@ function dur(n?: number | null): string {
export default function PlexBrowse({ q, library, show, sort }: Props) { export default function PlexBrowse({ q, library, show, sort }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350); const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" }); 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 }); 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 });
} }
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") { if (sub.view.kind === "player") {
return ( 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"> <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} onClick={() => onCard(c)} /> <PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
))} ))}
</div> </div>
)} )}
@ -116,21 +121,40 @@ const PLAYABLE_TINT: Record<string, string> = {
transcode: "bg-orange-600", 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 { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; 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 = const pct =
inProgress && card.duration_seconds inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0; : 0;
return ( return (
<button <div
onClick={onClick}
className="group text-left" className="group text-left"
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll). // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties} 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 <img
src={card.thumb} src={card.thumb}
alt="" alt=""
@ -138,8 +162,33 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
decoding="async" decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" 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" && ( {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")} {t("plex.watched")}
</span> </span>
)} )}
@ -162,7 +211,7 @@ function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void
</div> </div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</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> <div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</button> </div>
); );
} }

View file

@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { 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";
import { import {
ArrowLeft, ArrowLeft,
Download,
Maximize, Maximize,
Minimize, Minimize,
Pause, Pause,
@ -222,6 +223,16 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
[], [],
); );
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
const download = useCallback(() => {
const a = document.createElement("a");
a.href = api.plexDownloadUrl(id);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
}, [id]);
// Auto-advance to the next episode when one finishes. // Auto-advance to the next episode when one finishes.
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@ -234,11 +245,21 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
return () => video.removeEventListener("ended", onEnded); return () => video.removeEventListener("ended", onEnded);
}, [id, detail, go]); }, [id, detail, go]);
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setUiVisible(false);
}, 3000);
}, []);
// --- keyboard -------------------------------------------------------------------------------- // --- keyboard --------------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName; const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return; if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) { switch (e.key) {
case " ": case " ":
case "k": case "k":
@ -279,16 +300,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose]); }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
// Auto-hide the controls while playing.
const wake = useCallback(() => {
setUiVisible(true);
if (hideTimer.current) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
if (!videoRef.current?.paused) setUiVisible(false);
}, 3000);
}, []);
const activeMarker: PlexMarker | undefined = detail?.markers.find( const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1, (m) => abs >= m.start_s && abs < m.end_s - 1,
@ -384,46 +396,35 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div> </div>
<div className="flex items-center gap-3 text-white"> <div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="p-1 hover:text-accent" title={t("plex.player.playPause")}> <Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />} {playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
</button> </Ctrl>
<button onClick={() => seekTo(0)} className="p-1 hover:text-accent" title={t("plex.player.restart")}> <Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" /> <RotateCcw className="w-5 h-5" />
</button> </Ctrl>
{detail?.kind === "episode" && ( {detail?.kind === "episode" && (
<> <>
<button <Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
onClick={() => go(detail?.prev_id)}
disabled={!detail?.prev_id}
className="p-1 hover:text-accent disabled:opacity-30"
title={t("plex.player.prev")}
>
<SkipBack className="w-5 h-5" /> <SkipBack className="w-5 h-5" />
</button> </Ctrl>
<button <Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
onClick={() => go(detail?.next_id)}
disabled={!detail?.next_id}
className="p-1 hover:text-accent disabled:opacity-30"
title={t("plex.player.next")}
>
<SkipForward className="w-5 h-5" /> <SkipForward className="w-5 h-5" />
</button> </Ctrl>
</> </>
)} )}
<div className="flex items-center gap-1.5 group/vol"> <div className="flex items-center gap-1.5 group/vol">
<button <Ctrl
onClick={() => { label={t("plex.player.mute")}
onClick={() =>
setMuted((m) => { setMuted((m) => {
applyVolume(volume, !m); applyVolume(volume, !m);
return !m; return !m;
}); })
}} }
className="p-1 hover:text-accent"
title={t("plex.player.mute")}
> >
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />} {muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
</button> </Ctrl>
<input <input
type="range" type="range"
min={0} min={0}
@ -445,12 +446,45 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div> </div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<button onClick={toggleFs} className="p-1 hover:text-accent" title={t("plex.player.fullscreen")}> <Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />} {fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</button> </Ctrl>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<div className="relative flex group/ctrl">
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
>
{children}
</button>
<span className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100">
{label}
</span>
</div>
);
}

View file

@ -25,6 +25,11 @@
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
"loadMore": "Mehr laden", "loadMore": "Mehr laden",
"watched": "Angesehen", "watched": "Angesehen",
"play": "Abspielen",
"resume": "Fortsetzen",
"openShow": "Serie öffnen",
"markWatched": "Als gesehen markieren",
"markUnwatched": "Als ungesehen markieren",
"seasons": "{{count}} Staffeln", "seasons": "{{count}} Staffeln",
"playerSoon": "Player kommt bald — „{{title}}“", "playerSoon": "Player kommt bald — „{{title}}“",
"player": { "player": {
@ -37,7 +42,8 @@
"prev": "Vorherige Folge", "prev": "Vorherige Folge",
"next": "Nächste Folge", "next": "Nächste Folge",
"mute": "Stumm (m)", "mute": "Stumm (m)",
"fullscreen": "Vollbild (f)" "fullscreen": "Vollbild (f)",
"download": "Originaldatei herunterladen"
}, },
"playable": { "playable": {
"direct": "Spielt direkt im Browser", "direct": "Spielt direkt im Browser",

View file

@ -25,6 +25,11 @@
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
"loadMore": "Load more", "loadMore": "Load more",
"watched": "Watched", "watched": "Watched",
"play": "Play",
"resume": "Resume",
"openShow": "Open show",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"seasons": "{{count}} seasons", "seasons": "{{count}} seasons",
"playerSoon": "Player coming soon — “{{title}}”", "playerSoon": "Player coming soon — “{{title}}”",
"player": { "player": {
@ -37,7 +42,8 @@
"prev": "Previous episode", "prev": "Previous episode",
"next": "Next episode", "next": "Next episode",
"mute": "Mute (m)", "mute": "Mute (m)",
"fullscreen": "Fullscreen (f)" "fullscreen": "Fullscreen (f)",
"download": "Download original file"
}, },
"playable": { "playable": {
"direct": "Plays directly in the browser", "direct": "Plays directly in the browser",

View file

@ -25,6 +25,11 @@
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
"loadMore": "Több betöltése", "loadMore": "Több betöltése",
"watched": "Megnézve", "watched": "Megnézve",
"play": "Lejátszás",
"resume": "Folytatás",
"openShow": "Sorozat megnyitása",
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"seasons": "{{count}} évad", "seasons": "{{count}} évad",
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”", "playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
"player": { "player": {
@ -37,7 +42,8 @@
"prev": "Előző rész", "prev": "Előző rész",
"next": "Következő rész", "next": "Következő rész",
"mute": "Némítás (m)", "mute": "Némítás (m)",
"fullscreen": "Teljes képernyő (f)" "fullscreen": "Teljes képernyő (f)",
"download": "Eredeti fájl letölté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",

View file

@ -1054,6 +1054,8 @@ export const api = {
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}), }),
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`, plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
plexDownloadUrl: (id: string): string =>
`/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,
plexImageUrl: (id: string, variant?: "thumb" | "art"): string => plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`, `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
// --- admin: users & roles --- // --- admin: users & roles ---