diff --git a/VERSION b/VERSION index c86a09d..286d5b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.2 \ No newline at end of file +0.24.0 \ No newline at end of file diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index f54cd19..96862ad 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; 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 sub = useHistorySubview({ 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
(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({ queryKey: ["plex-browse", library, dq, sort, show], enabled: !!library && sub.view.kind === "grid", @@ -66,6 +79,7 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { }, [hasNextPage, isFetchingNextPage, fetchNextPage]); 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 }); else sub.open({ kind: "player", id: card.id }); } diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 9ecbfe6..28f62ed 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -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) { )} + {/* Live wall clock — handy on a lean-back / HTPC screen. */} +
+ + {now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
@@ -505,6 +534,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) { seekTo(0)}> + + + {detail?.kind === "episode" && ( <> go(detail?.prev_id)} disabled={!detail?.prev_id}> @@ -546,6 +578,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
{fmt(abs)} / {fmt(duration)} + -{fmt(Math.max(0, duration - abs))}
@@ -604,6 +637,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) { )}
)} + setHelpOpen((v) => !v)}> + + @@ -613,6 +649,65 @@ export default function PlexPlayer({ itemId, onClose }: Props) { + + {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */} + {helpOpen && ( +
{ + e.stopPropagation(); + setHelpOpen(false); + }} + > +
e.stopPropagation()} + > +
+
+ {t("plex.player.help")} +
+ +
+
+ {( + [ + [["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]) => ( + +
+ {keys.map((k) => ( + + {k} + + ))} +
+
{t(labelKey)}
+
+ ))} +
+
+
+ )} ); } diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 9a6ab69..a41342b 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -50,7 +50,19 @@ "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.", "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": { "direct": "Spielt direkt im Browser", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index d99d03e..7c85b68 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -50,7 +50,19 @@ "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.", "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": { "direct": "Plays directly in the browser", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 60c58ae..8162267 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -50,7 +50,19 @@ "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.", "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": { "direct": "Közvetlenül játszható a böngészőben", diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index e166739..a005b2c 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,19 @@ export interface 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", date: "2026-07-05",