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

+1
View File
@@ -5,6 +5,7 @@ export * from './lib/content-metadata.interface';
export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/subtitle-style.util';
export * from './lib/electron-api.interface';
export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
@@ -1,3 +1,5 @@
import type { PlayerSubtitleStyle } from './subtitle-style.util';
export type EmbeddedMpvSessionStatus =
| 'idle'
| 'loading'
@@ -29,14 +31,12 @@ export interface EmbeddedMpvCapabilities {
}
/**
* Engine-neutral subtitle presentation preferences forwarded to mpv.
* `sizePercent` maps to `sub-scale` (100 = 1.0); `color` maps to `sub-color`,
* null restores mpv's default.
* Subtitle presentation preferences forwarded to mpv: `sizePercent` maps to
* `sub-scale` (100 = 1.0); `color` maps to `sub-color`, null restores mpv's
* default. Alias of the canonical shared shape so the renderer controls and
* the IPC contract cannot drift structurally.
*/
export interface EmbeddedMpvSubtitleStyle {
sizePercent: number;
color: string | null;
}
export type EmbeddedMpvSubtitleStyle = PlayerSubtitleStyle;
export type EmbeddedMpvEngine = 'native' | 'frame-copy';
@@ -0,0 +1,71 @@
/**
* Canonical subtitle presentation contract shared by the renderer controls
* (`libs/ui/playback`) and the Electron main process. The clamp/normalize
* rules live HERE so the two sides cannot drift: the renderer applies them to
* user input, and the main process re-applies the exact same rules to
* untrusted IPC payloads (deliberate defense-in-depth, same implementation).
*/
/**
* Engine-neutral subtitle presentation preferences. `sizePercent` is relative
* to the engine's default rendering size (100 = default); `color` is a
* lowercase CSS hex color, or null for the engine default.
*/
export interface PlayerSubtitleStyle {
sizePercent: number;
color: string | null;
}
export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = {
sizePercent: 100,
color: null,
};
export const SUBTITLE_DELAY_LIMIT_SECONDS = 60;
export const SUBTITLE_SIZE_MIN_PERCENT = 25;
export const SUBTITLE_SIZE_MAX_PERCENT = 400;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
export function clampSubtitleDelay(seconds: number): number {
if (!Number.isFinite(seconds)) {
return 0;
}
const clamped = Math.max(
-SUBTITLE_DELAY_LIMIT_SECONDS,
Math.min(SUBTITLE_DELAY_LIMIT_SECONDS, seconds)
);
// Avoid float drift from repeated ±0.5 steps ("0.30000000000000004").
return Math.round(clamped * 1000) / 1000;
}
export function normalizeSubtitleStyle(value: unknown): PlayerSubtitleStyle {
if (typeof value !== 'object' || value === null) {
return { ...DEFAULT_SUBTITLE_STYLE };
}
const candidate = value as Partial<PlayerSubtitleStyle>;
const sizePercent =
typeof candidate.sizePercent === 'number' &&
Number.isFinite(candidate.sizePercent)
? Math.max(
SUBTITLE_SIZE_MIN_PERCENT,
Math.min(
SUBTITLE_SIZE_MAX_PERCENT,
Math.round(candidate.sizePercent)
)
)
: DEFAULT_SUBTITLE_STYLE.sizePercent;
const color =
typeof candidate.color === 'string' &&
HEX_COLOR_PATTERN.test(candidate.color)
? candidate.color.toLowerCase()
: null;
return { sizePercent, color };
}
export function isDefaultSubtitleStyle(style: PlayerSubtitleStyle): boolean {
return (
style.sizePercent === DEFAULT_SUBTITLE_STYLE.sizePercent &&
style.color === DEFAULT_SUBTITLE_STYLE.color
);
}