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:
parent
4993298838
commit
38c4bee869
7 changed files with 165 additions and 7 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.23.2
|
0.24.0
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
|
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
|
||||||
|
|
@ -34,6 +34,19 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||||
|
|
||||||
|
// Opening a show/player replaces the grid entirely (the component returns early below), so the
|
||||||
|
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
|
||||||
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||||
|
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
||||||
|
const scrollRef = useRef(0);
|
||||||
|
const scroller = () => document.querySelector("main");
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (sub.view.kind === "grid" && scrollRef.current) {
|
||||||
|
const el = scroller();
|
||||||
|
if (el) el.scrollTop = scrollRef.current;
|
||||||
|
}
|
||||||
|
}, [sub.view.kind]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: ["plex-browse", library, dq, sort, show],
|
queryKey: ["plex-browse", library, dq, sort, show],
|
||||||
enabled: !!library && sub.view.kind === "grid",
|
enabled: !!library && sub.view.kind === "grid",
|
||||||
|
|
@ -66,6 +79,7 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
function onCard(card: PlexCard) {
|
function onCard(card: PlexCard) {
|
||||||
|
scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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,
|
||||||
|
Clock,
|
||||||
Download,
|
Download,
|
||||||
|
Keyboard,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Pause,
|
Pause,
|
||||||
|
|
@ -13,8 +15,10 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
|
Square,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexMarker } from "../lib/api";
|
import { api, type PlexMarker } from "../lib/api";
|
||||||
|
|
||||||
|
|
@ -69,6 +73,15 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
audioRef.current = audioOrd;
|
audioRef.current = audioOrd;
|
||||||
subRef.current = subOrd;
|
subRef.current = subOrd;
|
||||||
menuOpenRef.current = menuOpen;
|
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;
|
durationRef.current = duration;
|
||||||
|
|
||||||
|
|
@ -392,8 +405,19 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
return !m;
|
return !m;
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "Escape":
|
||||||
if (!document.fullscreenElement) onClose();
|
if (helpOpenRef.current) setHelpOpen(false);
|
||||||
|
else if (!document.fullscreenElement) onClose();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -453,6 +477,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -505,6 +534,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||||
<RotateCcw className="w-5 h-5" />
|
<RotateCcw className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
|
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
||||||
|
<Square className="w-5 h-5" />
|
||||||
|
</Ctrl>
|
||||||
{detail?.kind === "episode" && (
|
{detail?.kind === "episode" && (
|
||||||
<>
|
<>
|
||||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
|
<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">
|
<div className="text-xs tabular-nums text-white/90">
|
||||||
{fmt(abs)} / {fmt(duration)}
|
{fmt(abs)} / {fmt(duration)}
|
||||||
|
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
|
@ -604,6 +637,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
|
|
@ -613,6 +649,65 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,19 @@
|
||||||
"subOff": "Aus",
|
"subOff": "Aus",
|
||||||
"errNotFound": "Diese Datei kann nicht abgespielt werden — die Medien sind auf dem Server nicht erreichbar (die Plex-Medieneinbindung fehlt oder die Pfadzuordnung ist falsch). Bitte den Administrator, die Plex-Konfiguration zu prüfen.",
|
"errNotFound": "Diese Datei kann nicht abgespielt werden — die Medien sind auf dem Server nicht erreichbar (die Plex-Medieneinbindung fehlt oder die Pfadzuordnung ist falsch). Bitte den Administrator, die Plex-Konfiguration zu prüfen.",
|
||||||
"errTranscode": "Das Format dieser Datei erfordert eine Transkodierung, die noch nicht unterstützt wird.",
|
"errTranscode": "Das Format dieser Datei erfordert eine Transkodierung, die noch nicht unterstützt wird.",
|
||||||
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen."
|
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||||
|
"stop": "Stopp",
|
||||||
|
"help": "Tastenkürzel",
|
||||||
|
"keys": {
|
||||||
|
"playPause": "Wiedergabe / Pause",
|
||||||
|
"seek": "10 Sekunden spulen",
|
||||||
|
"episode": "Vorherige / nächste Folge",
|
||||||
|
"volume": "Lautstärke lauter / leiser",
|
||||||
|
"mute": "Stumm",
|
||||||
|
"fullscreen": "Vollbild",
|
||||||
|
"back": "Stopp & zurück zum Feed",
|
||||||
|
"help": "Diese Hilfe anzeigen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,19 @@
|
||||||
"subOff": "Off",
|
"subOff": "Off",
|
||||||
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
|
"errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.",
|
||||||
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
|
"errTranscode": "This file's format needs transcoding, which isn't supported yet.",
|
||||||
"errGeneric": "Playback couldn't start. Please try again."
|
"errGeneric": "Playback couldn't start. Please try again.",
|
||||||
|
"stop": "Stop",
|
||||||
|
"help": "Keyboard shortcuts",
|
||||||
|
"keys": {
|
||||||
|
"playPause": "Play / pause",
|
||||||
|
"seek": "Seek 10 seconds",
|
||||||
|
"episode": "Previous / next episode",
|
||||||
|
"volume": "Volume up / down",
|
||||||
|
"mute": "Mute",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"back": "Stop & back to feed",
|
||||||
|
"help": "Show this help"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,19 @@
|
||||||
"subOff": "Ki",
|
"subOff": "Ki",
|
||||||
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
|
"errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.",
|
||||||
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
|
"errTranscode": "Ennek a fájlnak a formátuma transzkódolást igényel, ami még nem támogatott.",
|
||||||
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra."
|
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
|
||||||
|
"stop": "Leállítás",
|
||||||
|
"help": "Billentyűparancsok",
|
||||||
|
"keys": {
|
||||||
|
"playPause": "Lejátszás / szünet",
|
||||||
|
"seek": "Tekerés 10 másodperc",
|
||||||
|
"episode": "Előző / következő rész",
|
||||||
|
"volume": "Hangerő fel / le",
|
||||||
|
"mute": "Némítás",
|
||||||
|
"fullscreen": "Teljes képernyő",
|
||||||
|
"back": "Leállítás és vissza a feedre",
|
||||||
|
"help": "Súgó megjeleníté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",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.24.0",
|
||||||
|
date: "2026-07-05",
|
||||||
|
summary: "Plex player polish: a Stop button, a keyboard help overlay, time-remaining and a clock, and Back returns you to your place in the library.",
|
||||||
|
features: [
|
||||||
|
"Plex player: added an explicit Stop button, a time-remaining readout next to the seek bar, and a live clock in the top corner (both fade with the controls).",
|
||||||
|
"Plex player: press “H” (or the keyboard button) for an on-screen cheat sheet of all shortcuts.",
|
||||||
|
"Plex player: Backspace now stops playback and returns to the library — the same as the mouse Back button — matching a typical HTPC remote.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"Plex library: pressing Back from the player now returns you to the exact spot you were scrolled to, instead of jumping to the top of the library.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.23.2",
|
version: "0.23.2",
|
||||||
date: "2026-07-05",
|
date: "2026-07-05",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue