Merge: promote dev to prod

This commit is contained in:
npeter83 2026-07-05 22:10:19 +02:00
commit 63b72e6e3f
7 changed files with 165 additions and 7 deletions

View file

@ -1 +1 @@
0.23.2 0.24.0

View file

@ -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 });
} }

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 { 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>
); );
} }

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",