mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
724e4b1ab3
commit
5f4cf01467
3 files changed
+224
-24
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user