fix(playback): harden external-subtitle handling from PR #1471 review

Addresses the confirmed code-review findings:

- Deselect the hls.js engine track BEFORE activating an external subtitle
  track: hls.js reacts to subtitleTrack=-1 by disabling every subtitle-kind
  TextTrack, which undid the just-selected external track.
- Track ownership of every TextTrack the session ever created (addTextTrack
  tracks cannot leave the element), so stale or attach-failed tracks stay
  excluded from the native enumeration instead of reappearing as ghost
  engine tracks after source changes; failed attaches are silenced and
  their partial cues removed.
- Guard the file pick with a source generation so a pick that outlives a
  stream change (Up Next, zapping, failover) is discarded instead of
  attaching the previous stream's subtitles to the next one.
- Decode picked files encoding-aware (UTF-16 BOMs, strict UTF-8, then a
  Windows-1251/1252 heuristic) instead of Blob.text()'s silent UTF-8
  substitution that rendered legacy-encoded SRT files as mojibake.
- Gate the delay row on an external track being SELECTED, not merely
  loaded, so it can no longer sit enabled while visually inert.
- Keep real (possibly negative) cue times under a negative delay instead
  of clamping pre-roll cues into a simultaneous stack at t=0.
- Fix subtitleDelayLabel returning a signed negative zero for sub-tenth
  values.
- Hoist the canonical PlayerSubtitleStyle shape plus clamp/normalize rules
  into @iptvnator/shared/interfaces (subtitle-style.util.ts); the renderer
  and the Electron main process now share one implementation, removing the
  triplicated literals and the toLowerCase divergence.

Adds regression coverage for each fix; updates the player-controls contract
doc and CLAUDE.md accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-23 00:01:14 +02:00
1 parent 404deb92b0
commit 10bd5acd6f
16 files changed
+481 -119

No files matched your search

+33 -12
View File
@@ -618,23 +618,44 @@ as the shared `volume` key, and is normalized/clamped on every read and write.
The delay and any loaded file are deliberately per-session/per-source — they
correct one specific stream.
The canonical `PlayerSubtitleStyle` shape and the clamp/normalize rules
(delay limit, size bounds, color validation) live in
`@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). The renderer
applies them to user input and the Electron main process re-applies the exact
same implementation to untrusted IPC payloads — deliberate defense-in-depth
with a single source of truth, so widening a limit on one side cannot
silently re-clamp on the other.
Per-engine implementations:
- **HTML5 + ArtPlayer (shared-controls mode, neutral source bridge).** The
picker is a renderer-side DOM file input (`.srt`/`.vtt` only; works in the
PWA and Electron alike, and no filesystem path ever enters the app).
`WebVideoExternalSubtitles` parses the file
(`external-subtitle-cues.util.ts`) and renders it through a native
PWA and Electron alike, and no filesystem path ever enters the app). File
bytes are decoded encoding-aware (`decodeExternalSubtitleBytes`: UTF-16
BOMs, strict UTF-8, then a Windows-1251/1252 heuristic keyed on high-byte
density), because `Blob.text()`'s silent UTF-8 substitution turns common
legacy-encoded SRT files into mojibake. `WebVideoExternalSubtitles` parses
the file (`external-subtitle-cues.util.ts`) and renders it through a native
`TextTrack` on the video element, so it works under every source kind. The
native track enumeration excludes externally owned tracks, and
`WebVideoSourceTracks` merges them into the subtitle listing with IDs from
100000 up, routing selection so exactly one owner (engine or external) is
active. The delay capability is runtime-gated on a loaded file: only owned
cues can be re-timed exactly, while engine/stream cues arrive incrementally
and are left untouched. Style applies through a scoped `::cue` rule
(`WebVideoSubtitleStyle`), which covers embedded, hls.js-managed, and
external native cues. ASS rendering would need libass and is out of scope
for the web engines.
native track enumeration excludes externally owned tracks — ownership is
tracked for every track the session EVER created, because `addTextTrack`
tracks cannot leave the element and per-source ownership would let stale or
attach-failed tracks reappear as ghost engine tracks. `WebVideoSourceTracks`
merges external tracks into the subtitle listing with IDs from 100000 up,
routing selection so exactly one owner (engine or external) is active;
external selection deselects the engine BEFORE setting track modes, since
hls.js reacts to `subtitleTrack = -1` by disabling every subtitle-kind
`TextTrack` on the element. A pick captures the source generation and is
discarded if the stream changed while the dialog was open (mirroring the
Embedded MPV runner's session recheck). The delay capability is
runtime-gated on an external track being the SELECTED one — only owned cues
can be re-timed exactly, and with an engine track active the row would be
enabled yet visually inert. Negatively shifted cues keep their real
(possibly negative) times, which are valid and simply never active;
clamping them to t≈0 would stack every pre-roll cue at playback start.
Style applies through a scoped `::cue` rule (`WebVideoSubtitleStyle`),
which covers embedded, hls.js-managed, and external native cues. ASS
rendering would need libass and is out of scope for the web engines.
- **Embedded MPV frame-copy.** The helper protocol gained `sub-add`,
`sub-delay`, `sub-scale`, and `sub-color` commands. The picker is a
main-process open dialog (`.srt/.ass/.ssa/.vtt/.sub` — mpv renders ASS