diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index ffa30640..11ba73cf 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -44,6 +44,12 @@ export const SOURCES = { subType: 'mp4', live: true, }, + 'hls-audio-only-cmaf': { + label: 'HLS - Audio only (CMAF/fmp4)', + url: 'https://stream.mux.com/2NEjLyf6ETnskbfAtbM00Vdzb97B00OKUUQcRD6LZpBRw.m3u8', + type: 'hls', + subType: 'mp4', + }, 'mp4-1': { label: 'MP4 - Dancing Dude', url: 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4/highest.mp4', diff --git a/packages/spf/src/all.ts b/packages/spf/src/all.ts index 21b7fe74..e591f991 100644 --- a/packages/spf/src/all.ts +++ b/packages/spf/src/all.ts @@ -101,5 +101,5 @@ export { syncPreload } from './playback/behaviors/sync-preload'; // Features — Track Switching (video ABR + audio language selection) // ============================================================================= -export type { TrackSwitchingConfig, TrackSwitchingState } from './playback/behaviors/track-switching'; +export type { SwitchVideoTrackConfig, TrackSwitchingState } from './playback/behaviors/track-switching'; export { DEFAULT_INITIAL_BANDWIDTH, switchAudioTrack, switchVideoTrack } from './playback/behaviors/track-switching'; diff --git a/packages/spf/src/core/composition/share-signals.ts b/packages/spf/src/core/composition/share-signals.ts index ed2538da..266e7ae5 100644 --- a/packages/spf/src/core/composition/share-signals.ts +++ b/packages/spf/src/core/composition/share-signals.ts @@ -26,24 +26,25 @@ export interface ShareSignalsConfig { * intent can be expressed by typing captured refs as `Signal` or * `ReadonlySignal` at the call site). * - * Declares no keys of its own (`stateKeys: []`, `contextKeys: []`); the - * composition's state/context maps come from other behaviors' key - * declarations. This behavior just observes/forwards whatever signals - * the composition built. + * By default declares no keys; the composition's state/context maps come from + * other behaviors. Pass `inputStateKeys` / `inputContextKeys` to *materialize* + * consumer-input slots that no other behavior produces — a slot the consumer + * writes (e.g. `userAudioTrackSelection`) but only a rule reads. shareSignals + * is the consumer boundary, so it's the natural place to bring those slots into + * existence; readers then treat them as optional. * - * Uses a `Behavior<>` literal (not `defineBehavior`) so the empty key - * arrays don't trip the exhaustiveness check — the setup-param state/ - * context shapes here describe what the consumer's callback receives, - * not keys this behavior needs created. + * Uses a `Behavior<>` literal (not `defineBehavior`) so its (possibly empty, + * possibly partial) key arrays don't trip the exhaustiveness check — the + * setup-param state/context shapes describe what the callback receives (the + * full `S` / `C`), not the subset this behavior materializes. */ -export function makeShareSignals(): Behavior< - StateSignals, - ContextSignals, - ShareSignalsConfig -> { +export function makeShareSignals( + inputStateKeys: readonly (keyof S)[] = [], + inputContextKeys: readonly (keyof C)[] = [] +): Behavior, ContextSignals, ShareSignalsConfig> { return { - stateKeys: [], - contextKeys: [], + stateKeys: inputStateKeys, + contextKeys: inputContextKeys, setup: ({ state, context, config }) => { config.onSignalsReady?.({ state, context }); }, diff --git a/packages/spf/src/media/abr/quality-selection.ts b/packages/spf/src/media/abr/quality-selection.ts index 0387a944..7a1c00e2 100644 --- a/packages/spf/src/media/abr/quality-selection.ts +++ b/packages/spf/src/media/abr/quality-selection.ts @@ -153,6 +153,15 @@ export function selectLowestQuality(tracks: rea return tracks.reduce((min, t) => (t.bandwidth < min.bandwidth ? t : min)); } +/** + * Resolution as a total pixel count (`width × height`), the basis for + * comparing two tracks at the same bitrate. Missing dimensions count as 0, so + * tracks without resolution metadata (e.g. audio) area-compare equal. + */ +export function resolutionArea(track: { width?: number; height?: number }): number { + return (track.width ?? 0) * (track.height ?? 0); +} + /** * Check if track A has higher resolution than track B. * Compares by total pixel count (width × height). @@ -165,7 +174,5 @@ function hasHigherResolution( trackA: PartiallyResolvedVideoTrack | VideoTrack, trackB: PartiallyResolvedVideoTrack | VideoTrack ): boolean { - const pixelsA = (trackA.width ?? 0) * (trackA.height ?? 0); - const pixelsB = (trackB.width ?? 0) * (trackB.height ?? 0); - return pixelsA > pixelsB; + return resolutionArea(trackA) > resolutionArea(trackB); } diff --git a/packages/spf/src/media/abr/tests/quality-selection.test.ts b/packages/spf/src/media/abr/tests/quality-selection.test.ts index ed6caafd..0d9673b0 100644 --- a/packages/spf/src/media/abr/tests/quality-selection.test.ts +++ b/packages/spf/src/media/abr/tests/quality-selection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { PartiallyResolvedVideoTrack } from '../../types'; -import { DEFAULT_QUALITY_CONFIG, selectLowestQuality, selectQuality } from '../quality-selection'; +import { DEFAULT_QUALITY_CONFIG, resolutionArea, selectLowestQuality, selectQuality } from '../quality-selection'; // Helper to create test tracks const createTrack = (id: string, bandwidth: number, width = 1920, height = 1080): PartiallyResolvedVideoTrack => ({ @@ -308,3 +308,15 @@ describe('selectLowestQuality', () => { expect(selectLowestQuality(tracks)?.id).toBe('low'); }); }); + +describe('resolutionArea', () => { + it('returns the pixel count (width × height)', () => { + expect(resolutionArea({ width: 1920, height: 1080 })).toBe(2_073_600); + }); + + it('treats a missing dimension as 0', () => { + expect(resolutionArea({ width: 1920 })).toBe(0); + expect(resolutionArea({ height: 1080 })).toBe(0); + expect(resolutionArea({})).toBe(0); + }); +}); diff --git a/packages/spf/src/media/primitives/select-tracks.ts b/packages/spf/src/media/primitives/select-tracks.ts index 148d335d..2165020f 100644 --- a/packages/spf/src/media/primitives/select-tracks.ts +++ b/packages/spf/src/media/primitives/select-tracks.ts @@ -112,6 +112,24 @@ export type TrackPicker = ( 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(track: T, filter: Partial): 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. * diff --git a/packages/spf/src/playback/behaviors/tests/track-switching.test.ts b/packages/spf/src/playback/behaviors/tests/track-switching.test.ts index 44a9e2ea..88a3cd69 100644 --- a/packages/spf/src/playback/behaviors/tests/track-switching.test.ts +++ b/packages/spf/src/playback/behaviors/tests/track-switching.test.ts @@ -12,10 +12,11 @@ import type { } from '../../../media/types'; import type { BandwidthState } from '../../../network/bandwidth-estimator'; import { + applyRules, + type SelectionRule, + type SwitchVideoTrackConfig, switchAudioTrack, switchVideoTrack, - type TrackSwitchingConfig, - type TrackSwitchingState, } from '../track-switching'; // ============================================================================ @@ -29,7 +30,7 @@ interface SwitchVideoTrackState { userVideoTrackSelection?: Partial; } -function makeState(initial: Partial = {}): StateSignals { +function makeState(initial: Partial = {}): StateSignals { return { presentation: signal(initial.presentation), bandwidthState: signal(initial.bandwidthState), @@ -149,6 +150,28 @@ describe('switchVideoTrack', () => { reactor.destroy(); }); + + it('re-picks when the candidate set changes while staying resolved (constraint-readiness seam)', async () => { + // The playable candidate set is derived in a `computed` the effect reads + // reactively, so the pick updates whenever that set changes — not only on + // an unresolved→resolved gate transition. This is what readies the + // behavior for dynamic constraints (e.g. CDN failover) that re-prune the + // set while a presentation stays resolved. + const state = makeState({ presentation: createPresentation(tracks) }); + + const reactor = switchVideoTrack.setup({ state }); + await flush(); + expect(state.selectedVideoTrackId.get()).toBe('720p'); + + // Swap directly to a different resolved presentation (no undefined gap, so + // the gate state stays 'presentation-resolved' and never re-enters). + const narrowed = [createVideoTrack('480p', 1_200_000), createVideoTrack('540p', 1_500_000)]; + state.presentation.set({ ...createPresentation(narrowed), id: 'pres-2' }); + await flush(); + expect(state.selectedVideoTrackId.get()).toBe('540p'); + + reactor.destroy(); + }); }); describe('default-pick (no bandwidthState yet)', () => { @@ -355,6 +378,52 @@ describe('switchVideoTrack', () => { }); }); + describe('equal-bitrate resolution tie-break', () => { + const withResolution = (track: PartiallyResolvedVideoTrack, width: number, height: number) => ({ + ...track, + width, + height, + }); + + it('prefers the higher-resolution rendition when bitrates are equal', async () => { + // Same bitrate, but the lower-resolution variant is listed first — a + // bitrate-only ranker would pick it by manifest order. + const equalBitrate = [ + withResolution(createVideoTrack('sd', 3_000_000), 640, 360), + withResolution(createVideoTrack('hd', 3_000_000), 1920, 1080), + ]; + const state = makeState({ + presentation: createPresentation(equalBitrate), + bandwidthState: createBandwidthState(6_000_000), + }); + + const reactor = switchVideoTrack.setup({ state }); + await flush(); + expect(state.selectedVideoTrackId.get()).toBe('hd'); + + reactor.destroy(); + }); + + it('breaks ties by resolution among the smallest over-throughput renditions', async () => { + // Both exceed the throughput threshold (equal, lowest bitrate) — the + // fallback pick should still favor the higher-resolution variant. + const overThreshold = [ + withResolution(createVideoTrack('sd', 8_000_000), 640, 360), + withResolution(createVideoTrack('hd', 8_000_000), 1920, 1080), + ]; + const state = makeState({ + presentation: createPresentation(overThreshold), + bandwidthState: createBandwidthState(1_000_000), + }); + + const reactor = switchVideoTrack.setup({ state }); + await flush(); + expect(state.selectedVideoTrackId.get()).toBe('hd'); + + reactor.destroy(); + }); + }); + describe('configuration', () => { it('uses custom safetyMargin', async () => { const state = makeState({ @@ -363,7 +432,7 @@ describe('switchVideoTrack', () => { selectedVideoTrackId: '360p', }); - const config: TrackSwitchingConfig = { quality: { safetyMargin: 1.0 } }; + const config: SwitchVideoTrackConfig = { quality: { safetyMargin: 1.0 } }; const reactor = switchVideoTrack.setup({ state, config }); await flush(); expect(state.selectedVideoTrackId.get()).toBe('720p'); @@ -378,7 +447,7 @@ describe('switchVideoTrack', () => { selectedVideoTrackId: 'low', }); - const config: TrackSwitchingConfig = { quality: { upgradeMargin: 1.05 } }; + const config: SwitchVideoTrackConfig = { quality: { upgradeMargin: 1.05 } }; const reactor = switchVideoTrack.setup({ state, config }); await flush(); expect(state.selectedVideoTrackId.get()).toBe('high'); @@ -401,52 +470,13 @@ describe('switchVideoTrack', () => { selectedVideoTrackId: '360p', }); - const config: TrackSwitchingConfig = { initialBandwidth: 5_000_000 }; + const config: SwitchVideoTrackConfig = { initialBandwidth: 5_000_000 }; const reactor = switchVideoTrack.setup({ state, config }); await flush(); expect(state.selectedVideoTrackId.get()).toBe('720p'); reactor.destroy(); }); - it('uses custom picker for initial pick; ABR adjusts from there', async () => { - // Picker pins the initial pick to 360p (lowest, regardless of bandwidth). - // At 6 Mbps, ABR would default-pick 1080p; the picker overrides for - // the initial pick. Subsequent ABR upgrade applies normally. - const state = makeState({ - presentation: createPresentation(tracks), - bandwidthState: createBandwidthState(6_000_000), - }); - - const config: TrackSwitchingConfig = { picker: () => '360p' }; - const reactor = switchVideoTrack.setup({ state, config }); - await flush(); - // Picker drives the initial selection. - expect(state.selectedVideoTrackId.get()).toBe('360p'); - - // Bumping bandwidth re-fires the effect; the slot is no longer empty, - // so ABR runs and upgrades to 1080p (at 6 Mbps with default margins). - state.bandwidthState.set(createBandwidthState(6_000_001)); - await flush(); - expect(state.selectedVideoTrackId.get()).toBe('1080p'); - - reactor.destroy(); - }); - - it('picker returning undefined falls back to bandwidth-aware default', async () => { - const state = makeState({ - presentation: createPresentation(tracks), - bandwidthState: createBandwidthState(3_000_000), - }); - - const config: TrackSwitchingConfig = { picker: () => undefined }; - const reactor = switchVideoTrack.setup({ state, config }); - await flush(); - // 3 Mbps with default safetyMargin 0.85 → 720p (1080p needs 5.65 Mbps). - expect(state.selectedVideoTrackId.get()).toBe('720p'); - - reactor.destroy(); - }); - it('uses custom minTotalBytes threshold to trust the measured estimate sooner', async () => { // 800 kbps measured, only 50 KB sampled — below the default 128 KB // threshold (would fall back to initialBandwidth) but above our @@ -465,7 +495,7 @@ describe('switchVideoTrack', () => { selectedVideoTrackId: '720p', }); - const config: TrackSwitchingConfig = { + const config: SwitchVideoTrackConfig = { bandwidth: { minTotalBytes: 40_000 }, initialBandwidth: 5_000_000, }; @@ -484,11 +514,12 @@ describe('switchVideoTrack', () => { // switchAudioTrack // // The audio variant shares `setupTrackSwitching` with `switchVideoTrack` — -// these tests cover the audio-specific surface: default picker, language -// pinning, filter reactivity, single-candidate short-circuit. The -// bandwidth-driven re-evaluation tests live above under `switchVideoTrack` -// and don't need duplicating; audio's `selectAudioCurrent` pins to the -// current track and is exercised here by the steady-state assertions. +// these tests cover the audio-specific surface: default pick (first track), +// language pinning via the user-selection filter, filter reactivity, and the +// single-candidate early-bail. The bandwidth-driven re-evaluation tests live +// above under `switchVideoTrack` and don't need duplicating; audio's +// `selectAudioCurrent` pins to the current track and is exercised here by the +// steady-state assertions. // ============================================================================ interface SwitchAudioTrackState { @@ -566,22 +597,6 @@ describe('switchAudioTrack', () => { reactor.destroy(); }); - it('picks track matching preferredAudioLanguage when supplied', async () => { - const state = makeAudioState({ - presentation: createAudioPresentation([ - makeAudioTrack('audio-en', { language: 'en' }), - makeAudioTrack('audio-es', { language: 'es' }), - ]), - }); - - const reactor = switchAudioTrack.setup({ state, config: { preferredAudioLanguage: 'es' } }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(state.selectedAudioTrackId.get()).toBe('audio-es'); - - reactor.destroy(); - }); - it('clears selectedAudioTrackId on src unload', async () => { const state = makeAudioState({ presentation: createAudioPresentation([makeAudioTrack('audio-en', { language: 'en' })]), @@ -640,7 +655,7 @@ describe('switchAudioTrack — userAudioTrackSelection filter', () => { reactor.destroy(); }); - it('filter narrowing to a single track short-circuits the picker', async () => { + it('filter narrowing to a single track early-bails to that track', async () => { const state = makeAudioState({ presentation: createAudioPresentation([ makeAudioTrack('audio-en', { language: 'en' }), @@ -669,3 +684,53 @@ describe('switchAudioTrack — userAudioTrackSelection filter', () => { reactor.destroy(); }); }); + +// ============================================================================ +// applyRules — the rule-chain composer (pure; no signals) +// ============================================================================ + +describe('applyRules', () => { + const track = (id: string) => ({ id }); + const all = [track('a'), track('b'), track('c')]; + + const noDeps = { state: {}, context: {}, config: {} }; + + it('applies rules in order; the pick is the first survivor', () => { + const dropA: SelectionRule<{ id: string }> = (tracks) => tracks.filter((t) => t.id !== 'a'); + const reverse: SelectionRule<{ id: string }> = (tracks) => [...tracks].reverse(); + const result = applyRules([dropA, reverse], all, noDeps); + expect(result.map((t) => t.id)).toEqual(['c', 'b']); + }); + + it('skips a rule that returns nothing (fall-through), keeping the prior set', () => { + const matchNone: SelectionRule<{ id: string }> = () => []; + const result = applyRules([matchNone], all, noDeps); + expect(result.map((t) => t.id)).toEqual(['a', 'b', 'c']); + }); + + it('stops at one survivor and does not run later rules (early-bail)', () => { + const toA: SelectionRule<{ id: string }> = (tracks) => tracks.filter((t) => t.id === 'a'); + let laterCalled = false; + const later: SelectionRule<{ id: string }> = (tracks) => { + laterCalled = true; + return tracks; + }; + const result = applyRules([toA, later], all, noDeps); + expect(result.map((t) => t.id)).toEqual(['a']); + expect(laterCalled).toBe(false); + }); + + it('passes the candidate list and deps (state, context, config) through to each rule', () => { + const deps = { state: { marker: 1 }, context: { other: 2 }, config: { tuning: 3 } }; + let received: unknown[] = []; + const rule: SelectionRule<{ id: string }, typeof deps.state, typeof deps.context, typeof deps.config> = ( + tracks, + ruleDeps + ) => { + received = [tracks, ruleDeps]; + return tracks; + }; + applyRules([rule], all, deps); + expect(received).toEqual([all, deps]); + }); +}); diff --git a/packages/spf/src/playback/behaviors/track-switching.ts b/packages/spf/src/playback/behaviors/track-switching.ts index 6466e4f2..fa6017c1 100644 --- a/packages/spf/src/playback/behaviors/track-switching.ts +++ b/packages/spf/src/playback/behaviors/track-switching.ts @@ -1,49 +1,47 @@ /** - * **Per-type track-selection slot management with optional ABR.** While a - * presentation is resolved, owns the selection slot's lifecycle: pick a - * default, react to user intent (partial-track filter), re-evaluate - * algorithmically (today: bandwidth via `selectQuality` for video; pin-to- - * current for audio), and clear on src unload. + * **Per-type track selection as a rule chain.** While a presentation is + * resolved, owns that type's `selected{Video,Audio}TrackId` signal: pick a + * default, react to user intent and algorithmic ranking, and clear it on src + * unload. * - * User intent is expressed as a partial-track description in a sibling slot - * (e.g. `userVideoTrackSelection`, `userAudioTrackSelection`) which - * constrains the candidate set for selection — when the constraint narrows - * candidates to exactly one, the choice is fully determined and the - * selection algorithm is short-circuited (no bandwidth read, no effect - * re-fire on bandwidth changes). + * Selection runs a small ordered chain of rules over the candidate tracks + * (`applyRules`). Each rule narrows or reorders the list and reads the signals + * it needs at apply time, so the effect subscribes to exactly what the applied + * rules consult. Today the chain is two rules, most authoritative first: + * + * 1. **user intent** — a soft filter on `user*TrackSelection`: narrow to the + * partial-track match; an empty match falls through to the full set. + * 2. **ranking** — the terminal sort: `rankByBandwidth`, shared by video and + * audio. Fitting tracks (within the throughput threshold) first, highest + * bitrate first; over-throughput tracks after, least-over first. Hysteresis + * via boosting the current track's sort weight by `upgradeMargin`. + * + * The composer's early-bail (one survivor → stop) is load-bearing: a user + * selection that narrows to a single track is the pick without the ranker + * running, so the bandwidth estimate is never read and the effect doesn't + * re-fire on bandwidth while that choice holds. * * Lifecycle: `'presentation-unresolved'` ↔ `'presentation-resolved'`. The - * `'presentation-resolved'` state owns the selection slot; its - * entry-returned cleanup clears the slot on exit (canonical - * cleanup-binds-to-setup per `reactors.md`). + * resolved state owns the signal; its entry-returned cleanup clears it on exit + * (canonical cleanup-binds-to-setup per `reactors.md`). * - * Hysteresis (video ABR today, audio ABR when added): downgrades apply - * immediately; upgrades require the optimal track's bandwidth to exceed - * the current track's by `upgradeMargin`. No temporal state — short-term - * smoothing is the bandwidth estimator's job. + * The pick is the head of the chain's result (`applyRules(...)[0]`). Each + * variant supplies its **rule chain** via config; `setupTrackSwitching` owns + * only the lifecycle and runs whatever chain it's given. Both variants today run + * `[filterByUserSelection, rankByBandwidth]`; `switchVideoTrack` also accepts ABR + * tuning config, `switchAudioTrack` takes none. * - * Initial pick is configurable via `config.picker` — pass any `TrackPicker` - * to override the default-pick for the empty-slot case. Algorithmic - * re-evaluation (`selectOptimal`) is unaffected. - * - * Variants: `switchVideoTrack` (bandwidth-driven `selectOptimal`), - * `switchAudioTrack` (pin-to-current `selectOptimal`, ABR-ready shape for - * a future bandwidth-driven variant once audio-ABR lands). Both variants - * share this helper; the only point of variation is the per-variant - * `selectOptimal` and the candidate-track type. Future track-switching - * axes (text tracks, etc.) plug in the same way. + * Deferred (not yet in the chain): a hard-constraints pre-pass (capability + * probing, CDN failover) gating the candidate set, and audio's preferred- + * language / default-track selection as standing soft-filter rules — previously + * the empty-slot picker, dropped in the move to the rule chain. */ -import { defineBehavior } from '../../core/composition/create-composition'; +import { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition'; import { createMachineReactor } from '../../core/reactors/create-machine-reactor'; -import { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives'; -import { - DEFAULT_QUALITY_CONFIG, - type QualityConfig, - selectLowestQuality, - selectQuality, -} from '../../media/abr/quality-selection'; -import { pickAudioTrack, type TrackPicker } from '../../media/primitives/select-tracks'; +import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives'; +import { DEFAULT_QUALITY_CONFIG, type QualityConfig, resolutionArea } from '../../media/abr/quality-selection'; +import { matchesPartialTrack } from '../../media/primitives/select-tracks'; import { type AudioTrack, isResolvedPresentation, @@ -60,90 +58,105 @@ import { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate } from '../../network/ba // State + Config // ============================================================================ +/** + * The slots `setupTrackSwitching` itself owns: the `presentation` gate it reads + * and the per-type `selected*TrackId` it writes. Rule-only inputs are + * deliberately absent — `user*TrackSelection` and `bandwidthState` belong to + * whoever materializes them (the embedder via `shareSignals`, the buffer-actor + * sampler), and each rule declares the signal it consults as an optional slot + * on its own deps map, so the behavior never assumes a rule's signal exists. + */ export interface TrackSwitchingState { presentation?: MaybeResolvedPresentation; - bandwidthState?: BandwidthState; selectedVideoTrackId?: string; selectedAudioTrackId?: string; - /** - * Partial-track description expressing user intent for video. When set, - * narrows candidates to tracks matching every present field. Common case - * is `{ id: 'specific-track-id' }` for "manual quality"; other shapes - * work (e.g., `{ height: 720 }` constrains to 720p tracks — ABR - * continues to pick among them). - * - * When narrowed candidates contain exactly one track, ABR is - * short-circuited entirely (no bandwidth read, no effect re-fire). - * - * Falls back to the unfiltered set when the filter matches no tracks - * (e.g., user-picked id from a previous source doesn't exist here). - */ - userVideoTrackSelection?: Partial; - /** - * Partial-track description expressing user intent for audio. Common - * case is `{ language: 'es' }` for language-pinning, `{ id: 'X' }` for - * absolute pinning. Same narrowing + short-circuit + fallback semantics - * as `userVideoTrackSelection`. - */ - userAudioTrackSelection?: Partial; } -export interface TrackSwitchingConfig { - /** - * Quality-selection tuning consumed by bandwidth-driven `selectOptimal` - * variants (today: `switchVideoTrack`'s `selectQuality`). `safetyMargin` - * is the bandwidth-headroom multiplier; `upgradeMargin` is the - * hysteresis ratio gating upgrades. Defaults: `DEFAULT_QUALITY_CONFIG` - * (0.85 / 1.15). Ignored by pin-to-current variants - * (today: `switchAudioTrack`). - */ +/** + * Config for `switchVideoTrack` — the ABR tuning read by its ranker rule + * (`rankByBandwidth`). `quality.safetyMargin` is the bandwidth-headroom + * multiplier; `quality.upgradeMargin` the hysteresis ratio gating upgrades; + * `bandwidth` tunes the estimator; `initialBandwidth` is the pre-sample + * fallback. Defaults: `DEFAULT_QUALITY_CONFIG` (0.85 / 1.15), + * `DEFAULT_BANDWIDTH_CONFIG`, `DEFAULT_INITIAL_BANDWIDTH` (5 Mbps). + */ +export interface SwitchVideoTrackConfig { quality?: Partial; - - /** - * Bandwidth-estimator tuning passed through to `getBandwidthEstimate`. - * Merged over `DEFAULT_BANDWIDTH_CONFIG`. Consumed only by bandwidth- - * driven variants. - */ bandwidth?: Partial; - - /** - * Bandwidth estimate in bps to use before enough samples have been - * collected. Default: 5_000_000 (5 Mbps). - */ initialBandwidth?: number; - - /** - * Override the initial-pick algorithm. When set, the picker is called - * the first time the slot is empty in the `'presentation-resolved'` - * state; its returned id is set verbatim (no algorithmic logic). - * Subsequent re-evaluation via the variant's `selectOptimal` is - * unaffected. - * - * Honors of the user-selection filter are the picker's responsibility - * when overridden. If the picker returns `undefined`, the variant's - * default initial pick fires (graceful fallback). - */ - picker?: TrackPicker; - - /** - * Audio-variant config — preferred language consumed by the default - * audio picker (`pickAudioTrack`). Ignored by other variants. - */ - preferredAudioLanguage?: string; } /** Default initial-bandwidth value before bandwidth measurements arrive. */ export const DEFAULT_INITIAL_BANDWIDTH = 5_000_000; +// ============================================================================ +// Rule chain +// ============================================================================ + +/** + * Deps handed to each rule and to `applyRules`, mirroring a behavior's setup + * deps so a rule reads from the same surfaces a behavior does. `context` is + * optional — it's threaded through but absent on direct setup calls (and + * unread by today's rules), so the whole deps object can pass straight through. + */ +export interface SelectionRuleDeps { + state: State; + context?: Context; + config: Config; +} + +/** + * A selection rule narrows or reorders the candidate list. It reads the state, + * context, and config it needs at apply time (tightly-coupled reads), so a + * rule's `.get()`s subscribe the running effect to exactly what it consulted. + * Returning an empty list means "no match" — the composer skips it, so a soft + * filter never narrows the set to nothing. A ranker returns the list with its + * pick at the head. + */ +export type SelectionRule = ( + tracks: readonly T[], + deps: SelectionRuleDeps +) => readonly T[]; + +/** + * Apply rules to a candidate list in order; the pick is the first survivor. + * Two responsibilities the rules don't carry: a rule that returns nothing is + * skipped (fall-through — a preference never empties the set), and once one + * survivor remains the chain stops (early-bail — later rules, including the + * bandwidth ranker, never run, so the effect doesn't subscribe to their + * signals while the choice is fixed). + * + * @param rules - Rules to apply, most authoritative first + * @param tracks - Candidate tracks + * @param deps - The behavior's `{ state, context, config }`, passed through to each rule + * @returns The surviving candidates, pick first + */ +export function applyRules( + rules: readonly SelectionRule[], + tracks: readonly T[], + deps: SelectionRuleDeps +): readonly T[] { + let current = tracks; + for (const rule of rules) { + const remaining = rule(current, deps); + if (remaining.length === 0) continue; + current = remaining; + if (current.length === 1) break; + } + return current; +} + // ============================================================================ // Specialization helper // // `setupTrackSwitching` has the same shape as a Behavior `setup` function: // `({ state, config }) => Reactor`. Each `switchXTrack` export below calls -// it from inside its own `defineBehavior` setup, passing the per-type slot -// keys, track type, and selection algorithm via three generic parameters — -// `S` (selection slot key), `U` (user-selection slot key), `T` (candidate -// track type). +// it from inside its own `defineBehavior` setup. Its generics — `S` (selection +// slot key), `T` (candidate track type), `C` (the concrete config the variant +// builds) — all infer from the passed `state` + `config`, so the variants need +// no explicit type arguments. `C extends TrackSwitchingConfig` lets the +// variant's richer config (rule-specific fields included) flow through the +// helper untouched; the rules read those fields off their own config views. // // -- Design note: why narrow `SelectionKey` / `UserSelectionKey` unions ---- // Goal we did not reach: have callers "fully pass in" the slot keys, with @@ -151,8 +164,8 @@ export const DEFAULT_INITIAL_BANDWIDTH = 5_000_000; // What blocks it: indexing a mapped-type intersection by a generic key. // When `S extends keyof TrackSwitchingState` (or `string`), TS conservatively // treats `state[selectionKey]` as the union of every possible match across -// the intersected mapped portions — including the fixed-key signals -// (`presentation`, `bandwidthState`) — and widens to their value-type union. +// the intersected mapped portions — including the fixed-key signal +// (`presentation`) — and widens to their value-type union. // The sibling pattern hits the same constraint and answers it the same way: // `SelectedTrackKey` in `select-tracks.ts` is a hardcoded narrow union for // the same reason. @@ -164,60 +177,183 @@ export const DEFAULT_INITIAL_BANDWIDTH = 5_000_000; // -------------------------------------------------------------------------- // ============================================================================ -/** Minimum candidate-track shape consumed by the helper. */ -type SwitchableTrack = { id: string; bandwidth?: number }; +/** + * Minimum candidate-track shape consumed by the helper. `bandwidth` feeds the + * ranker's throughput sort; `width`/`height` are the equal-bitrate tie-break + * (absent on audio, so audio candidates area-compare equal). + */ +type SwitchableTrack = { id: string; bandwidth?: number; width?: number; height?: number }; type SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId'; type UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection'; // Each mapped value references `P` so TS keeps the per-key dependency and -// resolves `state[selectionKey]` / `state[userSelectionKey]` to the right -// arm. `T` (track type) deliberately stays out of the state map — pulling -// it in detaches the user-selection mapped value from `P` and TS collapses -// the intersection. T flows through `TrackSwitchingSetupConfig` instead; -// the user-filter access casts at the read site (see below). -type TrackSwitchingStateMap = { +// resolves `state[selectionKey]` to the right arm. `T` (track type) stays out +// of the state map (it flows through `TrackSwitchingConfig` instead). +// +// Only the behavior's own lifecycle signals are required here: the presentation +// gate and the selection slot it writes. Signals a *rule* reads but the +// behavior doesn't — `user*TrackSelection` (the user-selection filter) and +// `bandwidthState` (the bandwidth ranker) — are NOT here; each rule declares +// the signal it needs as *optional* on its own deps and reads it defensively, +// so the behavior never assumes a rule-only signal exists. Those slots are +// materialized by whoever owns them: `shareSignals` for the consumer-input +// `user*TrackSelection`, the buffer-actor sampler for `bandwidthState`. +type TrackSwitchingStateMap = { presentation: ReadonlySignal; - bandwidthState: ReadonlySignal; -} & { [P in S]: Signal } & { [P in U]: ReadonlySignal }; +} & { [P in S]: Signal }; /** - * Selection context passed to `selectOptimal`. Built once per effect run. - * Bandwidth-aware variants (video ABR, future audio ABR) read all fields; - * pin-to-current variants (audio today) ignore the bandwidth-shaped fields. - * - * The context is built *inside* the effect, so bandwidth-aware variants - * subscribe to `bandwidthState` automatically; pin-to-current variants - * receive the same context but never re-fire on bandwidth changes because - * the single-candidate short-circuit (above) bypasses the bandwidth read. + * Config `setupTrackSwitching` itself reads — its own wiring: which selection + * slot to write and clear (`selectionKey`), how to enumerate candidate tracks + * (`getTracks`), and the **rule chain** to run (`rules`). Rule-specific config + * is deliberately absent — each rule declares the fields it reads as *optional* + * on its own config view (`UserSelectionConfig`, `BandwidthRankerConfig`), so + * the behavior never enumerates a rule's config. The variant builds the + * concrete config as this base plus whatever its chain's rules consult; it + * flows through untouched as the `C` type param on `setupTrackSwitching`. */ -export interface SelectionCtx { - bandwidth: number; - safetyMargin: number; - upgradeMargin: number; - currentTrack?: T; -} - -interface TrackSwitchingSetupConfig - extends TrackSwitchingConfig { +interface TrackSwitchingConfig { selectionKey: S; - userSelectionKey: U; getTracks: (presentation: MaybeResolvedPresentation) => readonly T[]; - selectOptimal: (tracks: readonly T[], ctx: SelectionCtx) => T | undefined; + rules: readonly SelectionRule, AnySlotMap, TrackSwitchingConfig>[]; } -function setupTrackSwitching({ - state, - config, -}: { - state: TrackSwitchingStateMap; - config: TrackSwitchingSetupConfig; -}) { +/** + * State the user-selection filter reads: the lifecycle map plus an *optional* + * user-selection slot (keyed by `U`), holding a partial-track description to + * match against the candidates (`Partial` — `{ id }`, `{ language }`, + * `{ height }`, …). The slot exists only when the composition provides it + * (materialized by `shareSignals`); the filter reads it defensively and no-ops + * when it's absent (no user override). + */ +type UserSelectionStateMap< + S extends SelectionKey, + U extends UserSelectionKey, + T extends SwitchableTrack, +> = TrackSwitchingStateMap & { + [P in U]?: ReadonlySignal | undefined>; +}; + +/** + * Config the user-selection filter reads: `userSelectionKey` names the state + * slot holding the user's selection. *Optional* on the rule's view — the base + * config doesn't carry it, so an unwired key means "no user selection" and the + * filter passes through. The variants always supply it. + */ +type UserSelectionConfig< + S extends SelectionKey, + U extends UserSelectionKey, + T extends SwitchableTrack, +> = TrackSwitchingConfig & { userSelectionKey?: U }; + +/** + * State the bandwidth ranker reads: the lifecycle map plus an *optional* + * `bandwidthState`. The signal exists only when the composition includes a + * bandwidth sampler; the ranker reads it defensively and falls back to + * `initialBandwidth` (with a debug note) when it's absent. + */ +type BandwidthRankerStateMap = TrackSwitchingStateMap & { + bandwidthState?: ReadonlySignal; +}; + +/** + * Config the bandwidth ranker reads: the ABR tuning in `SwitchVideoTrackConfig` + * (`quality` / `bandwidth` / `initialBandwidth`), all optional with defaults. + */ +type BandwidthRankerConfig = TrackSwitchingConfig & + SwitchVideoTrackConfig; + +type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack; +type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack; + +// ---------------------------------------------------------------------------- +// Rules — defined outside the behavior closure, parameterized only by their +// deps. Each is generic over the slot keys + track type; the variant's +// concrete keys instantiate it where the chain is assembled. +// ---------------------------------------------------------------------------- + +/** + * User intent — a soft filter. Narrows to tracks matching the partial-track + * selection in `user*TrackSelection`; an empty match falls through (the + * composer skips it) to the unfiltered set — e.g. a stale id from a previous + * source. + */ +function filterByUserSelection( + tracks: readonly T[], + { state, config }: SelectionRuleDeps, AnySlotMap, UserSelectionConfig> +): readonly T[] { + const key = config.userSelectionKey; + if (!key) return tracks; + const filter = state[key]?.get(); + return filter ? tracks.filter((track) => matchesPartialTrack(track, filter)) : tracks; +} + +/** + * Bandwidth ranking — the terminal sort, shared by video and audio. Orders by + * the throughput estimate: tracks within the bandwidth threshold first + * (fitting), highest bitrate first; then over-threshold tracks, least-over + * first. The head is the best-quality track that fits, falling back to the + * smallest over-throughput track when nothing fits. + * + * Hysteresis without temporal state: the current track's effective bitrate is + * boosted by `upgradeMargin` in the fitting sort, so a higher track only + * outranks it once it clears `current.bitrate * upgradeMargin` (no flapping on + * marginal bandwidth gains). Downgrades fall out for free — a current track + * over the threshold isn't in the fitting set to be boosted, so the best fit (a + * downgrade) wins immediately. Equal-bitrate tracks break by resolution (higher + * `width × height` first), so an equal-bitrate ladder never picks a lower- + * quality rendition by manifest order; audio tracks carry no dimensions, so + * they area-compare equal and a stable sort keeps their candidate order (e.g. + * same-bitrate language variants). Early-bail skips this rule when a prior one + * narrowed to a single track, so the estimate is neither read nor subscribed + * while that holds. + */ +function rankByBandwidth( + tracks: readonly T[], + { state, config }: SelectionRuleDeps, AnySlotMap, BandwidthRankerConfig> +): readonly T[] { const safetyMargin = config.quality?.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin; const upgradeMargin = config.quality?.upgradeMargin ?? DEFAULT_QUALITY_CONFIG.upgradeMargin; const initialBandwidth = config.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH; const bandwidthConfig: BandwidthConfig = { ...DEFAULT_BANDWIDTH_CONFIG, ...config.bandwidth }; - const { selectionKey, userSelectionKey, getTracks, selectOptimal } = config; + if (!state.bandwidthState) { + console.debug( + '[track-switching] rankByBandwidth: no bandwidthState signal in composition; ranking on initialBandwidth' + ); + } + const threshold = getBandwidthEstimate(state.bandwidthState?.get(), initialBandwidth, bandwidthConfig) * safetyMargin; + const currentId = state[config.selectionKey].get(); + const bitrate = (track: T) => track.bandwidth ?? 0; + // Boost the current track's sort weight by upgradeMargin (fitting set only) so + // an upgrade must clear current.bitrate * upgradeMargin to outrank it. + const rank = (track: T) => (track.id === currentId ? bitrate(track) * upgradeMargin : bitrate(track)); + // Equal bitrate → prefer higher resolution (width × height), so an + // equal-bitrate ladder doesn't pick a lower-quality rendition by manifest + // order. Audio tracks carry no dimensions, so they area-compare equal and + // keep candidate order (stable sort). + const fitting = tracks + .filter((track) => bitrate(track) <= threshold) + .sort((a, b) => rank(b) - rank(a) || resolutionArea(b) - resolutionArea(a)); + const over = tracks + .filter((track) => bitrate(track) > threshold) + .sort((a, b) => bitrate(a) - bitrate(b) || resolutionArea(b) - resolutionArea(a)); + return [...fitting, ...over]; +} + +// `context` is the composition's context map, threaded in by each variant's +// rest-spread and typed as the generic slot-map shape (`AnySlotMap`). It can't +// be a typed param on the `defineBehavior` setup without widening `ContextMap` +// to its constraint and forcing the slot required, so the variants forward it +// untyped via the rest and it lands here — absent on direct setup calls, and +// passed straight through to the rules (which don't read it yet). +function setupTrackSwitching< + S extends SelectionKey, + T extends SwitchableTrack, + C extends TrackSwitchingConfig, +>(deps: { state: TrackSwitchingStateMap; context?: AnySlotMap; config: C }) { + const { state, config } = deps; + const { selectionKey, getTracks, rules } = config; const derivedStateSignal = computed(() => isResolvedPresentation(state.presentation.get()) @@ -225,108 +361,76 @@ function setupTrackSwitching( + () => { + const presentation = state.presentation.get(); + return isResolvedPresentation(presentation) ? getTracks(presentation) : []; + }, + { equals: (a, b) => a.length === b.length && a.every((track) => b.some((other) => other.id === track.id)) } + ); + return createMachineReactor({ initial: 'presentation-unresolved', monitor: () => derivedStateSignal.get(), states: { 'presentation-unresolved': {}, 'presentation-resolved': { - // Canonical cleanup-binds-to-setup: the selection slot's valid - // lifespan is exactly 'presentation-resolved'. Clear fires on - // 'presentation-resolved' exit, covering both src unload and - // behavior destroy. + // Canonical cleanup-binds-to-setup: the selection signal's valid + // lifespan is exactly 'presentation-resolved'. Clear fires on exit, + // covering both src unload and behavior destroy. entry: () => () => state[selectionKey].set(undefined), effects: [ () => { - const presentation = peek(state.presentation); - if (!presentation) return; + // Reactive read: subscribes the reaction to the candidate set, so a + // new presentation — or a future constraint pruning it — re-fires + // this and re-picks. + const tracks = candidateSet.get(); + // No playable tracks: no tracks of this type, or (once constraints + // exist) everything pruned. Nothing to pick. Surfacing "nothing + // playable" as a distinct not-ready state is left to the constraints + // work; for now it's a silent no-op, leaving any prior pick in place. + if (!tracks.length) return; - const allTracks = getTracks(presentation); - const [firstAllTrack] = allTracks; - if (!firstAllTrack) return; + // The whole deps object passes straight through to every rule in the + // variant-supplied chain (state + config from the behavior; context + // threaded in by the variant's rest-spread). Typed against the base + // config — each rule re-declares the extra fields it reads as + // optional, and the concrete `C` is assignable to the base. + const candidates = applyRules, AnySlotMap, TrackSwitchingConfig>( + rules, + tracks, + deps + ); - // State stores the filter as `Partial` (user-facing - // shape — includes per-type fields like `height` or - // `language`); the helper works against `Partial` so - // filter and track access share one index type. Filter keys - // absent on a partially-resolved track read as `undefined` - // and just exclude that track. - const userFilter = state[userSelectionKey].get() as Partial | undefined; - const matching = userFilter - ? allTracks.filter((track) => { - for (const key in userFilter) { - const filterValue = userFilter[key as keyof T]; - if (filterValue !== undefined && track[key as keyof T] !== filterValue) return false; - } - return true; - }) - : allTracks; - // Fall back to all tracks when the filter excludes everything - // (e.g., user-picked id doesn't exist in the current source). - const candidates = matching.length > 0 ? matching : allTracks; - if (!candidates.length) return; - - const selectedId = state[selectionKey].get(); - - // Common case: user has fully constrained the choice (e.g., - // `{ id: 'specific-720p' }` or `{ language: 'es' }` when only - // one Spanish track exists). Skip the algorithm path — and - // crucially, don't read `bandwidthState` so the effect - // doesn't re-fire on bandwidth changes while the user's - // selection holds. - if (candidates.length === 1) { - if (candidates[0]!.id !== selectedId) state[selectionKey].set(candidates[0]!.id); + // applyRules early-bails to a single survivor and never narrows to + // nothing (a soft filter that would empty the set falls through), so + // the pick is just the head. An empty result means a rule misbehaved + // — applyRules is supposed to account for those cases, so surface it. + if (!candidates.length) { + console.error('[track-switching] applyRules returned no candidates'); return; } - - // Read bandwidth up front to establish the signal subscription — - // future bandwidth changes must re-fire this effect even when - // the picker branch below takes the early-return path. (If the - // picker bails out without ever touching `bandwidthState`, - // bandwidth-driven `selectOptimal` would otherwise be deaf to - // subsequent bandwidth changes.) Pin-to-current variants read - // the field but ignore it — the subscription cost is fixed - // per effect run regardless. - // - // Single path for pre-trust and post-trust: `getBandwidthEstimate` - // returns `initialBandwidth` when state is undefined or bytes - // sampled hasn't crossed `minTotalBytes`, so the initial pick - // and early-ABR window run the same `selectOptimal` path as a - // fully-trusted measurement. - const bandwidth = getBandwidthEstimate(state.bandwidthState.get(), initialBandwidth, bandwidthConfig); - - // Picker-driven initial pick: when the slot is empty and the - // caller supplied a `picker`, defer to it instead of the - // algorithmic default. The picker sees the full presentation - // (not narrowed by the user-selection filter) — honoring the - // filter is the picker's responsibility when overridden. - // Returning `undefined` falls through to the algorithmic - // default pick (graceful fallback). - // - // Algorithmic re-evaluation runs as usual on subsequent - // effect re-runs once the slot is set. - if (!selectedId && config.picker) { - const id = config.picker(presentation, config); - if (id) { - state[selectionKey].set(id); - return; - } - } - // `selectOptimal` decides the track to apply now given current - // selection + context. Returns: - // - the optimal when no current track or on a downgrade - // - the optimal when an upgrade clears `upgradeMargin` - // - the current track itself when an upgrade doesn't clear - // margin (caller's id-compare below no-ops in that case) - // Outer `?? selectLowestQuality` is defensive — `selectQuality` - // falls back to lowest internally; pin-to-current variants - // return `currentTrack ?? tracks[0]` so they never produce - // `undefined` for a non-empty `candidates`. The fallback - // catches future variants that don't return a definitive pick. - const currentTrack = candidates.find((t) => t.id === selectedId); - const ctx: SelectionCtx = { bandwidth, safetyMargin, upgradeMargin, currentTrack }; - const optimal = selectOptimal(candidates, ctx) ?? selectLowestQualityWithBandwidth(candidates); - if (optimal && optimal.id !== selectedId) state[selectionKey].set(optimal.id); + // No change-guard needed: the slot uses default (Object.is) equality, + // so re-setting the same id is a no-op (no notify, no re-fire). + state[selectionKey].set(candidates[0]!.id); }, ], }, @@ -334,24 +438,10 @@ function setupTrackSwitching(tracks: readonly T[]): T | undefined { - if (tracks.length === 0) return undefined; - const withBandwidth = tracks.filter((t): t is T & { bandwidth: number } => typeof t.bandwidth === 'number'); - if (withBandwidth.length === 0) return tracks[0]; - return selectLowestQuality(withBandwidth); -} - // ============================================================================ // Variant: switchVideoTrack — bandwidth-driven ABR // ============================================================================ -type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack; - /** * Manage `selectedVideoTrackId`: pick a default on src load, dynamically * adjust based on bandwidth, clear on src unload. Honors @@ -362,45 +452,33 @@ type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack; * const reactor = switchVideoTrack.setup({ state }); */ export const switchVideoTrack = defineBehavior({ - stateKeys: ['presentation', 'bandwidthState', 'selectedVideoTrackId', 'userVideoTrackSelection'], + stateKeys: ['presentation', 'selectedVideoTrackId'], contextKeys: [], setup: ({ state, config, + ...otherProps }: { - state: TrackSwitchingStateMap<'selectedVideoTrackId', 'userVideoTrackSelection'>; - config?: TrackSwitchingConfig; + state: TrackSwitchingStateMap<'selectedVideoTrackId'>; + config?: SwitchVideoTrackConfig; }) => - setupTrackSwitching<'selectedVideoTrackId', 'userVideoTrackSelection', VideoTrackCandidate>({ + setupTrackSwitching({ + ...otherProps, state, config: { ...config, selectionKey: 'selectedVideoTrackId', userSelectionKey: 'userVideoTrackSelection', getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[], - selectOptimal: selectQuality, + rules: [filterByUserSelection, rankByBandwidth], }, }), }); // ============================================================================ -// Variant: switchAudioTrack — pin-to-current (ABR-ready shape) +// Variant: switchAudioTrack — bandwidth-ranked (shared ranker) // ============================================================================ -type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack; - -/** - * Audio's `selectOptimal` — pin-to-current variant. Returns the current - * track if it's in the candidate set; otherwise the first candidate. No - * bandwidth-driven re-evaluation today (audio is not ABR-driven yet); the - * `ctx` shape carries bandwidth so audio-ABR can swap this for a - * bandwidth-aware variant without touching the helper. - */ -const selectAudioCurrent = ( - tracks: readonly AudioTrackCandidate[], - { currentTrack }: SelectionCtx -): AudioTrackCandidate | undefined => currentTrack ?? tracks[0]; - /** * Manage `selectedAudioTrackId`: pick a default on src load, narrow by * `userAudioTrackSelection` filter, re-pick on filter change, clear on @@ -415,24 +493,17 @@ const selectAudioCurrent = ( * const reactor = switchAudioTrack.setup({ state }); */ export const switchAudioTrack = defineBehavior({ - stateKeys: ['presentation', 'bandwidthState', 'selectedAudioTrackId', 'userAudioTrackSelection'], + stateKeys: ['presentation', 'selectedAudioTrackId'], contextKeys: [], - setup: ({ - state, - config, - }: { - state: TrackSwitchingStateMap<'selectedAudioTrackId', 'userAudioTrackSelection'>; - config?: TrackSwitchingConfig; - }) => - setupTrackSwitching<'selectedAudioTrackId', 'userAudioTrackSelection', AudioTrackCandidate>({ + setup: ({ state, ...otherProps }: { state: TrackSwitchingStateMap<'selectedAudioTrackId'> }) => + setupTrackSwitching({ + ...otherProps, state, config: { - ...config, selectionKey: 'selectedAudioTrackId', userSelectionKey: 'userAudioTrackSelection', getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[], - selectOptimal: selectAudioCurrent, - picker: config?.picker ?? pickAudioTrack, + rules: [filterByUserSelection, rankByBandwidth], }, }), }); diff --git a/packages/spf/src/playback/engines/hls/engine-audio-only.ts b/packages/spf/src/playback/engines/hls/engine-audio-only.ts index 05231dc9..ee69cf98 100644 --- a/packages/spf/src/playback/engines/hls/engine-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/engine-audio-only.ts @@ -92,7 +92,11 @@ export interface SimpleHlsAudioOnlyEngineConfig // Audio-Only HLS Playback Engine // ============================================================================ -const shareSignals = makeShareSignals(); +// Materializes the consumer-input slot `userAudioTrackSelection` (only read by +// switchAudioTrack, produced by no behavior) in addition to forwarding refs. +const shareSignals = makeShareSignals([ + 'userAudioTrackSelection', +]); /** * Create an audio-only HLS playback engine. diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index e3151460..85c89791 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -194,9 +194,14 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig(); +const shareSignals = makeShareSignals([ + 'userVideoTrackSelection', + 'userAudioTrackSelection', +]); /** * Create an HLS playback engine.