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:
4gray authored and GitHub committed 2026-07-16 21:30:41 +02:00
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;
}
}
@@ -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();
});
});
@@ -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);
});
});
@@ -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>
@@ -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')
);
});
});
@@ -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,
};
}
@@ -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)"
@@ -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
+5 -1
View File
@@ -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"
]
}