Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions packages/vidstack/src/providers/hls/hls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type * as HLS from 'hls.js';
import { vi } from 'vitest';

import { QualitySymbol } from '../../core/quality/symbols';
import { ListSymbol } from '../../foundation/list/symbols';
import { HLSController } from './hls';
import type { HLSConstructor } from './types';

const Events = {
AUDIO_TRACK_SWITCHED: 'AUDIO_TRACK_SWITCHED',
CUES_PARSED: 'CUES_PARSED',
ERROR: 'ERROR',
LEVEL_LOADED: 'LEVEL_LOADED',
LEVEL_SWITCHED: 'LEVEL_SWITCHED',
LEVEL_UPDATED: 'LEVEL_UPDATED',
NON_NATIVE_TEXT_TRACKS_FOUND: 'NON_NATIVE_TEXT_TRACKS_FOUND',
} as const;

class FakeHLS {
static Events = Events;

audioTracks: any[] = [];
currentLevel = -1;
levels: any[] = [];
liveSyncPosition: number | null = null;
media: HTMLMediaElement | null = null;
subtitleTrack = -1;

readonly #listeners = new Map<string, Set<(eventType: string, data: any) => void>>();

constructor(_config: Partial<HLS.HlsConfig>) {}

on(event: string, callback: (eventType: string, data: any) => void) {
let listeners = this.#listeners.get(event);
if (!listeners) this.#listeners.set(event, (listeners = new Set()));
listeners.add(callback);
}

attachMedia(media: HTMLMediaElement) {
this.media = media;
}

destroy() {
this.media = null;
}

emit(event: string, data: any) {
for (const listener of this.#listeners.get(event) ?? []) {
listener(event, data);
}
}

loadSource() {}

recoverMediaError() {}
}

it('ignores stale level loaded callbacks after destroy', () => {
const { controller, ctx, video } = setup();
const instance = controller.instance as unknown as FakeHLS;
const onCanPlay = vi.fn();

video.addEventListener('canplay', onCanPlay);
ctx.player.dispatch.mockClear();
controller.destroy();

expect(() => instance.emit(Events.LEVEL_LOADED, createLevelLoadedData())).not.toThrow();

expect(ctx.player.dispatch).not.toHaveBeenCalled();
expect(ctx.notify).not.toHaveBeenCalled();
expect(ctx.audioTracks[ListSymbol.add]).not.toHaveBeenCalled();
expect(ctx.qualities[ListSymbol.add]).not.toHaveBeenCalled();
expect(ctx.qualities[QualitySymbol.setAuto]).not.toHaveBeenCalled();
expect(onCanPlay).not.toHaveBeenCalled();
});

it('returns from level loaded callbacks when media is unavailable', () => {
const { controller, ctx, video } = setup();
const instance = controller.instance as unknown as FakeHLS;
const onCanPlay = vi.fn();

video.addEventListener('canplay', onCanPlay);
instance.media = null;
ctx.player.dispatch.mockClear();

expect(() => instance.emit(Events.LEVEL_LOADED, createLevelLoadedData())).not.toThrow();

expect(ctx.notify).not.toHaveBeenCalled();
expect(ctx.audioTracks[ListSymbol.add]).not.toHaveBeenCalled();
expect(ctx.qualities[ListSymbol.add]).not.toHaveBeenCalled();
expect(ctx.qualities[QualitySymbol.setAuto]).not.toHaveBeenCalled();
expect(onCanPlay).not.toHaveBeenCalled();
});

function setup() {
const video = document.createElement('video'),
ctx = createContext(),
controller = new HLSController(video, ctx);

controller.setup(FakeHLS as unknown as HLSConstructor);

return { controller, ctx, video };
}

function createContext() {
const audioTracks = new EventTarget() as any,
qualities = new EventTarget() as any;

audioTracks.selectedIndex = -1;
audioTracks[ListSymbol.add] = vi.fn();
audioTracks[ListSymbol.select] = vi.fn();

qualities.auto = false;
qualities.selectedIndex = -1;
qualities.switch = 'current';
qualities[ListSymbol.add] = vi.fn();
qualities[ListSymbol.select] = vi.fn();
qualities[QualitySymbol.setAuto] = vi.fn();

return {
$state: {
canPlay: () => false,
inferredLiveDVRWindow: { set: vi.fn() },
live: () => false,
liveSyncPosition: { set: vi.fn() },
source: () => null,
streamType: () => 'on-demand',
},
audioTracks,
notify: vi.fn(),
player: { dispatch: vi.fn() },
qualities,
textTracks: {
add: vi.fn(),
getById: vi.fn(),
},
} as any;
}

function createLevelLoadedData() {
return {
details: {
live: false,
targetduration: 6,
totalduration: 30,
type: 'VOD',
},
} as HLS.LevelLoadedData;
}
69 changes: 44 additions & 25 deletions packages/vidstack/src/providers/hls/hls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,30 +38,46 @@ export class HLSController {
const isLive = peek(streamType).includes('live'),
isLiveLowLatency = peek(streamType).includes('ll-');

this.#instance = new ctor({
const instance = (this.#instance = new ctor({
lowLatencyMode: isLiveLowLatency,
backBufferLength: isLiveLowLatency ? 4 : isLive ? 8 : undefined,
renderTextTracksNatively: false,
...this.config,
});
}));

const guard =
<Data>(callback: (eventType: string, data: Data) => void) =>
(eventType: string, data: Data) => {
if (this.#instance !== instance) return;
callback(eventType, data);
};

const dispatcher = this.#dispatchHLSEvent.bind(this);
for (const event of Object.values(ctor.Events)) this.#instance.on(event, dispatcher);
const dispatcher = guard(this.#dispatchHLSEvent.bind(this));
for (const event of Object.values(ctor.Events)) instance.on(event, dispatcher);

this.#instance.on(ctor.Events.ERROR, this.#onError.bind(this));
for (const callback of this.#callbacks) callback(this.#instance);
instance.on(ctor.Events.ERROR, guard<HLS.ErrorData>(this.#onError.bind(this)));
for (const callback of this.#callbacks) callback(instance);

this.#ctx.player.dispatch('hls-instance', {
detail: this.#instance,
detail: instance,
});

this.#instance.attachMedia(this.#video);
this.#instance.on(ctor.Events.AUDIO_TRACK_SWITCHED, this.#onAudioSwitch.bind(this));
this.#instance.on(ctor.Events.LEVEL_SWITCHED, this.#onLevelSwitched.bind(this));
this.#instance.on(ctor.Events.LEVEL_LOADED, this.#onLevelLoaded.bind(this));
this.#instance.on(ctor.Events.LEVEL_UPDATED, this.#onLevelUpdated.bind(this));
this.#instance.on(ctor.Events.NON_NATIVE_TEXT_TRACKS_FOUND, this.#onTracksFound.bind(this));
this.#instance.on(ctor.Events.CUES_PARSED, this.#onCuesParsed.bind(this));
instance.attachMedia(this.#video);
instance.on(ctor.Events.AUDIO_TRACK_SWITCHED, guard(this.#onAudioSwitch.bind(this)));
instance.on(ctor.Events.LEVEL_SWITCHED, guard(this.#onLevelSwitched.bind(this)));
instance.on(
ctor.Events.LEVEL_LOADED,
guard<HLS.LevelLoadedData>(this.#onLevelLoaded.bind(this, instance)),
);
instance.on(ctor.Events.LEVEL_UPDATED, guard(this.#onLevelUpdated.bind(this)));
instance.on(
ctor.Events.NON_NATIVE_TEXT_TRACKS_FOUND,
guard<HLS.NonNativeTextTracksData>(this.#onTracksFound.bind(this, instance)),
);
instance.on(
ctor.Events.CUES_PARSED,
guard<HLS.CuesParsedData>(this.#onCuesParsed.bind(this, instance)),
);

this.#ctx.qualities[QualitySymbol.enableAuto] = this.#enableAutoQuality.bind(this);

Expand Down Expand Up @@ -90,7 +106,7 @@ export class HLSController {
this.#ctx.player?.dispatch(this.#createDOMEvent(type, data));
}

#onTracksFound(eventType: string, data: HLS.NonNativeTextTracksData) {
#onTracksFound(instance: HLS.default, eventType: string, data: HLS.NonNativeTextTracksData) {
const event = this.#createDOMEvent(eventType, data);

let currentTrack = -1;
Expand All @@ -109,11 +125,13 @@ export class HLSController {

track[TextTrackSymbol.readyState] = 2;
track[TextTrackSymbol.onModeChange] = () => {
if (this.#instance !== instance) return;

if (track.mode === 'showing') {
this.#instance!.subtitleTrack = i;
instance.subtitleTrack = i;
currentTrack = i;
} else if (currentTrack === i) {
this.#instance!.subtitleTrack = -1;
instance.subtitleTrack = -1;
currentTrack = -1;
}
};
Expand All @@ -122,8 +140,8 @@ export class HLSController {
}
}

#onCuesParsed(eventType: string, data: HLS.CuesParsedData) {
const index = this.#instance?.subtitleTrack,
#onCuesParsed(instance: HLS.default, eventType: string, data: HLS.CuesParsedData) {
const index = instance.subtitleTrack,
track = this.#ctx.textTracks.getById(`hls-${data.type}-${index}`);

if (!track) return;
Expand Down Expand Up @@ -158,9 +176,12 @@ export class HLSController {
}
}

#onLevelLoaded(eventType: string, data: HLS.LevelLoadedData): void {
#onLevelLoaded(instance: HLS.default, eventType: string, data: HLS.LevelLoadedData): void {
if (this.#ctx.$state.canPlay()) return;

const media = instance.media;
if (!media) return;

const { type, live, totalduration: duration, targetduration } = data.details,
trigger = this.#createDOMEvent(eventType, data);

Expand All @@ -176,13 +197,11 @@ export class HLSController {

this.#ctx.notify('duration-change', duration, trigger);

const media = this.#instance!.media!;

if (this.#instance!.currentLevel === -1) {
if (instance.currentLevel === -1) {
this.#ctx.qualities[QualitySymbol.setAuto](true, trigger);
}

for (const remoteTrack of this.#instance!.audioTracks) {
for (const remoteTrack of instance.audioTracks) {
const localTrack = {
id: remoteTrack.id.toString(),
label: remoteTrack.name,
Expand All @@ -193,7 +212,7 @@ export class HLSController {
this.#ctx.audioTracks[ListSymbol.add](localTrack, trigger);
}

for (const level of this.#instance!.levels) {
for (const level of instance.levels) {
const videoQuality = {
id: level.id?.toString() ?? level.height + 'p',
width: level.width,
Expand Down
Loading