feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import Hls from "hls.js";
|
|
|
|
|
import {
|
|
|
|
|
ArrowLeft,
|
2026-07-05 21:27:02 +02:00
|
|
|
Clock,
|
2026-07-05 05:26:16 +02:00
|
|
|
Download,
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
Info,
|
2026-07-05 21:27:02 +02:00
|
|
|
Keyboard,
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
Maximize,
|
|
|
|
|
Minimize,
|
|
|
|
|
Pause,
|
|
|
|
|
Play,
|
|
|
|
|
RotateCcw,
|
2026-07-05 06:08:44 +02:00
|
|
|
Settings,
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
SkipBack,
|
|
|
|
|
SkipForward,
|
2026-07-05 21:27:02 +02:00
|
|
|
Square,
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
Volume2,
|
|
|
|
|
VolumeX,
|
2026-07-05 21:27:02 +02:00
|
|
|
X,
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { api, type PlexMarker } from "../lib/api";
|
|
|
|
|
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
|
|
|
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
|
|
|
|
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
// 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);
|
2026-07-05 06:58:34 +02:00
|
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
const [uiVisible, setUiVisible] = useState(true);
|
|
|
|
|
const hideTimer = useRef<number | null>(null);
|
2026-07-05 06:08:44 +02:00
|
|
|
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
|
|
|
|
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
|
|
|
|
const [subOrd, setSubOrd] = useState<number | null>(null);
|
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
|
const audioRef = useRef<number | null>(null);
|
|
|
|
|
const subRef = useRef<number | null>(null);
|
|
|
|
|
const menuOpenRef = useRef(false);
|
|
|
|
|
audioRef.current = audioOrd;
|
|
|
|
|
subRef.current = subOrd;
|
|
|
|
|
menuOpenRef.current = menuOpen;
|
2026-07-05 21:27:02 +02:00
|
|
|
// 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;
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
|
|
|
|
const [infoOpen, setInfoOpen] = useState(false);
|
|
|
|
|
const infoOpenRef = useRef(false);
|
|
|
|
|
infoOpenRef.current = infoOpen;
|
2026-07-05 21:27:02 +02:00
|
|
|
const [now, setNow] = useState(() => new Date());
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const iv = window.setInterval(() => setNow(new Date()), 15000);
|
|
|
|
|
return () => window.clearInterval(iv);
|
|
|
|
|
}, []);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
|
|
|
|
|
durationRef.current = duration;
|
|
|
|
|
|
2026-07-05 06:08:44 +02:00
|
|
|
// A new item starts with default tracks (its stream layout differs from the last one's).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setAudioOrd(null);
|
|
|
|
|
setSubOrd(null);
|
|
|
|
|
}, [id]);
|
|
|
|
|
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
// --- 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);
|
2026-07-05 06:58:34 +02:00
|
|
|
setLoadError(null);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
let sess;
|
|
|
|
|
try {
|
2026-07-05 06:08:44 +02:00
|
|
|
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
|
2026-07-05 06:58:34 +02:00
|
|
|
} catch (e: any) {
|
|
|
|
|
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
|
|
|
|
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
|
|
|
|
// needs full transcoding (a later phase).
|
|
|
|
|
setLoadError(
|
|
|
|
|
e?.status === 501
|
|
|
|
|
? t("plex.player.errTranscode")
|
|
|
|
|
: e?.status === 404
|
|
|
|
|
? t("plex.player.errNotFound")
|
|
|
|
|
: t("plex.player.errGeneric"),
|
|
|
|
|
);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
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);
|
2026-07-05 06:08:44 +02:00
|
|
|
// Show the (single) muxed subtitle rendition when one was requested, else none. The
|
|
|
|
|
// subtitle tracks may not be known yet at MANIFEST_PARSED, so also enable on the dedicated
|
|
|
|
|
// event (otherwise hls.js never fetches the WebVTT segments).
|
|
|
|
|
const enableSubs = () => {
|
|
|
|
|
hls.subtitleDisplay = subRef.current != null;
|
|
|
|
|
hls.subtitleTrack = subRef.current != null ? 0 : -1;
|
|
|
|
|
};
|
|
|
|
|
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, enableSubs);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
2026-07-05 06:08:44 +02:00
|
|
|
enableSubs();
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
setReady(true);
|
|
|
|
|
video.play().catch(() => {});
|
|
|
|
|
});
|
2026-07-05 06:58:34 +02:00
|
|
|
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
|
|
|
|
// otherwise leave the spinner up forever — surface it instead.
|
|
|
|
|
hls.on(Hls.Events.ERROR, (_e, data) => {
|
|
|
|
|
if (data.fatal) setLoadError(t("plex.player.errGeneric"));
|
|
|
|
|
});
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-05 06:58:34 +02:00
|
|
|
[id, t],
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-05 05:26:16 +02:00
|
|
|
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
|
|
|
|
const download = useCallback(() => {
|
|
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = api.plexDownloadUrl(id);
|
|
|
|
|
a.rel = "noopener";
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
a.remove();
|
|
|
|
|
}, [id]);
|
|
|
|
|
|
2026-07-05 06:08:44 +02:00
|
|
|
// Switching audio/subtitle re-maps the track server-side, so it restarts the session at the
|
|
|
|
|
// current position (like a seek). The refs are updated synchronously so loadSession sees them.
|
|
|
|
|
const changeAudio = useCallback(
|
|
|
|
|
(ord: number | null) => {
|
|
|
|
|
audioRef.current = ord;
|
|
|
|
|
setAudioOrd(ord);
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
loadSession(absRef.current);
|
|
|
|
|
},
|
|
|
|
|
[loadSession],
|
|
|
|
|
);
|
|
|
|
|
const changeSubtitle = useCallback(
|
|
|
|
|
(ord: number | null) => {
|
|
|
|
|
subRef.current = ord;
|
|
|
|
|
setSubOrd(ord);
|
|
|
|
|
setMenuOpen(false);
|
|
|
|
|
loadSession(absRef.current);
|
|
|
|
|
},
|
|
|
|
|
[loadSession],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
|
|
|
|
|
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
|
|
|
|
|
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
|
|
|
|
|
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
|
if (!video) return;
|
|
|
|
|
const placeAll = () => {
|
|
|
|
|
for (const tr of Array.from(video.textTracks)) {
|
|
|
|
|
for (const c of Array.from(tr.cues || [])) {
|
|
|
|
|
try {
|
|
|
|
|
(c as VTTCue).snapToLines = false;
|
|
|
|
|
(c as VTTCue).line = 88;
|
|
|
|
|
} catch {
|
|
|
|
|
/* not a positionable cue */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
|
|
|
|
|
for (const tr of Array.from(video.textTracks)) bind(tr);
|
|
|
|
|
const onAddTrack = (e: TrackEvent) => {
|
|
|
|
|
if (e.track) {
|
|
|
|
|
bind(e.track as TextTrack);
|
|
|
|
|
placeAll();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
|
|
|
|
return () => {
|
|
|
|
|
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
|
|
|
|
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
|
|
|
|
};
|
|
|
|
|
}, [id]);
|
|
|
|
|
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-07-05 05:26:16 +02:00
|
|
|
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
|
|
|
|
const wake = useCallback(() => {
|
|
|
|
|
setUiVisible(true);
|
|
|
|
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
|
|
|
|
hideTimer.current = window.setTimeout(() => {
|
2026-07-05 06:08:44 +02:00
|
|
|
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
|
|
|
|
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
|
2026-07-05 05:26:16 +02:00
|
|
|
}, 3000);
|
|
|
|
|
}, []);
|
|
|
|
|
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
// --- keyboard --------------------------------------------------------------------------------
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
|
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
2026-07-05 05:26:16 +02:00
|
|
|
wake(); // keyboard use also reveals the controls
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
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;
|
2026-07-05 21:27:02 +02:00
|
|
|
case "h":
|
|
|
|
|
case "H":
|
|
|
|
|
setHelpOpen((v) => !v);
|
|
|
|
|
break;
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
case "i":
|
|
|
|
|
case "I":
|
|
|
|
|
setInfoOpen((v) => !v);
|
|
|
|
|
break;
|
2026-07-05 21:27:02 +02:00
|
|
|
case "Backspace":
|
|
|
|
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (helpOpenRef.current) setHelpOpen(false);
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
else if (infoOpenRef.current) setInfoOpen(false);
|
2026-07-05 21:27:02 +02:00
|
|
|
else onClose();
|
|
|
|
|
break;
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
case "Escape":
|
2026-07-05 21:27:02 +02:00
|
|
|
if (helpOpenRef.current) setHelpOpen(false);
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
else if (infoOpenRef.current) setInfoOpen(false);
|
2026-07-05 21:27:02 +02:00
|
|
|
else if (!document.fullscreenElement) onClose();
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
return () => window.removeEventListener("keydown", onKey);
|
2026-07-05 05:26:16 +02:00
|
|
|
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
|
|
|
|
|
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 && (
|
2026-07-05 06:58:34 +02:00
|
|
|
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
|
|
|
|
{loadError ? (
|
|
|
|
|
<span className="max-w-md text-red-300">{loadError}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-white/70">{t("plex.player.loading")}</span>
|
|
|
|
|
)}
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</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>
|
2026-07-05 21:27:02 +02:00
|
|
|
{/* 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>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</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">
|
2026-07-05 05:26:16 +02:00
|
|
|
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
|
|
|
|
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
<RotateCcw className="w-5 h-5" />
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
2026-07-05 21:27:02 +02:00
|
|
|
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
|
|
|
|
<Square className="w-5 h-5" />
|
|
|
|
|
</Ctrl>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
{detail?.kind === "episode" && (
|
|
|
|
|
<>
|
2026-07-05 05:26:16 +02:00
|
|
|
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
<SkipBack className="w-5 h-5" />
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
|
|
|
|
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
<SkipForward className="w-5 h-5" />
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1.5 group/vol">
|
2026-07-05 05:26:16 +02:00
|
|
|
<Ctrl
|
|
|
|
|
label={t("plex.player.mute")}
|
|
|
|
|
onClick={() =>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
setMuted((m) => {
|
|
|
|
|
applyVolume(volume, !m);
|
|
|
|
|
return !m;
|
2026-07-05 05:26:16 +02:00
|
|
|
})
|
|
|
|
|
}
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
>
|
|
|
|
|
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
<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)}
|
2026-07-05 21:27:02 +02:00
|
|
|
<span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
2026-07-05 06:08:44 +02:00
|
|
|
{detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setMenuOpen((o) => !o)}
|
|
|
|
|
aria-label={t("plex.player.tracks")}
|
|
|
|
|
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
|
|
|
|
>
|
|
|
|
|
<Settings className="w-5 h-5" />
|
|
|
|
|
</button>
|
|
|
|
|
{menuOpen && (
|
|
|
|
|
<div className="absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm">
|
|
|
|
|
{detail.audio_streams.length > 1 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
|
|
|
|
{t("plex.player.audio")}
|
|
|
|
|
</div>
|
|
|
|
|
{detail.audio_streams.map((a) => (
|
|
|
|
|
<button
|
|
|
|
|
key={a.ord}
|
|
|
|
|
onClick={() => changeAudio(a.ord)}
|
|
|
|
|
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
|
|
|
|
(audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{a.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
|
|
|
|
{t("plex.player.subtitles")}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => changeSubtitle(null)}
|
|
|
|
|
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
|
|
|
|
subOrd == null ? "text-accent" : "text-white"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{t("plex.player.subOff")}
|
|
|
|
|
</button>
|
|
|
|
|
{detail.subtitle_streams.map((s) => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.ord}
|
|
|
|
|
onClick={() => changeSubtitle(s.ord)}
|
|
|
|
|
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
|
|
|
|
subOrd === s.ord ? "text-accent" : "text-white"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{s.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
|
|
|
|
<Info className="w-5 h-5" />
|
|
|
|
|
</Ctrl>
|
2026-07-05 21:27:02 +02:00
|
|
|
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
|
|
|
|
<Keyboard className="w-5 h-5" />
|
|
|
|
|
</Ctrl>
|
2026-07-05 05:26:16 +02:00
|
|
|
<Ctrl label={t("plex.player.download")} onClick={download}>
|
|
|
|
|
<Download className="w-5 h-5" />
|
|
|
|
|
</Ctrl>
|
|
|
|
|
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
2026-07-05 05:26:16 +02:00
|
|
|
</Ctrl>
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-05 21:27:02 +02:00
|
|
|
|
|
|
|
|
{/* 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"],
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
[["I"], "plex.player.keys.info"],
|
2026-07-05 21:27:02 +02:00
|
|
|
[["⌫", "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>
|
|
|
|
|
)}
|
feat(plex): rich media info — info page, in-player info overlay, IMDb + cast photos
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
2026-07-05 22:57:18 +02:00
|
|
|
|
|
|
|
|
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
|
|
|
|
{infoOpen && detail && (
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setInfoOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<Suspense fallback={null}>
|
|
|
|
|
<PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
|
|
|
|
|
</Suspense>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
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.
2026-07-05 04:28:00 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-05 05:26:16 +02:00
|
|
|
|
|
|
|
|
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
|
|
|
|
// viewport, unlike a native title tooltip on the bottom control bar).
|
|
|
|
|
function Ctrl({
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
|
|
|
|
disabled,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative flex group/ctrl">
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
className="p-1 hover:text-accent disabled:opacity-30"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
<span className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100">
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|