Files
iptvnator/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts
T
4grayandClaude Fable 5 10bd5acd6f 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>
2026-08-23 00:01:14 +02:00

253 lines
8.5 KiB
TypeScript

import type Hls from 'hls.js';
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
import type { ExternalSubtitleFile } from './external-subtitle-cues.util';
import { WebVideoExternalSubtitles } from './web-video-external-subtitles';
import { WebVideoHlsControls } from './web-video-hls-controls';
import { WebVideoNativeTextTracks } from './web-video-native-text-tracks';
import { WebVideoShakaControls } from './web-video-shaka-controls';
export type WebVideoControlsSource =
| { kind: 'native' }
| { kind: 'mpegts' }
| { kind: 'hls'; hls: Hls }
| { kind: 'shaka'; session: ShakaVideoSession };
export interface WebVideoSourceTracksConfig {
video: HTMLVideoElement;
showCaptions: () => boolean;
/**
* Notifies the owning controls UI that track state changed. Legacy players
* have no shared controls to refresh and leave it unset — they use this
* class purely to apply the caption preference.
*/
refresh?: () => void;
/**
* Set by hosts that keep the engine's own caption UI (the preference-off
* players). The caption preference then seeds each new source instead of
* staying authoritative, so the vendor caption menu keeps working once
* playback is running. Shared controls own the caption UI and omit it.
*/
vendorCaptionControls?: boolean;
}
/**
* Owns the per-source audio/subtitle track controllers of a web video engine.
*
* It is deliberately free of any controls-UI dependency so both the shared
* controls bridge and the legacy (vendor-chrome) players apply the
* `showCaptions` preference through the exact same code path.
*/
export class WebVideoSourceTracks {
private readonly config: WebVideoSourceTracksConfig;
private readonly hlsControls: WebVideoHlsControls;
private readonly shakaControls: WebVideoShakaControls;
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;
constructor(config: WebVideoSourceTracksConfig) {
this.config = config;
const refresh = () => config.refresh?.();
const playbackStarted = config.vendorCaptionControls
? () => this.playbackStarted
: undefined;
this.hlsControls = new WebVideoHlsControls({
showCaptions: config.showCaptions,
refresh,
playbackStarted,
});
this.shakaControls = new WebVideoShakaControls({
showCaptions: config.showCaptions,
refresh,
playbackStarted,
});
this.externalSubtitles = new WebVideoExternalSubtitles({
getVideo: () => this.config.video,
deselectEngineSubtitles: () => this.applyEngineSubtitleTrack(-1),
refresh,
});
this.nativeTextTracks = new WebVideoNativeTextTracks({
video: config.video,
showCaptions: config.showCaptions,
refresh,
playbackStarted,
excludeTrack: (track) => this.externalSubtitles.ownsTrack(track),
});
if (config.vendorCaptionControls) {
const listener = () => {
this.playbackStarted = true;
};
this.playingListener = listener;
config.video.addEventListener('playing', listener);
}
}
get sourceKind(): WebVideoControlsSource['kind'] | null {
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.
this.playbackStarted = false;
if (source.kind === 'hls') {
this.hlsControls.bind(source.hls);
} else if (source.kind === 'shaka') {
this.shakaControls.bind(source.session);
} else {
this.nativeTextTracks.bind();
}
}
refreshInputs(): void {
if (this.destroyed) {
return;
}
if (this.source?.kind === 'hls') {
this.hlsControls.refreshInputs();
} else if (this.source?.kind === 'shaka') {
this.shakaControls.refreshInputs();
} else if (this.source) {
this.nativeTextTracks.refreshInputs();
}
}
clearSource(): void {
if (this.destroyed) {
return;
}
this.sourceGeneration += 1;
this.clearActiveSource();
this.source = null;
}
destroy(): void {
if (this.destroyed) {
return;
}
this.clearSource();
if (this.playingListener) {
this.config.video.removeEventListener(
'playing',
this.playingListener
);
this.playingListener = null;
}
this.destroyed = true;
}
getAudioTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.getAudioTracks();
}
if (this.source?.kind === 'shaka') {
return this.shakaControls.getAudioTracks();
}
return [];
}
setAudioTrack(id: number): void {
if (this.source?.kind === 'hls') {
this.hlsControls.setAudioTrack(id);
} else if (this.source?.kind === 'shaka') {
this.shakaControls.setAudioTrack(id);
}
}
getSubtitleTracks(): PlayerTrack[] {
// The native enumeration excludes external tracks, so appending them
// here is collision-free for every source kind.
return [
...this.getEngineSubtitleTracks(),
...this.externalSubtitles.getTracks(),
];
}
setSubtitleTrack(id: number): void {
if (this.externalSubtitles.ownsTrackId(id)) {
// select() also turns the engine-owned selection off.
this.externalSubtitles.select(id);
return;
}
this.externalSubtitles.deselectAll();
this.applyEngineSubtitleTrack(id);
}
/** Loads a user-picked subtitle file and selects it. */
addExternalSubtitleFile(file: ExternalSubtitleFile): boolean {
return this.externalSubtitles.addFromFile(file);
}
getExternalSubtitleDelay(): number {
return this.externalSubtitles.getDelay();
}
setExternalSubtitleDelay(seconds: number): void {
this.externalSubtitles.setDelay(seconds);
}
/**
* 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.hasSelectedTrack();
}
private getEngineSubtitleTracks(): PlayerTrack[] {
if (this.source?.kind === 'hls') {
return this.hlsControls.getSubtitleTracks();
}
if (this.source?.kind === 'shaka') {
return this.shakaControls.getSubtitleTracks();
}
return this.source ? this.nativeTextTracks.getSubtitleTracks() : [];
}
private applyEngineSubtitleTrack(id: number): void {
if (this.source?.kind === 'hls') {
this.hlsControls.setSubtitleTrack(id);
} else if (this.source?.kind === 'shaka') {
this.shakaControls.setSubtitleTrack(id);
} else if (this.source) {
this.nativeTextTracks.setSubtitleTrack(id);
}
}
private clearActiveSource(): void {
// External files correct one specific stream; drop them with it.
this.externalSubtitles.clear();
if (this.source?.kind === 'hls') {
this.hlsControls.clear();
} else if (this.source?.kind === 'shaka') {
this.shakaControls.clear();
} else if (this.source) {
this.nativeTextTracks.clear();
}
}
}