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
|
|
@ -6,6 +6,7 @@ plex.tv account, and playback is a local file. Admin endpoints test the connecti
|
||||||
catalog sync.
|
catalog sync.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
@ -294,6 +295,158 @@ def _item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||||
return it
|
return it
|
||||||
|
|
||||||
|
|
||||||
|
# Progress thresholds (mirror the YouTube player): ignore the first few seconds, and treat the
|
||||||
|
# last stretch as "finished" (mark watched, clear the resume point).
|
||||||
|
_PROGRESS_MIN_S = 5
|
||||||
|
_FINISH_MARGIN_S = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||||
|
if it.kind != "episode" or it.show_id is None:
|
||||||
|
return None, None
|
||||||
|
keys = [
|
||||||
|
r[0]
|
||||||
|
for r in db.query(PlexItem.rating_key)
|
||||||
|
.filter_by(show_id=it.show_id, kind="episode")
|
||||||
|
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
i = keys.index(it.rating_key)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/item/{rating_key}")
|
||||||
|
def item_detail(
|
||||||
|
rating_key: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
||||||
|
prev/next, and the per-user resume position + watch status."""
|
||||||
|
it = _item_or_404(db, rating_key)
|
||||||
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||||
|
|
||||||
|
cast: list[str] = []
|
||||||
|
markers: list[dict] = []
|
||||||
|
try:
|
||||||
|
with PlexClient(db) as plex:
|
||||||
|
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||||
|
cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
|
||||||
|
for m in meta.get("Marker") or []:
|
||||||
|
if m.get("type") in ("intro", "credits"):
|
||||||
|
markers.append(
|
||||||
|
{
|
||||||
|
"type": m.get("type"),
|
||||||
|
"start_s": round((m.get("startTimeOffset") or 0) / 1000, 1),
|
||||||
|
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (PlexError, PlexNotConfigured):
|
||||||
|
pass # metadata extras are best-effort; playback works without them
|
||||||
|
|
||||||
|
prev_id, next_id = _episode_nav(db, it)
|
||||||
|
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||||
|
return {
|
||||||
|
"id": it.rating_key,
|
||||||
|
"kind": it.kind,
|
||||||
|
"title": it.title,
|
||||||
|
"summary": it.summary,
|
||||||
|
"year": it.year,
|
||||||
|
"duration_seconds": it.duration_s,
|
||||||
|
"playable": it.playable,
|
||||||
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
|
"cast": cast,
|
||||||
|
"markers": markers,
|
||||||
|
"status": st.status if st else "new",
|
||||||
|
"position_seconds": st.position_seconds if st else 0,
|
||||||
|
"show_title": show.title if show else None,
|
||||||
|
"season_number": it.season_number,
|
||||||
|
"episode_number": it.episode_number,
|
||||||
|
"prev_id": prev_id,
|
||||||
|
"next_id": next_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/item/{rating_key}/progress")
|
||||||
|
def item_progress(
|
||||||
|
rating_key: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Checkpoint the resume position while playing (near the end → mark watched + clear it)."""
|
||||||
|
it = _item_or_404(db, rating_key)
|
||||||
|
try:
|
||||||
|
position = max(0, int(payload.get("position_seconds") or 0))
|
||||||
|
duration = int(payload.get("duration_seconds") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="position_seconds must be an integer")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||||
|
|
||||||
|
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
|
||||||
|
if near_end:
|
||||||
|
if st is None:
|
||||||
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
|
db.add(st)
|
||||||
|
st.status = "watched"
|
||||||
|
st.watched_at = now
|
||||||
|
st.position_seconds = 0
|
||||||
|
st.progress_updated_at = now
|
||||||
|
db.commit()
|
||||||
|
return {"status": "watched", "position_seconds": 0}
|
||||||
|
|
||||||
|
if position < _PROGRESS_MIN_S:
|
||||||
|
if st is not None and st.status == "new":
|
||||||
|
db.delete(st)
|
||||||
|
elif st is not None:
|
||||||
|
st.position_seconds = 0
|
||||||
|
st.progress_updated_at = now
|
||||||
|
db.commit()
|
||||||
|
return {"position_seconds": 0}
|
||||||
|
|
||||||
|
if st is None:
|
||||||
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
|
db.add(st)
|
||||||
|
st.position_seconds = position
|
||||||
|
st.progress_updated_at = now
|
||||||
|
db.commit()
|
||||||
|
return {"position_seconds": position}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/item/{rating_key}/state")
|
||||||
|
def item_state(
|
||||||
|
rating_key: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Set the per-user watch status (new|watched|hidden)."""
|
||||||
|
it = _item_or_404(db, rating_key)
|
||||||
|
status = payload.get("status")
|
||||||
|
if status not in ("new", "watched", "hidden"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
|
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||||
|
if status == "new":
|
||||||
|
if st is not None:
|
||||||
|
db.delete(st)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "new"}
|
||||||
|
if st is None:
|
||||||
|
st = PlexState(user_id=user.id, item_id=it.id)
|
||||||
|
db.add(st)
|
||||||
|
st.status = status
|
||||||
|
if status == "watched":
|
||||||
|
st.watched_at = datetime.now(timezone.utc)
|
||||||
|
st.position_seconds = 0
|
||||||
|
db.commit()
|
||||||
|
return {"status": status}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream/{rating_key}/session")
|
@router.post("/stream/{rating_key}/session")
|
||||||
def stream_session(
|
def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
@ -1855,6 +1856,12 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||||
|
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"lucide-react": "^0.408.0",
|
"lucide-react": "^0.408.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -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 { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { api, type PlexCard } from "../lib/api";
|
import { api, type PlexCard } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { useHistorySubview } from "../lib/history";
|
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
|
// 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
|
// 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
|
// 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) {
|
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
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({
|
const browseQ = useInfiniteQuery({
|
||||||
queryKey: ["plex-browse", library, dq, sort, show],
|
queryKey: ["plex-browse", library, dq, sort, show],
|
||||||
enabled: !!library && sub.view === null,
|
enabled: !!library && sub.view.kind === "grid",
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
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();
|
return () => io.disconnect();
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
function onPlay(card: PlexCard) {
|
|
||||||
notify({ level: "info", message: t("plex.playerSoon", { title: card.title }) });
|
|
||||||
}
|
|
||||||
function onCard(card: PlexCard) {
|
function onCard(card: PlexCard) {
|
||||||
if (card.type === "show") sub.open(card.id);
|
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||||
else onPlay(card);
|
else sub.open({ kind: "player", id: card.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.view) {
|
if (sub.view.kind === "player") {
|
||||||
return <PlexShowView showId={sub.view} onBack={sub.back} onPlay={onPlay} />;
|
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 (
|
return (
|
||||||
|
|
|
||||||
456
frontend/src/components/PlexPlayer.tsx
Normal file
456
frontend/src/components/PlexPlayer.tsx
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api, type PlexMarker } from "../lib/api";
|
||||||
|
|
||||||
|
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||||
|
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||||
|
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||||
|
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
||||||
|
// restart the session at the target. Watch state / resume persist to plex_states.
|
||||||
|
|
||||||
|
type Props = { itemId: string; onClose: () => void };
|
||||||
|
|
||||||
|
function fmt(t: number): string {
|
||||||
|
if (!isFinite(t) || t < 0) t = 0;
|
||||||
|
const s = Math.floor(t % 60);
|
||||||
|
const m = Math.floor((t / 60) % 60);
|
||||||
|
const h = Math.floor(t / 3600);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [id, setId] = useState(itemId);
|
||||||
|
const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||||
|
const detail = detailQ.data;
|
||||||
|
const duration = detail?.duration_seconds ?? 0;
|
||||||
|
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||||
|
const durationRef = useRef(0);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [abs, setAbs] = useState(0); // absolute current time
|
||||||
|
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [fs, setFs] = useState(false);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [uiVisible, setUiVisible] = useState(true);
|
||||||
|
const hideTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
durationRef.current = duration;
|
||||||
|
|
||||||
|
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
||||||
|
const loadSession = useCallback(
|
||||||
|
async (startAt: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
setReady(false);
|
||||||
|
let sess;
|
||||||
|
try {
|
||||||
|
sess = await api.plexSession(id, startAt);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionStartRef.current = sess.start_s;
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
if (sess.mode === "hls" && Hls.isSupported()) {
|
||||||
|
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
|
||||||
|
hlsRef.current = hls;
|
||||||
|
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
setReady(true);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// direct file (or Safari native HLS)
|
||||||
|
video.src = sess.url;
|
||||||
|
video.load();
|
||||||
|
const onMeta = () => {
|
||||||
|
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||||
|
setReady(true);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
video.removeEventListener("loadedmetadata", onMeta);
|
||||||
|
};
|
||||||
|
video.addEventListener("loadedmetadata", onMeta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detail) return;
|
||||||
|
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||||
|
loadSession(resume);
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy();
|
||||||
|
hlsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [detail, loadSession]);
|
||||||
|
|
||||||
|
// --- time tracking + progress checkpoints ----------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const onTime = () => {
|
||||||
|
const a = sessionStartRef.current + video.currentTime;
|
||||||
|
setAbs(a);
|
||||||
|
try {
|
||||||
|
const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
|
||||||
|
setSeekableEnd(end);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
video.addEventListener("timeupdate", onTime);
|
||||||
|
video.addEventListener("play", onPlay);
|
||||||
|
video.addEventListener("pause", onPause);
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener("timeupdate", onTime);
|
||||||
|
video.removeEventListener("play", onPlay);
|
||||||
|
video.removeEventListener("pause", onPause);
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Periodic + on-unmount progress checkpoint.
|
||||||
|
const absRef = useRef(0);
|
||||||
|
absRef.current = abs;
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => {
|
||||||
|
if (absRef.current > 0 && durationRef.current > 0)
|
||||||
|
api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
|
||||||
|
};
|
||||||
|
const iv = window.setInterval(flush, 10000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(iv);
|
||||||
|
flush();
|
||||||
|
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||||
|
};
|
||||||
|
}, [id, qc]);
|
||||||
|
|
||||||
|
// --- seeking (native within the loaded region, else restart the session) ---------------------
|
||||||
|
const seekTo = useCallback(
|
||||||
|
(target: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const dur = durationRef.current || target;
|
||||||
|
const T = Math.max(0, Math.min(target, dur - 1));
|
||||||
|
const rel = T - sessionStartRef.current;
|
||||||
|
if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
|
||||||
|
video.currentTime = rel;
|
||||||
|
} else {
|
||||||
|
loadSession(T);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- controls --------------------------------------------------------------------------------
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
if (v.paused) v.play().catch(() => {});
|
||||||
|
else v.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v) return;
|
||||||
|
v.volume = Math.max(0, Math.min(1, vol));
|
||||||
|
v.muted = mute;
|
||||||
|
}, []);
|
||||||
|
const nudgeVolume = useCallback(
|
||||||
|
(d: number) => {
|
||||||
|
setVolume((prev) => {
|
||||||
|
const nv = Math.max(0, Math.min(1, prev + d));
|
||||||
|
applyVolume(nv, false);
|
||||||
|
setMuted(nv === 0);
|
||||||
|
return nv;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[applyVolume],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFs = useCallback(() => {
|
||||||
|
const el = wrapRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||||
|
else el.requestFullscreen().catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const onFs = () => setFs(!!document.fullscreenElement);
|
||||||
|
document.addEventListener("fullscreenchange", onFs);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onFs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(otherId: string | null | undefined) => {
|
||||||
|
if (otherId) {
|
||||||
|
setAbs(0);
|
||||||
|
setId(otherId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-advance to the next episode when one finishes.
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const onEnded = () => {
|
||||||
|
api.plexSetState(id, "watched").catch(() => {});
|
||||||
|
if (detail?.next_id) go(detail.next_id);
|
||||||
|
};
|
||||||
|
video.addEventListener("ended", onEnded);
|
||||||
|
return () => video.removeEventListener("ended", onEnded);
|
||||||
|
}, [id, detail, go]);
|
||||||
|
|
||||||
|
// --- keyboard --------------------------------------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
|
switch (e.key) {
|
||||||
|
case " ":
|
||||||
|
case "k":
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case "f":
|
||||||
|
toggleFs();
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) go(detail?.prev_id);
|
||||||
|
else seekTo(absRef.current - 10);
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) go(detail?.next_id);
|
||||||
|
else seekTo(absRef.current + 10);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
nudgeVolume(0.05);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
nudgeVolume(-0.05);
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
setMuted((m) => {
|
||||||
|
applyVolume(volume, !m);
|
||||||
|
return !m;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
if (!document.fullscreenElement) onClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose]);
|
||||||
|
|
||||||
|
// Auto-hide the controls while playing.
|
||||||
|
const wake = useCallback(() => {
|
||||||
|
setUiVisible(true);
|
||||||
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = window.setTimeout(() => {
|
||||||
|
if (!videoRef.current?.paused) setUiVisible(false);
|
||||||
|
}, 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||||
|
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pct = duration > 0 ? (abs / duration) * 100 : 0;
|
||||||
|
const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
|
||||||
|
onMouseMove={wake}
|
||||||
|
onClick={wake}
|
||||||
|
style={{ cursor: uiVisible ? "default" : "none" }}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="max-h-full max-w-full w-auto h-auto"
|
||||||
|
onClick={togglePlay}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!ready && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center text-white/70 text-sm pointer-events-none">
|
||||||
|
{t("plex.player.loading")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top bar */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
|
||||||
|
uiVisible ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold truncate">
|
||||||
|
{detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
|
||||||
|
</div>
|
||||||
|
{detail?.kind === "episode" && (
|
||||||
|
<div className="text-xs text-white/70 truncate">
|
||||||
|
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip intro / credits */}
|
||||||
|
{activeMarker && (
|
||||||
|
<button
|
||||||
|
onClick={() => seekTo(activeMarker.end_s)}
|
||||||
|
className={`absolute right-6 bottom-28 px-4 py-2 rounded-lg bg-white/90 text-black text-sm font-medium hover:bg-white transition ${
|
||||||
|
uiVisible ? "opacity-100" : "opacity-80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
|
||||||
|
uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Seek bar */}
|
||||||
|
<div
|
||||||
|
className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
seekTo(((e.clientX - r.left) / r.width) * duration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
|
||||||
|
{/* intro/credit marker regions */}
|
||||||
|
{detail?.markers.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="absolute inset-y-0 bg-amber-400/50"
|
||||||
|
style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${pct}%` }} />
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-white">
|
||||||
|
<button onClick={togglePlay} className="p-1 hover:text-accent" title={t("plex.player.playPause")}>
|
||||||
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => seekTo(0)} className="p-1 hover:text-accent" title={t("plex.player.restart")}>
|
||||||
|
<RotateCcw className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{detail?.kind === "episode" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => go(detail?.prev_id)}
|
||||||
|
disabled={!detail?.prev_id}
|
||||||
|
className="p-1 hover:text-accent disabled:opacity-30"
|
||||||
|
title={t("plex.player.prev")}
|
||||||
|
>
|
||||||
|
<SkipBack className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => go(detail?.next_id)}
|
||||||
|
disabled={!detail?.next_id}
|
||||||
|
className="p-1 hover:text-accent disabled:opacity-30"
|
||||||
|
title={t("plex.player.next")}
|
||||||
|
>
|
||||||
|
<SkipForward className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMuted((m) => {
|
||||||
|
applyVolume(volume, !m);
|
||||||
|
return !m;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="p-1 hover:text-accent"
|
||||||
|
title={t("plex.player.mute")}
|
||||||
|
>
|
||||||
|
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={muted ? 0 : volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nv = Number(e.target.value);
|
||||||
|
setVolume(nv);
|
||||||
|
setMuted(nv === 0);
|
||||||
|
applyVolume(nv, false);
|
||||||
|
}}
|
||||||
|
className="w-20 accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs tabular-nums text-white/90">
|
||||||
|
{fmt(abs)} / {fmt(duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button onClick={toggleFs} className="p-1 hover:text-accent" title={t("plex.player.fullscreen")}>
|
||||||
|
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,18 @@
|
||||||
"watched": "Angesehen",
|
"watched": "Angesehen",
|
||||||
"seasons": "{{count}} Staffeln",
|
"seasons": "{{count}} Staffeln",
|
||||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||||
|
"player": {
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"back": "Zurück",
|
||||||
|
"skipIntro": "Intro überspringen",
|
||||||
|
"skipCredits": "Abspann überspringen",
|
||||||
|
"playPause": "Wiedergabe / Pause (Leertaste)",
|
||||||
|
"restart": "Von vorn beginnen",
|
||||||
|
"prev": "Vorherige Folge",
|
||||||
|
"next": "Nächste Folge",
|
||||||
|
"mute": "Stumm (m)",
|
||||||
|
"fullscreen": "Vollbild (f)"
|
||||||
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@
|
||||||
"watched": "Watched",
|
"watched": "Watched",
|
||||||
"seasons": "{{count}} seasons",
|
"seasons": "{{count}} seasons",
|
||||||
"playerSoon": "Player coming soon — “{{title}}”",
|
"playerSoon": "Player coming soon — “{{title}}”",
|
||||||
|
"player": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"back": "Back",
|
||||||
|
"skipIntro": "Skip intro",
|
||||||
|
"skipCredits": "Skip credits",
|
||||||
|
"playPause": "Play / pause (space)",
|
||||||
|
"restart": "Restart from beginning",
|
||||||
|
"prev": "Previous episode",
|
||||||
|
"next": "Next episode",
|
||||||
|
"mute": "Mute (m)",
|
||||||
|
"fullscreen": "Fullscreen (f)"
|
||||||
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
"remux": "Needs a light remux (no video re-encode)",
|
"remux": "Needs a light remux (no video re-encode)",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@
|
||||||
"watched": "Megnézve",
|
"watched": "Megnézve",
|
||||||
"seasons": "{{count}} évad",
|
"seasons": "{{count}} évad",
|
||||||
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
||||||
|
"player": {
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"back": "Vissza",
|
||||||
|
"skipIntro": "Intro átugrása",
|
||||||
|
"skipCredits": "Stáblista átugrása",
|
||||||
|
"playPause": "Lejátszás / szünet (szóköz)",
|
||||||
|
"restart": "Újra az elejéről",
|
||||||
|
"prev": "Előző rész",
|
||||||
|
"next": "Következő rész",
|
||||||
|
"mute": "Némítás (m)",
|
||||||
|
"fullscreen": "Teljes képernyő (f)"
|
||||||
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,37 @@ export interface PlexShowDetail {
|
||||||
seasons: PlexSeasonDetail[];
|
seasons: PlexSeasonDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlexMarker {
|
||||||
|
type: "intro" | "credits";
|
||||||
|
start_s: number;
|
||||||
|
end_s: number;
|
||||||
|
}
|
||||||
|
export interface PlexItemDetail {
|
||||||
|
id: string;
|
||||||
|
kind: "movie" | "episode";
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
year?: number | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
playable: string; // direct | remux | transcode
|
||||||
|
thumb: string;
|
||||||
|
art: string;
|
||||||
|
cast: string[];
|
||||||
|
markers: PlexMarker[];
|
||||||
|
status: string;
|
||||||
|
position_seconds: number;
|
||||||
|
show_title?: string | null;
|
||||||
|
season_number?: number | null;
|
||||||
|
episode_number?: number | null;
|
||||||
|
prev_id?: string | null;
|
||||||
|
next_id?: string | null;
|
||||||
|
}
|
||||||
|
export interface PlexPlaySession {
|
||||||
|
mode: "direct" | "hls";
|
||||||
|
url: string;
|
||||||
|
start_s: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Users & roles page.
|
// Admin Users & roles page.
|
||||||
export interface AdminUserRow {
|
export interface AdminUserRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -1006,6 +1037,23 @@ export const api = {
|
||||||
},
|
},
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
|
plexSession: (id: string, start = 0): Promise<PlexPlaySession> =>
|
||||||
|
req(`/api/plex/stream/${encodeURIComponent(id)}/session?start=${Math.max(0, Math.floor(start))}`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||||
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ position_seconds, duration_seconds }),
|
||||||
|
}),
|
||||||
|
plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
|
||||||
|
req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
|
||||||
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
||||||
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
||||||
// --- admin: users & roles ---
|
// --- admin: users & roles ---
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue