feat(spf): expose media tracks on the SPF media adapter (#1826)

This commit is contained in:
Santiago Puppo
2026-07-30 16:13:30 -03:00
committed by GitHub
parent b16f28fb74
commit c83b044986
8 changed files with 986 additions and 1 deletions
@@ -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;
}
+3 -1
View File
@@ -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);
});
});
+5
View File
@@ -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);
});
});
+1
View File
@@ -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',
},
});