mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(html-player): add feature-flagged shared controls (#1194)
* feat(html-player): bridge engine state to shared controls * fix(html-player): avoid HLS subtitle event reentry * fix(html-player): restore delayed HLS default subtitles * refactor(html-player): split controls bridge collaborators * feat(html-player): add feature-flagged shared controls * test(html-player): cover shared-controls source ownership * feat(html-player): pass shared-controls playback metadata * docs(player-controls): describe HTML5 shared-controls bridge * docs(player-controls): clarify HTML5 rollout effect * fix(html-player): reveal diagnostics from fullscreen * fix(html-player): defer HLS event resolution * refactor(html-player): isolate video element session
This commit is contained in:
1 parent
f611ea3d7c
commit
c49ea2f6a8
29 files changed
+4185
-236
No files matched your search
@@ -0,0 +1,142 @@
|
||||
import { HtmlVideoElementSession } from './html-video-element-session';
|
||||
|
||||
describe('HtmlVideoElementSession', () => {
|
||||
it('owns native video events and detaches them idempotently', () => {
|
||||
const video = document.createElement('video');
|
||||
const playbackIssues: unknown[] = [];
|
||||
const timeUpdates: Array<{ currentTime: number; duration: number }> =
|
||||
[];
|
||||
const playbackEnded = jest.fn();
|
||||
const removeEventListener = jest.spyOn(video, 'removeEventListener');
|
||||
const session = new HtmlVideoElementSession({
|
||||
video,
|
||||
getChannelUrl: () => 'https://example.test/video.ts',
|
||||
getStartTime: () => 12,
|
||||
showCaptions: () => true,
|
||||
sharedControls: () => false,
|
||||
emitPlaybackIssue: (issue) => playbackIssues.push(issue),
|
||||
emitTimeUpdate: (value) => timeUpdates.push(value),
|
||||
emitPlaybackEnded: playbackEnded,
|
||||
});
|
||||
Object.defineProperty(video, 'duration', {
|
||||
configurable: true,
|
||||
value: 90,
|
||||
});
|
||||
Object.defineProperty(video, 'error', {
|
||||
configurable: true,
|
||||
value: {
|
||||
code: 4,
|
||||
message: 'No compatible source was found',
|
||||
},
|
||||
});
|
||||
|
||||
session.attach();
|
||||
session.attach();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
expect(video.currentTime).toBe(12);
|
||||
video.currentTime = 18;
|
||||
video.dispatchEvent(new Event('timeupdate'));
|
||||
video.dispatchEvent(new Event('error'));
|
||||
video.dispatchEvent(new Event('loadeddata'));
|
||||
video.dispatchEvent(new Event('playing'));
|
||||
video.dispatchEvent(new Event('ended'));
|
||||
|
||||
expect(video.currentTime).toBe(18);
|
||||
expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]);
|
||||
expect(playbackIssues[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'https://example.test/video.ts',
|
||||
})
|
||||
);
|
||||
expect(playbackIssues.slice(1)).toEqual([null, null]);
|
||||
expect(playbackEnded).toHaveBeenCalledTimes(1);
|
||||
|
||||
session.destroy();
|
||||
session.destroy();
|
||||
video.dispatchEvent(new Event('ended'));
|
||||
|
||||
expect(playbackEnded).toHaveBeenCalledTimes(1);
|
||||
expect(removeEventListener).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('persists volume changes from the native video element', () => {
|
||||
const video = document.createElement('video');
|
||||
video.volume = 0.35;
|
||||
const session = createSession(video);
|
||||
localStorage.removeItem('volume');
|
||||
|
||||
session.attach();
|
||||
video.dispatchEvent(new Event('volumechange'));
|
||||
|
||||
expect(localStorage.getItem('volume')).toBe('0.35');
|
||||
session.destroy();
|
||||
});
|
||||
|
||||
it('suppresses legacy captions only after successful non-shared playback', async () => {
|
||||
const video = document.createElement('video');
|
||||
const tracks = [
|
||||
{ mode: 'showing' as TextTrackMode },
|
||||
{ mode: 'showing' as TextTrackMode },
|
||||
];
|
||||
Object.defineProperty(video, 'textTracks', {
|
||||
configurable: true,
|
||||
value: tracks,
|
||||
});
|
||||
jest.spyOn(video, 'play').mockResolvedValue(undefined);
|
||||
const session = createSession(video, {
|
||||
showCaptions: () => false,
|
||||
sharedControls: () => false,
|
||||
});
|
||||
|
||||
session.play();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(tracks.map((track) => track.mode)).toEqual(['hidden', 'hidden']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ showCaptions: true, sharedControls: false },
|
||||
{ showCaptions: false, sharedControls: true },
|
||||
])(
|
||||
'keeps captions when showCaptions=$showCaptions and sharedControls=$sharedControls',
|
||||
async ({ showCaptions, sharedControls }) => {
|
||||
const video = document.createElement('video');
|
||||
const tracks = [{ mode: 'showing' as TextTrackMode }];
|
||||
Object.defineProperty(video, 'textTracks', {
|
||||
configurable: true,
|
||||
value: tracks,
|
||||
});
|
||||
jest.spyOn(video, 'play').mockResolvedValue(undefined);
|
||||
const session = createSession(video, {
|
||||
showCaptions: () => showCaptions,
|
||||
sharedControls: () => sharedControls,
|
||||
});
|
||||
|
||||
session.play();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(tracks[0].mode).toBe('showing');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createSession(
|
||||
video: HTMLVideoElement,
|
||||
overrides: Partial<
|
||||
ConstructorParameters<typeof HtmlVideoElementSession>[0]
|
||||
> = {}
|
||||
): HtmlVideoElementSession {
|
||||
return new HtmlVideoElementSession({
|
||||
video,
|
||||
getChannelUrl: () => undefined,
|
||||
getStartTime: () => 0,
|
||||
showCaptions: () => true,
|
||||
sharedControls: () => false,
|
||||
emitPlaybackIssue: jest.fn(),
|
||||
emitTimeUpdate: jest.fn(),
|
||||
emitPlaybackEnded: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
type PlaybackDiagnostic,
|
||||
classifyNativePlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
export interface HtmlVideoElementSessionConfig {
|
||||
video: HTMLVideoElement;
|
||||
getChannelUrl: () => string | undefined;
|
||||
getStartTime: () => number;
|
||||
showCaptions: () => boolean;
|
||||
sharedControls: () => boolean;
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
|
||||
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
|
||||
emitPlaybackEnded: () => void;
|
||||
}
|
||||
|
||||
export class HtmlVideoElementSession {
|
||||
private attached = false;
|
||||
|
||||
private readonly handleNativePlaybackError = (): void => {
|
||||
const video = this.config.video;
|
||||
const url = this.config.getChannelUrl() ?? video.currentSrc;
|
||||
this.config.emitPlaybackIssue(
|
||||
classifyNativePlaybackIssue(
|
||||
video.error,
|
||||
createPlaybackSourceMetadata({
|
||||
url,
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = (): void => {
|
||||
this.config.emitPlaybackIssue(null);
|
||||
};
|
||||
|
||||
private readonly handleVolumeChange = (): void => {
|
||||
const currentVolume = this.config.video.volume;
|
||||
debugHtmlPlayer('Volume changed to:', currentVolume);
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
};
|
||||
|
||||
private readonly handleLoadedMetadata = (): void => {
|
||||
const startTime = this.config.getStartTime();
|
||||
if (startTime > 0) {
|
||||
this.config.video.currentTime = startTime;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleTimeUpdate = (): void => {
|
||||
const video = this.config.video;
|
||||
this.config.emitTimeUpdate({
|
||||
currentTime: video.currentTime,
|
||||
duration: video.duration,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly handlePlaybackEnded = (): void => {
|
||||
this.config.emitPlaybackEnded();
|
||||
};
|
||||
|
||||
private readonly listeners: ReadonlyArray<
|
||||
readonly [event: string, listener: EventListener]
|
||||
> = [
|
||||
['volumechange', this.handleVolumeChange],
|
||||
['loadedmetadata', this.handleLoadedMetadata],
|
||||
['timeupdate', this.handleTimeUpdate],
|
||||
['error', this.handleNativePlaybackError],
|
||||
['loadeddata', this.clearPlaybackIssue],
|
||||
['playing', this.clearPlaybackIssue],
|
||||
['ended', this.handlePlaybackEnded],
|
||||
];
|
||||
|
||||
constructor(private readonly config: HtmlVideoElementSessionConfig) {}
|
||||
|
||||
attach(): void {
|
||||
if (this.attached) {
|
||||
return;
|
||||
}
|
||||
this.attached = true;
|
||||
for (const [event, listener] of this.listeners) {
|
||||
this.config.video.addEventListener(event, listener);
|
||||
}
|
||||
}
|
||||
|
||||
play(): void {
|
||||
const playPromise = this.config.video.play();
|
||||
if (playPromise === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
void playPromise
|
||||
.then(() => {
|
||||
if (
|
||||
!this.config.sharedControls() &&
|
||||
!this.config.showCaptions()
|
||||
) {
|
||||
this.disableCaptions();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Autoplay failures are surfaced by the surrounding player UI.
|
||||
});
|
||||
}
|
||||
|
||||
disableCaptions(): void {
|
||||
const tracks = this.config.video.textTracks;
|
||||
for (let index = 0; index < tracks.length; index += 1) {
|
||||
tracks[index].mode = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (!this.attached) {
|
||||
return;
|
||||
}
|
||||
this.attached = false;
|
||||
for (const [event, listener] of this.listeners) {
|
||||
this.config.video.removeEventListener(event, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
|
||||
import {
|
||||
createVideo,
|
||||
readMpegtsState,
|
||||
} from './html-video-player-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge MPEG-TS duration', () => {
|
||||
it('prefers a finite positive video duration', () => {
|
||||
const state = readMpegtsState({
|
||||
duration: 120,
|
||||
seekableEnds: [115],
|
||||
bufferedEnds: [112],
|
||||
});
|
||||
|
||||
expect(state.durationSeconds).toBe(120);
|
||||
expect(state.isLive).toBe(false);
|
||||
expect(state.canSeek).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the last finite positive seekable end', () => {
|
||||
const state = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [90, NaN, 115],
|
||||
bufferedEnds: [112],
|
||||
});
|
||||
|
||||
expect(state.durationSeconds).toBe(115);
|
||||
expect(state.isLive).toBe(false);
|
||||
expect(state.canSeek).toBe(true);
|
||||
});
|
||||
|
||||
it('scans seekable ranges backward past invalid ends', () => {
|
||||
const state = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [105, NaN, -1],
|
||||
bufferedEnds: [112],
|
||||
});
|
||||
|
||||
expect(state.durationSeconds).toBe(105);
|
||||
});
|
||||
|
||||
it('falls back to the last finite positive buffered end', () => {
|
||||
const state = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [NaN],
|
||||
bufferedEnds: [80, NaN, 112],
|
||||
});
|
||||
|
||||
expect(state.durationSeconds).toBe(112);
|
||||
expect(state.isLive).toBe(false);
|
||||
expect(state.canSeek).toBe(true);
|
||||
});
|
||||
|
||||
it('returns no corrected duration when ranges throw or are invalid', () => {
|
||||
const throwingState = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [115],
|
||||
bufferedEnds: [112],
|
||||
seekableThrows: true,
|
||||
bufferedThrows: true,
|
||||
});
|
||||
const invalidState = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [NaN, -1, 0],
|
||||
bufferedEnds: [Infinity, -1, 0],
|
||||
});
|
||||
|
||||
expect(throwingState.durationSeconds).toBeNull();
|
||||
expect(invalidState.durationSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps non-live MPEG-TS non-seekable until a seekable range exists', () => {
|
||||
const state = readMpegtsState({
|
||||
duration: Infinity,
|
||||
bufferedEnds: [112],
|
||||
});
|
||||
|
||||
expect(state.durationSeconds).toBe(112);
|
||||
expect(state.isLive).toBe(false);
|
||||
expect(state.canSeek).toBe(false);
|
||||
});
|
||||
|
||||
it('uses only the authoritative live input for live classification', () => {
|
||||
const liveState = readMpegtsState(
|
||||
{
|
||||
duration: 120,
|
||||
seekableEnds: [115],
|
||||
},
|
||||
true
|
||||
);
|
||||
const vodState = readMpegtsState({
|
||||
duration: Infinity,
|
||||
seekableEnds: [115],
|
||||
});
|
||||
|
||||
expect(liveState.isLive).toBe(true);
|
||||
expect(liveState.durationSeconds).toBeNull();
|
||||
expect(vodState.isLive).toBe(false);
|
||||
expect(vodState.durationSeconds).toBe(115);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge lifecycle', () => {
|
||||
it('attaches the adapter exactly once with engine accessors', () => {
|
||||
const video = createVideo({ duration: 90 });
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const attachSpy = jest.spyOn(adapter, 'attach');
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video,
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
|
||||
bridge.attach();
|
||||
bridge.attach();
|
||||
|
||||
expect(attachSpy).toHaveBeenCalledTimes(1);
|
||||
expect(attachSpy).toHaveBeenCalledWith(
|
||||
video,
|
||||
expect.objectContaining({
|
||||
isLive: expect.any(Function),
|
||||
getDuration: expect.any(Function),
|
||||
getAudioTracks: expect.any(Function),
|
||||
setAudioTrack: expect.any(Function),
|
||||
getSubtitleTracks: expect.any(Function),
|
||||
setSubtitleTrack: expect.any(Function),
|
||||
})
|
||||
);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('lets non-MPEG-TS sources fall back to the video duration', () => {
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo({ duration: 87, seekableEnds: [80] }),
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind: 'native' });
|
||||
|
||||
expect(adapter.state().durationSeconds).toBe(87);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('clears corrected source state and refreshes authoritative inputs', () => {
|
||||
let isLive = false;
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo({
|
||||
duration: Infinity,
|
||||
seekableEnds: [115],
|
||||
}),
|
||||
adapter,
|
||||
isLive: () => isLive,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind: 'mpegts' });
|
||||
expect(adapter.state().durationSeconds).toBe(115);
|
||||
expect(adapter.state().isLive).toBe(false);
|
||||
|
||||
isLive = true;
|
||||
bridge.refreshInputs();
|
||||
expect(adapter.state().isLive).toBe(true);
|
||||
expect(adapter.state().durationSeconds).toBeNull();
|
||||
|
||||
isLive = false;
|
||||
bridge.clearSource();
|
||||
expect(adapter.state().isLive).toBe(false);
|
||||
expect(adapter.state().durationSeconds).toBeNull();
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('detaches the adapter exactly once when destroyed twice', () => {
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const detachSpy = jest.spyOn(adapter, 'detach');
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo(),
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
bridge.attach();
|
||||
detachSpy.mockClear();
|
||||
|
||||
bridge.destroy();
|
||||
bridge.destroy();
|
||||
|
||||
expect(detachSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type Hls from 'hls.js';
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { HtmlVideoPlayerHlsControls } from './html-video-player-hls-controls';
|
||||
import { HtmlVideoPlayerNativeTextTracks } from './html-video-player-native-text-tracks';
|
||||
|
||||
export type HtmlVideoControlsSource =
|
||||
| { kind: 'native' }
|
||||
| { kind: 'mpegts' }
|
||||
| { kind: 'hls'; hls: Hls };
|
||||
|
||||
export interface HtmlVideoPlayerControlsBridgeConfig {
|
||||
video: HTMLVideoElement;
|
||||
adapter: WebVideoControlsAdapter;
|
||||
isLive: () => boolean;
|
||||
showCaptions: () => boolean;
|
||||
}
|
||||
|
||||
export class HtmlVideoPlayerControlsBridge {
|
||||
private readonly config: HtmlVideoPlayerControlsBridgeConfig;
|
||||
private readonly hlsControls: HtmlVideoPlayerHlsControls;
|
||||
private readonly nativeTextTracks: HtmlVideoPlayerNativeTextTracks;
|
||||
private source: HtmlVideoControlsSource | null = null;
|
||||
private attached = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(config: HtmlVideoPlayerControlsBridgeConfig) {
|
||||
this.config = config;
|
||||
const refresh = () => this.config.adapter.refresh();
|
||||
this.hlsControls = new HtmlVideoPlayerHlsControls({
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
this.nativeTextTracks = new HtmlVideoPlayerNativeTextTracks({
|
||||
video: config.video,
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
if (this.attached || this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.adapter.attach(this.config.video, {
|
||||
isLive: this.config.isLive,
|
||||
getDuration: () => this.readDuration(),
|
||||
getAudioTracks: () => this.getAudioTracks(),
|
||||
setAudioTrack: (id) => this.setAudioTrack(id),
|
||||
getSubtitleTracks: () => this.getSubtitleTracks(),
|
||||
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
|
||||
});
|
||||
this.attached = true;
|
||||
}
|
||||
|
||||
setSource(source: HtmlVideoControlsSource): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearActiveSource();
|
||||
this.source = source;
|
||||
if (source.kind === 'hls') {
|
||||
this.hlsControls.bind(source.hls);
|
||||
} else {
|
||||
this.nativeTextTracks.bind();
|
||||
}
|
||||
this.config.adapter.refresh();
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.refreshInputs();
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.refreshInputs();
|
||||
}
|
||||
this.config.adapter.refresh();
|
||||
}
|
||||
|
||||
clearSource(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearActiveSource();
|
||||
this.source = null;
|
||||
this.config.adapter.refresh();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearSource();
|
||||
if (this.attached) {
|
||||
this.config.adapter.detach();
|
||||
this.attached = false;
|
||||
}
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
private getAudioTracks(): PlayerTrack[] {
|
||||
return this.source?.kind === 'hls'
|
||||
? this.hlsControls.getAudioTracks()
|
||||
: [];
|
||||
}
|
||||
|
||||
private setAudioTrack(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setAudioTrack(id);
|
||||
}
|
||||
}
|
||||
|
||||
private getSubtitleTracks(): PlayerTrack[] {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getSubtitleTracks();
|
||||
}
|
||||
return this.source ? this.nativeTextTracks.getSubtitleTracks() : [];
|
||||
}
|
||||
|
||||
private setSubtitleTrack(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setSubtitleTrack(id);
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.setSubtitleTrack(id);
|
||||
}
|
||||
}
|
||||
|
||||
private clearActiveSource(): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.clear();
|
||||
} else if (this.source) {
|
||||
this.nativeTextTracks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private readDuration(): number {
|
||||
if (this.source?.kind !== 'mpegts' || this.config.isLive()) {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
const videoDuration = this.readFinitePositive(() => {
|
||||
return this.config.video.duration;
|
||||
});
|
||||
if (!Number.isNaN(videoDuration)) {
|
||||
return videoDuration;
|
||||
}
|
||||
|
||||
const seekableEnd = this.readLastFinitePositiveEnd(() => {
|
||||
return this.config.video.seekable;
|
||||
});
|
||||
if (!Number.isNaN(seekableEnd)) {
|
||||
return seekableEnd;
|
||||
}
|
||||
|
||||
return this.readLastFinitePositiveEnd(() => {
|
||||
return this.config.video.buffered;
|
||||
});
|
||||
}
|
||||
|
||||
private readFinitePositive(read: () => number): number {
|
||||
try {
|
||||
const value = read();
|
||||
return Number.isFinite(value) && value > 0 ? value : NaN;
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
private readLastFinitePositiveEnd(read: () => TimeRanges): number {
|
||||
try {
|
||||
const ranges = read();
|
||||
for (let index = ranges.length - 1; index >= 0; index -= 1) {
|
||||
const end = ranges.end(index);
|
||||
if (Number.isFinite(end) && end > 0) {
|
||||
return end;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import Hls from 'hls.js';
|
||||
import { bindHls, FakeHls } from './html-video-player-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS caption preference', () => {
|
||||
it('suppresses initial and late HLS subtitle display while disabled', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }];
|
||||
hls.subtitleTrack = 0;
|
||||
hls.subtitleDisplay = true;
|
||||
const { adapter, bridge } = bindHls(hls, false);
|
||||
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
expect(adapter.state().subtitleTracks[0].selected).toBe(false);
|
||||
|
||||
hls.subtitleDisplay = true;
|
||||
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
expect(adapter.state().subtitleTracks[0].selected).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('restores an HLS default selected while display remains disabled', () => {
|
||||
const hls = new FakeHls();
|
||||
const { bridge, captionPreference } = bindHls(hls, false);
|
||||
hls.subtitleTracks = [{ name: 'English' }];
|
||||
hls.subtitleTrack = 0;
|
||||
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(hls.subtitleTrack).toBe(0);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('restores the retained HLS subtitle when preference returns', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
|
||||
hls.subtitleTrack = 1;
|
||||
hls.subtitleDisplay = true;
|
||||
const { adapter, bridge, captionPreference } = bindHls(hls, false);
|
||||
hls.subtitleTrack = -1;
|
||||
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.subtitleTrack).toBe(1);
|
||||
expect(adapter.state().subtitleTracks[1].selected).toBe(true);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('keeps an explicit HLS selection through events and preference changes', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
|
||||
const { adapter, bridge, captionPreference } = bindHls(hls);
|
||||
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
captionPreference.value = false;
|
||||
bridge.refreshInputs();
|
||||
hls.subtitleDisplay = false;
|
||||
hls.subtitleTrack = 0;
|
||||
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.subtitleTrack).toBe(1);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('keeps explicit HLS off through events and preference changes', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }];
|
||||
const { adapter, bridge, captionPreference } = bindHls(hls);
|
||||
|
||||
adapter.commands.setSubtitleTrack(-1);
|
||||
hls.subtitleTrack = 0;
|
||||
hls.subtitleDisplay = true;
|
||||
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
captionPreference.value = false;
|
||||
bridge.refreshInputs();
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(hls.subtitleTrack).toBe(-1);
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('resets the explicit HLS override on source replacement', () => {
|
||||
const firstHls = new FakeHls();
|
||||
firstHls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
|
||||
const { adapter, bridge } = bindHls(firstHls);
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
const secondHls = new FakeHls();
|
||||
secondHls.subtitleTracks = [{ name: 'Alpha' }, { name: 'Beta' }];
|
||||
secondHls.subtitleTrack = 0;
|
||||
secondHls.subtitleDisplay = true;
|
||||
|
||||
bridge.setSource({ kind: 'hls', hls: secondHls.asHls() });
|
||||
|
||||
expect(secondHls.subtitleTrack).toBe(0);
|
||||
expect(secondHls.subtitleDisplay).toBe(true);
|
||||
bridge.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS subtitle event reentry', () => {
|
||||
it('settles explicit valid selection after one synchronous switch event', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
hls.enableSynchronousSubtitleTrackSwitch();
|
||||
|
||||
expect(() => adapter.commands.setSubtitleTrack(1)).not.toThrow();
|
||||
|
||||
expect(hls.assignments).toEqual([
|
||||
'subtitleDisplay:true',
|
||||
'subtitleTrack:1',
|
||||
]);
|
||||
expect(hls.subtitleTrackSwitchEvents).toBe(1);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.subtitleTrack).toBe(1);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('settles explicit off after one synchronous switch event', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }];
|
||||
hls.subtitleTrack = 0;
|
||||
hls.subtitleDisplay = true;
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
hls.enableSynchronousSubtitleTrackSwitch();
|
||||
|
||||
expect(() => adapter.commands.setSubtitleTrack(-1)).not.toThrow();
|
||||
|
||||
expect(hls.assignments).toEqual([
|
||||
'subtitleTrack:-1',
|
||||
'subtitleDisplay:false',
|
||||
]);
|
||||
expect(hls.subtitleTrackSwitchEvents).toBe(1);
|
||||
expect(hls.subtitleTrack).toBe(-1);
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('restores retained preference after one synchronous switch event', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'English' }, { name: 'German' }];
|
||||
hls.subtitleTrack = 1;
|
||||
hls.subtitleDisplay = true;
|
||||
const { bridge, captionPreference } = bindHls(hls, false);
|
||||
hls.subtitleTrack = -1;
|
||||
captionPreference.value = true;
|
||||
hls.assignments.length = 0;
|
||||
hls.enableSynchronousSubtitleTrackSwitch();
|
||||
|
||||
expect(() => bridge.refreshInputs()).not.toThrow();
|
||||
|
||||
expect(hls.assignments).toEqual([
|
||||
'subtitleDisplay:true',
|
||||
'subtitleTrack:1',
|
||||
]);
|
||||
expect(hls.subtitleTrackSwitchEvents).toBe(1);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.subtitleTrack).toBe(1);
|
||||
bridge.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import Hls from 'hls.js';
|
||||
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
|
||||
import {
|
||||
bindHls,
|
||||
createVideo,
|
||||
FakeHls,
|
||||
} from './html-video-player-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS tracks', () => {
|
||||
it('projects current list indices, labels, and selected audio state', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.audioTracks = [
|
||||
{ id: 41, name: 'English' },
|
||||
{ id: 3, lang: 'de' },
|
||||
{ id: 99 },
|
||||
];
|
||||
hls.audioTrack = 1;
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
|
||||
expect(adapter.state().audioTracks).toEqual([
|
||||
{ id: 0, label: 'English', selected: false },
|
||||
{ id: 1, label: 'de', selected: true },
|
||||
{ id: 2, label: 'Audio 3', selected: false },
|
||||
]);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('selects an HLS subtitle only when display is enabled', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [
|
||||
{ id: 17, name: 'English CC' },
|
||||
{ id: 4, lang: 'fr' },
|
||||
{ id: 88 },
|
||||
];
|
||||
hls.subtitleTrack = 0;
|
||||
hls.subtitleDisplay = false;
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
|
||||
expect(adapter.state().subtitleTracks).toEqual([
|
||||
{ id: 0, label: 'English CC', selected: false },
|
||||
{ id: 1, label: 'fr', selected: false },
|
||||
{ id: 2, label: 'Subtitle 3', selected: false },
|
||||
]);
|
||||
|
||||
hls.subtitleDisplay = true;
|
||||
hls.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
expect(adapter.state().subtitleTracks[0].selected).toBe(true);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('accepts only valid current HLS audio indices', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.audioTracks = [{ name: 'One' }, { name: 'Two' }, { name: 'Three' }];
|
||||
hls.audioTrack = 0;
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setAudioTrack(2);
|
||||
expect(hls.audioTrack).toBe(2);
|
||||
expect(hls.assignments).toEqual(['audioTrack:2']);
|
||||
|
||||
hls.audioTrack = 0;
|
||||
hls.assignments.length = 0;
|
||||
adapter.commands.setAudioTrack(-1);
|
||||
adapter.commands.setAudioTrack(1.5);
|
||||
adapter.commands.setAudioTrack(8);
|
||||
adapter.commands.setAudioTrack(NaN);
|
||||
hls.audioTracks = [{ name: 'One' }];
|
||||
adapter.commands.setAudioTrack(2);
|
||||
|
||||
expect(hls.audioTrack).toBe(0);
|
||||
expect(hls.assignments).toEqual([]);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('enables valid HLS subtitles before selection and supports explicit off', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
expect(hls.assignments).toEqual([
|
||||
'subtitleDisplay:true',
|
||||
'subtitleTrack:1',
|
||||
]);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.subtitleTrack).toBe(1);
|
||||
|
||||
hls.assignments.length = 0;
|
||||
adapter.commands.setSubtitleTrack(-1);
|
||||
expect(hls.assignments).toEqual([
|
||||
'subtitleTrack:-1',
|
||||
'subtitleDisplay:false',
|
||||
]);
|
||||
expect(hls.subtitleTrack).toBe(-1);
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('ignores invalid and stale HLS subtitle indices', () => {
|
||||
const hls = new FakeHls();
|
||||
hls.subtitleTracks = [{ name: 'One' }, { name: 'Two' }];
|
||||
hls.subtitleTrack = 0;
|
||||
hls.subtitleDisplay = true;
|
||||
const { adapter, bridge } = bindHls(hls);
|
||||
hls.assignments.length = 0;
|
||||
|
||||
adapter.commands.setSubtitleTrack(-2);
|
||||
adapter.commands.setSubtitleTrack(0.5);
|
||||
adapter.commands.setSubtitleTrack(4);
|
||||
adapter.commands.setSubtitleTrack(NaN);
|
||||
hls.subtitleTracks = [{ name: 'One' }];
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
|
||||
expect(hls.subtitleTrack).toBe(0);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
expect(hls.assignments).toEqual([]);
|
||||
bridge.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => {
|
||||
const refreshEvents = [
|
||||
Hls.Events.AUDIO_TRACKS_UPDATED,
|
||||
Hls.Events.AUDIO_TRACK_SWITCHING,
|
||||
Hls.Events.AUDIO_TRACK_SWITCHED,
|
||||
Hls.Events.SUBTITLE_TRACKS_UPDATED,
|
||||
Hls.Events.SUBTITLE_TRACKS_CLEARED,
|
||||
Hls.Events.SUBTITLE_TRACK_SWITCH,
|
||||
Hls.Events.MANIFEST_LOADING,
|
||||
];
|
||||
|
||||
it('refreshes from every relevant HLS event with one callback reference', () => {
|
||||
const hls = new FakeHls();
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const refreshSpy = jest.spyOn(adapter, 'refresh');
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo(),
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind: 'hls', hls: hls.asHls() });
|
||||
|
||||
const registrations = hls.on.mock.calls;
|
||||
expect(registrations.map(([event]) => event)).toEqual(refreshEvents);
|
||||
expect(
|
||||
new Set(registrations.map(([, listener]) => listener)).size
|
||||
).toBe(1);
|
||||
|
||||
refreshSpy.mockClear();
|
||||
for (const event of refreshEvents) {
|
||||
hls.emit(event);
|
||||
}
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(refreshEvents.length);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('removes exact old HLS listeners before rebinding a source', () => {
|
||||
const firstHls = new FakeHls();
|
||||
const secondHls = new FakeHls();
|
||||
const { bridge } = bindHls(firstHls);
|
||||
const firstRegistrations = [...firstHls.on.mock.calls];
|
||||
|
||||
bridge.setSource({ kind: 'hls', hls: secondHls.asHls() });
|
||||
|
||||
expect(firstHls.off).toHaveBeenCalledTimes(refreshEvents.length);
|
||||
for (const [event, listener] of firstRegistrations) {
|
||||
expect(firstHls.off).toHaveBeenCalledWith(event, listener);
|
||||
}
|
||||
expect(firstHls.off.mock.calls.every((call) => call.length === 2)).toBe(
|
||||
true
|
||||
);
|
||||
expect(secondHls.on.mock.calls.map(([event]) => event)).toEqual(
|
||||
refreshEvents
|
||||
);
|
||||
bridge.destroy();
|
||||
});
|
||||
});
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
bindNativeTracks,
|
||||
createTextTrack,
|
||||
} from './html-video-player-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge native caption preference', () => {
|
||||
it('suppresses initial and late default captions while disabled', () => {
|
||||
const initial = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks(
|
||||
[initial],
|
||||
false
|
||||
);
|
||||
const late = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
|
||||
expect(initial.mode).toBe('hidden');
|
||||
textTracks.add(late);
|
||||
|
||||
expect(late.mode).toBe('hidden');
|
||||
expect(
|
||||
adapter.state().subtitleTracks.some((track) => track.selected)
|
||||
).toBe(false);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('restores suppressed engine/default modes when preference returns', () => {
|
||||
const initial = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'showing',
|
||||
});
|
||||
const untouched = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'disabled',
|
||||
});
|
||||
const { bridge, captionPreference, textTracks } = bindNativeTracks(
|
||||
[initial, untouched],
|
||||
false
|
||||
);
|
||||
const late = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
textTracks.add(late);
|
||||
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(initial.mode).toBe('showing');
|
||||
expect(late.mode).toBe('showing');
|
||||
expect(untouched.mode).toBe('disabled');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('keeps an explicit selection through events and preference changes', () => {
|
||||
const selected = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'hidden',
|
||||
});
|
||||
const other = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge, captionPreference, textTracks } =
|
||||
bindNativeTracks([selected, other]);
|
||||
|
||||
adapter.commands.setSubtitleTrack(0);
|
||||
captionPreference.value = false;
|
||||
bridge.refreshInputs();
|
||||
selected.mode = 'hidden';
|
||||
other.mode = 'showing';
|
||||
textTracks.emit('change');
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(selected.mode).toBe('showing');
|
||||
expect(other.mode).toBe('hidden');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('keeps explicit off through events and preference changes', () => {
|
||||
const first = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'showing',
|
||||
});
|
||||
const second = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'hidden',
|
||||
});
|
||||
const { adapter, bridge, captionPreference, textTracks } =
|
||||
bindNativeTracks([first, second]);
|
||||
|
||||
adapter.commands.setSubtitleTrack(-1);
|
||||
first.mode = 'showing';
|
||||
second.mode = 'showing';
|
||||
textTracks.emit('change');
|
||||
captionPreference.value = false;
|
||||
bridge.refreshInputs();
|
||||
captionPreference.value = true;
|
||||
bridge.refreshInputs();
|
||||
|
||||
expect(first.mode).toBe('hidden');
|
||||
expect(second.mode).toBe('hidden');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('resets native IDs and the explicit override on source replacement', () => {
|
||||
const first = createTextTrack({ kind: 'captions' });
|
||||
const second = createTextTrack({ kind: 'subtitles' });
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks([
|
||||
first,
|
||||
second,
|
||||
]);
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
const replacement = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
textTracks.replaceSilently([replacement]);
|
||||
|
||||
bridge.setSource({ kind: 'native' });
|
||||
|
||||
expect(adapter.state().subtitleTracks).toEqual([
|
||||
{ id: 0, label: 'Subtitle 1', selected: true },
|
||||
]);
|
||||
expect(replacement.mode).toBe('showing');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('cleans native listeners and detaches once on double destroy', () => {
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks([]);
|
||||
const detachSpy = jest.spyOn(adapter, 'detach');
|
||||
|
||||
bridge.destroy();
|
||||
bridge.destroy();
|
||||
|
||||
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(3);
|
||||
expect(detachSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
bindNativeTracks,
|
||||
createTextTrack,
|
||||
} from './html-video-player-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerControlsBridge native text tracks', () => {
|
||||
it('projects only captions/subtitles with stable IDs and fallback labels', () => {
|
||||
const first = createTextTrack({
|
||||
kind: 'captions',
|
||||
label: 'English CC',
|
||||
mode: 'showing',
|
||||
});
|
||||
const ignored = createTextTrack({
|
||||
kind: 'metadata',
|
||||
label: 'Cue metadata',
|
||||
mode: 'showing',
|
||||
});
|
||||
const second = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
language: 'de',
|
||||
});
|
||||
const third = createTextTrack({ kind: 'captions' });
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks([
|
||||
first,
|
||||
ignored,
|
||||
second,
|
||||
third,
|
||||
]);
|
||||
|
||||
expect(adapter.state().subtitleTracks).toEqual([
|
||||
{ id: 0, label: 'English CC', selected: true },
|
||||
{ id: 1, label: 'de', selected: false },
|
||||
{ id: 2, label: 'Subtitle 3', selected: false },
|
||||
]);
|
||||
|
||||
textTracks.remove(first);
|
||||
expect(adapter.state().subtitleTracks).toEqual([
|
||||
{ id: 1, label: 'de', selected: false },
|
||||
{ id: 2, label: 'Subtitle 3', selected: false },
|
||||
]);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('uses native text tracks for MPEG-TS sources too', () => {
|
||||
const subtitle = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
label: 'English',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge } = bindNativeTracks(
|
||||
[subtitle],
|
||||
true,
|
||||
'mpegts'
|
||||
);
|
||||
|
||||
expect(adapter.state().subtitleTracks).toEqual([
|
||||
{ id: 0, label: 'English', selected: true },
|
||||
]);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('selects a valid native track and hides other eligible tracks', () => {
|
||||
const first = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'showing',
|
||||
});
|
||||
const second = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'hidden',
|
||||
});
|
||||
const ignored = createTextTrack({
|
||||
kind: 'metadata',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge } = bindNativeTracks([first, second, ignored]);
|
||||
|
||||
adapter.commands.setSubtitleTrack(1);
|
||||
|
||||
expect(first.mode).toBe('hidden');
|
||||
expect(second.mode).toBe('showing');
|
||||
expect(ignored.mode).toBe('showing');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('supports explicit native subtitle off', () => {
|
||||
const first = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'showing',
|
||||
});
|
||||
const second = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge } = bindNativeTracks([first, second]);
|
||||
|
||||
adapter.commands.setSubtitleTrack(-1);
|
||||
|
||||
expect(first.mode).toBe('hidden');
|
||||
expect(second.mode).toBe('hidden');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('ignores invalid, non-integer, and stale native track IDs', () => {
|
||||
const removed = createTextTrack({
|
||||
kind: 'captions',
|
||||
mode: 'hidden',
|
||||
});
|
||||
const remaining = createTextTrack({
|
||||
kind: 'subtitles',
|
||||
mode: 'showing',
|
||||
});
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks([
|
||||
removed,
|
||||
remaining,
|
||||
]);
|
||||
textTracks.remove(removed);
|
||||
|
||||
adapter.commands.setSubtitleTrack(0);
|
||||
adapter.commands.setSubtitleTrack(0.5);
|
||||
adapter.commands.setSubtitleTrack(-2);
|
||||
adapter.commands.setSubtitleTrack(8);
|
||||
adapter.commands.setSubtitleTrack(NaN);
|
||||
|
||||
expect(remaining.mode).toBe('showing');
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('refreshes on addtrack, removetrack, and change', () => {
|
||||
const { adapter, bridge, textTracks } = bindNativeTracks([]);
|
||||
const refreshSpy = jest.spyOn(adapter, 'refresh');
|
||||
|
||||
textTracks.emit('addtrack');
|
||||
textTracks.emit('removetrack');
|
||||
textTracks.emit('change');
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(3);
|
||||
bridge.destroy();
|
||||
});
|
||||
|
||||
it('removes exact native listener references on rebind and destroy', () => {
|
||||
const { bridge, textTracks } = bindNativeTracks([]);
|
||||
const firstRegistrations = [...textTracks.addEventListener.mock.calls];
|
||||
|
||||
bridge.setSource({ kind: 'mpegts' });
|
||||
|
||||
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(3);
|
||||
for (const [event, listener] of firstRegistrations) {
|
||||
expect(textTracks.removeEventListener).toHaveBeenCalledWith(
|
||||
event,
|
||||
listener
|
||||
);
|
||||
}
|
||||
expect(
|
||||
textTracks.removeEventListener.mock.calls.every(
|
||||
(call) => call.length === 2
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
bridge.destroy();
|
||||
expect(textTracks.removeEventListener).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import Hls from 'hls.js';
|
||||
import { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { HtmlVideoPlayerControlsBridge } from './html-video-player-controls.bridge';
|
||||
|
||||
export interface VideoFixtureOptions {
|
||||
duration?: number;
|
||||
seekableEnds?: number[];
|
||||
bufferedEnds?: number[];
|
||||
seekableThrows?: boolean;
|
||||
bufferedThrows?: boolean;
|
||||
}
|
||||
|
||||
interface FakeHlsTrack {
|
||||
id?: number;
|
||||
name?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
type FakeHlsListener = (...args: unknown[]) => void;
|
||||
|
||||
export class FakeHls {
|
||||
audioTracks: FakeHlsTrack[] = [];
|
||||
subtitleTracks: FakeHlsTrack[] = [];
|
||||
readonly assignments: string[] = [];
|
||||
subtitleTrackSwitchEvents = 0;
|
||||
private readonly listeners = new Map<string, Set<FakeHlsListener>>();
|
||||
private emitSubtitleTrackSwitchOnAssignment = false;
|
||||
private selectedAudioTrack = -1;
|
||||
private selectedSubtitleTrack = -1;
|
||||
private displaySubtitles = false;
|
||||
|
||||
readonly on = jest.fn((event: string, listener: FakeHlsListener): void => {
|
||||
const eventListeners =
|
||||
this.listeners.get(event) ?? new Set<FakeHlsListener>();
|
||||
eventListeners.add(listener);
|
||||
this.listeners.set(event, eventListeners);
|
||||
});
|
||||
|
||||
readonly off = jest.fn((event: string, listener: FakeHlsListener): void => {
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
});
|
||||
|
||||
get audioTrack(): number {
|
||||
return this.selectedAudioTrack;
|
||||
}
|
||||
|
||||
set audioTrack(value: number) {
|
||||
this.assignments.push(`audioTrack:${value}`);
|
||||
this.selectedAudioTrack = value;
|
||||
}
|
||||
|
||||
get subtitleTrack(): number {
|
||||
return this.selectedSubtitleTrack;
|
||||
}
|
||||
|
||||
set subtitleTrack(value: number) {
|
||||
this.assignments.push(`subtitleTrack:${value}`);
|
||||
this.selectedSubtitleTrack = value;
|
||||
if (this.emitSubtitleTrackSwitchOnAssignment) {
|
||||
this.subtitleTrackSwitchEvents += 1;
|
||||
if (this.subtitleTrackSwitchEvents > 10) {
|
||||
throw new Error('recursive HLS subtitle track assignment');
|
||||
}
|
||||
this.emit(Hls.Events.SUBTITLE_TRACK_SWITCH);
|
||||
}
|
||||
}
|
||||
|
||||
get subtitleDisplay(): boolean {
|
||||
return this.displaySubtitles;
|
||||
}
|
||||
|
||||
set subtitleDisplay(value: boolean) {
|
||||
this.assignments.push(`subtitleDisplay:${value}`);
|
||||
this.displaySubtitles = value;
|
||||
}
|
||||
|
||||
emit(event: string): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(event, {});
|
||||
}
|
||||
}
|
||||
|
||||
enableSynchronousSubtitleTrackSwitch(): void {
|
||||
this.subtitleTrackSwitchEvents = 0;
|
||||
this.emitSubtitleTrackSwitchOnAssignment = true;
|
||||
}
|
||||
|
||||
asHls(): Hls {
|
||||
return this as unknown as Hls;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeTextTrackOptions {
|
||||
kind?: string;
|
||||
label?: string;
|
||||
language?: string;
|
||||
mode?: TextTrackMode;
|
||||
}
|
||||
|
||||
export function createTextTrack(options: FakeTextTrackOptions = {}): TextTrack {
|
||||
return {
|
||||
kind: options.kind ?? 'subtitles',
|
||||
label: options.label ?? '',
|
||||
language: options.language ?? '',
|
||||
mode: options.mode ?? 'hidden',
|
||||
} as unknown as TextTrack;
|
||||
}
|
||||
|
||||
export class FakeTextTrackList {
|
||||
private readonly listeners = new Map<
|
||||
string,
|
||||
Set<EventListenerOrEventListenerObject>
|
||||
>();
|
||||
private tracks: TextTrack[];
|
||||
private indexedLength = 0;
|
||||
|
||||
readonly addEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
const eventListeners =
|
||||
this.listeners.get(event) ??
|
||||
new Set<EventListenerOrEventListenerObject>();
|
||||
eventListeners.add(listener);
|
||||
this.listeners.set(event, eventListeners);
|
||||
}
|
||||
);
|
||||
|
||||
readonly removeEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
}
|
||||
);
|
||||
|
||||
constructor(tracks: TextTrack[] = []) {
|
||||
this.tracks = [...tracks];
|
||||
this.syncIndexedTracks();
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.tracks.length;
|
||||
}
|
||||
|
||||
getTrackById(): TextTrack | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
add(track: TextTrack): void {
|
||||
this.tracks.push(track);
|
||||
this.syncIndexedTracks();
|
||||
this.emit('addtrack');
|
||||
}
|
||||
|
||||
remove(track: TextTrack): void {
|
||||
const index = this.tracks.indexOf(track);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.tracks.splice(index, 1);
|
||||
this.syncIndexedTracks();
|
||||
this.emit('removetrack');
|
||||
}
|
||||
|
||||
replaceSilently(tracks: TextTrack[]): void {
|
||||
this.tracks = [...tracks];
|
||||
this.syncIndexedTracks();
|
||||
}
|
||||
|
||||
emit(eventName: 'addtrack' | 'removetrack' | 'change'): void {
|
||||
const event = new Event(eventName);
|
||||
for (const listener of this.listeners.get(eventName) ?? []) {
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(this, event);
|
||||
} else {
|
||||
listener.handleEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asTextTrackList(): TextTrackList {
|
||||
return this as unknown as TextTrackList;
|
||||
}
|
||||
|
||||
private syncIndexedTracks(): void {
|
||||
for (let index = 0; index < this.indexedLength; index += 1) {
|
||||
Reflect.deleteProperty(this, index);
|
||||
}
|
||||
this.tracks.forEach((track, index) => {
|
||||
Object.defineProperty(this, index, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: track,
|
||||
});
|
||||
});
|
||||
this.indexedLength = this.tracks.length;
|
||||
}
|
||||
}
|
||||
|
||||
function createTimeRanges(ends: number[], throwsWhenRead: boolean): TimeRanges {
|
||||
return {
|
||||
length: ends.length,
|
||||
start: (index: number) => {
|
||||
if (throwsWhenRead) {
|
||||
throw new Error('range start unavailable');
|
||||
}
|
||||
return index === 0 ? 0 : (ends[index - 1] ?? 0);
|
||||
},
|
||||
end: (index: number) => {
|
||||
if (throwsWhenRead) {
|
||||
throw new Error('range end unavailable');
|
||||
}
|
||||
return ends[index] ?? NaN;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createVideo(
|
||||
options: VideoFixtureOptions = {}
|
||||
): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
Object.defineProperties(video, {
|
||||
duration: {
|
||||
configurable: true,
|
||||
value: options.duration ?? NaN,
|
||||
},
|
||||
seekable: {
|
||||
configurable: true,
|
||||
value: createTimeRanges(
|
||||
options.seekableEnds ?? [],
|
||||
options.seekableThrows ?? false
|
||||
),
|
||||
},
|
||||
buffered: {
|
||||
configurable: true,
|
||||
value: createTimeRanges(
|
||||
options.bufferedEnds ?? [],
|
||||
options.bufferedThrows ?? false
|
||||
),
|
||||
},
|
||||
readyState: {
|
||||
configurable: true,
|
||||
value: 4,
|
||||
},
|
||||
networkState: {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
},
|
||||
paused: {
|
||||
configurable: true,
|
||||
value: false,
|
||||
},
|
||||
});
|
||||
return video;
|
||||
}
|
||||
|
||||
export function readMpegtsState(options: VideoFixtureOptions, isLive = false) {
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo(options),
|
||||
adapter,
|
||||
isLive: () => isLive,
|
||||
showCaptions: () => true,
|
||||
});
|
||||
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind: 'mpegts' });
|
||||
const state = adapter.state();
|
||||
bridge.destroy();
|
||||
return state;
|
||||
}
|
||||
|
||||
export function bindHls(fakeHls: FakeHls, showCaptions = true) {
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const captionPreference = { value: showCaptions };
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: createVideo({ duration: 90, seekableEnds: [80] }),
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => captionPreference.value,
|
||||
});
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind: 'hls', hls: fakeHls.asHls() });
|
||||
return { adapter, bridge, captionPreference };
|
||||
}
|
||||
|
||||
export function bindNativeTracks(
|
||||
tracks: TextTrack[],
|
||||
showCaptions = true,
|
||||
kind: 'native' | 'mpegts' = 'native'
|
||||
) {
|
||||
const textTracks = new FakeTextTrackList(tracks);
|
||||
const video = createVideo({ duration: 90, seekableEnds: [80] });
|
||||
Object.defineProperty(video, 'textTracks', {
|
||||
configurable: true,
|
||||
value: textTracks.asTextTrackList(),
|
||||
});
|
||||
const captionPreference = { value: showCaptions };
|
||||
const adapter = new WebVideoControlsAdapter();
|
||||
const bridge = new HtmlVideoPlayerControlsBridge({
|
||||
video,
|
||||
adapter,
|
||||
isLive: () => false,
|
||||
showCaptions: () => captionPreference.value,
|
||||
});
|
||||
bridge.attach();
|
||||
bridge.setSource({ kind });
|
||||
return {
|
||||
adapter,
|
||||
bridge,
|
||||
captionPreference,
|
||||
textTracks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Hls from 'hls.js';
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
|
||||
export interface HtmlVideoPlayerHlsControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export class HtmlVideoPlayerHlsControls {
|
||||
private hls: Hls | null = null;
|
||||
private refreshListener: (() => void) | null = null;
|
||||
private subtitleOverride: number | null = null;
|
||||
private suppressedSubtitleTrack: number | null = null;
|
||||
|
||||
constructor(private readonly config: HtmlVideoPlayerHlsControlsConfig) {}
|
||||
|
||||
bind(hls: Hls): void {
|
||||
this.clear();
|
||||
this.hls = hls;
|
||||
const refresh = () => {
|
||||
this.applyCaptionState();
|
||||
this.config.refresh();
|
||||
};
|
||||
this.refreshListener = refresh;
|
||||
for (const event of getHlsRefreshEvents()) {
|
||||
hls.on(event, refresh);
|
||||
}
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.hls && this.refreshListener) {
|
||||
for (const event of getHlsRefreshEvents()) {
|
||||
this.hls.off(event, this.refreshListener);
|
||||
}
|
||||
}
|
||||
this.hls = null;
|
||||
this.refreshListener = null;
|
||||
this.subtitleOverride = null;
|
||||
this.suppressedSubtitleTrack = null;
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
getAudioTracks(): PlayerTrack[] {
|
||||
if (!this.hls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hls = this.hls;
|
||||
return hls.audioTracks.map((track, index) => ({
|
||||
id: index,
|
||||
label: track.name || track.lang || `Audio ${index + 1}`,
|
||||
selected: index === hls.audioTrack,
|
||||
}));
|
||||
}
|
||||
|
||||
setAudioTrack(id: number): void {
|
||||
if (
|
||||
this.hls &&
|
||||
Number.isInteger(id) &&
|
||||
id >= 0 &&
|
||||
id < this.hls.audioTracks.length
|
||||
) {
|
||||
this.hls.audioTrack = id;
|
||||
}
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
if (!this.hls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hls = this.hls;
|
||||
return hls.subtitleTracks.map((track, index) => ({
|
||||
id: index,
|
||||
label: track.name || track.lang || `Subtitle ${index + 1}`,
|
||||
selected:
|
||||
hls.subtitleDisplay === true && index === hls.subtitleTrack,
|
||||
}));
|
||||
}
|
||||
|
||||
setSubtitleTrack(id: number): void {
|
||||
if (!this.hls || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === -1) {
|
||||
this.subtitleOverride = -1;
|
||||
this.suppressedSubtitleTrack = null;
|
||||
this.setSubtitleTrackValue(-1);
|
||||
this.setSubtitleDisplay(false);
|
||||
return;
|
||||
}
|
||||
if (id < 0 || id >= this.hls.subtitleTracks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subtitleOverride = id;
|
||||
this.suppressedSubtitleTrack = null;
|
||||
this.setSubtitleDisplay(true);
|
||||
this.setSubtitleTrackValue(id);
|
||||
}
|
||||
|
||||
private applyCaptionState(): void {
|
||||
if (!this.hls) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.subtitleOverride !== null) {
|
||||
if (this.subtitleOverride === -1) {
|
||||
this.setSubtitleTrackValue(-1);
|
||||
this.setSubtitleDisplay(false);
|
||||
return;
|
||||
}
|
||||
if (this.subtitleOverride < this.hls.subtitleTracks.length) {
|
||||
this.setSubtitleDisplay(true);
|
||||
this.setSubtitleTrackValue(this.subtitleOverride);
|
||||
} else {
|
||||
this.setSubtitleDisplay(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.showCaptions()) {
|
||||
// HLS may choose its default track while display is suppressed.
|
||||
// Explicit user-off remains owned by subtitleOverride === -1 above.
|
||||
if (
|
||||
Number.isInteger(this.hls.subtitleTrack) &&
|
||||
this.hls.subtitleTrack >= 0 &&
|
||||
this.hls.subtitleTrack < this.hls.subtitleTracks.length
|
||||
) {
|
||||
this.suppressedSubtitleTrack = this.hls.subtitleTrack;
|
||||
}
|
||||
this.setSubtitleDisplay(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const suppressedTrack = this.suppressedSubtitleTrack;
|
||||
if (
|
||||
suppressedTrack !== null &&
|
||||
suppressedTrack < this.hls.subtitleTracks.length
|
||||
) {
|
||||
this.suppressedSubtitleTrack = null;
|
||||
this.setSubtitleDisplay(true);
|
||||
this.setSubtitleTrackValue(suppressedTrack);
|
||||
}
|
||||
}
|
||||
|
||||
private setSubtitleTrackValue(id: number): void {
|
||||
if (this.hls && this.hls.subtitleTrack !== id) {
|
||||
this.hls.subtitleTrack = id;
|
||||
}
|
||||
}
|
||||
|
||||
private setSubtitleDisplay(display: boolean): void {
|
||||
if (this.hls && this.hls.subtitleDisplay !== display) {
|
||||
this.hls.subtitleDisplay = display;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHlsRefreshEvents() {
|
||||
return [
|
||||
Hls.Events.AUDIO_TRACKS_UPDATED,
|
||||
Hls.Events.AUDIO_TRACK_SWITCHING,
|
||||
Hls.Events.AUDIO_TRACK_SWITCHED,
|
||||
Hls.Events.SUBTITLE_TRACKS_UPDATED,
|
||||
Hls.Events.SUBTITLE_TRACKS_CLEARED,
|
||||
Hls.Events.SUBTITLE_TRACK_SWITCH,
|
||||
Hls.Events.MANIFEST_LOADING,
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
|
||||
const NATIVE_TRACK_EVENTS = ['addtrack', 'removetrack', 'change'] as const;
|
||||
|
||||
interface NativeSubtitleTrack {
|
||||
id: number;
|
||||
track: TextTrack;
|
||||
}
|
||||
|
||||
export interface HtmlVideoPlayerNativeTextTracksConfig {
|
||||
video: HTMLVideoElement;
|
||||
showCaptions: () => boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export class HtmlVideoPlayerNativeTextTracks {
|
||||
private textTrackList: TextTrackList | null = null;
|
||||
private refreshListener: EventListener | null = null;
|
||||
private trackIds = new WeakMap<TextTrack, number>();
|
||||
private suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
|
||||
private nextTrackId = 0;
|
||||
private subtitleOverride: number | null = null;
|
||||
private active = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: HtmlVideoPlayerNativeTextTracksConfig
|
||||
) {}
|
||||
|
||||
bind(): void {
|
||||
this.clear();
|
||||
this.active = true;
|
||||
const textTracks = this.config.video.textTracks;
|
||||
if (typeof textTracks.addEventListener === 'function') {
|
||||
const refresh: EventListener = () => {
|
||||
this.applyCaptionState();
|
||||
this.config.refresh();
|
||||
};
|
||||
this.textTrackList = textTracks;
|
||||
this.refreshListener = refresh;
|
||||
for (const event of NATIVE_TRACK_EVENTS) {
|
||||
textTracks.addEventListener(event, refresh);
|
||||
}
|
||||
}
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (
|
||||
this.textTrackList &&
|
||||
this.refreshListener &&
|
||||
typeof this.textTrackList.removeEventListener === 'function'
|
||||
) {
|
||||
for (const event of NATIVE_TRACK_EVENTS) {
|
||||
this.textTrackList.removeEventListener(
|
||||
event,
|
||||
this.refreshListener
|
||||
);
|
||||
}
|
||||
}
|
||||
this.textTrackList = null;
|
||||
this.refreshListener = null;
|
||||
this.trackIds = new WeakMap<TextTrack, number>();
|
||||
this.suppressedModes = new WeakMap<TextTrack, TextTrackMode>();
|
||||
this.nextTrackId = 0;
|
||||
this.subtitleOverride = null;
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
this.applyCaptionState();
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
return this.readSubtitleTracks().map(({ id, track }) => ({
|
||||
id,
|
||||
label: track.label || track.language || `Subtitle ${id + 1}`,
|
||||
selected: track.mode === 'showing',
|
||||
}));
|
||||
}
|
||||
|
||||
setSubtitleTrack(id: number): void {
|
||||
if (!Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = this.readSubtitleTracks();
|
||||
if (id === -1) {
|
||||
this.subtitleOverride = -1;
|
||||
this.applyOverride(tracks);
|
||||
return;
|
||||
}
|
||||
if (!tracks.some((entry) => entry.id === id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subtitleOverride = id;
|
||||
this.applyOverride(tracks);
|
||||
}
|
||||
|
||||
private readSubtitleTracks(): NativeSubtitleTrack[] {
|
||||
if (!this.active) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: NativeSubtitleTrack[] = [];
|
||||
try {
|
||||
const tracks = this.config.video.textTracks;
|
||||
for (let index = 0; index < tracks.length; index += 1) {
|
||||
const track = tracks[index];
|
||||
if (
|
||||
!track ||
|
||||
(track.kind !== 'captions' && track.kind !== 'subtitles')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = this.trackIds.get(track);
|
||||
if (id === undefined) {
|
||||
id = this.nextTrackId;
|
||||
this.nextTrackId += 1;
|
||||
this.trackIds.set(track, id);
|
||||
}
|
||||
result.push({ id, track });
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyCaptionState(): void {
|
||||
if (!this.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = this.readSubtitleTracks();
|
||||
if (this.subtitleOverride !== null) {
|
||||
this.applyOverride(tracks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.showCaptions()) {
|
||||
for (const { track } of tracks) {
|
||||
if (track.mode !== 'showing') {
|
||||
continue;
|
||||
}
|
||||
if (!this.suppressedModes.has(track)) {
|
||||
this.suppressedModes.set(track, track.mode);
|
||||
}
|
||||
track.mode = 'hidden';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { track } of tracks) {
|
||||
const suppressedMode = this.suppressedModes.get(track);
|
||||
if (suppressedMode === undefined) {
|
||||
continue;
|
||||
}
|
||||
track.mode = suppressedMode;
|
||||
this.suppressedModes.delete(track);
|
||||
}
|
||||
}
|
||||
|
||||
private applyOverride(tracks: NativeSubtitleTrack[]): void {
|
||||
for (const { id, track } of tracks) {
|
||||
track.mode =
|
||||
this.subtitleOverride !== -1 && id === this.subtitleOverride
|
||||
? 'showing'
|
||||
: 'hidden';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
<div class="html-video-player-shell">
|
||||
<div #playerRoot class="html-video-player-shell">
|
||||
<video
|
||||
#videoPlayer
|
||||
id="video-player"
|
||||
autoplay="true"
|
||||
controls="true"
|
||||
autoplay
|
||||
[controls]="!sharedControls"
|
||||
></video>
|
||||
|
||||
<app-series-playback-navigation-controls
|
||||
[navigation]="seriesNavigation"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@if (sharedControls) {
|
||||
<app-player-controls
|
||||
[controller]="controlsAdapter"
|
||||
[playerSurface]="playerRoot"
|
||||
[showControls]="interactionEnabled()"
|
||||
[shortcutsEnabled]="interactionEnabled()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else {
|
||||
<app-series-playback-navigation-controls
|
||||
[navigation]="seriesNavigation"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
MockHls,
|
||||
TEST_CHANNEL,
|
||||
cleanupSharedControlsTests,
|
||||
configureSharedControlsTests,
|
||||
hlsInstances,
|
||||
lifecycle,
|
||||
mpegTsCreatePlayer,
|
||||
mpegTsInstances,
|
||||
mpegTsIsSupported,
|
||||
observeBridgeSourceBinding,
|
||||
readHtmlPlayerInternals,
|
||||
renderSharedControls,
|
||||
type SharedControlsFixture,
|
||||
} from './html-video-player.component.shared-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
|
||||
const fixtures: SharedControlsFixture[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ HtmlVideoPlayerComponent } =
|
||||
await import('./html-video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await configureSharedControlsTests(HtmlVideoPlayerComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupSharedControlsTests(fixtures);
|
||||
});
|
||||
|
||||
it('retains an HLS source created before bridge initialization', () => {
|
||||
const { adapter } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{
|
||||
channel: TEST_CHANNEL,
|
||||
isLive: false,
|
||||
showCaptions: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(hlsInstances).toHaveLength(1);
|
||||
expect(adapter.capabilities().audioTracks).toBe(true);
|
||||
expect(adapter.state().audioTracks).toEqual([
|
||||
{ id: 0, label: 'English', selected: true },
|
||||
{ id: 1, label: 'Deutsch', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('binds HLS controls after media attach and before source loading', () => {
|
||||
const { component } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
lifecycle.length = 0;
|
||||
|
||||
component.playChannel(TEST_CHANNEL);
|
||||
|
||||
const attachIndex = lifecycle.indexOf('hls:attachMedia');
|
||||
const bindIndex = lifecycle.indexOf(
|
||||
`on:${MockHls.Events.AUDIO_TRACKS_UPDATED}`
|
||||
);
|
||||
const loadIndex = lifecycle.indexOf('hls:loadSource');
|
||||
expect(attachIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(bindIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(loadIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(attachIndex).toBeLessThan(bindIndex);
|
||||
expect(bindIndex).toBeLessThan(loadIndex);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'passes authoritative isLive=%s to raw MPEG-TS playback',
|
||||
(isLive) => {
|
||||
mpegTsIsSupported.mockReturnValue(true);
|
||||
|
||||
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
|
||||
channel: {
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://example.test/raw.ts',
|
||||
},
|
||||
isLive,
|
||||
});
|
||||
|
||||
expect(mpegTsCreatePlayer).toHaveBeenCalledWith({
|
||||
type: 'mpegts',
|
||||
isLive,
|
||||
url: 'https://example.test/raw.ts',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('owns one MPEG-TS source between media attachment and loading', () => {
|
||||
mpegTsIsSupported.mockReturnValue(true);
|
||||
const { component } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{ isLive: false }
|
||||
);
|
||||
const setSource = observeBridgeSourceBinding(component);
|
||||
lifecycle.length = 0;
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://example.test/raw.ts',
|
||||
});
|
||||
|
||||
const internals = readHtmlPlayerInternals(component);
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith({ kind: 'mpegts' });
|
||||
expect(internals.controlsSource).toEqual({ kind: 'mpegts' });
|
||||
expect(internals.hls).toBeNull();
|
||||
expect(hlsInstances).toHaveLength(0);
|
||||
expect(mpegTsInstances).toHaveLength(1);
|
||||
expect(internals.mpegtsPlayer).toBe(mpegTsInstances[0]);
|
||||
expect(mpegTsInstances[0].attachMediaElement).toHaveBeenCalledTimes(1);
|
||||
expect(mpegTsInstances[0].load).toHaveBeenCalledTimes(1);
|
||||
expect(lifecycle.indexOf('mpegts:attachMedia')).toBeLessThan(
|
||||
lifecycle.indexOf('bridge:mpegts')
|
||||
);
|
||||
expect(lifecycle.indexOf('bridge:mpegts')).toBeLessThan(
|
||||
lifecycle.indexOf('mpegts:load')
|
||||
);
|
||||
});
|
||||
|
||||
it('owns one native source before loading native media', () => {
|
||||
const { component, fixture } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
const setSource = observeBridgeSourceBinding(component);
|
||||
const video = fixture.debugElement.query(By.css('video'))
|
||||
.nativeElement as HTMLVideoElement;
|
||||
const load = jest
|
||||
.spyOn(video, 'load')
|
||||
.mockImplementation(() => lifecycle.push('native:load'));
|
||||
lifecycle.length = 0;
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
|
||||
const internals = readHtmlPlayerInternals(component);
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
|
||||
expect(internals.controlsSource).toEqual({ kind: 'native' });
|
||||
expect(internals.hls).toBeNull();
|
||||
expect(internals.mpegtsPlayer).toBeNull();
|
||||
expect(hlsInstances).toHaveLength(0);
|
||||
expect(mpegTsInstances).toHaveLength(0);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(lifecycle.indexOf('bridge:native')).toBeLessThan(
|
||||
lifecycle.indexOf('native:load')
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes live and caption inputs through authoritative closures', () => {
|
||||
const { fixture, adapter } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{
|
||||
channel: TEST_CHANNEL,
|
||||
isLive: true,
|
||||
showCaptions: false,
|
||||
}
|
||||
);
|
||||
const hls = hlsInstances[0];
|
||||
|
||||
expect(adapter.state().isLive).toBe(true);
|
||||
expect(hls.subtitleDisplay).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput('isLive', false);
|
||||
fixture.componentRef.setInput('showCaptions', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(adapter.state().isLive).toBe(false);
|
||||
expect(hls.subtitleDisplay).toBe(true);
|
||||
});
|
||||
|
||||
it('clears old HLS listeners and tracks before destroying the engine', () => {
|
||||
const { component, adapter } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{
|
||||
channel: TEST_CHANNEL,
|
||||
isLive: false,
|
||||
showCaptions: true,
|
||||
}
|
||||
);
|
||||
const hls = hlsInstances[0];
|
||||
const setSource = observeBridgeSourceBinding(component);
|
||||
lifecycle.length = 0;
|
||||
|
||||
component.playChannel({
|
||||
...TEST_CHANNEL,
|
||||
url: 'https://example.test/movie.mp4',
|
||||
});
|
||||
|
||||
const destroyIndex = lifecycle.indexOf('hls:destroy');
|
||||
expect(destroyIndex).toBeGreaterThan(0);
|
||||
expect(
|
||||
lifecycle
|
||||
.slice(0, destroyIndex)
|
||||
.filter((event) => event.startsWith('off:'))
|
||||
).toHaveLength(7);
|
||||
const internals = readHtmlPlayerInternals(component);
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
|
||||
expect(internals.controlsSource).toEqual({ kind: 'native' });
|
||||
expect(internals.hls).toBeNull();
|
||||
expect(internals.mpegtsPlayer).toBeNull();
|
||||
expect(hlsInstances).toHaveLength(1);
|
||||
expect(mpegTsInstances).toHaveLength(0);
|
||||
expect(hls.off).toHaveBeenCalledTimes(7);
|
||||
expect(adapter.state().audioTracks).toEqual([]);
|
||||
expect(adapter.state().subtitleTracks).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys the bridge before HLS and detaches the adapter once', () => {
|
||||
const { component, adapter } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{
|
||||
channel: TEST_CHANNEL,
|
||||
showCaptions: true,
|
||||
}
|
||||
);
|
||||
const originalDetach = adapter.detach.bind(adapter);
|
||||
const detach = jest.spyOn(adapter, 'detach').mockImplementation(() => {
|
||||
lifecycle.push('adapter:detach');
|
||||
originalDetach();
|
||||
});
|
||||
lifecycle.length = 0;
|
||||
|
||||
component.ngOnDestroy();
|
||||
component.ngOnDestroy();
|
||||
|
||||
expect(detach).toHaveBeenCalledTimes(1);
|
||||
expect(lifecycle.indexOf('adapter:detach')).toBeLessThan(
|
||||
lifecycle.indexOf('hls:destroy')
|
||||
);
|
||||
});
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import type { Channel } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlayerControlsComponent,
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
WebVideoControlsAdapter,
|
||||
} from '../player-controls';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
|
||||
|
||||
type HtmlVideoPlayerComponentType =
|
||||
typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
|
||||
type HlsListener = (event: string, data: unknown) => void;
|
||||
|
||||
export type SharedControlsFixture =
|
||||
ComponentFixture<HtmlVideoPlayerComponentInstance>;
|
||||
|
||||
export interface ControlsSource {
|
||||
kind: 'native' | 'mpegts' | 'hls';
|
||||
}
|
||||
|
||||
export interface HtmlPlayerInternals {
|
||||
controlsBridge: {
|
||||
setSource(source: ControlsSource): void;
|
||||
} | null;
|
||||
controlsSource: ControlsSource | null;
|
||||
hls: MockHls | null;
|
||||
mpegtsPlayer: MockMpegTsPlayer | null;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
channel?: Channel;
|
||||
interactionEnabled?: boolean;
|
||||
isLive?: boolean;
|
||||
seriesNavigation?: SeriesPlaybackNavigation | null;
|
||||
showCaptions?: boolean;
|
||||
}
|
||||
|
||||
export const lifecycle: string[] = [];
|
||||
export const hlsInstances: MockHls[] = [];
|
||||
export const mpegTsInstances: MockMpegTsPlayer[] = [];
|
||||
|
||||
export class MockHls {
|
||||
static readonly Events = {
|
||||
MANIFEST_PARSED: 'manifestParsed',
|
||||
ERROR: 'error',
|
||||
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
|
||||
AUDIO_TRACK_SWITCHING: 'audioTrackSwitching',
|
||||
AUDIO_TRACK_SWITCHED: 'audioTrackSwitched',
|
||||
SUBTITLE_TRACKS_UPDATED: 'subtitleTracksUpdated',
|
||||
SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared',
|
||||
SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch',
|
||||
MANIFEST_LOADING: 'manifestLoading',
|
||||
};
|
||||
static readonly isSupported = jest.fn(() => true);
|
||||
|
||||
readonly audioTracks = [{ name: 'English' }, { name: 'Deutsch' }];
|
||||
readonly subtitleTracks = [{ name: 'English CC' }];
|
||||
audioTrack = 0;
|
||||
subtitleTrack = 0;
|
||||
subtitleDisplay = true;
|
||||
private readonly listeners = new Map<string, Set<HlsListener>>();
|
||||
|
||||
readonly on = jest.fn((event: string, listener: HlsListener) => {
|
||||
lifecycle.push(`on:${event}`);
|
||||
const listeners = this.listeners.get(event) ?? new Set<HlsListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(event, listeners);
|
||||
});
|
||||
readonly off = jest.fn((event: string, listener: HlsListener) => {
|
||||
lifecycle.push(`off:${event}`);
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
});
|
||||
readonly attachMedia = jest.fn(() => lifecycle.push('hls:attachMedia'));
|
||||
readonly loadSource = jest.fn(() => {
|
||||
lifecycle.push('hls:loadSource');
|
||||
this.emit(MockHls.Events.MANIFEST_LOADING);
|
||||
});
|
||||
readonly destroy = jest.fn(() => lifecycle.push('hls:destroy'));
|
||||
|
||||
constructor() {
|
||||
hlsInstances.push(this);
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown = {}): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MockMpegTsPlayer {
|
||||
readonly attachMediaElement = jest.fn(() =>
|
||||
lifecycle.push('mpegts:attachMedia')
|
||||
);
|
||||
readonly on = jest.fn();
|
||||
readonly load = jest.fn(() => lifecycle.push('mpegts:load'));
|
||||
readonly pause = jest.fn();
|
||||
readonly unload = jest.fn();
|
||||
readonly detachMediaElement = jest.fn();
|
||||
readonly destroy = jest.fn();
|
||||
|
||||
constructor() {
|
||||
mpegTsInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const mpegTsCreatePlayer = jest.fn(() => new MockMpegTsPlayer());
|
||||
export const mpegTsIsSupported = jest.fn(() => false);
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({ default: MockHls }));
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
Events: { ERROR: 'error' },
|
||||
createPlayer: mpegTsCreatePlayer,
|
||||
isSupported: mpegTsIsSupported,
|
||||
},
|
||||
}));
|
||||
|
||||
export const TEST_CHANNEL: Channel = {
|
||||
id: 'channel-1',
|
||||
url: 'https://example.test/live/playlist.m3u8',
|
||||
name: 'Test channel',
|
||||
group: { title: 'Test' },
|
||||
http: { origin: '', referrer: '', 'user-agent': '' },
|
||||
radio: 'false',
|
||||
tvg: { id: '', logo: '', name: '', rec: '', url: '' },
|
||||
};
|
||||
|
||||
export async function configureSharedControlsTests(
|
||||
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType
|
||||
): Promise<void> {
|
||||
lifecycle.length = 0;
|
||||
hlsInstances.length = 0;
|
||||
mpegTsInstances.length = 0;
|
||||
MockHls.isSupported.mockReset().mockReturnValue(true);
|
||||
mpegTsIsSupported.mockReset().mockReturnValue(false);
|
||||
mpegTsCreatePlayer
|
||||
.mockReset()
|
||||
.mockImplementation(() => new MockMpegTsPlayer());
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: { setUserAgent: jest.fn().mockResolvedValue(true) },
|
||||
});
|
||||
jest.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||
jest.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
{ provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true },
|
||||
],
|
||||
}).compileComponents();
|
||||
}
|
||||
|
||||
export function cleanupSharedControlsTests(
|
||||
fixtures: SharedControlsFixture[]
|
||||
): void {
|
||||
for (const fixture of fixtures.splice(0)) {
|
||||
fixture.destroy();
|
||||
}
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
|
||||
export function renderSharedControls(
|
||||
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
|
||||
fixtures: SharedControlsFixture[],
|
||||
options: RenderOptions = {}
|
||||
) {
|
||||
const fixture = createFixture(HtmlVideoPlayerComponent, fixtures);
|
||||
if (options.channel) {
|
||||
fixture.componentRef.setInput('channel', options.channel);
|
||||
}
|
||||
fixture.componentRef.setInput(
|
||||
'interactionEnabled',
|
||||
options.interactionEnabled ?? true
|
||||
);
|
||||
fixture.componentRef.setInput('isLive', options.isLive ?? true);
|
||||
fixture.componentRef.setInput(
|
||||
'seriesNavigation',
|
||||
options.seriesNavigation ?? null
|
||||
);
|
||||
fixture.componentRef.setInput(
|
||||
'showCaptions',
|
||||
options.showCaptions ?? false
|
||||
);
|
||||
fixture.detectChanges();
|
||||
return renderedFixture(fixture);
|
||||
}
|
||||
|
||||
export function renderSharedControlsDefaults(
|
||||
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
|
||||
fixtures: SharedControlsFixture[]
|
||||
) {
|
||||
const fixture = createFixture(HtmlVideoPlayerComponent, fixtures);
|
||||
fixture.detectChanges();
|
||||
return renderedFixture(fixture);
|
||||
}
|
||||
|
||||
export function readHtmlPlayerInternals(
|
||||
component: HtmlVideoPlayerComponentInstance
|
||||
): HtmlPlayerInternals {
|
||||
return component as unknown as HtmlPlayerInternals;
|
||||
}
|
||||
|
||||
export function observeBridgeSourceBinding(
|
||||
component: HtmlVideoPlayerComponentInstance
|
||||
) {
|
||||
const bridge = readHtmlPlayerInternals(component).controlsBridge;
|
||||
if (!bridge) {
|
||||
throw new Error('Expected initialized HTML player controls bridge');
|
||||
}
|
||||
const setSource = bridge.setSource.bind(bridge);
|
||||
return jest.spyOn(bridge, 'setSource').mockImplementation((source) => {
|
||||
lifecycle.push(`bridge:${source.kind}`);
|
||||
setSource(source);
|
||||
});
|
||||
}
|
||||
|
||||
function createFixture(
|
||||
HtmlVideoPlayerComponent: HtmlVideoPlayerComponentType,
|
||||
fixtures: SharedControlsFixture[]
|
||||
): SharedControlsFixture {
|
||||
const fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
|
||||
fixtures.push(fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function renderedFixture(fixture: SharedControlsFixture) {
|
||||
return {
|
||||
fixture,
|
||||
component: fixture.componentInstance,
|
||||
adapter: fixture.debugElement.injector.get(WebVideoControlsAdapter),
|
||||
controls: fixture.debugElement.query(
|
||||
By.directive(PlayerControlsComponent)
|
||||
)?.componentInstance as PlayerControlsComponent | undefined,
|
||||
};
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
PlayerControlsComponent,
|
||||
WebVideoControlsAdapter,
|
||||
} from '../player-controls';
|
||||
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
|
||||
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
|
||||
import {
|
||||
cleanupSharedControlsTests,
|
||||
configureSharedControlsTests,
|
||||
renderSharedControls,
|
||||
renderSharedControlsDefaults,
|
||||
type SharedControlsFixture,
|
||||
} from './html-video-player.component.shared-controls.spec-fixtures';
|
||||
|
||||
describe('HtmlVideoPlayerComponent shared controls host', () => {
|
||||
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
|
||||
const fixtures: SharedControlsFixture[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ HtmlVideoPlayerComponent } =
|
||||
await import('./html-video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await configureSharedControlsTests(HtmlVideoPlayerComponent);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupSharedControlsTests(fixtures);
|
||||
});
|
||||
|
||||
it('uses declared shared-control defaults without input overrides', () => {
|
||||
const { component, controls } = renderSharedControlsDefaults(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
|
||||
expect(component.isLive()).toBe(true);
|
||||
expect(component.interactionEnabled()).toBe(true);
|
||||
expect(component.showCaptions()).toBe(false);
|
||||
expect(controls?.showControls()).toBe(true);
|
||||
expect(controls?.shortcutsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes one adapter and context initialization to each component', () => {
|
||||
const setContext = jest.spyOn(
|
||||
WebVideoControlsAdapter.prototype,
|
||||
'setContext'
|
||||
);
|
||||
const first = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
|
||||
const second = renderSharedControls(HtmlVideoPlayerComponent, fixtures);
|
||||
|
||||
expect(first.adapter).not.toBe(second.adapter);
|
||||
expect(setContext).toHaveBeenCalledTimes(2);
|
||||
|
||||
first.fixture.detectChanges();
|
||||
second.fixture.detectChanges();
|
||||
expect(setContext).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses one shared controls instance on the actual player shell', () => {
|
||||
const { fixture, controls } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{
|
||||
seriesNavigation: {
|
||||
canPrevious: true,
|
||||
canNext: true,
|
||||
autoplayEnabled: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const shell = fixture.debugElement.query(
|
||||
By.css('.html-video-player-shell')
|
||||
).nativeElement;
|
||||
const video = fixture.debugElement.query(By.css('video'))
|
||||
.nativeElement as HTMLVideoElement;
|
||||
|
||||
expect(video.controls).toBe(false);
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.directive(PlayerControlsComponent))
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(SeriesPlaybackNavigationControlsComponent)
|
||||
)
|
||||
).toBeNull();
|
||||
expect(controls?.playerSurface()).toBe(shell);
|
||||
});
|
||||
|
||||
it('gates the shared surface and shortcuts with interaction availability', () => {
|
||||
const { fixture, controls } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{ interactionEnabled: false }
|
||||
);
|
||||
|
||||
expect(controls?.showControls()).toBe(false);
|
||||
expect(controls?.shortcutsEnabled()).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput('interactionEnabled', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(controls?.showControls()).toBe(true);
|
||||
expect(controls?.shortcutsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('exits only its own fullscreen shell when interactions become unavailable', () => {
|
||||
const { fixture } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
const shell = fixture.debugElement.query(
|
||||
By.css('.html-video-player-shell')
|
||||
).nativeElement as HTMLElement;
|
||||
const unrelatedSurface = document.createElement('div');
|
||||
const fullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
'fullscreenElement'
|
||||
);
|
||||
const exitFullscreenDescriptor = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
'exitFullscreen'
|
||||
);
|
||||
let fullscreenElement: Element | null = unrelatedSurface;
|
||||
const exitFullscreen = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => fullscreenElement,
|
||||
});
|
||||
Object.defineProperty(document, 'exitFullscreen', {
|
||||
configurable: true,
|
||||
value: exitFullscreen,
|
||||
});
|
||||
|
||||
try {
|
||||
fixture.componentRef.setInput('interactionEnabled', false);
|
||||
fixture.detectChanges();
|
||||
expect(exitFullscreen).not.toHaveBeenCalled();
|
||||
|
||||
fixture.componentRef.setInput('interactionEnabled', true);
|
||||
fixture.detectChanges();
|
||||
fullscreenElement = shell;
|
||||
fixture.componentRef.setInput('interactionEnabled', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(exitFullscreen).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
restoreDocumentProperty(
|
||||
'fullscreenElement',
|
||||
fullscreenElementDescriptor
|
||||
);
|
||||
restoreDocumentProperty('exitFullscreen', exitFullscreenDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps series context reactive and forwards navigation outputs', () => {
|
||||
const setContext = jest.spyOn(
|
||||
WebVideoControlsAdapter.prototype,
|
||||
'setContext'
|
||||
);
|
||||
const { fixture, component, adapter, controls } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures,
|
||||
{ isLive: false }
|
||||
);
|
||||
const previous = jest.fn();
|
||||
const next = jest.fn();
|
||||
component.previousEpisodeRequested.subscribe(previous);
|
||||
component.nextEpisodeRequested.subscribe(next);
|
||||
|
||||
expect(adapter.capabilities().seriesNavigation).toBe(false);
|
||||
fixture.componentRef.setInput('seriesNavigation', {
|
||||
canPrevious: true,
|
||||
canNext: false,
|
||||
autoplayEnabled: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(adapter.capabilities().seriesNavigation).toBe(true);
|
||||
expect(adapter.state().canPreviousEpisode).toBe(true);
|
||||
expect(adapter.state().canNextEpisode).toBe(false);
|
||||
controls?.previousEpisodeRequested.emit();
|
||||
controls?.nextEpisodeRequested.emit();
|
||||
expect(previous).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(setContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not run legacy post-play caption suppression', async () => {
|
||||
const { component } = renderSharedControls(
|
||||
HtmlVideoPlayerComponent,
|
||||
fixtures
|
||||
);
|
||||
const disableCaptions = jest.spyOn(
|
||||
component as HtmlVideoPlayerComponentInstance,
|
||||
'disableCaptions'
|
||||
);
|
||||
|
||||
component.handlePlayOperation();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(disableCaptions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function restoreDocumentProperty(
|
||||
property: 'exitFullscreen' | 'fullscreenElement',
|
||||
descriptor: PropertyDescriptor | undefined
|
||||
): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(document, property, descriptor);
|
||||
return;
|
||||
}
|
||||
|
||||
delete (document as unknown as Record<string, unknown>)[property];
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlayerControlsComponent,
|
||||
WebVideoControlsAdapter,
|
||||
} from '../player-controls';
|
||||
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
|
||||
import { HtmlVideoPlayerComponent } from './html-video-player.component';
|
||||
|
||||
describe('HtmlVideoPlayerComponent', () => {
|
||||
let component: HtmlVideoPlayerComponent;
|
||||
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
|
||||
let adapterAttach: jest.SpiedFunction<WebVideoControlsAdapter['attach']>;
|
||||
const electronApi = {
|
||||
setUserAgent: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
@@ -49,6 +55,7 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
adapterAttach = jest.spyOn(WebVideoControlsAdapter.prototype, 'attach');
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electronApi,
|
||||
@@ -62,34 +69,61 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { electron?: unknown }).electron;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps native and legacy controls when shared controls are disabled', () => {
|
||||
const video = component.videoPlayer.nativeElement;
|
||||
|
||||
expect(video.controls).toBe(true);
|
||||
expect(
|
||||
fixture.debugElement.query(By.directive(PlayerControlsComponent))
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(SeriesPlaybackNavigationControlsComponent)
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(adapterAttach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps legacy post-play caption suppression when shared controls are disabled', async () => {
|
||||
const video = component.videoPlayer.nativeElement;
|
||||
const tracks = [{ mode: 'showing' as TextTrackMode }];
|
||||
Object.defineProperty(video, 'textTracks', {
|
||||
configurable: true,
|
||||
value: tracks,
|
||||
});
|
||||
jest.spyOn(video, 'play').mockResolvedValue(undefined);
|
||||
fixture.componentRef.setInput('showCaptions', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.handlePlayOperation();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(tracks[0].mode).toBe('hidden');
|
||||
});
|
||||
|
||||
it('detaches volume/metadata/timeupdate listeners on destroy (no leak)', () => {
|
||||
const el = component.videoPlayer.nativeElement;
|
||||
const removeSpy = jest.spyOn(el, 'removeEventListener');
|
||||
const handlers = component as unknown as {
|
||||
handleVolumeChange: EventListener;
|
||||
handleLoadedMetadata: EventListener;
|
||||
handleTimeUpdate: EventListener;
|
||||
};
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'volumechange',
|
||||
handlers.handleVolumeChange
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'loadedmetadata',
|
||||
handlers.handleLoadedMetadata
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'timeupdate',
|
||||
handlers.handleTimeUpdate
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -181,9 +215,7 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
const sources = Array.from(video.querySelectorAll('source'));
|
||||
const [source] = sources;
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(source?.src).toBe(
|
||||
'https://stream.example/series/s01e02.mp4'
|
||||
);
|
||||
expect(source?.src).toBe('https://stream.example/series/s01e02.mp4');
|
||||
expect(source?.type).toBe('video/mp4');
|
||||
expect(loadSpy).toHaveBeenCalledTimes(2);
|
||||
expect(playSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -3,30 +3,41 @@ import {
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
input,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
viewChild,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import {
|
||||
PlayerControlsComponent,
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
WebVideoControlsAdapter,
|
||||
} from '../player-controls';
|
||||
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { HtmlVideoElementSession } from './html-video-element-session';
|
||||
import {
|
||||
HtmlVideoPlayerControlsBridge,
|
||||
type HtmlVideoControlsSource,
|
||||
} from './html-video-player-controls.bridge';
|
||||
|
||||
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
|
||||
@@ -37,7 +48,11 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
|
||||
selector: 'app-html-video-player',
|
||||
templateUrl: './html-video-player.component.html',
|
||||
styleUrls: ['./html-video-player.component.scss'],
|
||||
imports: [SeriesPlaybackNavigationControlsComponent],
|
||||
imports: [
|
||||
PlayerControlsComponent,
|
||||
SeriesPlaybackNavigationControlsComponent,
|
||||
],
|
||||
providers: [WebVideoControlsAdapter],
|
||||
standalone: true,
|
||||
})
|
||||
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@@ -46,6 +61,9 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Input() volume = 1;
|
||||
@Input() startTime = 0;
|
||||
@Input() seriesNavigation: SeriesPlaybackNavigation | null = null;
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly showCaptions = input(false);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -55,90 +73,43 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@Output() previousEpisodeRequested = new EventEmitter<void>();
|
||||
@Output() nextEpisodeRequested = new EventEmitter<void>();
|
||||
|
||||
private readonly dataService = inject(DataService);
|
||||
readonly sharedControls = inject(WEB_PLAYER_SHARED_CONTROLS);
|
||||
readonly controlsAdapter = inject(WebVideoControlsAdapter);
|
||||
private readonly seriesNavigationSignal =
|
||||
signal<SeriesPlaybackNavigation | null>(null);
|
||||
|
||||
/** Video player DOM element */
|
||||
readonly playerRoot = viewChild<ElementRef<HTMLElement>>('playerRoot');
|
||||
|
||||
@ViewChild('videoPlayer', { static: true })
|
||||
videoPlayer!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
/** HLS object */
|
||||
hls!: Hls;
|
||||
hls: Hls | null = null;
|
||||
/** mpegts.js player for raw MPEG-TS streams */
|
||||
private mpegtsPlayer: mpegts.Player | null = null;
|
||||
|
||||
/** Captions/subtitles indicator */
|
||||
@Input() showCaptions!: boolean;
|
||||
|
||||
private readonly handleNativePlaybackError = () => {
|
||||
const metadata = this.createSourceMetadata(
|
||||
this.channel?.url ?? this.videoPlayer.nativeElement.currentSrc
|
||||
);
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyNativePlaybackIssue(
|
||||
this.videoPlayer.nativeElement.error,
|
||||
metadata
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = () => {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
private readonly handleVolumeChange = (): void => {
|
||||
this.onVolumeChange();
|
||||
};
|
||||
|
||||
private readonly handleLoadedMetadata = (): void => {
|
||||
if (this.startTime > 0) {
|
||||
this.videoPlayer.nativeElement.currentTime = this.startTime;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly handleTimeUpdate = (): void => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.videoPlayer.nativeElement.currentTime,
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly handlePlaybackEnded = (): void => {
|
||||
this.playbackEnded.emit();
|
||||
};
|
||||
private controlsSource: HtmlVideoControlsSource | null = null;
|
||||
private controlsBridge: HtmlVideoPlayerControlsBridge | null = null;
|
||||
private videoSession: HtmlVideoElementSession | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'volumechange',
|
||||
this.handleVolumeChange
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadedmetadata',
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'ended',
|
||||
this.handlePlaybackEnded
|
||||
);
|
||||
if (this.sharedControls) {
|
||||
this.seriesNavigationSignal.set(this.seriesNavigation);
|
||||
this.controlsAdapter.setContext({
|
||||
seriesNavigation: this.seriesNavigationSignal,
|
||||
});
|
||||
this.controlsBridge = new HtmlVideoPlayerControlsBridge({
|
||||
video: this.videoPlayer.nativeElement,
|
||||
adapter: this.controlsAdapter,
|
||||
isLive: () => this.isLive(),
|
||||
showCaptions: () => this.showCaptions(),
|
||||
});
|
||||
this.controlsBridge.attach();
|
||||
if (this.controlsSource) {
|
||||
this.controlsBridge.setSource(this.controlsSource);
|
||||
}
|
||||
}
|
||||
this.getVideoSession().attach();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,9 +117,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
* @param changes component changes
|
||||
*/
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['seriesNavigation']) {
|
||||
this.seriesNavigationSignal.set(this.seriesNavigation);
|
||||
}
|
||||
if (changes['channel'] && changes['channel'].currentValue) {
|
||||
this.playChannel(changes['channel'].currentValue);
|
||||
}
|
||||
if (changes['isLive'] || changes['showCaptions']) {
|
||||
this.controlsBridge?.refreshInputs();
|
||||
}
|
||||
if (changes['interactionEnabled']?.currentValue === false) {
|
||||
this.exitOwnedFullscreen();
|
||||
}
|
||||
if (changes['volume']?.currentValue !== undefined) {
|
||||
debugHtmlPlayer(
|
||||
'Setting HTML5 player volume to:',
|
||||
@@ -159,19 +139,37 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private exitOwnedFullscreen(): void {
|
||||
if (
|
||||
!this.sharedControls ||
|
||||
document.fullscreenElement !== this.playerRoot()?.nativeElement ||
|
||||
typeof document.exitFullscreen !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
void Promise.resolve(document.exitFullscreen()).catch(
|
||||
(error: unknown) => {
|
||||
debugHtmlPlayer(
|
||||
'Failed to exit HTML5 player fullscreen:',
|
||||
error
|
||||
);
|
||||
}
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
debugHtmlPlayer('Failed to exit HTML5 player fullscreen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts to play the given channel
|
||||
* @param channel given channel object
|
||||
*/
|
||||
playChannel(channel: Channel): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) this.hls.destroy();
|
||||
this.clearControlsSource();
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
this.clearNativeVideoSources(this.videoPlayer.nativeElement);
|
||||
if (channel.url) {
|
||||
this.playbackIssue.emit(null);
|
||||
@@ -199,12 +197,13 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
this.mpegtsPlayer = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
isLive: true,
|
||||
isLive: this.isLive(),
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(
|
||||
this.videoPlayer.nativeElement
|
||||
);
|
||||
this.bindControlsSource({ kind: 'mpegts' });
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(type: string, details: string, info: unknown): void => {
|
||||
@@ -229,15 +228,17 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
Hls.isSupported()
|
||||
) {
|
||||
debugHtmlPlayer('Switching channel to:', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
const hls = new Hls();
|
||||
this.hls = hls;
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
this.handleHlsError(url, data);
|
||||
});
|
||||
this.hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.hls.loadSource(url);
|
||||
hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.bindControlsSource({ kind: 'hls', hls });
|
||||
hls.loadSource(url);
|
||||
this.handlePlayOperation();
|
||||
} else {
|
||||
debugHtmlPlayer('Using native video player');
|
||||
@@ -266,40 +267,50 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
source.src = url;
|
||||
source.type = type;
|
||||
element.appendChild(source);
|
||||
this.bindControlsSource({ kind: 'native' });
|
||||
element.load();
|
||||
}
|
||||
|
||||
private bindControlsSource(source: HtmlVideoControlsSource): void {
|
||||
this.controlsSource = source;
|
||||
this.controlsBridge?.setSource(source);
|
||||
}
|
||||
|
||||
private clearControlsSource(): void {
|
||||
this.controlsBridge?.clearSource();
|
||||
this.controlsSource = null;
|
||||
}
|
||||
|
||||
private destroyMpegtsPlayer(): void {
|
||||
const player = this.mpegtsPlayer;
|
||||
this.mpegtsPlayer = null;
|
||||
if (!player) {
|
||||
return;
|
||||
}
|
||||
player.pause();
|
||||
player.unload();
|
||||
player.detachMediaElement();
|
||||
player.destroy();
|
||||
}
|
||||
|
||||
private destroyHls(): void {
|
||||
const hls = this.hls;
|
||||
this.hls = null;
|
||||
hls?.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables text based captions based on the global settings
|
||||
*/
|
||||
disableCaptions(): void {
|
||||
for (
|
||||
let i = 0;
|
||||
i < this.videoPlayer.nativeElement.textTracks.length;
|
||||
i++
|
||||
) {
|
||||
this.videoPlayer.nativeElement.textTracks[i].mode = 'hidden';
|
||||
}
|
||||
this.getVideoSession().disableCaptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles promise based play operation
|
||||
*/
|
||||
handlePlayOperation(): void {
|
||||
const playPromise = this.videoPlayer.nativeElement.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
// Automatic playback started!
|
||||
if (!this.showCaptions) {
|
||||
this.disableCaptions();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Do nothing
|
||||
});
|
||||
}
|
||||
this.getVideoSession().play();
|
||||
}
|
||||
|
||||
private handleHlsManifestParsed(
|
||||
@@ -356,56 +367,30 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save volume when user changes it
|
||||
*/
|
||||
onVolumeChange(): void {
|
||||
const currentVolume = this.videoPlayer.nativeElement.volume;
|
||||
debugHtmlPlayer('Volume changed to:', currentVolume);
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy hls instance on component destroy and clean up event listener
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'volumechange',
|
||||
this.handleVolumeChange
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadedmetadata',
|
||||
this.handleLoadedMetadata
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'timeupdate',
|
||||
this.handleTimeUpdate
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'ended',
|
||||
this.handlePlaybackEnded
|
||||
);
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
this.mpegtsPlayer.detachMediaElement();
|
||||
this.mpegtsPlayer.destroy();
|
||||
this.mpegtsPlayer = null;
|
||||
}
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.controlsBridge?.destroy();
|
||||
this.controlsBridge = null;
|
||||
this.controlsSource = null;
|
||||
this.videoSession?.destroy();
|
||||
this.videoSession = null;
|
||||
this.destroyMpegtsPlayer();
|
||||
this.destroyHls();
|
||||
}
|
||||
|
||||
private getVideoSession(): HtmlVideoElementSession {
|
||||
this.videoSession ??= new HtmlVideoElementSession({
|
||||
video: this.videoPlayer.nativeElement,
|
||||
getChannelUrl: () => this.channel?.url,
|
||||
getStartTime: () => this.startTime,
|
||||
showCaptions: () => this.showCaptions(),
|
||||
sharedControls: () => this.sharedControls,
|
||||
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
|
||||
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
|
||||
emitPlaybackEnded: () => this.playbackEnded.emit(),
|
||||
});
|
||||
return this.videoSession;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { InjectionToken } from '@angular/core';
|
||||
* Reserved rollout switch for shared `app-player-controls` chrome on the web
|
||||
* video engines (Video.js, html5+hls.js, ArtPlayer).
|
||||
*
|
||||
* DEFAULT OFF. #1148 adds no runtime consumer, so changing this constant alone
|
||||
* has no effect until a follow-up engine host reads the injectable
|
||||
* {@link WEB_PLAYER_SHARED_CONTROLS} token and performs the actual UI switch.
|
||||
* DEFAULT OFF. The built-in HTML5 player consumes the injectable
|
||||
* {@link WEB_PLAYER_SHARED_CONTROLS} token and switches atomically between its
|
||||
* native chrome and shared controls. Video.js and ArtPlayer do not consume the
|
||||
* token yet.
|
||||
*/
|
||||
export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false;
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
type ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
@Component({ selector: 'app-vjs-player', template: '' })
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-html-video-player', template: '' })
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-art-player', template: '' })
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-embedded-mpv-player', template: '' })
|
||||
class StubEmbeddedMpvPlayerComponent {
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly recordingFolder = input('');
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
describe('WebPlayerViewComponent shared HTML5 controls metadata', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
let component: WebPlayerViewComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('./web-player-view.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
deferBlockBehavior: DeferBlockBehavior.Playthrough,
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsManagedExternalPlayers: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(WebPlayerViewComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/default.ts'
|
||||
);
|
||||
fixture.componentRef.setInput('title', 'Default stream');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an explicit VOD value', { isLive: false }, false],
|
||||
[
|
||||
'an explicit live value with VOD content metadata',
|
||||
{ isLive: true, contentInfo: createVodContentInfo() },
|
||||
true,
|
||||
],
|
||||
[
|
||||
'VOD content metadata without an explicit value',
|
||||
{ contentInfo: createVodContentInfo() },
|
||||
false,
|
||||
],
|
||||
['missing content metadata and explicit value', {}, true],
|
||||
])('resolves %s', (_label, metadata, expected) => {
|
||||
setPlayback(metadata);
|
||||
|
||||
expect(component.resolvedIsLive()).toBe(expected);
|
||||
});
|
||||
|
||||
it('passes the resolved live value to the HTML5 player', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer({ isLive: false });
|
||||
|
||||
expect(htmlPlayer.isLive()).toBe(false);
|
||||
});
|
||||
|
||||
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackInteractionEnabled()).toBe(false);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.retryPlayback();
|
||||
fixture.detectChanges();
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.handlePlaybackIssue(null);
|
||||
fixture.detectChanges();
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['inferred VOD', { contentInfo: createVodContentInfo() }, false],
|
||||
[
|
||||
'explicit live VOD content',
|
||||
{ isLive: true, contentInfo: createVodContentInfo() },
|
||||
true,
|
||||
],
|
||||
])(
|
||||
'uses the resolved value for Video.js effect and retry: %s',
|
||||
(_label, metadata, expected) => {
|
||||
const streamUrl = 'https://example.com/video.ts';
|
||||
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
|
||||
setPlayback(metadata, streamUrl);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
|
||||
setVjsOptions.mockClear();
|
||||
component.retryPlayback();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
}
|
||||
);
|
||||
|
||||
function setPlayback(
|
||||
metadata: Partial<ResolvedPortalPlayback>,
|
||||
streamUrl = 'https://example.com/playback.ts'
|
||||
): void {
|
||||
fixture.componentRef.setInput('playback', {
|
||||
streamUrl,
|
||||
title: 'Playback',
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async function renderHtmlPlayer(
|
||||
metadata: Partial<ResolvedPortalPlayback> = {}
|
||||
): Promise<StubHtmlVideoPlayerComponent> {
|
||||
fixture.componentRef.setInput(
|
||||
'playerOverride',
|
||||
VideoPlayer.Html5Player
|
||||
);
|
||||
setPlayback(metadata);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
return fixture.debugElement.query(
|
||||
By.directive(StubHtmlVideoPlayerComponent)
|
||||
).componentInstance as StubHtmlVideoPlayerComponent;
|
||||
}
|
||||
});
|
||||
|
||||
function createVodContentInfo() {
|
||||
return {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 123,
|
||||
contentType: 'vod' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
sourceUrl: 'https://example.com/playback.ts',
|
||||
container: 'ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'html5',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,8 @@ class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly isLive = input(true);
|
||||
readonly interactionEnabled = input(true);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
|
||||
@@ -102,6 +102,9 @@ export class WebPlayerViewComponent {
|
||||
? null
|
||||
: this.playbackDiagnostic()
|
||||
);
|
||||
readonly playbackInteractionEnabled = computed(
|
||||
() => this.visiblePlaybackDiagnostic() === null
|
||||
);
|
||||
readonly canShowExternalFallbackActions = computed(
|
||||
() =>
|
||||
this.runtime.supportsManagedExternalPlayers &&
|
||||
@@ -125,6 +128,12 @@ export class WebPlayerViewComponent {
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
readonly resolvedIsLive = computed(() => {
|
||||
const playback = this.resolvedPlayback();
|
||||
return typeof playback.isLive === 'boolean'
|
||||
? playback.isLive
|
||||
: !playback.contentInfo;
|
||||
});
|
||||
readonly selectedPlayer = computed(
|
||||
() =>
|
||||
this.playerOverride() ??
|
||||
@@ -143,10 +152,7 @@ export class WebPlayerViewComponent {
|
||||
const playback = this.resolvedPlayback();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(
|
||||
playback.streamUrl,
|
||||
this.isLivePlayback(playback)
|
||||
);
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -233,7 +239,7 @@ export class WebPlayerViewComponent {
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.reloadToken.update((value) => value + 1);
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, this.isLivePlayback(playback));
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
}
|
||||
|
||||
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
@@ -331,14 +337,6 @@ export class WebPlayerViewComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private isLivePlayback(playback: ResolvedPortalPlayback): boolean {
|
||||
if (typeof playback.isLive === 'boolean') {
|
||||
return playback.isLive;
|
||||
}
|
||||
|
||||
return !playback.contentInfo;
|
||||
}
|
||||
|
||||
private getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-fixtures.ts"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user