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:
npeter83 2026-07-08 21:35:38 +02:00
parent 6232a66878
commit 0a0703b769
8 changed files with 707 additions and 114 deletions

View file

@ -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,19 +159,32 @@ 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
return s # 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
def current_session(key: str) -> HlsSession | None: def current_session(key: str) -> HlsSession | None:

View file

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

View 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(
setSubOrd(ord); (ord: number | null) => {
setMenuOpen(false); setSubOrd(ord);
}, []); 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>

View file

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

View file

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

View file

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

View file

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

View file

@ -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];
}