feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
This commit is contained in:
parent
4e9b18cda9
commit
86b86cb260
9 changed files with 726 additions and 11 deletions
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
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
|
||||
|
|
@ -27,11 +31,11 @@ function dur(n?: number | null): string {
|
|||
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
const sub = useHistorySubview<string | null>(null); // open show ratingKey (null = grid)
|
||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort, show],
|
||||
enabled: !!library && sub.view === null,
|
||||
enabled: !!library && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
||||
|
|
@ -60,16 +64,26 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
function onPlay(card: PlexCard) {
|
||||
notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) });
|
||||
}
|
||||
function onCard(card: PlexCard) {
|
||||
if (card.type === "show") sub.open(card.id);
|
||||
else onPlay(card);
|
||||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
|
||||
if (sub.view) {
|
||||
return <PlexShowView showId={sub.view} onBack={sub.back} onPlay={onPlay} />;
|
||||
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 (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue