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,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 { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
ArrowLeft,
Download,
Maximize,
Minimize,
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.
useEffect(() => {
const video = videoRef.current;
@ -234,11 +245,21 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
return () => video.removeEventListener("ended", onEnded);
}, [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 --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
wake(); // keyboard use also reveals the controls
switch (e.key) {
case " ":
case "k":
@ -279,16 +300,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose]);
// 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);
}, []);
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
const activeMarker: PlexMarker | undefined = detail?.markers.find(
(m) => abs >= m.start_s && abs < m.end_s - 1,
@ -384,46 +396,35 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
<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" />}
</button>
<button onClick={() => seekTo(0)} className="p-1 hover:text-accent" title={t("plex.player.restart")}>
</Ctrl>
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</button>
</Ctrl>
{detail?.kind === "episode" && (
<>
<button
onClick={() => go(detail?.prev_id)}
disabled={!detail?.prev_id}
className="p-1 hover:text-accent disabled:opacity-30"
title={t("plex.player.prev")}
>
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
<SkipBack className="w-5 h-5" />
</button>
<button
onClick={() => go(detail?.next_id)}
disabled={!detail?.next_id}
className="p-1 hover:text-accent disabled:opacity-30"
title={t("plex.player.next")}
>
</Ctrl>
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
<SkipForward className="w-5 h-5" />
</button>
</Ctrl>
</>
)}
<div className="flex items-center gap-1.5 group/vol">
<button
onClick={() => {
<Ctrl
label={t("plex.player.mute")}
onClick={() =>
setMuted((m) => {
applyVolume(volume, !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" />}
</button>
</Ctrl>
<input
type="range"
min={0}
@ -445,12 +446,45 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
<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" />}
</button>
</Ctrl>
</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>
);
}