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:
parent
86b86cb260
commit
29d306441a
7 changed files with 162 additions and 51 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue