Files
v10/packages/spf/src/playback/behaviors/select-tracks.ts
T

251 lines
10 KiB
TypeScript

/**
* **Default audio/text/video track selection on src load / unselect on src unload.**
* When a presentation is resolved, sets `selectedVideoTrackId` /
* `selectedAudioTrackId` / `selectedTextTrackId` to a per-type-picker default
* if no selection already exists. When the presentation is unset/reset
* (transitions back to unresolved), clears the selection so a stale id from
* the previous source doesn't persist.
*
* Lifecycle-driven: each transition fires its work once. Does not police the
* selection between transitions; external writes (user picks, ABR, programmatic
* filter-driven re-picks) are left alone.
*
* Picker is config-driven: each per-type export wires a sensible default
* (`pickAudioTrack` for audio — three-tier language-aware; `pickTextTrack`
* for text; `pickFirstTrackId` for video) and the caller can supply their
* own via `config.picker` for custom selection logic. The behavior's
* `config` is forwarded to the picker as its second argument, so options
* like `preferredAudioLanguage` / `preferredSubtitleLanguage` reach the
* picker without an intermediate wrapping layer.
*
* Compose `selectVideoTrack` for the simple "pick a default video track"
* behavior, or `switchVideoTrack` (`./track-switching.ts`) for the
* ABR-driven variant. Compose `selectAudioTrack` for the simple default
* pick, or `switchAudioTrack` (`./track-switching.ts`) for the
* filter-reactive + mid-stream-flush slot-owner variant — when audio-abr
* lands, `switchAudioTrack` extends into `switchAudioQuality`. Compose
* only one per type — they're alternatives, not stackable (each writes
* the same `selected*TrackId` slot). The simple variants tree-shake out
* the heavier machinery (bandwidth estimator, quality selection, flush
* orchestration).
*/
import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
import {
type AudioSelectionConfig,
pickAudioTrack,
pickFirstTrackId,
pickTextTrack,
type TextSelectionConfig,
type TrackPicker,
type TrackSelectionState,
type VideoSelectionConfig,
} from '../../media/primitives/select-tracks';
import { isResolvedPresentation } from '../../media/types';
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from './track-types';
// ============================================================================
// Specialization helper
//
// `setupTrackSelection` has the same shape as a Behavior `setup` function:
// `({ state, config }) => Reactor`. Each `selectXTrack` export below calls
// it from inside its own `defineBehavior` setup, supplying its per-type
// `selectedKey`, default picker, and forwarded picker config. The lifecycle
// — pick on entering 'presentation-resolved' if not already selected; clear
// on entering 'presentation-unresolved' — is shared.
// ============================================================================
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
type SelectStateMap<K extends SelectedTrackKey> = {
presentation: ReadonlySignal<TrackSelectionState['presentation']>;
} & { [P in K]: Signal<TrackSelectionState[P]> };
interface TrackSelectionSetupConfig<K extends SelectedTrackKey, PickerConfig> {
selectedKey: K;
picker: TrackPicker<PickerConfig>;
pickerConfig?: PickerConfig;
}
function setupTrackSelection<K extends SelectedTrackKey, PickerConfig>({
state,
config: { selectedKey, picker, pickerConfig },
}: {
state: SelectStateMap<K>;
config: TrackSelectionSetupConfig<K, PickerConfig>;
}) {
const derivedStateSignal = computed(() =>
isResolvedPresentation(state.presentation.get())
? ('presentation-resolved' as const)
: ('presentation-unresolved' as const)
);
return createMachineReactor({
initial: 'presentation-unresolved',
monitor: () => derivedStateSignal.get(),
states: {
'presentation-unresolved': {},
'presentation-resolved': {
// Entry: pick a default on entering presentation-resolved if none
// is set. External writes (user picks, ABR) that already populated
// the slot are left alone.
//
// The returned cleanup runs on state exit — which fires on src
// unload (presentation-resolved → presentation-unresolved) AND on
// behavior destroy (presentation-resolved → destroying →
// destroyed). Putting the clear here rather than as
// presentation-unresolved.entry is more cohesive (operation +
// cleanup co-located) and correctly covers destroy (destroy
// doesn't pass through presentation-unresolved).
entry: () => {
if (!state[selectedKey].get()) {
// `state.presentation.get()` is non-null inside this entry —
// the reactor's `'presentation-resolved'` gate is exactly
// `isResolvedPresentation(state.presentation.get())`, which
// requires a truthy Presentation.
const id = picker(state.presentation.get()!, pickerConfig);
if (id) state[selectedKey].set(id);
}
return () => state[selectedKey].set(undefined);
},
},
},
});
}
// ============================================================================
// Default pickers
//
// Each variant resolves its picker as `config?.picker ?? <default>` and
// forwards the whole engine config as `pickerConfig`, so rich pickers
// (`pickAudioTrack`, `pickTextTrack`) read their options directly. Audio
// and text use their primitive pickers as-is; video adapts
// `pickFirstTrackId` (positional `type` arg) into the `TrackPicker` shape.
// ============================================================================
/** Default video picker: first track in the video selection set. */
const defaultVideoPicker: TrackPicker = (presentation) => pickFirstTrackId(presentation, 'video');
// ============================================================================
// Specialized exports — one per track type
// ============================================================================
/**
* Config for `selectVideoTrack`. Pass `picker` to fully override selection
* logic; otherwise the default `pickFirstTrackId` is used.
*/
export interface SelectVideoTrackConfig extends VideoSelectionConfig {
picker?: TrackPicker<SelectVideoTrackConfig>;
}
/**
* Select a video track when a presentation loads. Clears the selection on
* src unload.
*
* This is the simple, non-ABR counterpart to `switchVideoTrack` — compose
* one or the other, not both (both write `selectedVideoTrackId`). Composing
* `selectVideoTrack` alone tree-shakes out the ABR code path
* (bandwidth-estimator, quality-selection); use it for sources without
* meaningful quality variants, test setups, or players that intentionally
* pin a quality.
*
* @example
* const reactor = selectVideoTrack.setup({ state });
*/
export const selectVideoTrack = defineBehavior({
stateKeys: ['presentation', 'selectedVideoTrackId'],
contextKeys: [],
setup: ({ state, config }: { state: SelectStateMap<'selectedVideoTrackId'>; config?: SelectVideoTrackConfig }) =>
setupTrackSelection({
state,
config: {
selectedKey: VIDEO_TYPE_CONFIG.selectedKey,
picker: config?.picker ?? defaultVideoPicker,
pickerConfig: config,
},
}),
});
/**
* Config for `selectAudioTrack`. Pass `picker` to fully override selection
* logic; otherwise the default `pickAudioTrack` is used (three-tier:
* `preferredAudioLanguage` → `DEFAULT=YES` → first track).
*/
export interface SelectAudioTrackConfig extends AudioSelectionConfig {
picker?: TrackPicker<SelectAudioTrackConfig>;
}
/**
* Select an audio track when a presentation loads. Clears the selection
* on src unload.
*
* This is the simple, lifecycle-only counterpart to `switchAudioTrack`
* (in `./track-switching.ts`) — compose one or the other, not both
* (both write `selectedAudioTrackId`). `switchAudioTrack` adds
* filter-reactivity (`userAudioTrackSelection`) and mid-stream-flush
* orchestration; `selectAudioTrack` covers the default-on-load case
* without those. Use this variant for test setups, audio-only flows
* that don't expose language switching, or composition variants that
* intentionally pin a track.
*
* @example
* const reactor = selectAudioTrack.setup({ state });
*
* @example
* // Custom picker with language preference
* const reactor = selectAudioTrack.setup({
* state,
* config: { preferredAudioLanguage: 'en', picker: myLanguageAwarePicker },
* });
*/
export const selectAudioTrack = defineBehavior({
stateKeys: ['presentation', 'selectedAudioTrackId'],
contextKeys: [],
setup: ({ state, config }: { state: SelectStateMap<'selectedAudioTrackId'>; config?: SelectAudioTrackConfig }) =>
setupTrackSelection({
state,
config: {
selectedKey: AUDIO_TYPE_CONFIG.selectedKey,
picker: config?.picker ?? pickAudioTrack,
pickerConfig: config,
},
}),
});
/**
* Config for `selectTextTrack`. Pass `picker` to fully override selection
* logic; otherwise the default `pickTextTrack` is used, which honors the
* other fields.
*/
export interface SelectTextTrackConfig extends TextSelectionConfig {
picker?: TrackPicker<SelectTextTrackConfig>;
}
/**
* Select a text track based on user preferences (preferred language,
* default-track auto-select, forced-track filtering). Clears the selection
* on src unload.
*
* Unlike audio selection, the default text picker returns `undefined` when
* no preference matches, leaving the selection unset — text-track
* selection is user opt-in.
*
* @example
* const reactor = selectTextTrack.setup({ state, config: { preferredSubtitleLanguage: 'en' } });
*/
export const selectTextTrack = defineBehavior({
stateKeys: ['presentation', 'selectedTextTrackId'],
contextKeys: [],
setup: ({ state, config }: { state: SelectStateMap<'selectedTextTrackId'>; config?: SelectTextTrackConfig }) =>
setupTrackSelection({
state,
config: {
selectedKey: TEXT_TYPE_CONFIG.selectedKey,
picker: config?.picker ?? pickTextTrack,
pickerConfig: config,
},
}),
});