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

@@ -27,6 +27,8 @@ import {
EMBEDDED_MPV_FRAME_SOURCE_CHANGED,
EMBEDDED_MPV_SESSION_UPDATE,
ResolvedPortalPlayback,
clampSubtitleDelay,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import { toNativeViewBounds } from './embedded-mpv-bounds.util';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
@@ -598,10 +600,9 @@ export class EmbeddedMpvNativeService {
'Embedded MPV addon does not support subtitle delay. Rebuild the native addon to enable this feature.'
);
}
const clamped = Number.isFinite(seconds)
? Math.max(-60, Math.min(60, seconds))
: 0;
addon.setSubtitleDelay(sessionId, clamped);
// Same rules as the renderer, same implementation: the shared helper
// is the one place the limits are defined.
addon.setSubtitleDelay(sessionId, clampSubtitleDelay(seconds));
return this.refreshSession(sessionId);
}
@@ -616,17 +617,8 @@ export class EmbeddedMpvNativeService {
'Embedded MPV addon does not support subtitle styling. Rebuild the native addon to enable this feature.'
);
}
const sizePercent =
typeof style?.sizePercent === 'number' &&
Number.isFinite(style.sizePercent)
? Math.max(25, Math.min(400, Math.round(style.sizePercent)))
: 100;
const color =
typeof style?.color === 'string' &&
/^#[0-9a-f]{6}$/i.test(style.color)
? style.color
: null;
addon.setSubtitleStyle(sessionId, { sizePercent, color });
// Re-validate untrusted IPC input with the exact renderer rules.
addon.setSubtitleStyle(sessionId, normalizeSubtitleStyle(style));
return this.refreshSession(sessionId);
}