feat(plex): player HTPC polish — Stop button, keyboard help, time-remaining + clock, scroll-restore

- Explicit Stop button (stops playback + returns to the library).
- Keyboard cheat-sheet overlay toggled with 'H' (movie hides the episode-nav row).
- Backspace = stop & back to feed (HTPC-remote convention); closes the help first.
- Time-remaining readout beside the seek bar + a live wall clock in the top bar
  (both fade with the controls).
- PlexBrowse restores the grid scroll position when returning from the player, so
  Back lands on the same card instead of the top of the library.
- New plex.player i18n keys (stop/help/keys.*) in en/hu/de.
This commit is contained in:
npeter83 2026-07-05 21:27:02 +02:00
parent 4993298838
commit 38c4bee869
7 changed files with 165 additions and 7 deletions

View file

@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Fragment, 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,
Clock,
Download,
Keyboard,
Maximize,
Minimize,
Pause,
@ -13,8 +15,10 @@ import {
Settings,
SkipBack,
SkipForward,
Square,
Volume2,
VolumeX,
X,
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
@ -69,6 +73,15 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
audioRef.current = audioOrd;
subRef.current = subOrd;
menuOpenRef.current = menuOpen;
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
const helpOpenRef = useRef(false);
helpOpenRef.current = helpOpen;
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const iv = window.setInterval(() => setNow(new Date()), 15000);
return () => window.clearInterval(iv);
}, []);
durationRef.current = duration;
@ -392,8 +405,19 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
return !m;
});
break;
case "h":
case "H":
setHelpOpen((v) => !v);
break;
case "Backspace":
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
e.preventDefault();
if (helpOpenRef.current) setHelpOpen(false);
else onClose();
break;
case "Escape":
if (!document.fullscreenElement) onClose();
if (helpOpenRef.current) setHelpOpen(false);
else if (!document.fullscreenElement) onClose();
break;
}
};
@ -453,6 +477,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
)}
</div>
{/* Live wall clock — handy on a lean-back / HTPC screen. */}
<div className="ml-auto flex items-center gap-1.5 text-sm tabular-nums text-white/80 shrink-0">
<Clock className="w-4 h-4" />
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
</div>
@ -505,6 +534,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
<RotateCcw className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
<Square className="w-5 h-5" />
</Ctrl>
{detail?.kind === "episode" && (
<>
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
@ -546,6 +578,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
<div className="text-xs tabular-nums text-white/90">
{fmt(abs)} / {fmt(duration)}
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
</div>
<div className="ml-auto flex items-center gap-2">
@ -604,6 +637,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
)}
</div>
)}
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
<Keyboard className="w-5 h-5" />
</Ctrl>
<Ctrl label={t("plex.player.download")} onClick={download}>
<Download className="w-5 h-5" />
</Ctrl>
@ -613,6 +649,65 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
</div>
</div>
</div>
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-20 grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
}}
>
<div
className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<Keyboard className="w-5 h-5" /> {t("plex.player.help")}
</div>
<button
onClick={() => setHelpOpen(false)}
className="p-1 rounded-lg hover:bg-white/10"
aria-label={t("plex.player.back")}
>
<X className="w-5 h-5" />
</button>
</div>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
{(
[
[["Space", "K"], "plex.player.keys.playPause"],
[["←", "→"], "plex.player.keys.seek"],
...(detail?.kind === "episode"
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
: []),
[["↑", "↓"], "plex.player.keys.volume"],
[["M"], "plex.player.keys.mute"],
[["F"], "plex.player.keys.fullscreen"],
[["⌫", "Esc"], "plex.player.keys.back"],
[["H"], "plex.player.keys.help"],
] as [string[], string][]
).map(([keys, labelKey]) => (
<Fragment key={labelKey}>
<dt className="flex flex-wrap gap-1">
{keys.map((k) => (
<kbd
key={k}
className="rounded bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
>
{k}
</kbd>
))}
</dt>
<dd className="text-white/80">{t(labelKey)}</dd>
</Fragment>
))}
</dl>
</div>
</div>
)}
</div>
);
}