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:
npeter83 2026-07-05 04:28:00 +02:00
parent 4e9b18cda9
commit 86b86cb260
9 changed files with 726 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -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 (

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

View file

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

View file

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

View file

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

View file

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