mirror of
https://github.com/video-dev/hls.js.git
synced 2026-05-17 13:30:38 +00:00
074821e317
Replaces #7558
590 lines
18 KiB
TypeScript
590 lines
18 KiB
TypeScript
import { expect, use } from 'chai';
|
|
import sinon from 'sinon';
|
|
import sinonChai from 'sinon-chai';
|
|
import BufferController from '../../../src/controller/buffer-controller';
|
|
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
|
|
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
|
|
import { Events } from '../../../src/events';
|
|
import Hls from '../../../src/hls';
|
|
import { MockMediaElement, MockMediaSource } from '../../mocks/mock-media';
|
|
import type BufferOperationQueue from '../../../src/controller/buffer-operation-queue';
|
|
import type {
|
|
ExtendedSourceBuffer,
|
|
ParsedTrack,
|
|
SourceBuffersTuple,
|
|
SourceBufferTrackSet,
|
|
} from '../../../src/types/buffer';
|
|
import type {
|
|
ComponentAPI,
|
|
NetworkComponentAPI,
|
|
} from '../../../src/types/component-api';
|
|
|
|
use(sinonChai);
|
|
|
|
type HlsTestable = Omit<Hls, 'networkControllers' | 'coreComponents'> & {
|
|
coreComponents: ComponentAPI[];
|
|
networkControllers: NetworkComponentAPI[];
|
|
};
|
|
|
|
type BufferOperationQueueTestable = Omit<BufferOperationQueue, 'tracks'> & {
|
|
tracks: SourceBufferTrackSet;
|
|
};
|
|
|
|
type BufferControllerTestable = Omit<
|
|
BufferController,
|
|
| '_onMediaSourceOpen'
|
|
| 'bufferCodecEventsTotal'
|
|
| 'checkPendingTracks'
|
|
| 'createSourceBuffers'
|
|
| 'media'
|
|
| 'mediaSource'
|
|
| 'operationQueue'
|
|
| 'pendingTrackCount'
|
|
| 'sourceBufferCount'
|
|
| 'sourceBuffers'
|
|
| 'tracks'
|
|
| 'tracksReady'
|
|
> & {
|
|
_onMediaSourceOpen: (e?: Event) => void;
|
|
bufferCodecEventsTotal: number;
|
|
checkPendingTracks: () => void;
|
|
createSourceBuffers: () => void;
|
|
media: HTMLMediaElement | null;
|
|
mediaSource: MediaSource | null;
|
|
operationQueue: BufferOperationQueueTestable;
|
|
pendingTrackCount: number;
|
|
sourceBufferCount: number;
|
|
sourceBuffers: SourceBuffersTuple;
|
|
tracks: SourceBufferTrackSet;
|
|
tracksReady: boolean;
|
|
_onMediaSourceClose: () => void;
|
|
};
|
|
|
|
describe('BufferController', function () {
|
|
let hls: HlsTestable;
|
|
let fragmentTracker: FragmentTracker;
|
|
let bufferController: BufferControllerTestable;
|
|
const sandbox = sinon.createSandbox();
|
|
|
|
beforeEach(function () {
|
|
hls = new Hls({
|
|
// debug: true,
|
|
}) as unknown as HlsTestable;
|
|
fragmentTracker = new FragmentTracker(hls as unknown as Hls);
|
|
hls.networkControllers.forEach((component) => component.destroy());
|
|
hls.networkControllers.length = 0;
|
|
hls.coreComponents.forEach((component) => component.destroy());
|
|
hls.coreComponents.length = 0;
|
|
bufferController = new BufferController(
|
|
hls as unknown as Hls,
|
|
fragmentTracker,
|
|
) as unknown as BufferControllerTestable;
|
|
});
|
|
|
|
afterEach(function () {
|
|
hls.destroy();
|
|
bufferController.destroy();
|
|
sandbox.restore();
|
|
});
|
|
|
|
describe('onBufferFlushing', function () {
|
|
beforeEach(function () {
|
|
bufferController.sourceBuffers = [
|
|
['video', {} as unknown as ExtendedSourceBuffer],
|
|
['audio', {} as unknown as ExtendedSourceBuffer],
|
|
];
|
|
bufferController.operationQueue.tracks = {
|
|
audio: {
|
|
id: 'audio',
|
|
container: '',
|
|
buffer: { updating: false } as unknown as ExtendedSourceBuffer,
|
|
listeners: [],
|
|
},
|
|
video: {
|
|
id: 'main',
|
|
container: '',
|
|
buffer: { updating: false } as unknown as ExtendedSourceBuffer,
|
|
listeners: [],
|
|
},
|
|
};
|
|
});
|
|
|
|
it('flushes a specific type when provided a type', function () {
|
|
const spy = sandbox.spy(bufferController.operationQueue, 'append');
|
|
hls.trigger(Events.BUFFER_FLUSHING, {
|
|
startOffset: 0,
|
|
endOffset: 10,
|
|
type: 'video',
|
|
});
|
|
expect(spy).to.have.been.calledOnce;
|
|
});
|
|
|
|
it('flushes all source buffers when buffer flush event type is null', function () {
|
|
const spy = sandbox.spy(bufferController.operationQueue, 'append');
|
|
hls.trigger(Events.BUFFER_FLUSHING, {
|
|
startOffset: 0,
|
|
endOffset: 10,
|
|
type: null,
|
|
});
|
|
expect(spy).to.have.been.calledTwice;
|
|
});
|
|
});
|
|
|
|
describe('sourcebuffer creation', function () {
|
|
let createSbStub;
|
|
let checkPendingTracksSpy;
|
|
beforeEach(function () {
|
|
createSbStub = sandbox
|
|
.stub(bufferController, 'createSourceBuffers')
|
|
.callsFake(() => {
|
|
Object.keys(bufferController.tracks).forEach((type) => {
|
|
bufferController.tracks[type] = {
|
|
appendBuffer: () => {},
|
|
remove: () => {},
|
|
};
|
|
});
|
|
});
|
|
checkPendingTracksSpy = sandbox.spy(
|
|
bufferController,
|
|
'checkPendingTracks',
|
|
);
|
|
});
|
|
|
|
it('initializes with zero expected BUFFER_CODEC events', function () {
|
|
expect(bufferController.bufferCodecEventsTotal).to.equal(0);
|
|
});
|
|
|
|
it('should throw if no media element has been attached', function () {
|
|
(bufferController.createSourceBuffers as sinon.SinonStub).restore();
|
|
bufferController.tracks = {
|
|
video: {
|
|
id: 'main',
|
|
container: '',
|
|
listeners: [],
|
|
},
|
|
};
|
|
|
|
expect(bufferController.checkPendingTracks).to.throw();
|
|
});
|
|
|
|
it('exposes tracks from buffer controller through BUFFER_CREATED event', function () {
|
|
(bufferController.createSourceBuffers as sinon.SinonStub).restore();
|
|
|
|
// MEDIA_ATTACHING
|
|
bufferController.media =
|
|
new MockMediaElement() as unknown as HTMLMediaElement;
|
|
bufferController.mediaSource =
|
|
new MockMediaSource() as unknown as MediaSource;
|
|
sandbox.stub(bufferController.mediaSource as any, 'addSourceBuffer');
|
|
|
|
return new Promise((resolve, reject) => {
|
|
hls.on(Hls.Events.BUFFER_CREATED, (event, data) => {
|
|
expect(bufferController.tracks)
|
|
.to.have.property('video')
|
|
.which.deep.equals(
|
|
{
|
|
buffer: undefined,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
codec: 'avc1.42e01e',
|
|
levelCodec: undefined,
|
|
supplemental: undefined,
|
|
listeners: bufferController.tracks.video?.listeners,
|
|
metadata: undefined,
|
|
},
|
|
JSON.stringify(bufferController.tracks.video),
|
|
);
|
|
resolve({});
|
|
});
|
|
|
|
hls.once(Hls.Events.ERROR, (event, data) => {
|
|
reject(data.error);
|
|
});
|
|
|
|
hls.trigger(Events.BUFFER_CODECS, {
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
codec: 'avc1.42e01e',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it('expects one bufferCodec event by default', function () {
|
|
hls.trigger(Events.MANIFEST_PARSED, {} as any);
|
|
expect(bufferController.bufferCodecEventsTotal).to.equal(1);
|
|
});
|
|
|
|
it('expects two bufferCodec events if altAudio is signaled', function () {
|
|
hls.trigger(Events.MANIFEST_PARSED, {
|
|
altAudio: true,
|
|
} as any);
|
|
expect(bufferController.bufferCodecEventsTotal).to.equal(2);
|
|
});
|
|
|
|
it('expects one bufferCodec event if altAudio is signaled with audio only', function () {
|
|
hls.trigger(Events.MANIFEST_PARSED, {
|
|
altAudio: true,
|
|
audio: true,
|
|
video: false,
|
|
} as any);
|
|
expect(bufferController.bufferCodecEventsTotal).to.equal(1);
|
|
});
|
|
|
|
it('creates sourceBuffers when no more BUFFER_CODEC events are expected', function () {
|
|
bufferController.tracks = {
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
},
|
|
};
|
|
bufferController.checkPendingTracks();
|
|
expect(createSbStub).to.have.been.calledOnce;
|
|
});
|
|
|
|
it('creates sourceBuffers on the first even if two tracks are received', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
},
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
},
|
|
};
|
|
bufferController.bufferCodecEventsTotal = 2;
|
|
bufferController.checkPendingTracks();
|
|
expect(bufferController.tracksReady).to.be.true;
|
|
expect(createSbStub).to.have.been.calledOnce;
|
|
});
|
|
|
|
it('does not create sourceBuffers when BUFFER_CODEC events are expected', function () {
|
|
bufferController.tracks = {
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
},
|
|
};
|
|
bufferController.bufferCodecEventsTotal = 2;
|
|
expect(bufferController.pendingTrackCount).to.equal(1);
|
|
bufferController.checkPendingTracks();
|
|
expect(bufferController.tracksReady).to.be.false;
|
|
expect(createSbStub).to.not.have.been.called;
|
|
});
|
|
|
|
it('checks pending tracks even when more events are expected', function () {
|
|
bufferController.tracks = {};
|
|
bufferController.mediaSource = {
|
|
readyState: 'open',
|
|
removeEventListener: () => {},
|
|
} as unknown as MediaSource;
|
|
bufferController.bufferCodecEventsTotal = 2;
|
|
|
|
hls.trigger(Events.BUFFER_CODECS, {
|
|
audio: {
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
},
|
|
});
|
|
expect(checkPendingTracksSpy).to.have.been.calledOnce;
|
|
expect(bufferController.pendingTrackCount).to.equal(1);
|
|
expect(bufferController.sourceBufferCount).to.equal(0);
|
|
expect(bufferController.tracksReady).to.be.false;
|
|
|
|
hls.trigger(Events.BUFFER_CODECS, {
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
},
|
|
});
|
|
expect(checkPendingTracksSpy).to.have.been.calledTwice;
|
|
expect(bufferController.pendingTrackCount).to.equal(2);
|
|
expect(bufferController.sourceBufferCount).to.equal(0);
|
|
expect(bufferController.tracksReady).to.be.true;
|
|
});
|
|
|
|
it('creates the expected amount of sourceBuffers given the standard event flow', function () {
|
|
bufferController.tracks = {};
|
|
bufferController.mediaSource =
|
|
new MockMediaSource() as unknown as MediaSource;
|
|
sandbox.stub(bufferController.mediaSource, 'removeEventListener');
|
|
hls.trigger(Events.MANIFEST_PARSED, {
|
|
altAudio: true,
|
|
} as any);
|
|
bufferController._onMediaSourceOpen();
|
|
hls.trigger(Events.BUFFER_CODECS, { audio: {} } as any);
|
|
hls.trigger(Events.BUFFER_CODECS, { video: {} } as any);
|
|
|
|
expect(createSbStub).to.have.been.calledOnce;
|
|
expect(bufferController.tracks).to.have.property('audio');
|
|
expect(bufferController.tracks).to.have.property('video');
|
|
});
|
|
});
|
|
|
|
describe('onBufferCodecs', function () {
|
|
it('calls changeType if needed and stores current track info', function () {
|
|
/* eslint-disable-next-line no-unused-vars */
|
|
const appendChangeType = sandbox.stub(
|
|
bufferController as any,
|
|
'appendChangeType',
|
|
);
|
|
const buffer = {
|
|
changeType: sandbox.stub(),
|
|
} as unknown as ExtendedSourceBuffer;
|
|
const originalAudioTrack: ParsedTrack = {
|
|
id: 'main',
|
|
codec: 'mp4a.40.2',
|
|
levelCodec: undefined,
|
|
container: 'audio/mp4',
|
|
metadata: {
|
|
channelCount: 1,
|
|
},
|
|
};
|
|
const newAudioTrack: ParsedTrack = {
|
|
id: 'main',
|
|
codec: 'mp4a.40.5',
|
|
levelCodec: undefined,
|
|
container: 'audio/mp4',
|
|
metadata: {
|
|
channelCount: 1,
|
|
},
|
|
};
|
|
bufferController.tracks = {
|
|
audio: {
|
|
...originalAudioTrack,
|
|
buffer,
|
|
listeners: [],
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
[null, null],
|
|
['audio', buffer],
|
|
];
|
|
hls.trigger(Events.BUFFER_CODECS, {
|
|
audio: newAudioTrack,
|
|
});
|
|
expect(appendChangeType).to.have.been.calledOnce;
|
|
expect(appendChangeType).to.have.been.calledWith(
|
|
'audio',
|
|
'audio/mp4',
|
|
'mp4a.40.5',
|
|
);
|
|
expect(bufferController.tracks.audio?.pendingCodec).to.equal(
|
|
newAudioTrack.codec,
|
|
);
|
|
hls.trigger(Events.BUFFER_CODECS, {
|
|
audio: originalAudioTrack,
|
|
});
|
|
expect(appendChangeType).to.have.been.calledTwice;
|
|
expect(appendChangeType).to.have.been.calledWith(
|
|
'audio',
|
|
'audio/mp4',
|
|
'mp4a.40.2',
|
|
);
|
|
expect(bufferController.tracks.audio?.pendingCodec).to.equal(
|
|
originalAudioTrack.codec,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bufferedToEnd', function () {
|
|
it('returns false when there are no source buffers and no pending tracks', function () {
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(0);
|
|
expect(bufferController.bufferedToEnd).to.equal(false);
|
|
});
|
|
|
|
it('returns false when there are no source buffers', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
},
|
|
video: {
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
},
|
|
};
|
|
expect(bufferController.pendingTrackCount).to.equal(2);
|
|
expect(bufferController.sourceBufferCount).to.equal(0);
|
|
expect(bufferController.bufferedToEnd).to.equal(false);
|
|
});
|
|
|
|
it('returns false when there is a track that has not ended', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
},
|
|
video: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
['video', {} as unknown as ExtendedSourceBuffer],
|
|
['audio', {} as unknown as ExtendedSourceBuffer],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(2);
|
|
expect(bufferController.bufferedToEnd).to.equal(false);
|
|
});
|
|
|
|
it('returns false when there is a track that is ending', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: true,
|
|
},
|
|
video: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: true,
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
['video', {} as unknown as ExtendedSourceBuffer],
|
|
['audio', {} as unknown as ExtendedSourceBuffer],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(2);
|
|
expect(bufferController.bufferedToEnd).to.equal(false);
|
|
});
|
|
|
|
it('returns true when audio and video tracks haved ended', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: false,
|
|
},
|
|
video: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: false,
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
['video', {} as unknown as ExtendedSourceBuffer],
|
|
['audio', {} as unknown as ExtendedSourceBuffer],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(2);
|
|
expect(bufferController.bufferedToEnd).to.be.true;
|
|
});
|
|
|
|
it('returns true when the audio-only track has ended', function () {
|
|
bufferController.tracks = {
|
|
audio: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'audio',
|
|
container: 'audio/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: false,
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
[null, null],
|
|
['audio', {} as unknown as ExtendedSourceBuffer],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(1);
|
|
expect(bufferController.bufferedToEnd).to.be.true;
|
|
});
|
|
|
|
it('returns true when the video-only track has ended', function () {
|
|
bufferController.tracks = {
|
|
video: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: false,
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
['video', {} as unknown as ExtendedSourceBuffer],
|
|
[null, null],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(1);
|
|
expect(bufferController.bufferedToEnd).to.be.true;
|
|
});
|
|
|
|
it('returns true when the audiovideo track has ended', function () {
|
|
bufferController.tracks = {
|
|
audiovideo: {
|
|
buffer: {} as unknown as ExtendedSourceBuffer,
|
|
id: 'main',
|
|
container: 'video/mp4',
|
|
listeners: [],
|
|
ended: true,
|
|
ending: false,
|
|
},
|
|
};
|
|
bufferController.sourceBuffers = [
|
|
['audiovideo', {} as unknown as ExtendedSourceBuffer],
|
|
[null, null],
|
|
];
|
|
expect(bufferController.pendingTrackCount).to.equal(0);
|
|
expect(bufferController.sourceBufferCount).to.equal(1);
|
|
expect(bufferController.bufferedToEnd).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe('MediaSource requires reset', function () {
|
|
it('emits error when sourceclose fires with media attached', function () {
|
|
const media = new MockMediaElement() as unknown as HTMLMediaElement;
|
|
const mediaSource = new MockMediaSource() as unknown as MediaSource;
|
|
const triggerSpy = sandbox.spy(hls, 'trigger');
|
|
bufferController.media = media;
|
|
bufferController.mediaSource = mediaSource;
|
|
bufferController._onMediaSourceClose();
|
|
expect(triggerSpy).to.have.been.calledWith(
|
|
Events.ERROR,
|
|
sinon.match({
|
|
type: ErrorTypes.MEDIA_ERROR,
|
|
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
|
|
fatal: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not emit error when media is not attached', function () {
|
|
const mediaSource = new MockMediaSource() as unknown as MediaSource;
|
|
const triggerSpy = sandbox.spy(hls, 'trigger');
|
|
bufferController.media = null;
|
|
bufferController.mediaSource = mediaSource;
|
|
bufferController._onMediaSourceClose();
|
|
expect(triggerSpy).to.not.have.been.calledWith(Events.ERROR);
|
|
});
|
|
});
|
|
});
|