From d539de3ddac81a1a9ba639b5d13514e11afecef9 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 10 May 2026 12:07:53 +0200 Subject: [PATCH] fix(playback): avoid redundant VideoJS source resets --- libs/ui/playback/project.json | 15 +++ .../vjs-player/vjs-player.component.spec.ts | 117 ++++++++++++++++++ .../lib/vjs-player/vjs-player.component.ts | 30 +++-- 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts 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..78b08315a --- /dev/null +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -0,0 +1,117 @@ +import { SimpleChange } from '@angular/core'; +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']; + + beforeAll(async () => { + ({ VjsPlayerComponent } = await import('./vjs-player.component')); + }); + + beforeEach(() => { + component = new VjsPlayerComponent(); + player = { + src: jest.fn(), + reset: jest.fn(), + volume: jest.fn(), + } as unknown as VjsPlayerComponentInstance['player']; + component.player = player; + }); + + 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); + }); +}); 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..f14f1f70b 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 @@ -198,14 +198,18 @@ 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)) { - this.destroyMpegTs(); - this.player.reset(); - this.initMpegTs(newSource.src); - } else { - this.destroyMpegTs(); - this.player.src(newSource); + const previousSource = + changes['options'].previousValue.sources?.[0]; + const newSource = changes['options'].currentValue.sources?.[0]; + if (newSource && !this.isSameSource(previousSource, newSource)) { + if (this.isMpegTsSource(newSource.src)) { + this.destroyMpegTs(); + this.player.reset(); + this.initMpegTs(newSource.src); + } else { + this.destroyMpegTs(); + 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 isSameSource( + previousSource: VideoPlayerSource | undefined, + newSource: VideoPlayerSource + ): 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;