mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
1 parent
404deb92b0
commit
10bd5acd6f
16 files changed
+481
-119
No files matched your search
@@ -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[] {
|
||||
|
||||
Reference in new issue
Block a user