diff --git a/packages/vidstack/src/providers/dash/dash.test.ts b/packages/vidstack/src/providers/dash/dash.test.ts new file mode 100644 index 000000000..e4ec6d5c5 --- /dev/null +++ b/packages/vidstack/src/providers/dash/dash.test.ts @@ -0,0 +1,143 @@ +import type { MediaContext } from '../../core/api/media-context'; +import { QualitySymbol } from '../../core/quality/symbols'; +import { ListSymbol } from '../../foundation/list/symbols'; +import { DASHController } from './dash'; + +vi.mock('maverick.js/std', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + DOMEvent: class DOMEvent extends Event { + detail: T | undefined; + trigger: Event | undefined; + + constructor(type: string, init?: EventInit & { detail?: T; trigger?: Event }) { + super(type, init); + this.detail = init?.detail; + this.trigger = init?.trigger; + } + }, + }; +}); + +const DASH_EVENTS = { + ERROR: 'error', + FRAGMENT_LOADING_STARTED: 'fragmentLoadingStarted', + FRAGMENT_LOADING_COMPLETED: 'fragmentLoadingCompleted', + MANIFEST_LOADED: 'manifestLoaded', + QUALITY_CHANGE_RENDERED: 'qualityChangeRendered', + TEXT_TRACKS_ADDED: 'textTracksAdded', + TRACK_CHANGE_RENDERED: 'trackChangeRendered', +}; + +it('continues manifest processing when no supported video quality is found', () => { + const video = document.createElement('video'); + vi.spyOn(video, 'canPlayType').mockImplementation((type) => { + return type === 'audio/mp4' ? 'probably' : ''; + }); + + const ctx = { + player: { dispatch: vi.fn() }, + qualities: createTestList(true), + audioTracks: createTestList(), + notify: vi.fn(), + $state: { + canPlay: () => false, + live: () => false, + }, + } as unknown as MediaContext; + + const listeners: Record void)[]> = {}; + const dash = { + on: vi.fn((type: string, listener: (event: any) => void) => { + (listeners[type] ??= []).push(listener); + }), + initialize: vi.fn(), + updateSettings: vi.fn(), + getVideoElement: () => video, + getTracksForTypeFromManifest: vi.fn((type: string) => { + return type === 'audio' + ? [ + { + index: 0, + lang: 'en', + labels: [], + mimeType: 'audio/mp4', + codec: 'mp4a.40.2', + }, + ] + : [ + { + index: 0, + bitrateList: [{ id: 1, width: 1920, height: 1080, bandwidth: 5_000_000 }], + mimeType: 'video/mp4', + codec: 'avc1.640028', + }, + ]; + }), + duration: () => 0, + time: () => 0, + destroy: vi.fn(), + }; + + const ctor = Object.assign( + () => ({ + create: () => dash, + }), + { events: DASH_EVENTS }, + ); + + const dispatchEvent = vi.spyOn(video, 'dispatchEvent').mockImplementation(() => true); + + new DASHController(video, ctx).setup(ctor as any); + + const event = { + type: DASH_EVENTS.MANIFEST_LOADED, + data: { type: 'static', mediaPresentationDuration: 10 }, + }; + + expect(() => { + listeners[DASH_EVENTS.MANIFEST_LOADED]!.forEach((listener) => listener(event)); + }).not.toThrow(); + + expect(ctx.qualities.length).to.equal(0); + expect(ctx.audioTracks.length).to.equal(1); + expect((ctx.audioTracks[0] as any)?.mimeType).to.equal('audio/mp4'); + expect(dispatchEvent).toHaveBeenCalledOnce(); + expect(dispatchEvent.mock.calls[0]?.[0].type).to.equal('canplay'); +}); + +function createTestList(qualityList = false) { + const list = [] as any; + + list.addEventListener = vi.fn(); + list.removeEventListener = vi.fn(); + list.getById = (id: string) => list.find((item) => item.id === id) ?? null; + list.toArray = () => [...list]; + list[ListSymbol.add] = (item: any) => { + list.push({ ...item, selected: false }); + }; + list[ListSymbol.select] = (item: any, selected: boolean) => { + if (item) item.selected = selected; + }; + + Object.defineProperties(list, { + selected: { + get: () => list.find((item) => item.selected) ?? null, + }, + selectedIndex: { + get: () => list.findIndex((item) => item.selected), + }, + }); + + if (qualityList) { + list.auto = false; + list.switch = 'current'; + list[QualitySymbol.setAuto] = (auto: boolean) => { + list.auto = auto; + }; + } + + return list; +} diff --git a/packages/vidstack/src/providers/dash/dash.ts b/packages/vidstack/src/providers/dash/dash.ts index b9a3daf0f..860d37050 100644 --- a/packages/vidstack/src/providers/dash/dash.ts +++ b/packages/vidstack/src/providers/dash/dash.ts @@ -221,9 +221,7 @@ export class DASHController { (type) => type && canPlayVideoType(media, type), ); - const videoQuality = videoQualities.filter( - (track) => supportedVideoMimeType === track.mimeType, - )[0]; + const videoQuality = videoQualities.find((track) => supportedVideoMimeType === track.mimeType); let audioTracks = (this.#instance.getTracksForTypeFromManifest as DashGetMediaTracks)( 'audio', @@ -236,22 +234,24 @@ export class DASHController { audioTracks = audioTracks.filter((track) => supportedAudioMimeType === track.mimeType); - videoQuality.bitrateList.forEach((bitrate, index) => { - const quality = { - id: bitrate.id?.toString() ?? `dash-bitrate-${index}`, - width: bitrate.width ?? 0, - height: bitrate.height ?? 0, - bitrate: bitrate.bandwidth ?? 0, - codec: videoQuality.codec, - index, - }; - - this.#ctx.qualities[ListSymbol.add](quality, trigger); - }); + if (videoQuality) { + videoQuality.bitrateList.forEach((bitrate, index) => { + const quality = { + id: bitrate.id?.toString() ?? `dash-bitrate-${index}`, + width: bitrate.width ?? 0, + height: bitrate.height ?? 0, + bitrate: bitrate.bandwidth ?? 0, + codec: videoQuality.codec, + index, + }; + + this.#ctx.qualities[ListSymbol.add](quality, trigger); + }); - if (isNumber(videoQuality.index)) { - const quality = this.#ctx.qualities[videoQuality.index]; - if (quality) this.#ctx.qualities[ListSymbol.select](quality, true, trigger); + if (isNumber(videoQuality.index)) { + const quality = this.#ctx.qualities[videoQuality.index]; + if (quality) this.#ctx.qualities[ListSymbol.select](quality, true, trigger); + } } audioTracks.forEach((audioTrack: DASH.MediaInfo, index) => {