fix(playback): avoid redundant VideoJS source resets (#920)

* fix(playback): avoid redundant VideoJS source resets

* fix(playback): clean up cleared VideoJS sources

* refactor(playback): use signal APIs in VideoJS player

closes #608
This commit is contained in:
4gray authored and GitHub committed 2026-05-10 15:32:00 +02:00
1 parent 724e4b1ab3
commit 5f4cf01467
3 files changed
+224 -24

No files matched your search

+15
View File
@@ -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"
}
@@ -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();
});
});
@@ -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<Element>;
readonly target = viewChild.required<ElementRef<Element>>('target');
/** Options of VideoJs player */
@Input() options!: VideoPlayerOptions;
readonly options = input.required<VideoPlayerOptions>();
/** 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;