feat(plex): rework player timing (copyts) + player settings & personalization
Streaming / subtitle sync (the core fix): - stream.py: add -copyts so HLS segments carry the true absolute PTS, and measure the real keyframe start K from seg_0 (ffprobe) -> return it as the session start. Fixes the seconds-long subtitle lead caused by using the requested seek offset instead of the keyframe ffmpeg actually lands on with video stream-copy. - Add -noaccurate_seek so the re-encoded audio starts at the same keyframe as the video (was starting (X-K)s later -> seconds of silence after each seek/audio switch). - Compensate the fixed ~1.0s lag hls.js introduces for non-zero-start copyts streams, folded into the session start so the clock, seeking and the subtitle shift are all content-accurate. - /subtitle gains an offset param; _shift_vtt shifts absolute cues onto the session's zero-based clock and DROPS fully-past cue blocks (collapsing them to 0->0 made every past cue active at currentTime 0 on resume -> a pile-up until playback advanced). Audio: - /session + stream.py gain an audio A/V-sync offset (-itsoffset, full +/-, second input only when non-zero). Player settings & personalization (per-account, persisted -> survive F5): - storage.ts: useAccountPersistedObject (per-account JSON prefs blob). - PlexPlayer: volume/mute, audio+subtitle language (index-based match, fixes the F5 audio-revert), sync offsets, seek steps, subtitle style, auto-hide, play intent. - Hotkeys A (cycle audio) / S (cycle subtitle), mouse-wheel volume, Ctrl+arrow fine seek, per-user plain/fine seek-step + auto-hide toggle. - Subtitle appearance: size / colour / vertical position / background via ::cue + line. - UI: split into a Tracks quick-menu + a tabbed gear panel (Sync | Playback | Subtitle); both dismiss on outside-click; edge-aware control tooltips. - i18n en/hu/de for all new strings.
This commit is contained in:
parent
6232a66878
commit
0a0703b769
8 changed files with 707 additions and 114 deletions
|
|
@ -43,21 +43,47 @@ class HlsSession:
|
||||||
self.key = key
|
self.key = key
|
||||||
self.dir = directory
|
self.dir = directory
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
self.start_s = start_s
|
self.start_s = start_s # the REQUESTED seek offset
|
||||||
|
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
|
||||||
|
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
|
||||||
|
# We measure K from the first segment's absolute PTS (kept absolute by `-copyts`) and report
|
||||||
|
# THAT as the session start, so the client's absolute clock (start + currentTime) and the
|
||||||
|
# subtitle cue-shift both key off the true content position — no drift. Provisional = X until
|
||||||
|
# the first segment is probed.
|
||||||
|
self.media_start_s = start_s
|
||||||
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]:
|
def _seek_opts(start_s: float) -> list[str]:
|
||||||
|
# -noaccurate_seek: with `-ss` before -i, video (stream-copy) backs up to the keyframe K<=X,
|
||||||
|
# but the re-encoded audio is normally trimmed to the exact X — so audio starts (X-K) AFTER
|
||||||
|
# video → seconds of silence at the start of every seek/audio-switch. noaccurate_seek makes
|
||||||
|
# audio ALSO start at K, aligned with the video (K is our real clock anchor anyway).
|
||||||
|
return ["-noaccurate_seek", "-ss", f"{start_s:.3f}"] if start_s > 0 else []
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None, aoff: float = 0.0) -> list[str]:
|
||||||
|
ao = audio_ord if audio_ord is not None else 0
|
||||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||||
if start_s > 0:
|
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
|
||||||
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
|
||||||
args += ["-i", str(src)]
|
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
|
||||||
|
# rewritten toward 0 and the true offset is unknowable, which caused the ~seconds subtitle lead.
|
||||||
|
args += _seek_opts(start_s) + ["-copyts", "-i", str(src)]
|
||||||
|
if aoff:
|
||||||
|
# A/V-sync offset: read the file a SECOND time for audio only, with `-itsoffset` shifting the
|
||||||
|
# audio PTS by ±aoff relative to the (input-0) video. Only when the user set a non-zero offset
|
||||||
|
# (default single-input path is unchanged, no double read). Maps audio from input 1 then.
|
||||||
|
args += ["-itsoffset", f"{aoff:.3f}"] + _seek_opts(start_s) + ["-copyts", "-i", str(src)]
|
||||||
|
amap = f"1:a:{ao}?"
|
||||||
|
else:
|
||||||
|
amap = f"0:a:{ao}?"
|
||||||
# Video always stream-copied; the selected audio track (default = first) is transcoded to AAC.
|
# Video always stream-copied; the selected audio track (default = first) is transcoded to AAC.
|
||||||
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
|
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
|
||||||
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
|
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
|
||||||
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
|
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
|
||||||
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"]
|
args += ["-map", "0:v:0", "-map", amap, "-sn"]
|
||||||
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||||
args += [
|
args += [
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
|
|
@ -74,6 +100,25 @@ def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_first_pts(seg: Path) -> float | None:
|
||||||
|
"""Read the first video packet's absolute PTS from a finished HLS segment (`-copyts` kept it
|
||||||
|
absolute). This is the true content position of the segment's first frame — the value hls.js
|
||||||
|
zero-bases the timeline to. Returns None on any failure (caller falls back to the requested offset)."""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", "%+#1",
|
||||||
|
"-show_entries", "packet=pts_time", "-of", "csv=p=0", str(seg)],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
for line in out.stdout.splitlines():
|
||||||
|
tok = line.strip().strip(",")
|
||||||
|
if tok:
|
||||||
|
return float(tok)
|
||||||
|
except (subprocess.SubprocessError, ValueError, OSError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _kill(s: HlsSession) -> None:
|
def _kill(s: HlsSession) -> None:
|
||||||
try:
|
try:
|
||||||
s.proc.terminate()
|
s.proc.terminate()
|
||||||
|
|
@ -100,6 +145,7 @@ def start_session(
|
||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
start_s: float,
|
start_s: float,
|
||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
|
aoff: float = 0.0,
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
|
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
|
||||||
(ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns
|
(ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns
|
||||||
|
|
@ -113,18 +159,31 @@ def start_session(
|
||||||
old = _sessions.pop(key, None)
|
old = _sessions.pop(key, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
_kill(old)
|
||||||
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}"
|
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}_{aoff:+.2f}"
|
||||||
shutil.rmtree(directory, ignore_errors=True)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
_ffmpeg_cmd(src, start_s, directory, audio_ord),
|
_ffmpeg_cmd(src, start_s, directory, audio_ord, aoff),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
|
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
|
||||||
_sessions[key] = s
|
_sessions[key] = s
|
||||||
_enforce_cap()
|
_enforce_cap()
|
||||||
log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord)
|
# Learn the REAL start offset K from the first segment (outside the lock — this blocks on ffmpeg
|
||||||
|
# producing seg_0, and we must not hold up other sessions). The client reads `media_start_s`, so
|
||||||
|
# its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On
|
||||||
|
# timeout/probe failure we keep the provisional requested offset (old behaviour, small drift).
|
||||||
|
if start_s > 0:
|
||||||
|
seg0 = directory / "seg_0.ts"
|
||||||
|
if wait_for(seg0, timeout=25.0):
|
||||||
|
k = _probe_first_pts(seg0)
|
||||||
|
if k is not None and k >= 0:
|
||||||
|
s.media_start_s = k
|
||||||
|
log.info(
|
||||||
|
"plex hls session start key=%s req=%.1f real=%.3f audio=%s",
|
||||||
|
key, start_s, s.media_start_s, audio_ord,
|
||||||
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -943,6 +943,63 @@ def _srt_to_vtt(text: str) -> str:
|
||||||
return "WEBVTT\n\n" + body.strip() + "\n"
|
return "WEBVTT\n\n" + body.strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _vtt_ts_to_s(ts: str) -> float:
|
||||||
|
parts = ts.split(":")
|
||||||
|
h = "0" if len(parts) == 2 else parts[0]
|
||||||
|
m, rest = parts[-2], parts[-1]
|
||||||
|
sec, _, ms = rest.partition(".")
|
||||||
|
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _vtt_s_to_ts(x: float) -> str:
|
||||||
|
x = max(0.0, x)
|
||||||
|
h = int(x // 3600); x -= h * 3600
|
||||||
|
m = int(x // 60); x -= m * 60
|
||||||
|
s = int(x); ms = int(round((x - s) * 1000))
|
||||||
|
if ms >= 1000:
|
||||||
|
s += 1; ms -= 1000
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_vtt(vtt: str, offset: float) -> str:
|
||||||
|
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
|
||||||
|
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
|
||||||
|
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
|
||||||
|
|
||||||
|
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
|
||||||
|
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
|
||||||
|
— where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue
|
||||||
|
straddling 0 is clamped to start at 0."""
|
||||||
|
if not offset:
|
||||||
|
return vtt
|
||||||
|
out_blocks: list[str] = []
|
||||||
|
for block in re.split(r"\n[ \t]*\n", vtt):
|
||||||
|
if "-->" not in block:
|
||||||
|
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
|
||||||
|
continue
|
||||||
|
lines = block.split("\n")
|
||||||
|
keep = True
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if "-->" not in ln:
|
||||||
|
continue
|
||||||
|
left, _, right = ln.partition("-->")
|
||||||
|
rparts = right.strip().split(None, 1)
|
||||||
|
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
|
||||||
|
try:
|
||||||
|
st = _vtt_ts_to_s(left.strip()) - offset
|
||||||
|
en = _vtt_ts_to_s(rparts[0]) - offset
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
break # unparseable timing → leave the block as-is
|
||||||
|
if en <= 0:
|
||||||
|
keep = False # whole cue is before the session start → drop the block
|
||||||
|
else:
|
||||||
|
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
||||||
|
break
|
||||||
|
if keep:
|
||||||
|
out_blocks.append("\n".join(lines))
|
||||||
|
return "\n\n".join(out_blocks)
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
def _ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
||||||
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
|
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
|
||||||
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
|
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
|
||||||
|
|
@ -1047,16 +1104,18 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||||
def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response:
|
def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response:
|
||||||
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
|
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
|
||||||
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
|
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
|
||||||
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
|
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
|
||||||
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached."""
|
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
|
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
|
||||||
|
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
|
||||||
cache_key = f"sub_{rating_key}_{sub_ord}"
|
cache_key = f"sub_{rating_key}_{sub_ord}"
|
||||||
hit = _sub_cache_read(cache_key)
|
hit = _sub_cache_read(cache_key)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
return Response(hit, media_type="text/vtt")
|
return Response(_shift_vtt(hit, offset), media_type="text/vtt")
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
|
|
@ -1094,7 +1153,7 @@ def subtitle(rating_key: str, sub_ord: int, request: Request) -> Response:
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
||||||
_sub_cache_write(cache_key, vtt)
|
_sub_cache_write(cache_key, vtt)
|
||||||
return Response(vtt, media_type="text/vtt")
|
return Response(_shift_vtt(vtt, offset), media_type="text/vtt")
|
||||||
|
|
||||||
|
|
||||||
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||||
|
|
@ -1439,6 +1498,7 @@ def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
start: float = 0.0,
|
start: float = 0.0,
|
||||||
audio: int | None = None,
|
audio: int | None = None,
|
||||||
|
aoff: float = 0.0,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -1450,14 +1510,16 @@ def stream_session(
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
if it.playable == "transcode":
|
||||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
||||||
if it.playable == "direct" and audio is None:
|
if it.playable == "direct" and audio is None and not aoff:
|
||||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
||||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
s = plex_stream.start_session(db, it, start, audio)
|
s = plex_stream.start_session(db, it, start, audio, aoff)
|
||||||
if s is None:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||||
|
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||||
|
return {"mode": "hls", "start_s": s.media_start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stream/{rating_key}/file")
|
@router.get("/stream/{rating_key}/file")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
import {
|
||||||
|
Fragment,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
type WheelEvent as ReactWheelEvent,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
|
|
@ -8,6 +18,7 @@ import {
|
||||||
Download,
|
Download,
|
||||||
Info,
|
Info,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Languages,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
Pause,
|
Pause,
|
||||||
|
|
@ -22,10 +33,71 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexMarker } from "../lib/api";
|
import { api, type PlexMarker } from "../lib/api";
|
||||||
|
import { useAccountPersistedObject } from "../lib/storage";
|
||||||
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
|
||||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
|
|
||||||
|
// Audio/subtitle selections are persisted per-account by LANGUAGE, not by ordinal — the same track
|
||||||
|
// ordinal means different languages in different files, so we re-resolve the saved language to this
|
||||||
|
// item's track layout on load (and it carries across episodes/movies, like Plex's preferred-language).
|
||||||
|
type AudioStream = { ord: number; language?: string | null; default: boolean };
|
||||||
|
type SubStream = { ord: number; language?: string | null; text: boolean };
|
||||||
|
function audioOrdForLang(streams: AudioStream[], lang: string): number | null {
|
||||||
|
if (!lang) return null; // "" = use the file's default first track (keeps direct-play, no forced HLS)
|
||||||
|
const i = streams.findIndex((s) => (s.language ?? "") === lang);
|
||||||
|
// absent (-1) or already the FIRST track (0, which the backend plays by default) → no override.
|
||||||
|
// (Match by index, NOT the `default` flag — some files mark a non-first track default, which made
|
||||||
|
// the restored language silently fall back to track 0.)
|
||||||
|
return i <= 0 ? null : streams[i].ord;
|
||||||
|
}
|
||||||
|
function subOrdForLang(subs: SubStream[], lang: string): number | null {
|
||||||
|
if (!lang) return null; // "" = subtitles off
|
||||||
|
const m = subs.find((s) => s.text && (s.language ?? "") === lang);
|
||||||
|
return m ? m.ord : null; // preferred language not available here → off
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-account player preferences, persisted so they survive F5 and carry across items.
|
||||||
|
type PlexPlayerPrefs = {
|
||||||
|
volume: number; // 0..1
|
||||||
|
muted: boolean;
|
||||||
|
wasPlaying: boolean; // resume-playing intent across reloads (best-effort; browsers may block autoplay)
|
||||||
|
audioLang: string; // "" = default first audio
|
||||||
|
subLang: string; // "" = subtitles off
|
||||||
|
subOffset: number; // seconds, +later / -earlier (client-side cue shift)
|
||||||
|
audioOffset: number; // seconds, +later / -earlier (server-side -itsoffset)
|
||||||
|
barAutoHide: boolean; // auto-hide the control bar during playback
|
||||||
|
seekStep: number; // plain ←/→ jump (s)
|
||||||
|
fineSeekStep: number; // Ctrl+←/→ jump (s)
|
||||||
|
subSize: number; // subtitle font size (%)
|
||||||
|
subColor: string; // subtitle text color (hex)
|
||||||
|
subBg: string; // subtitle background ("transparent" or hex)
|
||||||
|
subPos: number; // subtitle vertical position (cue line %, higher = lower on screen)
|
||||||
|
};
|
||||||
|
const DEFAULT_PREFS: PlexPlayerPrefs = {
|
||||||
|
volume: 1,
|
||||||
|
muted: false,
|
||||||
|
wasPlaying: true,
|
||||||
|
audioLang: "",
|
||||||
|
subLang: "",
|
||||||
|
subOffset: 0,
|
||||||
|
audioOffset: 0,
|
||||||
|
barAutoHide: true,
|
||||||
|
seekStep: 10,
|
||||||
|
fineSeekStep: 1,
|
||||||
|
subSize: 100,
|
||||||
|
subColor: "#ffffff",
|
||||||
|
subBg: "transparent",
|
||||||
|
subPos: 88,
|
||||||
|
};
|
||||||
|
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||||
|
// hls.js plays our non-zero-start `-copyts` HLS stream ~1.0s BEHIND the media clock (measured by
|
||||||
|
// frame-capture: the first frame at source K lands at currentTime≈1.0, not 0 — constant, framerate- and
|
||||||
|
// segment-type-independent). Native-time subtitles (shifted by K) would therefore run 1s early, so we
|
||||||
|
// bake this compensation into the shift. Only on the HLS path; direct-play uses the file's real clock.
|
||||||
|
const HLS_SUB_LAG = 1.0;
|
||||||
|
|
||||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
// 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
|
// (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
|
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||||
|
|
@ -65,8 +137,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [abs, setAbs] = useState(0); // absolute current time
|
const [abs, setAbs] = useState(0); // absolute current time
|
||||||
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
|
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 [fs, setFs] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
@ -75,11 +145,46 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
// Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
|
||||||
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
const [audioOrd, setAudioOrd] = useState<number | null>(null);
|
||||||
const [subOrd, setSubOrd] = useState<number | null>(null);
|
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
// The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are
|
||||||
|
// absolute-time VTT shifted by K to the session's zero-based clock, so the <track> src/key keys off
|
||||||
|
// this and re-fetches when a seek-restart changes it.
|
||||||
|
const [sessionK, setSessionK] = useState(0);
|
||||||
|
|
||||||
|
// Per-account persisted player prefs — survive F5, carry across items.
|
||||||
|
const [prefs, patchPrefs] = useAccountPersistedObject<PlexPlayerPrefs>(
|
||||||
|
"siftlode.plexPlayerPrefs",
|
||||||
|
DEFAULT_PREFS,
|
||||||
|
);
|
||||||
|
const { volume, muted } = prefs;
|
||||||
|
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
|
||||||
|
const barAutoHideRef = useRef(prefs.barAutoHide);
|
||||||
|
barAutoHideRef.current = prefs.barAutoHide;
|
||||||
|
const seekStepRef = useRef(prefs.seekStep);
|
||||||
|
seekStepRef.current = prefs.seekStep;
|
||||||
|
const fineSeekStepRef = useRef(prefs.fineSeekStep);
|
||||||
|
fineSeekStepRef.current = prefs.fineSeekStep;
|
||||||
|
const aoffRef = useRef(prefs.audioOffset);
|
||||||
|
aoffRef.current = prefs.audioOffset;
|
||||||
|
// Debounced user subtitle offset: avoids re-fetching the VTT on every slider step while dragging.
|
||||||
|
const [subOffApplied, setSubOffApplied] = useState(prefs.subOffset);
|
||||||
|
useEffect(() => {
|
||||||
|
const tmr = window.setTimeout(() => setSubOffApplied(prefs.subOffset), 250);
|
||||||
|
return () => window.clearTimeout(tmr);
|
||||||
|
}, [prefs.subOffset]);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false); // gear = tuning (tabbed)
|
||||||
|
const [tracksOpen, setTracksOpen] = useState(false); // separate quick menu = audio/subtitle tracks
|
||||||
|
const [settingsTab, setSettingsTab] = useState<"sync" | "playback" | "subtitle">("sync");
|
||||||
const audioRef = useRef<number | null>(null);
|
const audioRef = useRef<number | null>(null);
|
||||||
const menuOpenRef = useRef(false);
|
const menuOpenRef = useRef(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const tracksRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
audioRef.current = audioOrd;
|
audioRef.current = audioOrd;
|
||||||
menuOpenRef.current = menuOpen;
|
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
|
||||||
|
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
|
||||||
|
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
|
||||||
|
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
|
||||||
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const helpOpenRef = useRef(false);
|
const helpOpenRef = useRef(false);
|
||||||
|
|
@ -96,11 +201,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
|
|
||||||
durationRef.current = duration;
|
durationRef.current = duration;
|
||||||
|
|
||||||
// A new item starts with default tracks (its stream layout differs from the last one's).
|
// Track selection is (re)derived from the saved language prefs when each item's detail arrives
|
||||||
useEffect(() => {
|
// (see the detail effect below) — nothing to reset on id change.
|
||||||
setAudioOrd(null);
|
|
||||||
setSubOrd(null);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
// --- media setup: (re)load a session for `id` at a start offset -------------------------------
|
||||||
const loadSession = useCallback(
|
const loadSession = useCallback(
|
||||||
|
|
@ -111,7 +213,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
let sess;
|
let sess;
|
||||||
try {
|
try {
|
||||||
sess = await api.plexSession(id, startAt, audioRef.current);
|
sess = await api.plexSession(id, startAt, audioRef.current, aoffRef.current);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
// 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
|
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
||||||
|
|
@ -125,7 +227,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStartRef.current = sess.start_s;
|
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
|
||||||
|
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
|
||||||
|
// content-accurate. Direct-play (start_s 0) uses the file's real clock — no compensation.
|
||||||
|
const trueStart = sess.mode === "hls" && sess.start_s > 0 ? sess.start_s - HLS_SUB_LAG : sess.start_s;
|
||||||
|
sessionStartRef.current = trueStart;
|
||||||
|
setSessionK(trueStart);
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
hlsRef.current.destroy();
|
hlsRef.current.destroy();
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
|
|
@ -164,6 +271,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
// Start playback when the detail arrives (resume from the saved position unless already watched).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
|
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
|
||||||
|
// audio ref synchronously so the loadSession below picks the right track on the first request.
|
||||||
|
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
|
||||||
|
audioRef.current = aOrd;
|
||||||
|
setAudioOrd(aOrd);
|
||||||
|
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
|
||||||
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
|
||||||
loadSession(resume);
|
loadSession(resume);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -172,6 +285,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
hlsRef.current = null;
|
hlsRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
|
||||||
|
// double-load the session — the picker handlers already reload/toggle).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [detail, loadSession]);
|
}, [detail, loadSession]);
|
||||||
|
|
||||||
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
||||||
|
|
@ -210,8 +326,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onPlay = () => setPlaying(true);
|
const onPlay = () => {
|
||||||
const onPause = () => setPlaying(false);
|
setPlaying(true);
|
||||||
|
patchPrefs({ wasPlaying: true }); // remember play intent for F5 (best-effort; autoplay may be blocked)
|
||||||
|
};
|
||||||
|
const onPause = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
patchPrefs({ wasPlaying: false });
|
||||||
|
};
|
||||||
video.addEventListener("timeupdate", onTime);
|
video.addEventListener("timeupdate", onTime);
|
||||||
video.addEventListener("play", onPlay);
|
video.addEventListener("play", onPlay);
|
||||||
video.addEventListener("pause", onPause);
|
video.addEventListener("pause", onPause);
|
||||||
|
|
@ -220,7 +342,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
video.removeEventListener("play", onPlay);
|
video.removeEventListener("play", onPlay);
|
||||||
video.removeEventListener("pause", onPause);
|
video.removeEventListener("pause", onPause);
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id, patchPrefs]);
|
||||||
|
|
||||||
// Periodic + on-unmount progress checkpoint.
|
// Periodic + on-unmount progress checkpoint.
|
||||||
const absRef = useRef(0);
|
const absRef = useRef(0);
|
||||||
|
|
@ -267,19 +389,31 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
const applyVolume = useCallback((vol: number, mute: boolean) => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
v.volume = Math.max(0, Math.min(1, vol));
|
v.volume = clamp01(vol);
|
||||||
v.muted = mute;
|
v.muted = mute;
|
||||||
}, []);
|
}, []);
|
||||||
const nudgeVolume = useCallback(
|
// Set volume/mute on the <video> AND persist (survives F5).
|
||||||
(d: number) => {
|
const setVol = useCallback(
|
||||||
setVolume((prev) => {
|
(vol: number, mute: boolean) => {
|
||||||
const nv = Math.max(0, Math.min(1, prev + d));
|
applyVolume(vol, mute);
|
||||||
applyVolume(nv, false);
|
patchPrefs({ volume: clamp01(vol), muted: mute });
|
||||||
setMuted(nv === 0);
|
|
||||||
return nv;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[applyVolume],
|
[applyVolume, patchPrefs],
|
||||||
|
);
|
||||||
|
const nudgeVolume = useCallback((d: number) => setVol(clamp01(prefs.volume + d), clamp01(prefs.volume + d) === 0), [prefs.volume, setVol]);
|
||||||
|
const toggleMute = useCallback(() => setVol(prefs.volume, !prefs.muted), [prefs.volume, prefs.muted, setVol]);
|
||||||
|
// Re-apply the persisted volume/mute to the <video> each time a session becomes ready (the element
|
||||||
|
// is fresh after a load/seek-restart, and starts at its default volume otherwise).
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) applyVolume(prefs.volume, prefs.muted);
|
||||||
|
}, [ready, applyVolume, prefs.volume, prefs.muted]);
|
||||||
|
// Mouse-wheel over the player adjusts volume (5 units per notch); overlays scroll normally.
|
||||||
|
const onWheel = useCallback(
|
||||||
|
(e: ReactWheelEvent) => {
|
||||||
|
if (infoOpenRef.current || helpOpenRef.current) return;
|
||||||
|
nudgeVolume(e.deltaY < 0 ? 0.05 : -0.05);
|
||||||
|
},
|
||||||
|
[nudgeVolume],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleFs = useCallback(() => {
|
const toggleFs = useCallback(() => {
|
||||||
|
|
@ -325,37 +459,76 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
(ord: number | null) => {
|
(ord: number | null) => {
|
||||||
audioRef.current = ord;
|
audioRef.current = ord;
|
||||||
setAudioOrd(ord);
|
setAudioOrd(ord);
|
||||||
setMenuOpen(false);
|
// Remember the LANGUAGE (not the ord) so F5 / the next item restores this choice. Menu stays open.
|
||||||
|
patchPrefs({ audioLang: ord == null ? "" : (detail?.audio_streams.find((s) => s.ord === ord)?.language ?? "") });
|
||||||
loadSession(absRef.current);
|
loadSession(absRef.current);
|
||||||
},
|
},
|
||||||
[loadSession],
|
[loadSession, detail, patchPrefs],
|
||||||
);
|
);
|
||||||
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
|
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
|
||||||
// (applied by the effect below), NO session restart. Off = null.
|
// (applied by the effect below), NO session restart. Off = null.
|
||||||
const changeSubtitle = useCallback((ord: number | null) => {
|
const changeSubtitle = useCallback(
|
||||||
|
(ord: number | null) => {
|
||||||
setSubOrd(ord);
|
setSubOrd(ord);
|
||||||
setMenuOpen(false);
|
patchPrefs({ subLang: ord == null ? "" : (detail?.subtitle_streams.find((s) => s.ord === ord)?.language ?? "") });
|
||||||
}, []);
|
},
|
||||||
|
[detail, patchPrefs],
|
||||||
|
);
|
||||||
|
// 'A' cycles audio tracks; 'S' cycles subtitle (off → each text sub → off).
|
||||||
|
const cycleAudio = useCallback(() => {
|
||||||
|
const streams = detail?.audio_streams ?? [];
|
||||||
|
if (streams.length < 2) return;
|
||||||
|
const idx = streams.findIndex((s) => s.ord === (audioRef.current ?? 0));
|
||||||
|
const next = streams[(idx + 1) % streams.length];
|
||||||
|
changeAudio(next.ord === 0 ? null : next.ord);
|
||||||
|
}, [detail, changeAudio]);
|
||||||
|
const cycleSubtitle = useCallback(() => {
|
||||||
|
const subs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||||
|
const order: (number | null)[] = [null, ...subs.map((s) => s.ord)];
|
||||||
|
const i = order.findIndex((o) => o === subOrd);
|
||||||
|
changeSubtitle(order[(i + 1) % order.length]);
|
||||||
|
}, [detail, subOrd, changeSubtitle]);
|
||||||
|
// Audio A/V-sync offset: persist immediately (for the slider) but debounce the session restart so
|
||||||
|
// dragging doesn't respawn ffmpeg on every step.
|
||||||
|
const aoffTimer = useRef<number | null>(null);
|
||||||
|
const changeAudioOffset = useCallback(
|
||||||
|
(v: number) => {
|
||||||
|
patchPrefs({ audioOffset: v });
|
||||||
|
aoffRef.current = v;
|
||||||
|
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
|
||||||
|
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
|
||||||
|
},
|
||||||
|
[patchPrefs, loadSession],
|
||||||
|
);
|
||||||
|
|
||||||
// Text subtitles offered to the browser as <track>s; image subs (PGS/VobSub) can't be shown as text.
|
// Text subtitles offered in the picker; image subs (PGS/VobSub) can't be shown as text.
|
||||||
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||||
// Apply the selected subtitle: show the matching <track>, disable the rest. The <track> DOM order
|
// Render ONLY the selected sub as a single <track> (below) — rendering all of them and flipping
|
||||||
// matches `textSubs`, so textTracks[i] ↔ textSubs[i]. Setting mode!="disabled" triggers the VTT fetch.
|
// modes raced (multiple "showing" at once → overlapping HU+EN) especially across a session restart.
|
||||||
|
const selectedSub = textSubs.find((s) => s.ord === subOrd) ?? null;
|
||||||
|
// The backend shifts the absolute-time VTT by (sessionK − userSubOffset): sessionK aligns it to the
|
||||||
|
// HLS session's zero-based clock; subtracting the user offset makes +offset show the sub LATER. Uses
|
||||||
|
// the DEBOUNCED user offset so slider drags don't refetch the whole VTT per step.
|
||||||
|
// sessionK is already lag-compensated (see loadSession), so it is the true content-zero the VTT
|
||||||
|
// must be shifted by; the user offset nudges on top.
|
||||||
|
const subTrackOffset = sessionK - subOffApplied;
|
||||||
|
const settingsTabs = [
|
||||||
|
{ key: "sync" as const, label: t("plex.player.sync") },
|
||||||
|
{ key: "playback" as const, label: t("plex.player.playback") },
|
||||||
|
...(textSubs.length > 0 ? [{ key: "subtitle" as const, label: t("plex.player.subStyle") }] : []),
|
||||||
|
];
|
||||||
|
const activeTab = settingsTabs.some((x) => x.key === settingsTab) ? settingsTab : "sync";
|
||||||
|
// Force the single mounted track to "showing" (its VTT loads on mount; `default` alone can be flaky
|
||||||
|
// after a remount). Re-runs when the selection or the shifted-track identity changes (fresh <track>).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
const tracks = Array.from(video.textTracks);
|
for (const tr of Array.from(video.textTracks)) tr.mode = "showing";
|
||||||
tracks.forEach((tr, i) => {
|
}, [subOrd, id, subTrackOffset]);
|
||||||
const s = textSubs[i];
|
|
||||||
tr.mode = s && s.ord === subOrd ? "showing" : "disabled";
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [subOrd, id, textSubs.length]);
|
|
||||||
|
|
||||||
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
|
// Position subtitle cues per the user's vertical setting (prefs.subPos). The video fills the viewport
|
||||||
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
|
// height, so a default (line "auto") cue renders at the extreme bottom and clips — line% lifts it.
|
||||||
// to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
|
// Re-applied on each cue change (hls.js streams cues in as subtitle segments load; no per-cue event).
|
||||||
// cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
@ -364,7 +537,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
for (const c of Array.from(tr.cues || [])) {
|
for (const c of Array.from(tr.cues || [])) {
|
||||||
try {
|
try {
|
||||||
(c as VTTCue).snapToLines = false;
|
(c as VTTCue).snapToLines = false;
|
||||||
(c as VTTCue).line = 88;
|
(c as VTTCue).line = prefs.subPos;
|
||||||
} catch {
|
} catch {
|
||||||
/* not a positionable cue */
|
/* not a positionable cue */
|
||||||
}
|
}
|
||||||
|
|
@ -380,11 +553,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
|
||||||
|
placeAll();
|
||||||
return () => {
|
return () => {
|
||||||
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
|
||||||
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
|
||||||
};
|
};
|
||||||
}, [id]);
|
}, [id, prefs.subPos]);
|
||||||
|
|
||||||
// Auto-advance to the next episode when one finishes.
|
// Auto-advance to the next episode when one finishes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -402,6 +576,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
const wake = useCallback(() => {
|
const wake = useCallback(() => {
|
||||||
setUiVisible(true);
|
setUiVisible(true);
|
||||||
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
||||||
|
if (!barAutoHideRef.current) return; // per-user setting: never auto-hide the control bar
|
||||||
hideTimer.current = window.setTimeout(() => {
|
hideTimer.current = window.setTimeout(() => {
|
||||||
// Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
|
// 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);
|
if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
|
||||||
|
|
@ -426,12 +601,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) go(prevId);
|
if (e.shiftKey) go(prevId);
|
||||||
else seekTo(absRef.current - 10);
|
else seekTo(absRef.current - (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) go(nextId);
|
if (e.shiftKey) go(nextId);
|
||||||
else seekTo(absRef.current + 10);
|
else seekTo(absRef.current + (e.ctrlKey ? fineSeekStepRef.current : seekStepRef.current));
|
||||||
break;
|
break;
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -442,10 +617,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
nudgeVolume(-0.05);
|
nudgeVolume(-0.05);
|
||||||
break;
|
break;
|
||||||
case "m":
|
case "m":
|
||||||
setMuted((m) => {
|
toggleMute();
|
||||||
applyVolume(volume, !m);
|
break;
|
||||||
return !m;
|
case "a":
|
||||||
});
|
case "A":
|
||||||
|
cycleAudio();
|
||||||
|
break;
|
||||||
|
case "s":
|
||||||
|
case "S":
|
||||||
|
cycleSubtitle();
|
||||||
break;
|
break;
|
||||||
case "h":
|
case "h":
|
||||||
case "H":
|
case "H":
|
||||||
|
|
@ -458,12 +638,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
case "Backspace":
|
case "Backspace":
|
||||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (helpOpenRef.current) setHelpOpen(false);
|
if (menuOpenRef.current) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setTracksOpen(false);
|
||||||
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
else onClose();
|
else onClose();
|
||||||
break;
|
break;
|
||||||
case "Escape":
|
case "Escape":
|
||||||
if (helpOpenRef.current) setHelpOpen(false);
|
if (menuOpenRef.current) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setTracksOpen(false);
|
||||||
|
} else if (helpOpenRef.current) setHelpOpen(false);
|
||||||
else if (infoOpenRef.current) setInfoOpen(false);
|
else if (infoOpenRef.current) setInfoOpen(false);
|
||||||
else if (!document.fullscreenElement) onClose();
|
else if (!document.fullscreenElement) onClose();
|
||||||
break;
|
break;
|
||||||
|
|
@ -471,7 +657,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, prevId, nextId, onClose, wake]);
|
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, toggleMute, cycleAudio, cycleSubtitle, detail, prevId, nextId, onClose, wake]);
|
||||||
|
|
||||||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||||
|
|
@ -486,8 +672,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
|
className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
|
||||||
onMouseMove={wake}
|
onMouseMove={wake}
|
||||||
onClick={wake}
|
onClick={wake}
|
||||||
|
onWheel={onWheel}
|
||||||
style={{ cursor: uiVisible ? "default" : "none" }}
|
style={{ cursor: uiVisible ? "default" : "none" }}
|
||||||
>
|
>
|
||||||
|
{/* Per-user subtitle appearance (font size / colour / background). ::cue is global, but this
|
||||||
|
player owns the only <video> on screen. */}
|
||||||
|
<style>{`video::cue{font-size:${prefs.subSize}%;color:${prefs.subColor};background-color:${prefs.subBg};}`}</style>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="max-h-full max-w-full w-auto h-auto"
|
className="max-h-full max-w-full w-auto h-auto"
|
||||||
|
|
@ -496,15 +686,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
>
|
>
|
||||||
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||||
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
||||||
{textSubs.map((s) => (
|
{selectedSub && (
|
||||||
<track
|
<track
|
||||||
key={s.ord}
|
// One track = the selected sub only (no multi-track overlap). Key by the shifted-offset so
|
||||||
|
// a seek-restart or a (debounced) sub-offset change remounts → fresh VTT fetch.
|
||||||
|
key={`${id}-${selectedSub.ord}-${subTrackOffset.toFixed(2)}`}
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
src={api.plexSubtitleUrl(id, s.ord)}
|
src={api.plexSubtitleUrl(id, selectedSub.ord, subTrackOffset)}
|
||||||
srcLang={s.language ?? undefined}
|
srcLang={selectedSub.language ?? undefined}
|
||||||
label={s.label}
|
label={selectedSub.label}
|
||||||
|
default
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
{!ready && (
|
{!ready && (
|
||||||
|
|
@ -588,7 +781,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-white">
|
<div className="flex items-center gap-3 text-white">
|
||||||
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
|
<Ctrl label={t("plex.player.playPause")} onClick={togglePlay} align="start">
|
||||||
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
{playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
<Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
|
||||||
|
|
@ -609,15 +802,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 group/vol">
|
<div className="flex items-center gap-1.5 group/vol">
|
||||||
<Ctrl
|
<Ctrl label={t("plex.player.mute")} onClick={toggleMute}>
|
||||||
label={t("plex.player.mute")}
|
|
||||||
onClick={() =>
|
|
||||||
setMuted((m) => {
|
|
||||||
applyVolume(volume, !m);
|
|
||||||
return !m;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
{muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
<input
|
<input
|
||||||
|
|
@ -628,9 +813,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
value={muted ? 0 : volume}
|
value={muted ? 0 : volume}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nv = Number(e.target.value);
|
const nv = Number(e.target.value);
|
||||||
setVolume(nv);
|
setVol(nv, nv === 0);
|
||||||
setMuted(nv === 0);
|
|
||||||
applyVolume(nv, false);
|
|
||||||
}}
|
}}
|
||||||
className="w-20 accent-accent"
|
className="w-20 accent-accent"
|
||||||
/>
|
/>
|
||||||
|
|
@ -642,22 +825,26 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/* Quick TRACKS menu (audio / subtitle) — the frequent action, on its own button. */}
|
||||||
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
ref={tracksTriggerRef}
|
||||||
|
onClick={() => setTracksOpen((o) => !o)}
|
||||||
aria-label={t("plex.player.tracks")}
|
aria-label={t("plex.player.tracks")}
|
||||||
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
className={`p-1 hover:text-accent ${tracksOpen ? "text-accent" : ""}`}
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5" />
|
<Languages className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{menuOpen && (
|
{tracksOpen && (
|
||||||
<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">
|
<div
|
||||||
|
ref={tracksRef}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
|
>
|
||||||
{detail.audio_streams.length > 1 && (
|
{detail.audio_streams.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
<PanelHead>{t("plex.player.audio")}</PanelHead>
|
||||||
{t("plex.player.audio")}
|
|
||||||
</div>
|
|
||||||
{detail.audio_streams.map((a) => (
|
{detail.audio_streams.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a.ord}
|
key={a.ord}
|
||||||
|
|
@ -673,9 +860,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
)}
|
)}
|
||||||
{textSubs.length > 0 && (
|
{textSubs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
<PanelHead>{t("plex.player.subtitles")}</PanelHead>
|
||||||
{t("plex.player.subtitles")}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => changeSubtitle(null)}
|
onClick={() => changeSubtitle(null)}
|
||||||
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
|
|
@ -701,6 +886,149 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gear = tuning, split into tabs (Sync / Playback / Subtitle). */}
|
||||||
|
{detail && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={menuTriggerRef}
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
aria-label={t("plex.player.settings")}
|
||||||
|
className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="glass-menu absolute bottom-full right-0 mb-2 w-72 rounded-xl p-2 text-sm text-white shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex gap-1">
|
||||||
|
{settingsTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setSettingsTab(tab.key)}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-[11px] ${
|
||||||
|
activeTab === tab.key ? "bg-white/15 text-accent" : "text-white/70 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "sync" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subOffset")}
|
||||||
|
value={prefs.subOffset}
|
||||||
|
min={-10}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ subOffset: v })}
|
||||||
|
onReset={() => patchPrefs({ subOffset: 0 })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.audioOffset")}
|
||||||
|
value={prefs.audioOffset}
|
||||||
|
min={-5}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={changeAudioOffset}
|
||||||
|
onReset={() => changeAudioOffset(0)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "playback" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.seekStep")}
|
||||||
|
value={prefs.seekStep}
|
||||||
|
min={5}
|
||||||
|
max={30}
|
||||||
|
step={1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ seekStep: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.fineSeek")}
|
||||||
|
value={prefs.fineSeekStep}
|
||||||
|
min={0.1}
|
||||||
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
suffix="s"
|
||||||
|
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label={t("plex.player.autoHide")}
|
||||||
|
checked={prefs.barAutoHide}
|
||||||
|
onChange={(v) => patchPrefs({ barAutoHide: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "subtitle" && (
|
||||||
|
<>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subSize")}
|
||||||
|
value={prefs.subSize}
|
||||||
|
min={50}
|
||||||
|
max={250}
|
||||||
|
step={5}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ subSize: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label={t("plex.player.subPos")}
|
||||||
|
value={prefs.subPos}
|
||||||
|
min={50}
|
||||||
|
max={95}
|
||||||
|
step={1}
|
||||||
|
suffix="%"
|
||||||
|
onChange={(v) => patchPrefs({ subPos: v })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[12px] text-white/80">{t("plex.player.subColor")}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.subColor}
|
||||||
|
onChange={(e) => patchPrefs({ subColor: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[12px] text-white/80">{t("plex.player.subBg")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={prefs.subBg === "transparent"}
|
||||||
|
onChange={(e) => patchPrefs({ subBg: e.target.checked ? "transparent" : "#000000" })}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
{t("plex.player.subBgNone")}
|
||||||
|
</label>
|
||||||
|
{prefs.subBg !== "transparent" && (
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={prefs.subBg}
|
||||||
|
onChange={(e) => patchPrefs({ subBg: e.target.value })}
|
||||||
|
className="h-6 w-10 cursor-pointer rounded bg-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||||
<Info className="w-5 h-5" />
|
<Info className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
|
|
@ -710,7 +1038,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
<Ctrl label={t("plex.player.download")} onClick={download}>
|
<Ctrl label={t("plex.player.download")} onClick={download}>
|
||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
|
<Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs} align="end">
|
||||||
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
{fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -747,11 +1075,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
[
|
[
|
||||||
[["Space", "K"], "plex.player.keys.playPause"],
|
[["Space", "K"], "plex.player.keys.playPause"],
|
||||||
[["←", "→"], "plex.player.keys.seek"],
|
[["←", "→"], "plex.player.keys.seek"],
|
||||||
|
[["Ctrl ←", "Ctrl →"], "plex.player.keys.fineSeek"],
|
||||||
...(detail?.kind === "episode"
|
...(detail?.kind === "episode"
|
||||||
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
|
? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
|
||||||
: []),
|
: []),
|
||||||
[["↑", "↓"], "plex.player.keys.volume"],
|
[["↑", "↓"], "plex.player.keys.volume"],
|
||||||
[["M"], "plex.player.keys.mute"],
|
[["M"], "plex.player.keys.mute"],
|
||||||
|
[["A"], "plex.player.keys.audio"],
|
||||||
|
[["S"], "plex.player.keys.subtitle"],
|
||||||
[["F"], "plex.player.keys.fullscreen"],
|
[["F"], "plex.player.keys.fullscreen"],
|
||||||
[["I"], "plex.player.keys.info"],
|
[["I"], "plex.player.keys.info"],
|
||||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||||
|
|
@ -800,19 +1131,83 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- settings-panel building blocks ------------------------------------------------------------
|
||||||
|
function PanelHead({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="px-1 pt-2 pb-1 text-[11px] uppercase tracking-wide text-white/50">{children}</div>;
|
||||||
|
}
|
||||||
|
function Slider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
suffix,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
suffix?: string;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[12px] text-white/80">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="tabular-nums text-white/60">
|
||||||
|
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||||||
|
{suffix ?? ""}
|
||||||
|
</span>
|
||||||
|
{onReset && (
|
||||||
|
<button onClick={onReset} className="text-white/40 hover:text-white" aria-label="reset">
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center justify-between px-1 py-1.5">
|
||||||
|
<span className="text-[12px] text-white/80">{label}</span>
|
||||||
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-accent" />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
|
// 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).
|
// viewport, unlike a native title tooltip on the bottom control bar).
|
||||||
function Ctrl({
|
function Ctrl({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
align = "center",
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
// Tooltip horizontal anchor: "start"/"end" for edge buttons so it doesn't clip off the viewport.
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const pos = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
|
||||||
return (
|
return (
|
||||||
<div className="relative flex group/ctrl">
|
<div className="relative flex group/ctrl">
|
||||||
<button
|
<button
|
||||||
|
|
@ -823,7 +1218,9 @@ function Ctrl({
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</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">
|
<span
|
||||||
|
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,25 @@
|
||||||
"fullscreen": "Vollbild",
|
"fullscreen": "Vollbild",
|
||||||
"info": "Medieninfo",
|
"info": "Medieninfo",
|
||||||
"back": "Stopp & zurück zum Feed",
|
"back": "Stopp & zurück zum Feed",
|
||||||
"help": "Diese Hilfe anzeigen"
|
"help": "Diese Hilfe anzeigen",
|
||||||
}
|
"audio": "Audiospur wechseln",
|
||||||
|
"subtitle": "Untertitel wechseln",
|
||||||
|
"fineSeek": "Feines Springen"
|
||||||
|
},
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"sync": "Synchronität",
|
||||||
|
"subOffset": "Untertitel-Verzögerung",
|
||||||
|
"audioOffset": "Audio-Verzögerung",
|
||||||
|
"playback": "Wiedergabe",
|
||||||
|
"seekStep": "Sprungweite",
|
||||||
|
"fineSeek": "Feines Springen (Strg)",
|
||||||
|
"autoHide": "Steuerung ausblenden",
|
||||||
|
"subStyle": "Untertitel-Stil",
|
||||||
|
"subSize": "Schriftgröße",
|
||||||
|
"subPos": "Position",
|
||||||
|
"subColor": "Textfarbe",
|
||||||
|
"subBg": "Hintergrund",
|
||||||
|
"subBgNone": "Keiner"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Medieninfo",
|
"title": "Medieninfo",
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,25 @@
|
||||||
"fullscreen": "Fullscreen",
|
"fullscreen": "Fullscreen",
|
||||||
"info": "Media info",
|
"info": "Media info",
|
||||||
"back": "Stop & back to feed",
|
"back": "Stop & back to feed",
|
||||||
"help": "Show this help"
|
"help": "Show this help",
|
||||||
}
|
"audio": "Cycle audio track",
|
||||||
|
"subtitle": "Cycle subtitle",
|
||||||
|
"fineSeek": "Fine seek"
|
||||||
|
},
|
||||||
|
"settings": "Settings",
|
||||||
|
"sync": "Sync offset",
|
||||||
|
"subOffset": "Subtitle delay",
|
||||||
|
"audioOffset": "Audio delay",
|
||||||
|
"playback": "Playback",
|
||||||
|
"seekStep": "Seek step",
|
||||||
|
"fineSeek": "Fine seek (Ctrl)",
|
||||||
|
"autoHide": "Auto-hide controls",
|
||||||
|
"subStyle": "Subtitle style",
|
||||||
|
"subSize": "Text size",
|
||||||
|
"subPos": "Position",
|
||||||
|
"subColor": "Text color",
|
||||||
|
"subBg": "Background",
|
||||||
|
"subBgNone": "None"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Media info",
|
"title": "Media info",
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,25 @@
|
||||||
"fullscreen": "Teljes képernyő",
|
"fullscreen": "Teljes képernyő",
|
||||||
"info": "Média infó",
|
"info": "Média infó",
|
||||||
"back": "Leállítás és vissza a feedre",
|
"back": "Leállítás és vissza a feedre",
|
||||||
"help": "Súgó megjelenítése"
|
"help": "Súgó megjelenítése",
|
||||||
}
|
"audio": "Hangsáv váltása",
|
||||||
|
"subtitle": "Felirat váltása",
|
||||||
|
"fineSeek": "Finom léptetés"
|
||||||
|
},
|
||||||
|
"settings": "Beállítások",
|
||||||
|
"sync": "Szinkron-eltolás",
|
||||||
|
"subOffset": "Felirat-eltolás",
|
||||||
|
"audioOffset": "Hang-eltolás",
|
||||||
|
"playback": "Lejátszás",
|
||||||
|
"seekStep": "Léptetés",
|
||||||
|
"fineSeek": "Finom léptetés (Ctrl)",
|
||||||
|
"autoHide": "Vezérlők elrejtése",
|
||||||
|
"subStyle": "Felirat stílusa",
|
||||||
|
"subSize": "Szövegméret",
|
||||||
|
"subPos": "Pozíció",
|
||||||
|
"subColor": "Szövegszín",
|
||||||
|
"subBg": "Háttér",
|
||||||
|
"subBgNone": "Nincs"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Média infó",
|
"title": "Média infó",
|
||||||
|
|
|
||||||
|
|
@ -1218,14 +1218,17 @@ export const api = {
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
plexSession: (id: string, start = 0, audio?: number | null): Promise<PlexPlaySession> => {
|
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0): Promise<PlexPlaySession> => {
|
||||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||||
if (audio != null) p.set("audio", String(audio));
|
if (audio != null) p.set("audio", String(audio));
|
||||||
|
if (aoff) p.set("aoff", String(aoff));
|
||||||
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||||
},
|
},
|
||||||
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||||
plexSubtitleUrl: (id: string, ord: number): string =>
|
// `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
|
||||||
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`,
|
// cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
|
||||||
|
plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
|
||||||
|
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
|
||||||
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
||||||
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
||||||
|
|
@ -158,3 +158,24 @@ export function useAccountPersistedState(
|
||||||
};
|
};
|
||||||
return [value, set];
|
return [value, set];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
|
||||||
|
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
|
||||||
|
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
|
||||||
|
export function useAccountPersistedObject<T extends object>(
|
||||||
|
base: string,
|
||||||
|
defaults: T,
|
||||||
|
): [T, (patch: Partial<T>) => void] {
|
||||||
|
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||||||
|
const patch = useCallback(
|
||||||
|
(p: Partial<T>) => {
|
||||||
|
setValue((prev) => {
|
||||||
|
const next = { ...prev, ...p };
|
||||||
|
writeAccount(base, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[base],
|
||||||
|
);
|
||||||
|
return [value, patch];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue