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.dir = directory
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.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"]
if start_s > 0:
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
args += ["-i", str(src)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (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.
# 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
# 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 += [
"-f", "hls",
@ -74,6 +100,25 @@ def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None)
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:
try:
s.proc.terminate()
@ -100,6 +145,7 @@ def start_session(
item: PlexItem,
start_s: float,
audio_ord: int | None = None,
aoff: float = 0.0,
) -> HlsSession | None:
"""(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
@ -113,19 +159,32 @@ def start_session(
old = _sessions.pop(key, None)
if old is not None:
_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)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(src, start_s, directory, audio_ord),
_ffmpeg_cmd(src, start_s, directory, audio_ord, aoff),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
_sessions[key] = s
_enforce_cap()
log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord)
return s
# 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
def current_session(key: str) -> HlsSession | None: