mirror of
https://github.com/zoriya/v10.git
synced 2026-08-13 09:30:38 +00:00
feat(spf): expose media tracks on the SPF media adapter (#1826)
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import { type Composition, computed, effect, untrack } from '@videojs/spf';
|
||||
import type { SimpleHlsEngineContext, SimpleHlsEngineState } from '@videojs/spf/hls';
|
||||
import {
|
||||
type AudioTrack,
|
||||
dedupedAudioTracks,
|
||||
dedupedVideoTracks,
|
||||
findAudioTrackById,
|
||||
findVideoTrackById,
|
||||
frameRateToNumber,
|
||||
isSameAudioTrack,
|
||||
isSameVideoTrack,
|
||||
toUserAudioTrackSelection,
|
||||
toUserVideoTrackSelection,
|
||||
type VideoTrack,
|
||||
} from '@videojs/spf/media-tracks';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type {
|
||||
AudioTrackLike,
|
||||
MediaAudioTrackCapability,
|
||||
MediaVideoRenditionCapability,
|
||||
MediaVideoTrackCapability,
|
||||
VideoRenditionLike,
|
||||
} from '../../core/types';
|
||||
|
||||
// Translate a DOM rendition/track into the SPF dedupe-key shape
|
||||
const toVideoKey = (rendition: VideoRenditionLike) => ({
|
||||
width: rendition.width,
|
||||
height: rendition.height,
|
||||
bandwidth: rendition.bitrate!,
|
||||
});
|
||||
|
||||
const toAudioKey = (track: AudioTrackLike) => ({ language: track.language, name: track.label });
|
||||
|
||||
type SimpleHlsEngineHost = {
|
||||
readonly engine: Composition<SimpleHlsEngineState, SimpleHlsEngineContext>;
|
||||
destroy?(): void;
|
||||
};
|
||||
|
||||
type MediaTracksHost = SimpleHlsEngineHost &
|
||||
MediaVideoTrackCapability &
|
||||
MediaAudioTrackCapability &
|
||||
MediaVideoRenditionCapability;
|
||||
|
||||
/** Two track lists carry the same set when their id sequences match. */
|
||||
const sameIds = (a: { id: string }[], b: { id: string }[]): boolean =>
|
||||
a.length === b.length && a.every((item, i) => item.id === b[i]!.id);
|
||||
|
||||
/**
|
||||
* Projects the SPF engine's presentation onto the media element's
|
||||
* `videoRenditions` / `audioTracks` lists, and wires user selection back to the
|
||||
* engine's `userVideoTrackSelection` / `userAudioTrackSelection` signals.
|
||||
*
|
||||
* Requires the media-tracks mixin (track-list infrastructure) earlier in the
|
||||
* chain so the host exposes `addVideoTrack`, `videoRenditions`, and friends.
|
||||
*/
|
||||
export function SimpleHlsMediaMediaTracksMixin<Base extends Constructor<MediaTracksHost>>(BaseClass: Base) {
|
||||
class SimpleHlsMediaMediaTracks extends (BaseClass as Constructor<MediaTracksHost>) {
|
||||
#abort = new AbortController();
|
||||
#destroyed = false;
|
||||
// Memoized SPF model — the last `computed` result per type — so a DOM
|
||||
// selection maps back to the engine's match criteria by id via the SPF
|
||||
// selection helpers (the DOM lists carry only DOM-shape props).
|
||||
#renditions: VideoTrack[] = [];
|
||||
#audioTracks: AudioTrack[] = [];
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
|
||||
const { state } = this.engine;
|
||||
const { signal } = this.#abort;
|
||||
|
||||
const renditionsSignal = computed(() => dedupedVideoTracks(state.presentation.get()), { equals: sameIds });
|
||||
const audioTracksSignal = computed(() => dedupedAudioTracks(state.presentation.get()), { equals: sameIds });
|
||||
|
||||
// Rebuild video renditions when the set changes; seed `active` from the
|
||||
// current resolved selection (read untracked — `active` deltas are the
|
||||
// reflection effect's job).
|
||||
const reflectRenditions = () => {
|
||||
const renditions = renditionsSignal.get();
|
||||
this.#renditions = renditions;
|
||||
this.#removeVideoTracks();
|
||||
if (!renditions.length) return;
|
||||
|
||||
// "video track" here is the Media UI Extensions concept, which maps onto
|
||||
// the SPF model differently than the name suggests:
|
||||
// MUE track -> SPF selection set
|
||||
// MUE rendition -> SPF track
|
||||
// @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0011-renditions-list.md
|
||||
//
|
||||
// Every rendition hangs off a single "main" video track. Multi-track
|
||||
// video (multiple camera angles, CMAF multi-track) isn't supported yet,
|
||||
// so exactly one video track is always exposed; when it lands, this
|
||||
// becomes one track (selection set) per angle.
|
||||
const videoTrack = this.addVideoTrack('main');
|
||||
videoTrack.selected = true;
|
||||
|
||||
const resolved = untrack(() => findVideoTrackById(state.presentation.get(), state.selectedVideoTrackId.get()));
|
||||
for (const rendition of renditions) {
|
||||
const domRendition = videoTrack.addRendition(
|
||||
'',
|
||||
rendition.width,
|
||||
rendition.height,
|
||||
rendition.codecs.join(','),
|
||||
rendition.bandwidth,
|
||||
rendition.frameRate ? frameRateToNumber(rendition.frameRate) : undefined
|
||||
);
|
||||
domRendition.id = rendition.id;
|
||||
domRendition.active = isSameVideoTrack(toVideoKey(domRendition), resolved);
|
||||
}
|
||||
};
|
||||
|
||||
const reflectSelectedVideo = () => {
|
||||
const resolved = findVideoTrackById(state.presentation.get(), state.selectedVideoTrackId.get());
|
||||
for (const rendition of this.videoRenditions) {
|
||||
rendition.active = isSameVideoTrack(toVideoKey(rendition), resolved);
|
||||
}
|
||||
};
|
||||
|
||||
// Rebuild audio tracks when the set changes; seed `enabled` from the
|
||||
// current resolved selection.
|
||||
const reflectAudioTracks = () => {
|
||||
const tracks = audioTracksSignal.get();
|
||||
this.#audioTracks = tracks;
|
||||
this.#removeAudioTracks();
|
||||
if (!tracks.length) return;
|
||||
|
||||
const resolved = untrack(() => findAudioTrackById(state.presentation.get(), state.selectedAudioTrackId.get()));
|
||||
for (const track of tracks) {
|
||||
const domTrack = this.addAudioTrack(track.default ? 'main' : 'alternative', track.name, track.language ?? '');
|
||||
domTrack.id = track.id;
|
||||
domTrack.enabled = isSameAudioTrack(toAudioKey(domTrack), resolved);
|
||||
}
|
||||
};
|
||||
|
||||
const reflectSelectedAudio = () => {
|
||||
const resolved = findAudioTrackById(state.presentation.get(), state.selectedAudioTrackId.get());
|
||||
for (const track of this.audioTracks) {
|
||||
track.enabled = isSameAudioTrack(toAudioKey(track), resolved);
|
||||
}
|
||||
};
|
||||
|
||||
// A source change (new manifest URL) invalidates any pinned selection:
|
||||
// rendition identity (width/height/bandwidth) and audio identity
|
||||
// (language/name) don't reliably carry across sources, so drop both pins
|
||||
// and let the engine re-pick for the new source.
|
||||
const sourceUrl = computed(() => state.presentation.get()?.url);
|
||||
const resetSelectionOnSourceChange = () => {
|
||||
sourceUrl.get();
|
||||
state.userVideoTrackSelection.set(undefined);
|
||||
state.userAudioTrackSelection.set(undefined);
|
||||
};
|
||||
|
||||
const effectCleanups = [
|
||||
effect(reflectRenditions),
|
||||
effect(reflectSelectedVideo),
|
||||
effect(reflectAudioTracks),
|
||||
effect(reflectSelectedAudio),
|
||||
effect(resetSelectionOnSourceChange),
|
||||
];
|
||||
|
||||
this.videoRenditions.addEventListener('change', this.#selectRendition, { signal });
|
||||
this.audioTracks.addEventListener('change', this.#selectAudio, { signal });
|
||||
signal.addEventListener('abort', () => effectCleanups.forEach((cleanup) => cleanup()), { once: true });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.#destroyed) return;
|
||||
this.#destroyed = true;
|
||||
this.#abort.abort();
|
||||
this.#removeVideoTracks();
|
||||
this.#removeAudioTracks();
|
||||
super.destroy?.();
|
||||
}
|
||||
|
||||
// Manual quality pin → the chosen rendition's width/height/bandwidth as the
|
||||
// match criteria (the properties renditions were deduped on).
|
||||
// `selectedIndex === -1` (Auto) clears the pin, resuming ABR.
|
||||
#selectRendition = () => {
|
||||
const { userVideoTrackSelection } = this.engine.state;
|
||||
const index = this.videoRenditions.selectedIndex;
|
||||
const domRendition = index < 0 ? undefined : this.videoRenditions[index];
|
||||
const rendition = this.#renditions.find((candidate) => candidate.id === domRendition?.id);
|
||||
userVideoTrackSelection.set(toUserVideoTrackSelection(rendition));
|
||||
};
|
||||
|
||||
#selectAudio = () => {
|
||||
const { presentation, selectedAudioTrackId, userAudioTrackSelection } = this.engine.state;
|
||||
const resolved = findAudioTrackById(presentation.get(), selectedAudioTrackId.get());
|
||||
const current = [...this.audioTracks].find((track) => isSameAudioTrack(toAudioKey(track), resolved));
|
||||
|
||||
// `enabled` is not exclusive like video `selected`, so prefer a newly
|
||||
// enabled track over the one that is already playing.
|
||||
const enabled = [...this.audioTracks].filter((track) => track.enabled);
|
||||
const target = enabled.find((track) => track !== current) ?? enabled[0];
|
||||
if (!target) return;
|
||||
|
||||
// Disable the rest so future change events resolve unambiguously.
|
||||
for (const track of enabled) {
|
||||
if (track !== target) track.enabled = false;
|
||||
}
|
||||
// Skip the write when the enabled track is already the resolved one
|
||||
// that's this projection's own reflection (or a no-op re-affirm), not a user switch.
|
||||
if (target === current) return;
|
||||
|
||||
const audioTrack = this.#audioTracks.find((candidate) => candidate.id === target.id);
|
||||
userAudioTrackSelection.set(toUserAudioTrackSelection(audioTrack));
|
||||
};
|
||||
|
||||
#removeVideoTracks() {
|
||||
for (const videoTrack of [...this.videoTracks]) this.removeVideoTrack(videoTrack);
|
||||
}
|
||||
|
||||
#removeAudioTracks() {
|
||||
for (const audioTrack of [...this.audioTracks]) this.removeAudioTrack(audioTrack);
|
||||
}
|
||||
}
|
||||
|
||||
return SimpleHlsMediaMediaTracks as unknown as Base;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SimpleHlsMediaMixin } from '@videojs/spf/hls';
|
||||
import { MediaTracksMixin } from '../../core/media-tracks';
|
||||
import { HTMLVideoElementHost } from '../video-host';
|
||||
import { SimpleHlsMediaMediaTracksMixin } from './media-tracks';
|
||||
|
||||
const SimpleHlsMediaBase = SimpleHlsMediaMixin(HTMLVideoElementHost);
|
||||
const SimpleHlsMediaBase = SimpleHlsMediaMediaTracksMixin(MediaTracksMixin(SimpleHlsMediaMixin(HTMLVideoElementHost)));
|
||||
|
||||
export class SimpleHlsMedia extends SimpleHlsMediaBase {}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { effect, signal } from '@videojs/spf';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MediaTracksMixin } from '../../../core/media-tracks';
|
||||
import { HTMLVideoElementHost } from '../../video-host';
|
||||
import { SimpleHlsMediaMediaTracksMixin } from '../media-tracks';
|
||||
|
||||
// The projection only touches these five engine-state signals; a fake engine
|
||||
// backed by real signals is enough to drive the effects.
|
||||
function createEngine() {
|
||||
return {
|
||||
state: {
|
||||
presentation: signal<any>(undefined),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
selectedAudioTrackId: signal<string | undefined>(undefined),
|
||||
userVideoTrackSelection: signal<any>(undefined),
|
||||
userAudioTrackSelection: signal<any>(undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type Engine = ReturnType<typeof createEngine>;
|
||||
|
||||
class FakeHost extends HTMLVideoElementHost {
|
||||
engine: Engine;
|
||||
constructor(engine: Engine) {
|
||||
super();
|
||||
this.engine = engine;
|
||||
}
|
||||
}
|
||||
|
||||
const SimpleHlsMediaMediaTracks = SimpleHlsMediaMediaTracksMixin(MediaTracksMixin(FakeHost as any));
|
||||
|
||||
const presentation = (video: any[], audio: any[] = [], url = 'https://example.com/master.m3u8') => ({
|
||||
id: 'pres-1',
|
||||
url,
|
||||
selectionSets: [
|
||||
{ id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: video }] },
|
||||
{ id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: audio }] },
|
||||
],
|
||||
});
|
||||
|
||||
const vTrack = (over: any) => ({
|
||||
type: 'video',
|
||||
url: 'https://cdn-a/v.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
codecs: ['avc1.640028'],
|
||||
...over,
|
||||
});
|
||||
|
||||
const aTrack = (over: any) => ({
|
||||
type: 'audio',
|
||||
url: 'https://cdn-a/a.m3u8',
|
||||
bandwidth: 128_000,
|
||||
codecs: ['mp4a.40.2'],
|
||||
name: 'Audio',
|
||||
...over,
|
||||
});
|
||||
|
||||
// Flush by riding the same scheduler the projection uses: bump a throwaway
|
||||
// signal and resolve once its effect re-runs. That re-run lands after the SPF
|
||||
// effect queue and the microtask-queued track/rendition `change` events have
|
||||
// drained, so the flush inherits the runtime's timing instead of hard-coding a
|
||||
// micro/macrotask.
|
||||
const flush = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const sig = signal(0);
|
||||
const stop = effect(() => {
|
||||
if (sig.get() === 0) return; // skip the synchronous initial run
|
||||
stop();
|
||||
resolve();
|
||||
});
|
||||
sig.set(1);
|
||||
});
|
||||
|
||||
describe('SimpleHlsMediaMediaTracksMixin', () => {
|
||||
let engine: Engine;
|
||||
let host: any;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = createEngine();
|
||||
host = new SimpleHlsMediaMediaTracks(engine) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
host.destroy();
|
||||
});
|
||||
|
||||
it('projects video renditions into a selected main track, deduped by w/h/bandwidth', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation([
|
||||
vTrack({ id: 'a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }),
|
||||
vTrack({ id: 'b-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://b/v.m3u8' }),
|
||||
vTrack({ id: 'a-720', width: 1280, height: 720, bandwidth: 3_000_000 }),
|
||||
])
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(host.videoTracks.length).toBe(1);
|
||||
expect(host.videoTracks[0].selected).toBe(true);
|
||||
expect([...host.videoRenditions].map((r: any) => r.id)).toEqual(['a-1080', 'a-720']);
|
||||
expect([...host.videoRenditions].map((r: any) => r.height)).toEqual([1080, 720]);
|
||||
expect(host.videoRenditions[0].codec).toBe('avc1.640028');
|
||||
});
|
||||
|
||||
it('maps a rational frame rate onto the DOM rendition as a number', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation([
|
||||
vTrack({
|
||||
id: 'v1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frameRate: { frameRateNumerator: 30000, frameRateDenominator: 1001 },
|
||||
}),
|
||||
vTrack({ id: 'v2', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
])
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(host.videoRenditions[0].frameRate).toBeCloseTo(29.97, 2);
|
||||
// No FRAME-RATE on the source → undefined, not coerced to 0.
|
||||
expect(host.videoRenditions[1].frameRate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('projects audio tracks by language and enables the resolved one', async () => {
|
||||
engine.state.selectedAudioTrackId.set('en');
|
||||
engine.state.presentation.set(
|
||||
presentation(
|
||||
[],
|
||||
[
|
||||
aTrack({ id: 'en', language: 'en', name: 'English', default: true }),
|
||||
aTrack({ id: 'es', language: 'es', name: 'Spanish' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect([...host.audioTracks].map((t: any) => t.language)).toEqual(['en', 'es']);
|
||||
expect([...host.audioTracks].map((t: any) => t.label)).toEqual(['English', 'Spanish']);
|
||||
expect(host.audioTracks[0].enabled).toBe(true);
|
||||
expect(host.audioTracks[1].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reflects the resolved video selection as active', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation([
|
||||
vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
])
|
||||
);
|
||||
await flush();
|
||||
|
||||
engine.state.selectedVideoTrackId.set('lo');
|
||||
await flush();
|
||||
|
||||
expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('reflects active by properties, so a non-primary CDN resolved id still lights up its rendition', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation([
|
||||
vTrack({ id: 'a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }),
|
||||
vTrack({ id: 'b-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://b/v.m3u8' }),
|
||||
vTrack({ id: 'a-720', width: 1280, height: 720, bandwidth: 3_000_000 }),
|
||||
])
|
||||
);
|
||||
await flush();
|
||||
|
||||
// The DOM rendition kept the first copy's id ('a-1080'); the engine resolved
|
||||
// the second-CDN copy (as on failover). Property-based reflection still marks
|
||||
// the collapsed rendition active.
|
||||
engine.state.selectedVideoTrackId.set('b-1080');
|
||||
await flush();
|
||||
|
||||
expect([...host.videoRenditions].map((r: any) => r.id)).toEqual(['a-1080', 'a-720']);
|
||||
expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('reflects audio enabled by properties across per-CDN copies', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation(
|
||||
[],
|
||||
[
|
||||
aTrack({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }),
|
||||
aTrack({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }),
|
||||
aTrack({ id: 'es-a', language: 'es', name: 'Spanish' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
await flush();
|
||||
|
||||
engine.state.selectedAudioTrackId.set('en-b');
|
||||
await flush();
|
||||
|
||||
expect([...host.audioTracks].map((t: any) => t.id)).toEqual(['en-a', 'es-a']);
|
||||
expect([...host.audioTracks].map((t: any) => t.enabled)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('pins a rendition selection as width/height/bandwidth criteria; Auto clears it', async () => {
|
||||
engine.state.presentation.set(
|
||||
presentation([
|
||||
vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
])
|
||||
);
|
||||
await flush();
|
||||
|
||||
host.videoRenditions.selectedIndex = 1;
|
||||
await flush();
|
||||
expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 });
|
||||
|
||||
host.videoRenditions.selectedIndex = -1;
|
||||
await flush();
|
||||
expect(engine.state.userVideoTrackSelection.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('pins an audio selection as a language criterion when the user selects another track', async () => {
|
||||
engine.state.selectedAudioTrackId.set('en');
|
||||
engine.state.presentation.set(
|
||||
presentation(
|
||||
[],
|
||||
[
|
||||
aTrack({ id: 'en', language: 'en', name: 'English', default: true }),
|
||||
aTrack({ id: 'es', language: 'es', name: 'Spanish' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
await flush();
|
||||
|
||||
// Exclusive selection, as the audio-track store feature drives it.
|
||||
host.audioTracks[0].enabled = false;
|
||||
host.audioTracks[1].enabled = true;
|
||||
await flush();
|
||||
|
||||
expect(engine.state.userAudioTrackSelection.get()).toEqual({ language: 'es', name: 'Spanish' });
|
||||
});
|
||||
|
||||
it('does not write a selection when reflection enables the resolved track', async () => {
|
||||
// The host (built in beforeEach) is wired for side effects; asserted via engine state below.
|
||||
engine.state.selectedAudioTrackId.set('en');
|
||||
engine.state.presentation.set(
|
||||
presentation([], [aTrack({ id: 'en', language: 'en', default: true }), aTrack({ id: 'es', language: 'es' })])
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(engine.state.userAudioTrackSelection.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves renditions across a live-reload presentation swap with the same set', async () => {
|
||||
const tracks = [
|
||||
vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
];
|
||||
engine.state.presentation.set(presentation(tracks));
|
||||
await flush();
|
||||
engine.state.selectedVideoTrackId.set('lo');
|
||||
await flush();
|
||||
|
||||
// New presentation object, identical rendition set (as a live refresh does).
|
||||
engine.state.presentation.set(presentation(tracks.map((t) => ({ ...t }))));
|
||||
await flush();
|
||||
|
||||
// Set-equality gate means no rebuild; the reflected active state survives.
|
||||
expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('clears the pinned rendition and audio selection when the source changes', async () => {
|
||||
engine.state.selectedAudioTrackId.set('en');
|
||||
engine.state.presentation.set(
|
||||
presentation(
|
||||
[
|
||||
vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
],
|
||||
[
|
||||
aTrack({ id: 'en', language: 'en', name: 'English', default: true }),
|
||||
aTrack({ id: 'es', language: 'es', name: 'Spanish' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
await flush();
|
||||
|
||||
// User pins a rendition and switches to an alternate audio track.
|
||||
host.videoRenditions.selectedIndex = 1;
|
||||
host.audioTracks[0].enabled = false;
|
||||
host.audioTracks[1].enabled = true;
|
||||
await flush();
|
||||
expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 });
|
||||
expect(engine.state.userAudioTrackSelection.get()).toEqual({ language: 'es', name: 'Spanish' });
|
||||
|
||||
// A different source (new manifest URL) drops both pins so the engine re-picks.
|
||||
engine.state.presentation.set(
|
||||
presentation(
|
||||
[vTrack({ id: 'hi2', width: 1920, height: 1080, bandwidth: 5_000_000 })],
|
||||
[aTrack({ id: 'en2', language: 'en', name: 'English', default: true })],
|
||||
'https://example.com/other.m3u8'
|
||||
)
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(engine.state.userVideoTrackSelection.get()).toBeUndefined();
|
||||
expect(engine.state.userAudioTrackSelection.get()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the pinned rendition across a same-URL live reload', async () => {
|
||||
const tracks = [
|
||||
vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }),
|
||||
];
|
||||
engine.state.presentation.set(presentation(tracks));
|
||||
await flush();
|
||||
|
||||
host.videoRenditions.selectedIndex = 1;
|
||||
await flush();
|
||||
expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 });
|
||||
|
||||
// Live reload: same URL, new presentation object — the pin must survive.
|
||||
engine.state.presentation.set(presentation(tracks.map((t) => ({ ...t }))));
|
||||
await flush();
|
||||
expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 });
|
||||
});
|
||||
|
||||
it('clears tracks when the source is unset', async () => {
|
||||
engine.state.presentation.set(presentation([vTrack({ id: 'hi', width: 1920, height: 1080 })]));
|
||||
await flush();
|
||||
expect(host.videoRenditions.length).toBe(1);
|
||||
|
||||
engine.state.presentation.set(undefined);
|
||||
await flush();
|
||||
expect(host.videoTracks.length).toBe(0);
|
||||
expect(host.videoRenditions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('disposes effects and clears tracks on destroy', async () => {
|
||||
engine.state.presentation.set(presentation([vTrack({ id: 'hi' })], [aTrack({ id: 'en', language: 'en' })]));
|
||||
await flush();
|
||||
expect(host.videoTracks.length).toBe(1);
|
||||
|
||||
host.destroy();
|
||||
expect(host.videoTracks.length).toBe(0);
|
||||
expect(host.audioTracks.length).toBe(0);
|
||||
|
||||
// Effects are torn down: further presentation changes are ignored.
|
||||
engine.state.presentation.set(presentation([vTrack({ id: 'other' })]));
|
||||
await flush();
|
||||
expect(host.videoTracks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,11 @@
|
||||
"development": "./dist/dev/hls.js",
|
||||
"default": "./dist/default/hls.js"
|
||||
},
|
||||
"./media-tracks": {
|
||||
"types": "./dist/dev/media-tracks.d.ts",
|
||||
"development": "./dist/dev/media-tracks.js",
|
||||
"default": "./dist/default/media-tracks.js"
|
||||
},
|
||||
"./background-video": {
|
||||
"types": "./dist/dev/background-video.d.ts",
|
||||
"development": "./dist/dev/background-video.js",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Media-track translation utilities — pure transforms from SPF's track model
|
||||
* onto the deduped video-rendition / audio-track lists a media-element adapter
|
||||
* exposes, plus the selection-criteria builders.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export type { AudioDedupeKey, AudioTrack, VideoDedupeKey, VideoTrack } from './media-tracks';
|
||||
export {
|
||||
dedupedAudioTracks,
|
||||
dedupedVideoTracks,
|
||||
findAudioTrackById,
|
||||
findVideoTrackById,
|
||||
frameRateToNumber,
|
||||
isSameAudioTrack,
|
||||
isSameVideoTrack,
|
||||
toUserAudioTrackSelection,
|
||||
toUserVideoTrackSelection,
|
||||
} from './media-tracks';
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Media-track translation utilities.
|
||||
*
|
||||
* Pure, DOM-free transforms from SPF's CMAF-HAM track model onto the deduped
|
||||
* lists a media-element adapter exposes (video renditions, audio tracks), plus
|
||||
* the selection-criteria builders that turn a chosen rendition/track back into a
|
||||
* `user*TrackSelection` partial the engine's track-switching reads.
|
||||
*
|
||||
* These return SPF *model vocabulary* (`bandwidth`, `codecs: string[]`,
|
||||
* `frameRate` as a rational `FrameRate`); the consuming adapter owns the
|
||||
* mapping (e.g. for DOM `bandwidth` -> `bitrate`, `codecs.join(',')` -> `codec`,
|
||||
* `name` -> `label`).
|
||||
*
|
||||
* Deduplication is by *properties*, never URL, so a multi-CDN source that lists
|
||||
* the same rendition on several hosts collapses to one entry: video renditions
|
||||
* by `width` + `height` + `bandwidth`, audio tracks by `language` + `name`.
|
||||
* The selection builders emit those same properties as the match criteria, so
|
||||
* selecting a collapsed entry re-selects, for example, every underlying per-CDN track.
|
||||
*/
|
||||
|
||||
import type { AudioTrack, FrameRate, MaybeResolvedPresentation, VideoTrack } from '../types';
|
||||
import { findTrackById, getTracksByType } from '../utils/tracks';
|
||||
|
||||
export type { AudioTrack, VideoTrack };
|
||||
|
||||
/** Properties that identify a distinct video rendition (multi-CDN copies share them). */
|
||||
export interface VideoDedupeKey {
|
||||
width?: VideoTrack['width'];
|
||||
height?: VideoTrack['height'];
|
||||
bandwidth?: VideoTrack['bandwidth'];
|
||||
}
|
||||
|
||||
/** Properties that identify a distinct audio track (multi-CDN copies share them). */
|
||||
export interface AudioDedupeKey {
|
||||
language?: AudioTrack['language'];
|
||||
name?: AudioTrack['name'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct video tracks of a presentation, deduped by {@link VideoDedupeKey} (first occurrence wins).
|
||||
*
|
||||
* Returns `[]` when the presentation is unresolved or has no video tracks.
|
||||
*/
|
||||
export function dedupedVideoTracks(presentation: MaybeResolvedPresentation | undefined): VideoTrack[] {
|
||||
if (!presentation) return [];
|
||||
|
||||
return dedupe({
|
||||
tracks: getTracksByType(presentation, 'video') as readonly VideoTrack[],
|
||||
keyFn: toUserVideoTrackSelection,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The distinct audio tracks of a presentation, deduped by `language` + `name`
|
||||
* (first occurrence wins). Returns `[]` when the presentation is unresolved or has no audio tracks.
|
||||
*/
|
||||
export function dedupedAudioTracks(presentation: MaybeResolvedPresentation | undefined): AudioTrack[] {
|
||||
if (!presentation) return [];
|
||||
|
||||
return dedupe({
|
||||
tracks: getTracksByType(presentation, 'audio') as readonly AudioTrack[],
|
||||
keyFn: toUserAudioTrackSelection,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a video track by id, searching the same candidate set the engine resolves
|
||||
* against ({@link dedupedVideoTracks}'s pre-dedupe source). Returns `undefined`
|
||||
* when absent. Maps the engine's resolved `selectedVideoTrackId` back to its
|
||||
* properties for `active` reflection — the resolved id may be a per-CDN copy that
|
||||
* isn't the representative {@link dedupedVideoTracks} kept.
|
||||
*/
|
||||
export function findVideoTrackById(
|
||||
presentation: MaybeResolvedPresentation | undefined,
|
||||
id: string | undefined
|
||||
): VideoTrack | undefined {
|
||||
if (!presentation || !id) return undefined;
|
||||
const track = findTrackById(presentation, id);
|
||||
return track?.type === 'video' ? (track as VideoTrack) : undefined;
|
||||
}
|
||||
|
||||
/** Audio counterpart of {@link findVideoTrackById}, for `enabled` reflection. */
|
||||
export function findAudioTrackById(
|
||||
presentation: MaybeResolvedPresentation | undefined,
|
||||
id: string | undefined
|
||||
): AudioTrack | undefined {
|
||||
if (!presentation || !id) return undefined;
|
||||
const track = findTrackById(presentation, id);
|
||||
return track?.type === 'audio' ? (track as AudioTrack) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-equal two key objects by their own properties. Both come from the same
|
||||
* key builder, so they carry the same keys — a one-directional scan suffices.
|
||||
*/
|
||||
function sameKey<K extends object>(a: K, b: K): boolean {
|
||||
for (const attr in a) {
|
||||
if (a[attr] !== b[attr]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupe tracks by a key function, keeping the first occurrence of each key.
|
||||
* Keys are compared field-by-field ({@link sameKey}).
|
||||
*/
|
||||
function dedupe<T, K extends object>({
|
||||
tracks,
|
||||
keyFn,
|
||||
}: {
|
||||
tracks: readonly T[];
|
||||
keyFn: (track: T) => K | undefined;
|
||||
}): T[] {
|
||||
const seen: K[] = [];
|
||||
const kept: T[] = [];
|
||||
for (const track of tracks) {
|
||||
const key = keyFn(track);
|
||||
if (!key || seen.some((other) => sameKey(other, key))) continue;
|
||||
seen.push(key);
|
||||
kept.push(track);
|
||||
}
|
||||
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a partial video track that can be used as `userVideoTrackSelection`.
|
||||
*/
|
||||
export function toUserVideoTrackSelection<T extends VideoDedupeKey>(rendition?: T): Partial<VideoTrack> | undefined {
|
||||
return rendition ? { width: rendition.width, height: rendition.height, bandwidth: rendition.bandwidth } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a partial audio track that can be used as a `userAudioTrackSelection`.
|
||||
*/
|
||||
export function toUserAudioTrackSelection<T extends AudioDedupeKey>(track?: T): Partial<AudioTrack> | undefined {
|
||||
return track ? { language: track.language, name: track.name } : undefined;
|
||||
}
|
||||
|
||||
/** Whether two video tracks are the same by dedupe key */
|
||||
export function isSameVideoTrack(a: VideoDedupeKey, b: VideoDedupeKey | undefined): boolean {
|
||||
return !!b && a.width === b.width && a.height === b.height && a.bandwidth === b.bandwidth;
|
||||
}
|
||||
|
||||
/** Whether two audio tracks are the same by dedupe key */
|
||||
export function isSameAudioTrack(a: AudioDedupeKey, b: AudioDedupeKey | undefined): boolean {
|
||||
return !!b && (a.language ?? '') === (b.language ?? '') && a.name === b.name;
|
||||
}
|
||||
|
||||
/** Collapse a rational frame rate (numerator/denominator) to frames per second. */
|
||||
export const frameRateToNumber = (frameRate: FrameRate) => {
|
||||
return frameRate.frameRateNumerator / (frameRate.frameRateDenominator ?? 1);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { matchesPartialTrack } from '../../primitives/select-tracks';
|
||||
import type { AudioTrack, MaybeResolvedPresentation, VideoTrack } from '../../types';
|
||||
import {
|
||||
dedupedAudioTracks,
|
||||
dedupedVideoTracks,
|
||||
findAudioTrackById,
|
||||
findVideoTrackById,
|
||||
isSameAudioTrack,
|
||||
isSameVideoTrack,
|
||||
toUserAudioTrackSelection,
|
||||
toUserVideoTrackSelection,
|
||||
} from '../media-tracks';
|
||||
|
||||
// Minimal presentation builders. Tracks carry only the fields the transforms
|
||||
// read — the `as unknown as` cast keeps the fixtures terse (matching the
|
||||
// sibling track-util tests).
|
||||
const presentationWith = (
|
||||
videoTracks: Partial<VideoTrack>[],
|
||||
audioTracks: Partial<AudioTrack>[] = []
|
||||
): MaybeResolvedPresentation =>
|
||||
({
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
selectionSets: [
|
||||
{ id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: videoTracks }] },
|
||||
{ id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: audioTracks }] },
|
||||
],
|
||||
}) as unknown as MaybeResolvedPresentation;
|
||||
|
||||
const video = (over: Partial<VideoTrack>): Partial<VideoTrack> => ({
|
||||
type: 'video',
|
||||
url: 'https://cdn-a.example.com/v.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
codecs: ['avc1.640028'],
|
||||
...over,
|
||||
});
|
||||
|
||||
const audio = (over: Partial<AudioTrack>): Partial<AudioTrack> => ({
|
||||
type: 'audio',
|
||||
url: 'https://cdn-a.example.com/a.m3u8',
|
||||
bandwidth: 128_000,
|
||||
codecs: ['mp4a.40.2'],
|
||||
name: 'Audio',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('dedupedVideoTracks', () => {
|
||||
it('returns the model video tracks in order', () => {
|
||||
const renditions = dedupedVideoTracks(
|
||||
presentationWith([
|
||||
video({ id: 'v1', width: 1920, height: 1080, bandwidth: 5_000_000 }),
|
||||
video({ id: 'v2', width: 1280, height: 720, bandwidth: 3_000_000 }),
|
||||
])
|
||||
);
|
||||
|
||||
expect(renditions.map((r) => r.id)).toEqual(['v1', 'v2']);
|
||||
expect(renditions[0]).toMatchObject({ width: 1920, height: 1080, bandwidth: 5_000_000, codecs: ['avc1.640028'] });
|
||||
});
|
||||
|
||||
it('dedups by width + height + bandwidth, collapsing multi-CDN copies (first wins)', () => {
|
||||
const renditions = dedupedVideoTracks(
|
||||
presentationWith([
|
||||
video({ id: 'cdn-a-720', width: 1280, height: 720, bandwidth: 3_000_000, url: 'https://a/v.m3u8' }),
|
||||
video({ id: 'cdn-b-720', width: 1280, height: 720, bandwidth: 3_000_000, url: 'https://b/v.m3u8' }),
|
||||
video({ id: 'cdn-a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }),
|
||||
])
|
||||
);
|
||||
|
||||
expect(renditions.map((r) => r.id)).toEqual(['cdn-a-720', 'cdn-a-1080']);
|
||||
});
|
||||
|
||||
it('keeps renditions with the same bandwidth but different resolution distinct', () => {
|
||||
const renditions = dedupedVideoTracks(
|
||||
presentationWith([
|
||||
video({ id: 'a', width: 1280, height: 720, bandwidth: 3_000_000 }),
|
||||
video({ id: 'b', width: 1920, height: 1080, bandwidth: 3_000_000 }),
|
||||
])
|
||||
);
|
||||
expect(renditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns [] for an unresolved presentation, no video tracks, or undefined', () => {
|
||||
expect(dedupedVideoTracks({ url: 'https://example.com/x.m3u8' })).toEqual([]);
|
||||
expect(dedupedVideoTracks(presentationWith([]))).toEqual([]);
|
||||
expect(dedupedVideoTracks(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupedAudioTracks', () => {
|
||||
it('returns the model audio tracks in order', () => {
|
||||
const tracks = dedupedAudioTracks(
|
||||
presentationWith([], [audio({ id: 'a-en', language: 'en', name: 'English', default: true })])
|
||||
);
|
||||
expect(tracks.map((t) => t.id)).toEqual(['a-en']);
|
||||
expect(tracks[0]).toMatchObject({ language: 'en', name: 'English', default: true });
|
||||
});
|
||||
|
||||
it('dedups by language + name, collapsing multi-CDN copies (first wins)', () => {
|
||||
const tracks = dedupedAudioTracks(
|
||||
presentationWith(
|
||||
[],
|
||||
[
|
||||
audio({ id: 'en-cdn-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }),
|
||||
audio({ id: 'en-cdn-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }),
|
||||
audio({ id: 'es-cdn-a', language: 'es', name: 'Spanish', url: 'https://a/a.m3u8' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
expect(tracks.map((t) => t.id)).toEqual(['en-cdn-a', 'es-cdn-a']);
|
||||
});
|
||||
|
||||
it('keeps same-language tracks with different names distinct (e.g. commentary)', () => {
|
||||
const tracks = dedupedAudioTracks(
|
||||
presentationWith(
|
||||
[],
|
||||
[
|
||||
audio({ id: 'en', language: 'en', name: 'English' }),
|
||||
audio({ id: 'en-commentary', language: 'en', name: 'English (Commentary)' }),
|
||||
]
|
||||
)
|
||||
);
|
||||
expect(tracks.map((t) => t.id)).toEqual(['en', 'en-commentary']);
|
||||
});
|
||||
|
||||
it('returns [] when there are no audio tracks or the presentation is undefined', () => {
|
||||
expect(dedupedAudioTracks(presentationWith([]))).toEqual([]);
|
||||
expect(dedupedAudioTracks(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserVideoTrackSelection', () => {
|
||||
it('emits the width/height/bandwidth match criteria', () => {
|
||||
const [rendition] = dedupedVideoTracks(
|
||||
presentationWith([video({ id: 'v1', width: 1280, height: 720, bandwidth: 3_000_000 })])
|
||||
);
|
||||
expect(toUserVideoTrackSelection(rendition!)).toEqual({ width: 1280, height: 720, bandwidth: 3_000_000 });
|
||||
});
|
||||
|
||||
it('matches every underlying track sharing those properties (multi-CDN)', () => {
|
||||
const criteria = toUserVideoTrackSelection({ width: 1280, height: 720, bandwidth: 3_000_000 })!;
|
||||
const cdnA = video({ id: 'cdn-a', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack;
|
||||
const cdnB = video({ id: 'cdn-b', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack;
|
||||
const other = video({ id: 'other', width: 1920, height: 1080, bandwidth: 5_000_000 }) as VideoTrack;
|
||||
|
||||
expect(matchesPartialTrack(cdnA, criteria)).toBe(true);
|
||||
expect(matchesPartialTrack(cdnB, criteria)).toBe(true);
|
||||
expect(matchesPartialTrack(other, criteria)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserAudioTrackSelection', () => {
|
||||
it('emits the language + name match criteria', () => {
|
||||
const [track] = dedupedAudioTracks(presentationWith([], [audio({ id: 'a-es', language: 'es', name: 'Spanish' })]));
|
||||
expect(toUserAudioTrackSelection(track!)).toEqual({ language: 'es', name: 'Spanish' });
|
||||
});
|
||||
|
||||
it('matches same-language same-name tracks (multi-CDN) but not a different role', () => {
|
||||
const criteria = toUserAudioTrackSelection({ language: 'en', name: 'English' })!;
|
||||
const enA = audio({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }) as AudioTrack;
|
||||
const enB = audio({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }) as AudioTrack;
|
||||
const commentary = audio({ id: 'en-c', language: 'en', name: 'English (Commentary)' }) as AudioTrack;
|
||||
|
||||
expect(matchesPartialTrack(enA, criteria)).toBe(true);
|
||||
expect(matchesPartialTrack(enB, criteria)).toBe(true);
|
||||
expect(matchesPartialTrack(commentary, criteria)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVideoTrackById', () => {
|
||||
const pres = presentationWith([
|
||||
video({ id: 'cdn-a-1080', width: 1920, height: 1080, url: 'https://a/v.m3u8' }),
|
||||
video({ id: 'cdn-b-1080', width: 1920, height: 1080, url: 'https://b/v.m3u8' }),
|
||||
]);
|
||||
|
||||
it('finds a track by id, including a non-first per-CDN copy the dedup drops', () => {
|
||||
// 'cdn-b-1080' is collapsed out of dedupedVideoTracks but still resolvable.
|
||||
expect(findVideoTrackById(pres, 'cdn-b-1080')?.id).toBe('cdn-b-1080');
|
||||
});
|
||||
|
||||
it('returns undefined for a missing id or absent presentation', () => {
|
||||
expect(findVideoTrackById(pres, 'nope')).toBeUndefined();
|
||||
expect(findVideoTrackById(undefined, 'cdn-a-1080')).toBeUndefined();
|
||||
expect(findVideoTrackById(pres, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAudioTrackById', () => {
|
||||
const pres = presentationWith(
|
||||
[],
|
||||
[
|
||||
audio({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }),
|
||||
audio({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }),
|
||||
]
|
||||
);
|
||||
|
||||
it('finds a track by id, including a non-first per-CDN copy', () => {
|
||||
expect(findAudioTrackById(pres, 'en-b')?.id).toBe('en-b');
|
||||
});
|
||||
|
||||
it('returns undefined for a missing id or absent presentation', () => {
|
||||
expect(findAudioTrackById(pres, 'nope')).toBeUndefined();
|
||||
expect(findAudioTrackById(undefined, 'en-a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameVideoTrack', () => {
|
||||
it('matches by width + height + bandwidth regardless of id/url', () => {
|
||||
const a = { width: 1280, height: 720, bandwidth: 3_000_000 };
|
||||
const b = video({ id: 'other-cdn', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack;
|
||||
expect(isSameVideoTrack(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a different quality, and is false when the track is undefined', () => {
|
||||
const a = { width: 1280, height: 720, bandwidth: 3_000_000 };
|
||||
expect(isSameVideoTrack(a, video({ width: 1920, height: 1080, bandwidth: 5_000_000 }) as VideoTrack)).toBe(false);
|
||||
expect(isSameVideoTrack(a, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameAudioTrack', () => {
|
||||
it('matches by language + name, treating empty and absent language alike', () => {
|
||||
expect(
|
||||
isSameAudioTrack({ language: 'en', name: 'English' }, audio({ language: 'en', name: 'English' }) as AudioTrack)
|
||||
).toBe(true);
|
||||
// DOM coerces a missing language to '' — must still match the model's `undefined`.
|
||||
expect(
|
||||
isSameAudioTrack({ language: '', name: 'Audio' }, audio({ language: undefined, name: 'Audio' }) as AudioTrack)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match a different role, and is false when the track is undefined', () => {
|
||||
expect(
|
||||
isSameAudioTrack({ language: 'en', name: 'English' }, audio({ language: 'en', name: 'Commentary' }) as AudioTrack)
|
||||
).toBe(false);
|
||||
expect(isSameAudioTrack({ language: 'en', name: 'English' }, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const createConfig = (mode: PackageBuildMode): UserConfig => ({
|
||||
index: 'src/index.ts',
|
||||
dom: 'src/dom.ts',
|
||||
hls: 'src/playback/engines/hls/index.ts',
|
||||
'media-tracks': 'src/media/media-tracks/index.ts',
|
||||
'background-video': 'src/playback/engines/background-video/index.ts',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user