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
);
}
@@ -1,4 +1,5 @@
import type { Signal } from '@angular/core';
import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces';
export type PlayerStatus =
| 'idle'
@@ -28,15 +29,9 @@ export interface PlayerControlsCapabilities {
seriesNavigation: boolean;
}
/**
* Engine-neutral subtitle presentation preferences. `sizePercent` is relative
* to the engine's default rendering size (100 = default); `color` is a CSS hex
* color or null for the engine default.
*/
export interface PlayerSubtitleStyle {
sizePercent: number;
color: string | null;
}
// Canonical shape lives in @iptvnator/shared/interfaces so the Electron main
// process validates IPC payloads against the identical contract.
export type { PlayerSubtitleStyle };
export interface PlayerTrack {
id: number;
@@ -95,5 +95,10 @@ describe('subtitle-style', () => {
expect(subtitleDelayLabel(0.5)).toBe('+0.5 s');
expect(subtitleDelayLabel(-1.5)).toBe('−1.5 s');
});
it('never shows a signed negative zero for sub-tenth values', () => {
expect(subtitleDelayLabel(0.02)).toBe('0 s');
expect(subtitleDelayLabel(-0.04)).toBe('0 s');
});
});
});
@@ -1,19 +1,36 @@
import type { PlayerPreset, PlayerSubtitleStyle } from './player-controls.model';
import {
DEFAULT_SUBTITLE_STYLE,
type PlayerSubtitleStyle,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
import type { PlayerPreset } from './player-controls.model';
/**
* Shared subtitle presentation preferences. The style (size/color) persists
* across sessions through the same localStorage mechanism the players already
* use for the shared 'volume' key, so every engine adapter reads one source of
* truth. The delay and any loaded external subtitle file are deliberately
* per-session: they correct one specific stream, not a user preference.
* UI-side subtitle presentation preferences. The canonical shape and the
* clamp/normalize rules live in `@iptvnator/shared/interfaces`
* (`subtitle-style.util.ts`) so the Electron main process re-validates IPC
* input with the exact same implementation; this file adds only what the
* controls UI needs — presets, the delay step, persistence, and labels.
*
* The style (size/color) persists across sessions through the same
* localStorage mechanism the players already use for the shared 'volume' key,
* so every engine adapter reads one source of truth. The delay and any loaded
* external subtitle file are deliberately per-session: they correct one
* specific stream, not a user preference.
*/
export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle';
export {
DEFAULT_SUBTITLE_STYLE,
SUBTITLE_DELAY_LIMIT_SECONDS,
SUBTITLE_SIZE_MAX_PERCENT,
SUBTITLE_SIZE_MIN_PERCENT,
clampSubtitleDelay,
isDefaultSubtitleStyle,
normalizeSubtitleStyle,
} from '@iptvnator/shared/interfaces';
export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = {
sizePercent: 100,
color: null,
};
export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle';
export const SUBTITLE_SIZE_PRESETS: ReadonlyArray<PlayerPreset<number>> = [
{ value: 75, label: '75%' },
@@ -36,51 +53,6 @@ export const SUBTITLE_COLOR_PRESETS: ReadonlyArray<PlayerPreset<string | null>>
];
export const SUBTITLE_DELAY_STEP_SECONDS = 0.5;
export const SUBTITLE_DELAY_LIMIT_SECONDS = 60;
const MIN_SIZE_PERCENT = 25;
const MAX_SIZE_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(
MIN_SIZE_PERCENT,
Math.min(MAX_SIZE_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
);
}
export function readStoredSubtitleStyle(): PlayerSubtitleStyle {
try {
@@ -112,10 +84,15 @@ export function persistSubtitleStyle(style: PlayerSubtitleStyle): void {
/** "+0.5 s" / "−1.5 s" / "0 s" display label for the delay row. */
export function subtitleDelayLabel(seconds: number): string {
if (!Number.isFinite(seconds) || seconds === 0) {
if (!Number.isFinite(seconds)) {
return '0 s';
}
const rounded = Math.round(seconds * 10) / 10;
// Derive the sign AFTER rounding: 0.02 rounds to 0 and must render as
// "0 s", not "−0.0 s".
if (rounded === 0) {
return '0 s';
}
const magnitude = Math.abs(rounded).toFixed(1);
return `${rounded > 0 ? '+' : '−'}${magnitude} s`;
}
@@ -1,4 +1,5 @@
import {
decodeExternalSubtitleBytes,
detectExternalSubtitleFormat,
parseExternalSubtitleCues,
} from './external-subtitle-cues.util';
@@ -18,6 +19,54 @@ describe('external-subtitle-cues.util', () => {
});
});
describe('decodeExternalSubtitleBytes', () => {
const toBuffer = (bytes: number[]): ArrayBuffer =>
Uint8Array.from(bytes).buffer;
it('decodes valid UTF-8 as-is', () => {
const utf8 = new TextEncoder().encode('Привет\nmonde');
expect(decodeExternalSubtitleBytes(utf8.buffer)).toBe(
'Привет\nmonde'
);
});
it('decodes Cyrillic Windows-1251 bytes (high-byte-heavy text)', () => {
// "Привет мир" in CP1251: every letter is a high byte.
const cp1251 = [
0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0x20, 0xec, 0xe8, 0xf0,
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1251))).toBe(
'Привет мир'
);
});
it('decodes mostly-ASCII Windows-1252 bytes (sparse accents)', () => {
// "resume: cafe" with two accented letters among ASCII.
const cp1252 = [
...Array.from('r').map((c) => c.charCodeAt(0)),
0xe9, // é
...Array.from('sum').map((c) => c.charCodeAt(0)),
0xe9, // é
...Array.from(': cafe and plain ascii words').map((c) =>
c.charCodeAt(0)
),
];
expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toBe(
'résumé: cafe and plain ascii words'
);
});
it('honors a UTF-16LE byte-order mark', () => {
const text = '1\n00:00:01,000';
const bytes: number[] = [0xff, 0xfe];
for (const char of text) {
const code = char.charCodeAt(0);
bytes.push(code & 0xff, code >> 8);
}
expect(decodeExternalSubtitleBytes(toBuffer(bytes))).toBe(text);
});
});
describe('parseExternalSubtitleCues', () => {
it('parses a standard SRT file', () => {
const content = [
@@ -39,6 +39,49 @@ export function detectExternalSubtitleFormat(
return null;
}
/**
* Decodes a picked subtitle file's bytes. `Blob.text()` is strictly UTF-8 with
* silent U+FFFD substitution, which turns the still-common legacy-encoded SRT
* files (Windows-1251 Cyrillic, Windows-1252 Western European) and Windows'
* UTF-16 saves into mojibake that parses "successfully". Best-effort order:
* UTF-16 BOMs, then strict UTF-8, then a single-byte fallback chosen by how
* much of the text is non-ASCII — a non-Latin script (Cyrillic) makes high
* bytes dominate, while Latin text with accents stays mostly ASCII.
*/
export function decodeExternalSubtitleBytes(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
if (bytes.length >= 2) {
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
return new TextDecoder('utf-16le').decode(buffer);
}
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
return new TextDecoder('utf-16be').decode(buffer);
}
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch {
// Not valid UTF-8: a legacy single-byte encoding.
}
let highBytes = 0;
for (const byte of bytes) {
if (byte >= 0x80) {
highBytes += 1;
}
}
const encoding =
highBytes / Math.max(1, bytes.length) > 0.15
? 'windows-1251'
: 'windows-1252';
try {
return new TextDecoder(encoding).decode(buffer);
} catch {
// Runtime without legacy decoders: non-fatal UTF-8 is the last resort.
return new TextDecoder().decode(buffer);
}
}
// SRT uses "00:00:01,500"; VTT uses "00:00:01.500" and allows a missing hour
// part ("01:23.456"). One pattern covers both.
const TIMESTAMP_PATTERN =
@@ -121,9 +121,11 @@ describe('WebVideoExternalSubtitles', () => {
[12.5, 14.5],
]);
// Negative delay clamps a cue start at zero without inverting it.
// Negative delay keeps real cue times: negative values are legal and
// simply never active. Clamping to [0, ~0] would stack every pre-roll
// cue simultaneously at t=0.
session.setDelay(-2);
expect(track.added[0].startTime).toBe(0);
expect(track.added[0].startTime).toBe(-1);
expect(track.added[0].endTime).toBe(1);
session.setDelay(0);
@@ -162,6 +164,60 @@ describe('WebVideoExternalSubtitles', () => {
expect(track.added).toHaveLength(0);
expect(session.hasTracks()).toBe(false);
expect(session.getDelay()).toBe(0);
// addTextTrack tracks cannot leave the element: ownership must
// survive clear() so the native enumeration keeps excluding them
// instead of listing ghost tracks on the next source.
expect(session.ownsTrack(track as unknown as TextTrack)).toBe(true);
});
it('keeps the external track showing when engine deselect disables all tracks (hls.js)', () => {
// hls.js reacts to `subtitleTrack = -1` by disabling every
// subtitle-kind TextTrack on the element. select() must deselect the
// engine BEFORE setting its own modes so its writes win.
deselectEngineSubtitles.mockImplementation(() => {
for (const result of video.addTextTrack.mock.results) {
(result.value as FakeTextTrack).mode = 'disabled';
}
});
expect(session.addFromFile(SRT_FILE)).toBe(true);
expect(lastTrack().mode).toBe('showing');
});
it('reports selection separately from loaded files', () => {
session.addFromFile(SRT_FILE);
expect(session.hasTracks()).toBe(true);
expect(session.hasSelectedTrack()).toBe(true);
session.deselectAll();
expect(session.hasTracks()).toBe(true);
expect(session.hasSelectedTrack()).toBe(false);
});
it('silences and retains ownership of a track whose attach fails mid-file', () => {
const failingTrack = new FakeTextTrack('broken.srt');
let added = 0;
failingTrack.addCue = (cue: FakeVTTCue) => {
added += 1;
if (added > 1) {
throw new Error('addCue rejected');
}
failingTrack.added.push(cue);
};
video.addTextTrack.mockReturnValueOnce(failingTrack);
expect(
session.addFromFile({ ...SRT_FILE, name: 'broken.srt' })
).toBe(false);
expect(session.hasTracks()).toBe(false);
// The half-populated track is silenced, emptied, and stays owned so
// the native enumeration cannot surface it as a phantom engine track.
expect(failingTrack.mode).toBe('disabled');
expect(failingTrack.added).toHaveLength(0);
expect(
session.ownsTrack(failingTrack as unknown as TextTrack)
).toBe(true);
});
it('fails closed when the runtime lacks addTextTrack or VTTCue', () => {
@@ -4,6 +4,7 @@ import {
type ExternalSubtitleFile,
type ParsedSubtitleCue,
WEB_SUBTITLE_FILE_EXTENSIONS,
decodeExternalSubtitleBytes,
detectExternalSubtitleFormat,
parseExternalSubtitleCues,
} from './external-subtitle-cues.util';
@@ -43,6 +44,13 @@ export class WebVideoExternalSubtitles {
private entries: ExternalSubtitleEntry[] = [];
private delaySeconds = 0;
private nextId = EXTERNAL_SUBTITLE_TRACK_ID_BASE;
/**
* Every TextTrack this session ever created. `addTextTrack` tracks cannot
* be removed from the element, so ownership must outlive `clear()` — a
* dropped-per-source set would let stale (or attach-failed) tracks
* reappear in the native enumeration as ghost engine tracks.
*/
private readonly createdTracks = new Set<TextTrack>();
constructor(private readonly config: WebVideoExternalSubtitlesConfig) {}
@@ -50,8 +58,13 @@ export class WebVideoExternalSubtitles {
return this.entries.length > 0;
}
/** True while an external track is the one actually rendering. */
hasSelectedTrack(): boolean {
return this.entries.some((entry) => entry.track?.mode === 'showing');
}
ownsTrack(track: TextTrack): boolean {
return this.entries.some((entry) => entry.track === track);
return this.createdTracks.has(track);
}
ownsTrackId(id: number): boolean {
@@ -94,12 +107,15 @@ export class WebVideoExternalSubtitles {
if (!this.ownsTrackId(id)) {
return;
}
// Deselect the engine FIRST: hls.js reacts to `subtitleTrack = -1` by
// disabling every subtitle-kind TextTrack on the element, which would
// immediately undo a mode we had already set.
this.config.deselectEngineSubtitles();
for (const entry of this.entries) {
if (entry.track) {
entry.track.mode = entry.id === id ? 'showing' : 'hidden';
}
}
this.config.deselectEngineSubtitles();
this.config.refresh();
}
@@ -151,8 +167,11 @@ export class WebVideoExternalSubtitles {
try {
const track = video.addTextTrack('subtitles', entry.label);
this.createdTracks.add(track);
entry.track = track;
entry.trackCues = entry.cues.map((cue) => {
// Push incrementally so the catch below can remove exactly the
// cues that made it onto the track before a mid-loop failure.
for (const cue of entry.cues) {
const shifted = this.shiftCueTimes(cue);
const vttCue = new CueCtor(
shifted.startSeconds,
@@ -160,10 +179,14 @@ export class WebVideoExternalSubtitles {
cue.text
);
track.addCue(vttCue);
return vttCue;
});
entry.trackCues.push(vttCue);
}
return true;
} catch {
// A mid-loop failure leaves an unremovable track on the element:
// silence it so the half-populated cue set can never render. It
// stays in `createdTracks`, so the enumeration keeps excluding it.
this.detachEntry(entry);
return false;
}
}
@@ -197,12 +220,13 @@ export class WebVideoExternalSubtitles {
startSeconds: number;
endSeconds: number;
} {
const startSeconds = Math.max(0, cue.startSeconds + this.delaySeconds);
const endSeconds = Math.max(
startSeconds + 0.001,
cue.endSeconds + this.delaySeconds
);
return { startSeconds, endSeconds };
// No clamping: negative cue times are valid VTTCue values that are
// simply never active. Clamping early cues to [0, ~0] would stack
// every pre-roll cue simultaneously at t=0 under a negative delay.
return {
startSeconds: cue.startSeconds + this.delaySeconds,
endSeconds: cue.endSeconds + this.delaySeconds,
};
}
}
@@ -227,8 +251,15 @@ export function pickExternalSubtitleFile(
if (!format) {
return;
}
void file.text().then(
(content) => onPicked({ name: file.name, format, content }),
// Raw bytes, not file.text(): legacy encodings (CP1251/1252, UTF-16)
// are still common for downloaded subtitles and need detection.
void file.arrayBuffer().then(
(buffer) =>
onPicked({
name: file.name,
format,
content: decodeExternalSubtitleBytes(buffer),
}),
() => undefined
);
});
@@ -99,8 +99,15 @@ export class WebVideoSourceControlsBridge {
}
private pickExternalSubtitle(): void {
// The picker is modal-slow: the stream can change (Up Next, zapping,
// failover) before the user confirms. A pick made for one source must
// never attach to its successor.
const generation = this.tracks.getSourceGeneration();
pickExternalSubtitleFile(this.config.video.ownerDocument, (file) => {
if (this.destroyed) {
if (
this.destroyed ||
this.tracks.getSourceGeneration() !== generation
) {
return;
}
this.tracks.addExternalSubtitleFile(file);
@@ -0,0 +1,98 @@
import { EXTERNAL_SUBTITLE_TRACK_ID_BASE } from './web-video-external-subtitles';
import { WebVideoSourceTracks } from './web-video-source-tracks';
class FakeVTTCue {
constructor(
public startTime: number,
public endTime: number,
public text: string
) {}
}
class FakeTextTrack {
kind = 'subtitles';
mode: TextTrackMode = 'hidden';
readonly added: FakeVTTCue[] = [];
constructor(public label: string) {}
addCue(cue: FakeVTTCue): void {
this.added.push(cue);
}
removeCue(cue: FakeVTTCue): void {
const index = this.added.indexOf(cue);
if (index >= 0) {
this.added.splice(index, 1);
}
}
}
const SRT_FILE = {
name: 'movie.srt',
format: 'srt' as const,
content: ['1', '00:00:01,000 --> 00:00:03,000', 'Hello', ''].join('\n'),
};
function createFakeVideo() {
return {
textTracks: {
length: 0,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
addTextTrack: jest.fn(
(_kind: string, label: string) => new FakeTextTrack(label)
),
} as unknown as HTMLVideoElement;
}
describe('WebVideoSourceTracks external subtitle integration', () => {
let tracks: WebVideoSourceTracks;
beforeEach(() => {
(globalThis as { VTTCue?: unknown }).VTTCue = FakeVTTCue;
tracks = new WebVideoSourceTracks({
video: createFakeVideo(),
showCaptions: () => false,
});
tracks.setSource({ kind: 'native' });
});
afterEach(() => {
tracks.destroy();
delete (globalThis as { VTTCue?: unknown }).VTTCue;
});
it('advances the source generation on every source change', () => {
const initial = tracks.getSourceGeneration();
tracks.setSource({ kind: 'mpegts' });
expect(tracks.getSourceGeneration()).toBe(initial + 1);
tracks.clearSource();
expect(tracks.getSourceGeneration()).toBe(initial + 2);
});
it('offers delay adjustment only while an external track is selected', () => {
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
expect(tracks.addExternalSubtitleFile(SRT_FILE)).toBe(true);
expect(tracks.canAdjustSubtitleDelay()).toBe(true);
// Turning subtitles off deselects the external track; the delay UI
// must retire with it — otherwise it is enabled yet visually inert.
tracks.setSubtitleTrack(-1);
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
tracks.setSubtitleTrack(EXTERNAL_SUBTITLE_TRACK_ID_BASE);
expect(tracks.canAdjustSubtitleDelay()).toBe(true);
});
it('drops loaded external files with the source they corrected', () => {
tracks.addExternalSubtitleFile(SRT_FILE);
expect(tracks.getSubtitleTracks()).toHaveLength(1);
tracks.setSource({ kind: 'native' });
expect(tracks.getSubtitleTracks()).toHaveLength(0);
expect(tracks.canAdjustSubtitleDelay()).toBe(false);
});
});
@@ -45,6 +45,7 @@ export class WebVideoSourceTracks {
private readonly nativeTextTracks: WebVideoNativeTextTracks;
private readonly externalSubtitles: WebVideoExternalSubtitles;
private source: WebVideoControlsSource | null = null;
private sourceGeneration = 0;
private playbackStarted = false;
private playingListener: (() => void) | null = null;
private destroyed = false;
@@ -90,11 +91,21 @@ export class WebVideoSourceTracks {
return this.source?.kind ?? null;
}
/**
* Changes whenever the bound source changes. Asynchronous work started
* against one source (the external subtitle file picker) captures this and
* bails when it no longer matches, so a pick cannot land on a later stream.
*/
getSourceGeneration(): number {
return this.sourceGeneration;
}
setSource(source: WebVideoControlsSource): void {
if (this.destroyed) {
return;
}
this.sourceGeneration += 1;
this.clearActiveSource();
this.source = source;
// A new source starts unsettled so its own defaults are seeded again.
@@ -127,6 +138,7 @@ export class WebVideoSourceTracks {
return;
}
this.sourceGeneration += 1;
this.clearActiveSource();
this.source = null;
}
@@ -197,9 +209,13 @@ export class WebVideoSourceTracks {
this.externalSubtitles.setDelay(seconds);
}
/** Delay applies to owned external cues only, so it needs a loaded file. */
/**
* Delay re-times owned external cues only, so the UI is offered exactly
* while an external track is the one rendering — with an engine track
* selected the row would be enabled yet visually inert.
*/
canAdjustSubtitleDelay(): boolean {
return this.externalSubtitles.hasTracks();
return this.externalSubtitles.hasSelectedTrack();
}
private getEngineSubtitleTracks(): PlayerTrack[] {