Files
v10/packages/spf/src/media/primitives/select-tracks.ts
T
2026-06-08 14:48:34 -07:00

330 lines
11 KiB
TypeScript

import { DEFAULT_QUALITY_CONFIG, selectQuality } from '../abr/quality-selection';
import type { AudioSelectionSet, MaybeResolvedPresentation, TrackType, VideoSelectionSet } from '../types';
import { SelectedTrackIdKeyByType } from '../utils/track-selection';
/**
* Default initial bandwidth estimate for cold start (bits per second).
* Conservative 1 Mbps to avoid over-selecting on slow connections.
*/
export const DEFAULT_INITIAL_BANDWIDTH = 1_000_000;
/**
* State shape for track selection.
*/
export interface TrackSelectionState {
presentation?: MaybeResolvedPresentation;
selectedVideoTrackId?: string;
selectedAudioTrackId?: string;
selectedTextTrackId?: string;
}
/**
* Context shape for track selection.
* Currently empty - reserved for future use (e.g., bandwidth estimator).
*/
export type TrackSelectionContext = Record<string, never>;
/**
* Action types for track selection.
* Reserved for future event-driven selection triggers.
*/
export type TrackSelectionAction = { type: 'presentation-loaded' };
/**
* Configuration for video track selection.
*/
export interface VideoSelectionConfig {
/**
* Initial bandwidth estimate for cold start (bits per second).
* Used to select video quality before we have real measurements.
* Default: 1 Mbps (conservative).
*/
initialBandwidth?: number;
/**
* Safety margin for quality selection (0-1).
* Default: 0.85 (15% headroom).
*/
safetyMargin?: number;
}
/**
* Configuration for audio track selection.
*/
export interface AudioSelectionConfig {
/**
* Preferred audio language (ISO 639 code, e.g., "en", "es").
* If not specified, selects first audio track.
*/
preferredAudioLanguage?: string;
}
/**
* Configuration for text track selection.
*/
export interface TextSelectionConfig {
/**
* Preferred subtitle language (ISO 639 code, e.g., "en", "es").
* If specified, selects matching track if available.
*/
preferredSubtitleLanguage?: string;
/**
* Include FORCED subtitle tracks in selection.
* Default: false (follows hls.js/http-streaming pattern)
*
* Note: Per Apple's HLS spec, if content has forced and regular subtitles
* in the same language, the regular track MUST contain both forced and
* regular content. Therefore, forced-only tracks are redundant and excluded
* by default.
*/
includeForcedTracks?: boolean;
/**
* Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).
* Default: false (user opt-in, matches hls.js/http-streaming)
*
* When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES
* will be automatically selected if no user preference matches.
*/
enableDefaultTrack?: boolean;
}
// =============================================================================
// Helper Functions (Pure Selection Logic)
// =============================================================================
/**
* Contract for a track picker — a pure function that consults a
* presentation (and optional config) and returns the id of the track to
* select, or `undefined` to leave the slot unset.
*
* Behaviors that own a track-selection slot (`selectAudioTrack`,
* `selectTextTrack`, `selectVideoTrack`, `switchVideoTrack`) accept a
* `TrackPicker` via config. The behavior passes its own config straight
* through as the picker's second argument — pickers that need richer
* options (language preferences, default-track filtering, bandwidth-aware
* selection) read from `config`; pickers that don't (e.g., first-track)
* ignore it.
*/
export type TrackPicker<Config = unknown> = (
presentation: MaybeResolvedPresentation,
config?: Config
) => string | undefined;
/**
* Test whether a track matches a partial-track description: every present,
* defined field of `filter` equals the track's. Absent or `undefined` filter
* fields don't constrain. Used to narrow candidates by a user selection
* (`{ id }`, `{ language }`, `{ height }`, …).
*
* @param track - The track to test
* @param filter - Partial-track description; only present, defined fields constrain
* @returns `true` when the track matches every constraining field
*/
export function matchesPartialTrack<T>(track: T, filter: Partial<T>): boolean {
for (const key in filter) {
const filterValue = filter[key as keyof T];
if (filterValue !== undefined && track[key as keyof T] !== filterValue) return false;
}
return true;
}
/**
* Pick the first track of the given type from a presentation.
*
* Returns the first track in the first switching set of the matching
* selection set, or `undefined` if either is missing. POC-shaped
* default-pick — `pickVideoTrack` / `pickAudioTrack` honor bandwidth +
* language preferences and will replace this once selection callers are
* ready.
*/
export function pickFirstTrackId(presentation: MaybeResolvedPresentation, type: TrackType): string | undefined {
return presentation.selectionSets?.find((set) => set.type === type)?.switchingSets[0]?.tracks[0]?.id;
}
/**
* Pick video track using quality selection algorithm.
*
* Uses bandwidth-based selection with safety margin to pick
* the highest quality track that fits available bandwidth.
*
* @param presentation - Presentation with video tracks
* @param config - Selection configuration (bandwidth, safety margin)
* @returns Selected video track ID, or undefined if no video tracks
*/
export function pickVideoTrack(
presentation: MaybeResolvedPresentation,
config?: VideoSelectionConfig
): string | undefined {
const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;
if (!videoSet || videoSet.switchingSets.length === 0) {
return undefined;
}
// Get first switching set's tracks (HLS typically has one switching set per type)
const switchingSet = videoSet.switchingSets[0];
if (!switchingSet || switchingSet.tracks.length === 0) {
return undefined;
}
const initialBandwidth = config?.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH;
const safetyMargin = config?.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;
// selectQuality works with both partially resolved and resolved tracks
const selected = selectQuality(switchingSet.tracks as any, { bandwidth: initialBandwidth, safetyMargin });
return selected?.id;
}
/**
* Pick the video track with the highest resolution (width x height).
*
* Falls back to `bandwidth` when resolution metadata is missing.
*
* Pair with `selectVideoTrack`; compose `switchVideoQuality` instead
* for runtime-adapted quality.
*/
export function pickMaxResolutionVideoTrack(presentation: MaybeResolvedPresentation): string | undefined {
const videoSet = presentation.selectionSets?.find((set) => set.type === 'video') as VideoSelectionSet | undefined;
const tracks = videoSet?.switchingSets[0]?.tracks;
if (!tracks?.length) return undefined;
let bestId: string | undefined;
let bestArea = -1;
let bestBandwidth = -1;
for (const track of tracks) {
const area = track.width && track.height ? track.width * track.height : 0;
const bandwidth = track.bandwidth ?? 0;
if (area > bestArea || (area === bestArea && bandwidth > bestBandwidth)) {
bestArea = area;
bestBandwidth = bandwidth;
bestId = track.id;
}
}
return bestId;
}
/**
* Pick audio track.
*
* Selection priority:
* 1. First track matching preferred language (if specified)
* 2. First default track
* 3. First audio track
*
* @param presentation - Presentation with audio tracks
* @param config - Selection configuration (preferred language)
* @returns Selected audio track ID, or undefined if no audio tracks
*/
export function pickAudioTrack(
presentation: MaybeResolvedPresentation,
config?: AudioSelectionConfig
): string | undefined {
const audioSet = presentation.selectionSets?.find((set) => set.type === 'audio') as AudioSelectionSet | undefined;
if (!audioSet || audioSet.switchingSets.length === 0) {
return undefined;
}
// Get first switching set's tracks
const switchingSet = audioSet.switchingSets[0];
if (!switchingSet || switchingSet.tracks.length === 0) {
return undefined;
}
const tracks = switchingSet.tracks;
// Try preferred language first
if (config?.preferredAudioLanguage) {
const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);
if (languageMatch) {
return languageMatch.id;
}
}
// Try default track
const defaultTrack = tracks.find((track) => track.default === true);
if (defaultTrack) {
return defaultTrack.id;
}
// Fall back to first track
return tracks[0]?.id;
}
/**
* Pick text track to activate. Conforms to the `TrackPicker` contract so it
* can be used directly as a default picker for `selectTextTrack` without an
* adapter wrapper.
*
* Selection priority (if enabled):
* 1. User preference (preferredSubtitleLanguage)
* 2. DEFAULT track (if enableDefaultTrack is true and track has DEFAULT=YES + AUTOSELECT=YES)
* 3. No auto-selection (user opt-in)
*
* By default, FORCED tracks are excluded per Apple's HLS spec.
*/
export function pickTextTrack(
presentation: MaybeResolvedPresentation,
config?: TextSelectionConfig
): string | undefined {
const textSet = presentation.selectionSets?.find((set) => set.type === 'text');
if (!textSet?.switchingSets?.[0]?.tracks.length) return undefined;
const tracks = textSet.switchingSets[0].tracks;
// Filter out FORCED tracks by default (following hls.js/http-streaming pattern)
// Per Apple spec: regular tracks MUST contain forced content when both exist
const availableTracks = config?.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);
if (availableTracks.length === 0) return undefined;
const { preferredSubtitleLanguage, enableDefaultTrack = false } = config ?? {};
// 1. Preferred language match (if specified)
if (preferredSubtitleLanguage) {
const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);
if (languageMatch) return languageMatch.id;
}
// 2. DEFAULT track (if enabled AND track has both DEFAULT=YES + AUTOSELECT=YES)
// Note: Parser only sets default=true when BOTH attributes present
if (enableDefaultTrack) {
const defaultTrack = availableTracks.find((track) => track.default === true);
if (defaultTrack) return defaultTrack.id;
}
// 3. User opt-in (no auto-selection)
return undefined;
}
/**
* Check if we can select a track of the given type.
*
* Returns true when:
* - Presentation exists
* - Has tracks of the specified type
*
* Generic over track type - works for video, audio, or text.
*/
export function canSelectTrack(state: TrackSelectionState, type: TrackType): boolean {
return !!state?.presentation?.selectionSets?.find((set) => set.type === type)?.switchingSets?.[0]?.tracks.length;
}
/**
* Check if we should select a track of the given type.
*
* Returns true when:
* - Track of this type is not already selected
*
* Generic over track type - works for video, audio, or text.
*
* @TODO figure out reactive model for ABR cases - right now we're only selecting
* if we have nothing selected (CJP)
*/
export function shouldSelectTrack(state: TrackSelectionState, type: TrackType): boolean {
return !state[SelectedTrackIdKeyByType[type]];
}