Files
siftlode/frontend/src/components/PlexBrowse.tsx
T
peter 7c9829087e 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.
2026-07-05 21:27:02 +02:00

316 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
// search term comes from the shared top Header search box (q). A show drill-down rides history
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).
type Props = { q: string; library: string; show: string; sort: string };
const PAGE = 40;
function dur(n?: number | null): string {
if (!n) return "";
const h = Math.floor(n / 3600);
const m = Math.floor((n % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexBrowse({ q, library, show, sort }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
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({
queryKey: ["plex-browse", library, dq, sort, show],
enabled: !!library && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
getNextPageParam: (last) => {
const seen = last.offset + last.items.length;
return seen < last.total ? seen : undefined;
},
});
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
const sentinel = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
useEffect(() => {
const el = sentinel.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: "600px" },
);
io.observe(el);
return () => io.disconnect();
}, [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 });
}
async function toggleWatched(card: PlexCard) {
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
qc.invalidateQueries({ queryKey: ["plex-browse"] });
}
if (sub.view.kind === "player") {
return (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
</Suspense>
);
}
if (sub.view.kind === "show") {
return (
<PlexShowView
showId={sub.view.id}
onBack={sub.back}
onPlay={(ep) => sub.open({ kind: "player", id: ep.id })}
/>
);
}
return (
<div className="p-4 max-w-[1600px] mx-auto">
<p className="text-xs text-muted mb-3">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p>
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : items.length === 0 ? (
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => (
<PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
))}
</div>
)}
<div ref={sentinel} className="h-8" />
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
</div>
);
}
const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-emerald-500",
remux: "bg-amber-500",
transcode: "bg-orange-600",
};
function PlexPosterCard({
card,
onOpen,
onToggleWatched,
}: {
card: PlexCard;
onOpen: () => void;
onToggleWatched: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
return (
<div
className="group text-left"
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
>
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Hover affordance: what a click does (play / resume / open show). */}
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
{isPlayable ? (
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-10 h-10" fill="currentColor" />
<span className="text-xs font-medium">{inProgress ? t("plex.resume") : t("plex.play")}</span>
</div>
) : (
<span className="text-white text-xs font-medium">{t("plex.openShow")}</span>
)}
</div>
{/* Quick watched toggle (movies/episodes), on hover. */}
{isPlayable && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWatched(card);
}}
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
</button>
)}
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
{card.status === "watched" && (
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
{t("plex.watched")}
</span>
)}
{card.type === "show" && card.season_count != null && (
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
{card.playable && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
title={t(`plex.playable.${card.playable}`)}
/>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
</div>
);
}
function PlexShowView({
showId,
onBack,
onPlay,
}: {
showId: string;
onBack: () => void;
onPlay: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
const d = q.data;
return (
<div className="p-4 max-w-[1200px] mx-auto">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
{q.isLoading || !d ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
) : (
<>
<div className="flex gap-4 mb-6">
<img
src={d.show.thumb}
alt=""
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
/>
<div className="min-w-0">
<h1 className="text-xl font-semibold">{d.show.title}</h1>
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
{d.show.summary && (
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
)}
</div>
</div>
{d.seasons.map((se) => (
<div key={se.id} className="mb-6">
<h2 className="text-sm font-semibold mb-2">{se.title}</h2>
<div className="flex flex-col divide-y divide-border/60">
{se.episodes.map((ep) => {
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
return (
<button
key={ep.id}
onClick={() => onPlay(ep)}
className="flex items-center gap-3 py-2 text-left group"
>
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
{ep.status === "watched" && (
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
</span>
)}
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
{ep.title}
</div>
{ep.summary && (
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
)}
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
</div>
</button>
);
})}
</div>
</div>
))}
</>
)}
</div>
);
}