mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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>
253 lines
8.5 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|