diff --git a/libs/ui/playback/project.json b/libs/ui/playback/project.json index 36936ed98..5e1117fe5 100644 --- a/libs/ui/playback/project.json +++ b/libs/ui/playback/project.json @@ -6,6 +6,21 @@ "projectType": "library", "tags": ["scope:shared", "domain:playback", "type:ui"], "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/ui/playback/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts new file mode 100644 index 000000000..4aed167b9 --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -0,0 +1,171 @@ +import { SimpleChange } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; + +const videoJsMock = jest.fn(); +const mpegTsIsSupportedMock = jest.fn(() => false); + +jest.unstable_mockModule('video.js', () => ({ + default: videoJsMock, +})); + +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +jest.unstable_mockModule('mpegts.js', () => ({ + default: { + createPlayer: jest.fn(), + isSupported: mpegTsIsSupportedMock, + }, +})); + +describe('VjsPlayerComponent', () => { + let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent; + let component: VjsPlayerComponentInstance; + let player: VjsPlayerComponentInstance['player']; + type SignalApiShape = { + options: () => unknown; + volume: () => number; + startTime: () => number; + timeUpdate: { emit: (event: unknown) => void }; + }; + + beforeAll(async () => { + ({ VjsPlayerComponent } = await import('./vjs-player.component')); + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VjsPlayerComponent], + }).compileComponents(); + + component = TestBed.createComponent(VjsPlayerComponent).componentInstance; + player = { + src: jest.fn(), + reset: jest.fn(), + volume: jest.fn(), + dispose: jest.fn(), + } as unknown as VjsPlayerComponentInstance['player']; + component.player = player; + }); + + it('uses signal-based inputs and outputs', () => { + const signalComponent = component as unknown as SignalApiShape; + + expect(typeof signalComponent.options).toBe('function'); + expect(signalComponent.volume()).toBe(1); + expect(signalComponent.startTime()).toBe(0); + expect(typeof signalComponent.timeUpdate.emit).toBe('function'); + }); + + it('does not reset VideoJS when options change without changing the source', () => { + const previousOptions = { + sources: [ + { + src: 'https://example.com/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + const currentOptions = { + sources: [ + { + src: 'https://example.com/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + + component.ngOnChanges({ + options: new SimpleChange(previousOptions, currentOptions, false), + }); + + expect(player.src).not.toHaveBeenCalled(); + }); + + it('updates VideoJS when options change to a different source', () => { + const previousOptions = { + sources: [ + { + src: 'https://example.com/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + const currentOptions = { + sources: [ + { + src: 'https://example.com/live/other-playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + + component.ngOnChanges({ + options: new SimpleChange(previousOptions, currentOptions, false), + }); + + expect(player.src).toHaveBeenCalledWith(currentOptions.sources[0]); + }); + + it('updates volume when options change without changing the source', () => { + const previousOptions = { + sources: [ + { + src: 'https://example.com/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + const currentOptions = { + sources: [ + { + src: 'https://example.com/live/playlist.m3u8', + type: 'application/x-mpegURL', + }, + ], + }; + + component.ngOnChanges({ + options: new SimpleChange(previousOptions, currentOptions, false), + volume: new SimpleChange(0.5, 0.75, false), + }); + + expect(player.src).not.toHaveBeenCalled(); + expect(player.volume).toHaveBeenCalledWith(0.75); + }); + + it('tears down mpegts playback when options clear the source', () => { + const mpegtsPlayer = { + pause: jest.fn(), + unload: jest.fn(), + detachMediaElement: jest.fn(), + destroy: jest.fn(), + }; + const componentInternals = component as unknown as { + mpegtsPlayer: typeof mpegtsPlayer | null; + }; + componentInternals.mpegtsPlayer = mpegtsPlayer; + const previousOptions = { + sources: [ + { + src: 'https://example.com/live/stream.ts', + type: 'video/mp2t', + }, + ], + }; + + component.ngOnChanges({ + options: new SimpleChange(previousOptions, { sources: [] }, false), + }); + + expect(mpegtsPlayer.pause).toHaveBeenCalled(); + expect(mpegtsPlayer.unload).toHaveBeenCalled(); + expect(mpegtsPlayer.detachMediaElement).toHaveBeenCalled(); + expect(mpegtsPlayer.destroy).toHaveBeenCalled(); + expect(componentInternals.mpegtsPlayer).toBeNull(); + expect(player.reset).toHaveBeenCalled(); + expect(player.src).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 202d162e6..86fe0bd61 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -1,15 +1,14 @@ import { Component, ElementRef, - EventEmitter, - Input, OnChanges, OnDestroy, OnInit, - Output, SimpleChanges, - ViewChild, ViewEncapsulation, + input, + output, + viewChild, } from '@angular/core'; import '@yangkghjh/videojs-aspect-ratio-panel'; import { getExtensionFromUrl } from 'm3u-utils'; @@ -88,16 +87,16 @@ type VideoJsPlayer = Omit< }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { /** DOM-element reference */ - @ViewChild('target', { static: true }) target!: ElementRef; + readonly target = viewChild.required>('target'); /** Options of VideoJs player */ - @Input() options!: VideoPlayerOptions; + readonly options = input.required(); /** VideoJs object */ player!: VideoJsPlayer; /** mpegts.js player for raw MPEG-TS streams */ private mpegtsPlayer: mpegts.Player | null = null; - @Input() volume = 1; - @Input() startTime = 0; - @Output() timeUpdate = new EventEmitter<{ + readonly volume = input(1); + readonly startTime = input(0); + readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); @@ -106,27 +105,27 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { * Instantiate Video.js on component init */ ngOnInit(): void { - const source = this.options?.sources?.[0]; + const source = this.options().sources?.[0]; const isMpegTs = this.isMpegTsSource(source?.src); // For raw MPEG-TS streams, init Video.js without a source (UI/controls only) const vjsOptions = isMpegTs - ? { ...this.options, sources: [], autoplay: false } - : { ...this.options, autoplay: true }; + ? { ...this.options(), sources: [], autoplay: false } + : { ...this.options(), autoplay: true }; this.player = videoJs( - this.target.nativeElement, + this.target().nativeElement, vjsOptions, () => { console.log( 'Setting VideoJS player initial volume to:', - this.volume + this.volume() ); - this.player.volume(this.volume); + this.player.volume(this.volume()); this.player.on('loadedmetadata', () => { - if (this.startTime > 0) { - this.player.currentTime(this.startTime); + if (this.startTime() > 0) { + this.player.currentTime(this.startTime()); } this.logAudioTracks(); this.setupAudioTrackMenu(); @@ -198,14 +197,19 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { */ ngOnChanges(changes: SimpleChanges): void { if (changes['options']?.previousValue) { - const newSource = changes['options'].currentValue.sources[0]; - if (this.isMpegTsSource(newSource?.src)) { + const previousSource = + changes['options'].previousValue.sources?.[0]; + const newSource = changes['options'].currentValue.sources?.[0]; + if (this.hasSourceChanged(previousSource, newSource)) { this.destroyMpegTs(); - this.player.reset(); - this.initMpegTs(newSource.src); - } else { - this.destroyMpegTs(); - this.player.src(newSource); + if (!newSource) { + this.player.reset(); + } else if (this.isMpegTsSource(newSource.src)) { + this.player.reset(); + this.initMpegTs(newSource.src); + } else { + this.player.src(newSource); + } } } if (changes['volume']?.currentValue !== undefined && this.player) { @@ -232,6 +236,16 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { return getExtensionFromUrl(url) === 'ts' && mpegts.isSupported(); } + private hasSourceChanged( + previousSource: VideoPlayerSource | undefined, + newSource: VideoPlayerSource | undefined + ): boolean { + return ( + previousSource?.src !== newSource?.src || + previousSource?.type !== newSource?.type + ); + } + private initMpegTs(url: string): void { const videoEl = this.player.tech({ IWillNotUseThisInPlugins: true })?.el(); if (!videoEl) return;