@videojs/spf 10.0.0-beta.1 → 10.0.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -40
- package/dist/default/all.js +16 -0
- package/dist/default/core/abr/bandwidth-estimator.js +96 -0
- package/dist/default/core/abr/bandwidth-estimator.js.map +1 -0
- package/dist/default/core/abr/ewma.js +71 -0
- package/dist/default/core/abr/ewma.js.map +1 -0
- package/dist/default/core/abr/quality-selection.js +53 -0
- package/dist/default/core/abr/quality-selection.js.map +1 -0
- package/dist/default/core/buffer/back-buffer.js +46 -0
- package/dist/default/core/buffer/back-buffer.js.map +1 -0
- package/dist/default/core/buffer/forward-buffer.js +73 -0
- package/dist/default/core/buffer/forward-buffer.js.map +1 -0
- package/dist/default/core/events/create-event-stream.js +48 -0
- package/dist/default/core/events/create-event-stream.js.map +1 -0
- package/dist/default/core/features/calculate-presentation-duration.js +51 -0
- package/dist/default/core/features/calculate-presentation-duration.js.map +1 -0
- package/dist/default/core/features/quality-switching.js +68 -0
- package/dist/default/core/features/quality-switching.js.map +1 -0
- package/dist/default/core/features/resolve-presentation.js +99 -0
- package/dist/default/core/features/resolve-presentation.js.map +1 -0
- package/dist/default/core/features/resolve-track.js +77 -0
- package/dist/default/core/features/resolve-track.js.map +1 -0
- package/dist/default/core/features/select-tracks.js +147 -0
- package/dist/default/core/features/select-tracks.js.map +1 -0
- package/dist/default/core/hls/parse-attributes.js +134 -0
- package/dist/default/core/hls/parse-attributes.js.map +1 -0
- package/dist/default/core/hls/parse-media-playlist.js +83 -0
- package/dist/default/core/hls/parse-media-playlist.js.map +1 -0
- package/dist/default/core/hls/parse-multivariant.js +215 -0
- package/dist/default/core/hls/parse-multivariant.js.map +1 -0
- package/dist/default/core/hls/resolve-url.js +11 -0
- package/dist/default/core/hls/resolve-url.js.map +1 -0
- package/dist/default/core/reactive/combine-latest.js +72 -0
- package/dist/default/core/reactive/combine-latest.js.map +1 -0
- package/dist/default/core/state/create-state.js +143 -0
- package/dist/default/core/state/create-state.js.map +1 -0
- package/dist/default/core/task.js +122 -0
- package/dist/default/core/task.js.map +1 -0
- package/dist/default/core/types/index.js +15 -0
- package/dist/default/core/types/index.js.map +1 -0
- package/dist/default/core/utils/generate-id.js +21 -0
- package/dist/default/core/utils/generate-id.js.map +1 -0
- package/dist/default/core/utils/track-selection.js +31 -0
- package/dist/default/core/utils/track-selection.js.map +1 -0
- package/dist/default/dom/features/end-of-stream.js +169 -0
- package/dist/default/dom/features/end-of-stream.js.map +1 -0
- package/dist/default/dom/features/load-segments.js +188 -0
- package/dist/default/dom/features/load-segments.js.map +1 -0
- package/dist/default/dom/features/load-text-track-cues.js +151 -0
- package/dist/default/dom/features/load-text-track-cues.js.map +1 -0
- package/dist/default/dom/features/segment-loader-actor.js +197 -0
- package/dist/default/dom/features/segment-loader-actor.js.map +1 -0
- package/dist/default/dom/features/setup-mediasource.js +55 -0
- package/dist/default/dom/features/setup-mediasource.js.map +1 -0
- package/dist/default/dom/features/setup-sourcebuffer.js +70 -0
- package/dist/default/dom/features/setup-sourcebuffer.js.map +1 -0
- package/dist/default/dom/features/setup-text-tracks.js +88 -0
- package/dist/default/dom/features/setup-text-tracks.js.map +1 -0
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js +63 -0
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +1 -0
- package/dist/default/dom/features/sync-text-track-modes.js +38 -0
- package/dist/default/dom/features/sync-text-track-modes.js.map +1 -0
- package/dist/default/dom/features/track-current-time.js +46 -0
- package/dist/default/dom/features/track-current-time.js.map +1 -0
- package/dist/default/dom/features/track-playback-initiated.js +55 -0
- package/dist/default/dom/features/track-playback-initiated.js.map +1 -0
- package/dist/{dom/index.js → default/dom/features/track-playback-rate.js} +2 -4
- package/dist/default/dom/features/track-playback-rate.js.map +1 -0
- package/dist/default/dom/features/update-duration.js +72 -0
- package/dist/default/dom/features/update-duration.js.map +1 -0
- package/dist/default/dom/media/append-segment.js +60 -0
- package/dist/default/dom/media/append-segment.js.map +1 -0
- package/dist/default/dom/media/buffer-flusher.js +55 -0
- package/dist/default/dom/media/buffer-flusher.js.map +1 -0
- package/dist/default/dom/media/mediasource-setup.js +154 -0
- package/dist/default/dom/media/mediasource-setup.js.map +1 -0
- package/dist/default/dom/media/source-buffer-actor.js +186 -0
- package/dist/default/dom/media/source-buffer-actor.js.map +1 -0
- package/dist/default/dom/network/chunked-stream-iterable.js +49 -0
- package/dist/default/dom/network/chunked-stream-iterable.js.map +1 -0
- package/dist/default/dom/network/fetch.js +56 -0
- package/dist/default/dom/network/fetch.js.map +1 -0
- package/dist/default/dom/playback-engine/adapter.js +95 -0
- package/dist/default/dom/playback-engine/adapter.js.map +1 -0
- package/dist/default/dom/playback-engine/engine.js +176 -0
- package/dist/default/dom/playback-engine/engine.js.map +1 -0
- package/dist/default/dom/playback-engine.js +4 -0
- package/dist/default/dom/text/parse-vtt-segment.js +56 -0
- package/dist/default/dom/text/parse-vtt-segment.js.map +1 -0
- package/dist/default/dom.js +11 -0
- package/dist/{src-DRsS_H80.js → default/index.js} +2 -2
- package/dist/default/index.js.map +1 -0
- package/dist/dev/all.js +16 -0
- package/dist/dev/core/abr/bandwidth-estimator.d.ts +35 -0
- package/dist/dev/core/abr/bandwidth-estimator.d.ts.map +1 -0
- package/dist/dev/core/abr/bandwidth-estimator.js +96 -0
- package/dist/dev/core/abr/bandwidth-estimator.js.map +1 -0
- package/dist/dev/core/abr/ewma.js +71 -0
- package/dist/dev/core/abr/ewma.js.map +1 -0
- package/dist/dev/core/abr/quality-selection.js +53 -0
- package/dist/dev/core/abr/quality-selection.js.map +1 -0
- package/dist/dev/core/actor.d.ts +31 -0
- package/dist/dev/core/actor.d.ts.map +1 -0
- package/dist/dev/core/buffer/back-buffer.js +46 -0
- package/dist/dev/core/buffer/back-buffer.js.map +1 -0
- package/dist/dev/core/buffer/forward-buffer.js +73 -0
- package/dist/dev/core/buffer/forward-buffer.js.map +1 -0
- package/dist/dev/core/events/create-event-stream.d.ts +48 -0
- package/dist/dev/core/events/create-event-stream.d.ts.map +1 -0
- package/dist/dev/core/events/create-event-stream.js +48 -0
- package/dist/dev/core/events/create-event-stream.js.map +1 -0
- package/dist/dev/core/features/calculate-presentation-duration.js +51 -0
- package/dist/dev/core/features/calculate-presentation-duration.js.map +1 -0
- package/dist/dev/core/features/quality-switching.js +68 -0
- package/dist/dev/core/features/quality-switching.js.map +1 -0
- package/dist/dev/core/features/resolve-presentation.d.ts +16 -0
- package/dist/dev/core/features/resolve-presentation.d.ts.map +1 -0
- package/dist/dev/core/features/resolve-presentation.js +99 -0
- package/dist/dev/core/features/resolve-presentation.js.map +1 -0
- package/dist/dev/core/features/resolve-track.d.ts +13 -0
- package/dist/dev/core/features/resolve-track.d.ts.map +1 -0
- package/dist/dev/core/features/resolve-track.js +77 -0
- package/dist/dev/core/features/resolve-track.js.map +1 -0
- package/dist/dev/core/features/select-tracks.d.ts +11 -0
- package/dist/dev/core/features/select-tracks.d.ts.map +1 -0
- package/dist/dev/core/features/select-tracks.js +147 -0
- package/dist/dev/core/features/select-tracks.js.map +1 -0
- package/dist/dev/core/hls/parse-attributes.js +134 -0
- package/dist/dev/core/hls/parse-attributes.js.map +1 -0
- package/dist/dev/core/hls/parse-media-playlist.js +83 -0
- package/dist/dev/core/hls/parse-media-playlist.js.map +1 -0
- package/dist/dev/core/hls/parse-multivariant.js +215 -0
- package/dist/dev/core/hls/parse-multivariant.js.map +1 -0
- package/dist/dev/core/hls/resolve-url.js +11 -0
- package/dist/dev/core/hls/resolve-url.js.map +1 -0
- package/dist/dev/core/reactive/combine-latest.js +72 -0
- package/dist/dev/core/reactive/combine-latest.js.map +1 -0
- package/dist/dev/core/state/create-state.d.ts +104 -0
- package/dist/dev/core/state/create-state.d.ts.map +1 -0
- package/dist/dev/core/state/create-state.js +143 -0
- package/dist/dev/core/state/create-state.js.map +1 -0
- package/dist/dev/core/task.js +122 -0
- package/dist/dev/core/task.js.map +1 -0
- package/dist/dev/core/types/index.d.ts +173 -0
- package/dist/dev/core/types/index.d.ts.map +1 -0
- package/dist/dev/core/types/index.js +15 -0
- package/dist/dev/core/types/index.js.map +1 -0
- package/dist/dev/core/utils/generate-id.js +21 -0
- package/dist/dev/core/utils/generate-id.js.map +1 -0
- package/dist/dev/core/utils/track-selection.d.ts +16 -0
- package/dist/dev/core/utils/track-selection.d.ts.map +1 -0
- package/dist/dev/core/utils/track-selection.js +31 -0
- package/dist/dev/core/utils/track-selection.js.map +1 -0
- package/dist/dev/dom/features/end-of-stream.js +169 -0
- package/dist/dev/dom/features/end-of-stream.js.map +1 -0
- package/dist/dev/dom/features/load-segments.d.ts +72 -0
- package/dist/dev/dom/features/load-segments.d.ts.map +1 -0
- package/dist/dev/dom/features/load-segments.js +188 -0
- package/dist/dev/dom/features/load-segments.js.map +1 -0
- package/dist/dev/dom/features/load-text-track-cues.d.ts +61 -0
- package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -0
- package/dist/dev/dom/features/load-text-track-cues.js +151 -0
- package/dist/dev/dom/features/load-text-track-cues.js.map +1 -0
- package/dist/dev/dom/features/segment-loader-actor.js +197 -0
- package/dist/dev/dom/features/segment-loader-actor.js.map +1 -0
- package/dist/dev/dom/features/setup-mediasource.js +55 -0
- package/dist/dev/dom/features/setup-mediasource.js.map +1 -0
- package/dist/dev/dom/features/setup-sourcebuffer.d.ts +9 -0
- package/dist/dev/dom/features/setup-sourcebuffer.d.ts.map +1 -0
- package/dist/dev/dom/features/setup-sourcebuffer.js +70 -0
- package/dist/dev/dom/features/setup-sourcebuffer.js.map +1 -0
- package/dist/dev/dom/features/setup-text-tracks.js +88 -0
- package/dist/dev/dom/features/setup-text-tracks.js.map +1 -0
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +63 -0
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +1 -0
- package/dist/dev/dom/features/sync-text-track-modes.js +38 -0
- package/dist/dev/dom/features/sync-text-track-modes.js.map +1 -0
- package/dist/dev/dom/features/track-current-time.d.ts +40 -0
- package/dist/dev/dom/features/track-current-time.d.ts.map +1 -0
- package/dist/dev/dom/features/track-current-time.js +46 -0
- package/dist/dev/dom/features/track-current-time.js.map +1 -0
- package/dist/dev/dom/features/track-playback-initiated.d.ts +53 -0
- package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -0
- package/dist/dev/dom/features/track-playback-initiated.js +55 -0
- package/dist/dev/dom/features/track-playback-initiated.js.map +1 -0
- package/dist/dev/dom/features/track-playback-rate.d.ts +34 -0
- package/dist/dev/dom/features/track-playback-rate.d.ts.map +1 -0
- package/dist/dev/dom/features/track-playback-rate.js +36 -0
- package/dist/dev/dom/features/track-playback-rate.js.map +1 -0
- package/dist/dev/dom/features/update-duration.js +72 -0
- package/dist/dev/dom/features/update-duration.js.map +1 -0
- package/dist/dev/dom/media/append-segment.d.ts +24 -0
- package/dist/dev/dom/media/append-segment.d.ts.map +1 -0
- package/dist/dev/dom/media/append-segment.js +60 -0
- package/dist/dev/dom/media/append-segment.js.map +1 -0
- package/dist/dev/dom/media/buffer-flusher.d.ts +24 -0
- package/dist/dev/dom/media/buffer-flusher.d.ts.map +1 -0
- package/dist/dev/dom/media/buffer-flusher.js +55 -0
- package/dist/dev/dom/media/buffer-flusher.js.map +1 -0
- package/dist/dev/dom/media/mediasource-setup.js +154 -0
- package/dist/dev/dom/media/mediasource-setup.js.map +1 -0
- package/dist/dev/dom/media/source-buffer-actor.d.ts +56 -0
- package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -0
- package/dist/dev/dom/media/source-buffer-actor.js +186 -0
- package/dist/dev/dom/media/source-buffer-actor.js.map +1 -0
- package/dist/dev/dom/network/chunked-stream-iterable.js +49 -0
- package/dist/dev/dom/network/chunked-stream-iterable.js.map +1 -0
- package/dist/dev/dom/network/fetch.js +56 -0
- package/dist/dev/dom/network/fetch.js.map +1 -0
- package/dist/dev/dom/playback-engine/adapter.d.ts +41 -0
- package/dist/dev/dom/playback-engine/adapter.d.ts.map +1 -0
- package/dist/dev/dom/playback-engine/adapter.js +95 -0
- package/dist/dev/dom/playback-engine/adapter.js.map +1 -0
- package/dist/dev/dom/playback-engine/engine.d.ts +135 -0
- package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -0
- package/dist/dev/dom/playback-engine/engine.js +176 -0
- package/dist/dev/dom/playback-engine/engine.js.map +1 -0
- package/dist/dev/dom/playback-engine.d.ts +3 -0
- package/dist/dev/dom/playback-engine.js +4 -0
- package/dist/dev/dom/text/parse-vtt-segment.d.ts +12 -0
- package/dist/dev/dom/text/parse-vtt-segment.d.ts.map +1 -0
- package/dist/dev/dom/text/parse-vtt-segment.js +56 -0
- package/dist/dev/dom/text/parse-vtt-segment.js.map +1 -0
- package/dist/dev/dom.d.ts +10 -0
- package/dist/dev/dom.js +11 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +14 -0
- package/dist/dev/index.js.map +1 -0
- package/package.json +22 -22
- package/dist/adapter-CMw-rvbk.js +0 -3512
- package/dist/adapter-CMw-rvbk.js.map +0 -1
- package/dist/adapter-l8iJN_GI.d.ts +0 -689
- package/dist/adapter-l8iJN_GI.d.ts.map +0 -1
- package/dist/dom/index.d.ts +0 -243
- package/dist/dom/index.d.ts.map +0 -1
- package/dist/dom/index.js.map +0 -1
- package/dist/dom/playback-engine/index.d.ts +0 -2
- package/dist/dom/playback-engine/index.js +0 -4
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/src-DRsS_H80.js.map +0 -1
- /package/dist/{index.d.ts → dev/index.d.ts} +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getBandwidthEstimate } from "../abr/bandwidth-estimator.js";
|
|
2
|
+
import { selectQuality } from "../abr/quality-selection.js";
|
|
3
|
+
|
|
4
|
+
//#region src/core/features/quality-switching.ts
|
|
5
|
+
/**
|
|
6
|
+
* Default quality switching configuration.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_SWITCHING_CONFIG = {
|
|
9
|
+
safetyMargin: .85,
|
|
10
|
+
minUpgradeInterval: 8e3,
|
|
11
|
+
defaultBandwidth: 5e6
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Get all video tracks from a presentation's first switching set.
|
|
15
|
+
* Returns [] when the presentation is still unresolved (no selectionSets yet).
|
|
16
|
+
*/
|
|
17
|
+
function getVideoTracks(presentation) {
|
|
18
|
+
return (presentation.selectionSets?.find((s) => s.type === "video"))?.switchingSets[0]?.tracks ?? [];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Quality switching orchestration (F9).
|
|
22
|
+
*
|
|
23
|
+
* Reacts to bandwidth estimate changes and updates `selectedVideoTrackId`
|
|
24
|
+
* when a different quality is optimal:
|
|
25
|
+
*
|
|
26
|
+
* - **Downgrades** happen immediately to avoid buffering stalls.
|
|
27
|
+
* - **Upgrades** are gated by `minUpgradeInterval` to prevent oscillation.
|
|
28
|
+
* - The first switch (from any track, or no track) is always immediate.
|
|
29
|
+
*
|
|
30
|
+
* Smooth switching is handled downstream: when `selectedVideoTrackId` changes,
|
|
31
|
+
* `resolveTrack` fetches the new playlist and `loadSegments` reloads the init
|
|
32
|
+
* segment, then appends media segments from the current position in the new
|
|
33
|
+
* quality. The browser's SourceBuffer replaces the overlapping buffered range.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const cleanup = switchQuality({ state });
|
|
37
|
+
* // Later, when done:
|
|
38
|
+
* cleanup();
|
|
39
|
+
*/
|
|
40
|
+
function switchQuality({ state }, config = {}) {
|
|
41
|
+
const safetyMargin = config.safetyMargin ?? DEFAULT_SWITCHING_CONFIG.safetyMargin;
|
|
42
|
+
const minUpgradeInterval = config.minUpgradeInterval ?? DEFAULT_SWITCHING_CONFIG.minUpgradeInterval;
|
|
43
|
+
const defaultBandwidth = config.defaultBandwidth ?? DEFAULT_SWITCHING_CONFIG.defaultBandwidth;
|
|
44
|
+
let lastUpgradeTime = Date.now();
|
|
45
|
+
let firstMeaningfulFire = true;
|
|
46
|
+
return state.subscribe((currentState) => {
|
|
47
|
+
const { presentation, bandwidthState, selectedVideoTrackId, abrDisabled } = currentState;
|
|
48
|
+
if (abrDisabled === true) return;
|
|
49
|
+
if (!presentation || !bandwidthState) return;
|
|
50
|
+
const videoTracks = getVideoTracks(presentation);
|
|
51
|
+
if (videoTracks.length === 0) return;
|
|
52
|
+
const isFirst = firstMeaningfulFire;
|
|
53
|
+
firstMeaningfulFire = false;
|
|
54
|
+
const optimal = selectQuality(videoTracks, getBandwidthEstimate(bandwidthState, defaultBandwidth), { safetyMargin });
|
|
55
|
+
if (!optimal || optimal.id === selectedVideoTrackId) return;
|
|
56
|
+
const currentTrack = videoTracks.find((t) => t.id === selectedVideoTrackId);
|
|
57
|
+
if (!currentTrack || optimal.bandwidth > currentTrack.bandwidth) {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (!isFirst && now - lastUpgradeTime < minUpgradeInterval) return;
|
|
60
|
+
lastUpgradeTime = now;
|
|
61
|
+
}
|
|
62
|
+
state.patch({ selectedVideoTrackId: optimal.id });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
export { DEFAULT_SWITCHING_CONFIG, switchQuality };
|
|
68
|
+
//# sourceMappingURL=quality-switching.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quality-switching.js","names":[],"sources":["../../../../src/core/features/quality-switching.ts"],"sourcesContent":["import type { BandwidthState } from '../abr/bandwidth-estimator';\nimport { getBandwidthEstimate } from '../abr/bandwidth-estimator';\nimport { selectQuality } from '../abr/quality-selection';\nimport type { WritableState } from '../state/create-state';\nimport type { Presentation, VideoSelectionSet } from '../types';\n\n/**\n * State shape for quality switching.\n */\nexport interface QualitySwitchingState {\n presentation?: Presentation;\n bandwidthState?: BandwidthState;\n selectedVideoTrackId?: string;\n // TODO: abrDisabled is a blunt instrument — it requires callers to know they're\n // competing with ABR and explicitly opt out. A better long-term design would\n // separate ABR selection (abrVideoTrackId) from manual selection (manualVideoTrackId)\n // and derive the effective selectedVideoTrackId as manualVideoTrackId ?? abrVideoTrackId.\n // That way the two concerns never write to the same field and consumers are unchanged.\n /** When true, ABR quality switching is suppressed. Use for manual quality selection. */\n abrDisabled?: boolean;\n}\n\n/**\n * Configuration for quality switching behavior.\n */\nexport interface QualitySwitchingConfig {\n /**\n * Safety margin for quality selection (0–1).\n * Track is selected only when bandwidth >= track.bandwidth / safetyMargin.\n * Default: 0.85 (15% headroom).\n */\n safetyMargin?: number;\n\n /**\n * Minimum milliseconds between upgrades.\n * Prevents oscillation when bandwidth fluctuates around a quality threshold.\n * Downgrades are always immediate regardless of this setting.\n * Default: 8000 (8 seconds).\n */\n minUpgradeInterval?: number;\n\n /**\n * Bandwidth estimate in bps to use before enough samples have been collected.\n * Default: 5_000_000 (5 Mbps).\n */\n defaultBandwidth?: number;\n}\n\n/**\n * Default quality switching configuration.\n */\nexport const DEFAULT_SWITCHING_CONFIG: Required<QualitySwitchingConfig> = {\n safetyMargin: 0.85,\n minUpgradeInterval: 8000,\n defaultBandwidth: 5_000_000,\n};\n\n/**\n * Get all video tracks from a presentation's first switching set.\n * Returns [] when the presentation is still unresolved (no selectionSets yet).\n */\nfunction getVideoTracks(presentation: Presentation) {\n const videoSet = presentation.selectionSets?.find((s) => s.type === 'video') as VideoSelectionSet | undefined;\n return videoSet?.switchingSets[0]?.tracks ?? [];\n}\n\n/**\n * Quality switching orchestration (F9).\n *\n * Reacts to bandwidth estimate changes and updates `selectedVideoTrackId`\n * when a different quality is optimal:\n *\n * - **Downgrades** happen immediately to avoid buffering stalls.\n * - **Upgrades** are gated by `minUpgradeInterval` to prevent oscillation.\n * - The first switch (from any track, or no track) is always immediate.\n *\n * Smooth switching is handled downstream: when `selectedVideoTrackId` changes,\n * `resolveTrack` fetches the new playlist and `loadSegments` reloads the init\n * segment, then appends media segments from the current position in the new\n * quality. The browser's SourceBuffer replaces the overlapping buffered range.\n *\n * @example\n * const cleanup = switchQuality({ state });\n * // Later, when done:\n * cleanup();\n */\nexport function switchQuality(\n { state }: { state: WritableState<QualitySwitchingState> },\n config: QualitySwitchingConfig = {}\n): () => void {\n const safetyMargin = config.safetyMargin ?? DEFAULT_SWITCHING_CONFIG.safetyMargin;\n const minUpgradeInterval = config.minUpgradeInterval ?? DEFAULT_SWITCHING_CONFIG.minUpgradeInterval;\n const defaultBandwidth = config.defaultBandwidth ?? DEFAULT_SWITCHING_CONFIG.defaultBandwidth;\n\n // Initialize to creation time so the interval starts counting immediately.\n // The first time we have enough data to make a meaningful quality decision\n // (presentation resolved + bandwidth available), the upgrade gate is skipped\n // so the initial ABR correction does not wait for minUpgradeInterval.\n let lastUpgradeTime = Date.now();\n let firstMeaningfulFire = true;\n\n return state.subscribe((currentState: QualitySwitchingState) => {\n const { presentation, bandwidthState, selectedVideoTrackId, abrDisabled } = currentState;\n\n if (abrDisabled === true) return;\n if (!presentation || !bandwidthState) return;\n\n const videoTracks = getVideoTracks(presentation);\n if (videoTracks.length === 0) return;\n\n // Consume the first-meaningful-fire flag now that we have all required data.\n const isFirst = firstMeaningfulFire;\n firstMeaningfulFire = false;\n\n const bandwidth = getBandwidthEstimate(bandwidthState, defaultBandwidth);\n const optimal = selectQuality(videoTracks as any, bandwidth, { safetyMargin });\n if (!optimal || optimal.id === selectedVideoTrackId) return;\n\n // Determine whether this is an upgrade or downgrade.\n const currentTrack = videoTracks.find((t) => t.id === selectedVideoTrackId);\n const isUpgrade = !currentTrack || optimal.bandwidth > currentTrack.bandwidth;\n\n if (isUpgrade) {\n const now = Date.now();\n // Gate upgrades with minUpgradeInterval to prevent oscillation.\n // Downgrades are always immediate; the first meaningful evaluation is always allowed.\n if (!isFirst && now - lastUpgradeTime < minUpgradeInterval) return;\n lastUpgradeTime = now;\n }\n\n state.patch({ selectedVideoTrackId: optimal.id });\n });\n}\n"],"mappings":";;;;;;;AAmDA,MAAa,2BAA6D;CACxE,cAAc;CACd,oBAAoB;CACpB,kBAAkB;CACnB;;;;;AAMD,SAAS,eAAe,cAA4B;AAElD,SADiB,aAAa,eAAe,MAAM,MAAM,EAAE,SAAS,QAAQ,GAC3D,cAAc,IAAI,UAAU,EAAE;;;;;;;;;;;;;;;;;;;;;;AAuBjD,SAAgB,cACd,EAAE,SACF,SAAiC,EAAE,EACvB;CACZ,MAAM,eAAe,OAAO,gBAAgB,yBAAyB;CACrE,MAAM,qBAAqB,OAAO,sBAAsB,yBAAyB;CACjF,MAAM,mBAAmB,OAAO,oBAAoB,yBAAyB;CAM7E,IAAI,kBAAkB,KAAK,KAAK;CAChC,IAAI,sBAAsB;AAE1B,QAAO,MAAM,WAAW,iBAAwC;EAC9D,MAAM,EAAE,cAAc,gBAAgB,sBAAsB,gBAAgB;AAE5E,MAAI,gBAAgB,KAAM;AAC1B,MAAI,CAAC,gBAAgB,CAAC,eAAgB;EAEtC,MAAM,cAAc,eAAe,aAAa;AAChD,MAAI,YAAY,WAAW,EAAG;EAG9B,MAAM,UAAU;AAChB,wBAAsB;EAGtB,MAAM,UAAU,cAAc,aADZ,qBAAqB,gBAAgB,iBAAiB,EACX,EAAE,cAAc,CAAC;AAC9E,MAAI,CAAC,WAAW,QAAQ,OAAO,qBAAsB;EAGrD,MAAM,eAAe,YAAY,MAAM,MAAM,EAAE,OAAO,qBAAqB;AAG3E,MAFkB,CAAC,gBAAgB,QAAQ,YAAY,aAAa,WAErD;GACb,MAAM,MAAM,KAAK,KAAK;AAGtB,OAAI,CAAC,WAAW,MAAM,kBAAkB,mBAAoB;AAC5D,qBAAkB;;AAGpB,QAAM,MAAM,EAAE,sBAAsB,QAAQ,IAAI,CAAC;GACjD"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { parseMultivariantPlaylist } from "../hls/parse-multivariant.js";
|
|
2
|
+
import { fetchResolvable, getResponseText } from "../../dom/network/fetch.js";
|
|
3
|
+
import { combineLatest } from "../reactive/combine-latest.js";
|
|
4
|
+
|
|
5
|
+
//#region src/core/features/resolve-presentation.ts
|
|
6
|
+
/**
|
|
7
|
+
* Type guard to check if presentation is unresolved.
|
|
8
|
+
*/
|
|
9
|
+
function isUnresolved(presentation) {
|
|
10
|
+
return presentation !== void 0 && "url" in presentation && !("id" in presentation);
|
|
11
|
+
}
|
|
12
|
+
function canResolve(state) {
|
|
13
|
+
return isUnresolved(state.presentation);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Determines if resolution conditions are met based on preload policy and event.
|
|
17
|
+
*
|
|
18
|
+
* Resolution conditions:
|
|
19
|
+
* - State-driven: preload is 'auto' or 'metadata'
|
|
20
|
+
* - Event-driven: play event
|
|
21
|
+
*
|
|
22
|
+
* @param state - Current presentation state
|
|
23
|
+
* @param event - Current action/event
|
|
24
|
+
* @returns true if resolution conditions are met
|
|
25
|
+
*/
|
|
26
|
+
function shouldResolve(state, event) {
|
|
27
|
+
const { preload } = state;
|
|
28
|
+
return ["auto", "metadata"].includes(preload) || event.type === "play";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Syncs preload attribute from mediaElement to state.
|
|
32
|
+
*
|
|
33
|
+
* Watches the owners state for mediaElement changes and copies the
|
|
34
|
+
* preload attribute to the immutable state.
|
|
35
|
+
*
|
|
36
|
+
* @param state - Immutable state container
|
|
37
|
+
* @param owners - Mutable platform objects container
|
|
38
|
+
* @returns Cleanup function to stop syncing
|
|
39
|
+
*/
|
|
40
|
+
function syncPreloadAttribute(state, owners) {
|
|
41
|
+
return owners.subscribe((current) => {
|
|
42
|
+
if (state.current.preload !== void 0) return;
|
|
43
|
+
const preload = current.mediaElement?.preload || void 0;
|
|
44
|
+
state.patch({ preload });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolves unresolved presentations using reactive composition.
|
|
49
|
+
*
|
|
50
|
+
* Uses combineLatest to compose state + events, enabling both state-driven
|
|
51
|
+
* and event-driven resolution triggers.
|
|
52
|
+
*
|
|
53
|
+
* Triggers resolution when:
|
|
54
|
+
* - State-driven: Unresolved presentation + preload allows (auto/metadata)
|
|
55
|
+
* - Event-driven: PLAY event when preload="none"
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const state = createState({ presentation: undefined, preload: 'auto' });
|
|
60
|
+
* const events = createEventStream<PresentationAction>();
|
|
61
|
+
*
|
|
62
|
+
* const cleanup = resolvePresentation({ state, events });
|
|
63
|
+
*
|
|
64
|
+
* // State-driven: resolves immediately when preload allows
|
|
65
|
+
* state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } });
|
|
66
|
+
*
|
|
67
|
+
* // Event-driven: resolves on PLAY when preload="none"
|
|
68
|
+
* state.patch({ preload: 'none', presentation: { url: '...' } });
|
|
69
|
+
* events.dispatch({ type: 'PLAY' });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
function resolvePresentation({ state, events }) {
|
|
73
|
+
let resolving = false;
|
|
74
|
+
let abortController = null;
|
|
75
|
+
const cleanup = combineLatest([state, events]).subscribe(async ([currentState, event]) => {
|
|
76
|
+
if (!canResolve(currentState) || !shouldResolve(currentState, event) || resolving) return;
|
|
77
|
+
try {
|
|
78
|
+
resolving = true;
|
|
79
|
+
abortController = new AbortController();
|
|
80
|
+
const { presentation } = currentState;
|
|
81
|
+
const parsed = parseMultivariantPlaylist(await getResponseText(await fetchResolvable(presentation, { signal: abortController.signal })), presentation);
|
|
82
|
+
state.patch({ presentation: parsed });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && error.name === "AbortError") return;
|
|
85
|
+
throw error;
|
|
86
|
+
} finally {
|
|
87
|
+
resolving = false;
|
|
88
|
+
abortController = null;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return () => {
|
|
92
|
+
abortController?.abort();
|
|
93
|
+
cleanup();
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { canResolve, isUnresolved, resolvePresentation, shouldResolve, syncPreloadAttribute };
|
|
99
|
+
//# sourceMappingURL=resolve-presentation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-presentation.js","names":[],"sources":["../../../../src/core/features/resolve-presentation.ts"],"sourcesContent":["import { fetchResolvable, getResponseText } from '../../dom/network/fetch';\nimport type { EventStream } from '../events/create-event-stream';\nimport { parseMultivariantPlaylist } from '../hls/parse-multivariant';\nimport { combineLatest } from '../reactive/combine-latest';\nimport type { WritableState } from '../state/create-state';\nimport type { AddressableObject, MediaElementLike, Presentation } from '../types';\n\n/**\n * Unresolved presentation - has a URL but no data yet.\n * Identical to AddressableObject per user requirement.\n */\nexport type UnresolvedPresentation = AddressableObject;\n\n/**\n * State shape for presentation resolution.\n */\nexport interface PresentationState {\n presentation?: UnresolvedPresentation | Presentation | undefined;\n preload?: 'auto' | 'metadata' | 'none' | undefined;\n}\n\n/**\n * Mutable platform objects.\n */\nexport interface PlatformOwners {\n mediaElement?: MediaElementLike | undefined;\n}\n\n/**\n * Type guard to check if presentation is unresolved.\n */\nexport function isUnresolved(\n presentation: UnresolvedPresentation | Presentation | undefined\n): presentation is UnresolvedPresentation {\n return presentation !== undefined && 'url' in presentation && !('id' in presentation);\n}\n\nexport function canResolve(\n state: PresentationState\n): state is PresentationState & { presentation: UnresolvedPresentation } {\n return isUnresolved(state.presentation);\n}\n\n/**\n * Determines if resolution conditions are met based on preload policy and event.\n *\n * Resolution conditions:\n * - State-driven: preload is 'auto' or 'metadata'\n * - Event-driven: play event\n *\n * @param state - Current presentation state\n * @param event - Current action/event\n * @returns true if resolution conditions are met\n */\nexport function shouldResolve(state: PresentationState, event: PresentationAction): boolean {\n const { preload } = state;\n return (\n // State-driven: preload allows (auto/metadata)\n ['auto', 'metadata'].includes(preload as any) ||\n // Event-driven: play event\n event.type === 'play'\n );\n}\n\n/**\n * Syncs preload attribute from mediaElement to state.\n *\n * Watches the owners state for mediaElement changes and copies the\n * preload attribute to the immutable state.\n *\n * @param state - Immutable state container\n * @param owners - Mutable platform objects container\n * @returns Cleanup function to stop syncing\n */\nexport function syncPreloadAttribute(\n state: WritableState<PresentationState>,\n owners: WritableState<PlatformOwners>\n): () => void {\n return owners.subscribe((current) => {\n // Only infer preload from the element when no explicit value has been set.\n // An explicit value (set via SpfMedia.preload) always wins.\n if (state.current.preload !== undefined) return;\n const preload = current.mediaElement?.preload || undefined;\n state.patch({ preload: preload as 'auto' | 'metadata' | 'none' | undefined });\n });\n}\n\n/**\n * Action types for presentation resolution.\n * Event names match HTMLMediaElement events (lowercase).\n */\nexport type PresentationAction = { type: 'play' } | { type: 'pause' } | { type: 'load'; url: string };\n\n/**\n * Resolves unresolved presentations using reactive composition.\n *\n * Uses combineLatest to compose state + events, enabling both state-driven\n * and event-driven resolution triggers.\n *\n * Triggers resolution when:\n * - State-driven: Unresolved presentation + preload allows (auto/metadata)\n * - Event-driven: PLAY event when preload=\"none\"\n *\n * @example\n * ```ts\n * const state = createState({ presentation: undefined, preload: 'auto' });\n * const events = createEventStream<PresentationAction>();\n *\n * const cleanup = resolvePresentation({ state, events });\n *\n * // State-driven: resolves immediately when preload allows\n * state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } });\n *\n * // Event-driven: resolves on PLAY when preload=\"none\"\n * state.patch({ preload: 'none', presentation: { url: '...' } });\n * events.dispatch({ type: 'PLAY' });\n * ```\n */\nexport function resolvePresentation({\n state,\n events,\n}: {\n state: WritableState<PresentationState>;\n events: EventStream<PresentationAction>;\n}): () => void {\n // This is effectively a very simple finite state model. We can formalize this if needed.\n let resolving = false;\n let abortController: AbortController | null = null;\n\n const cleanup = combineLatest([state, events]).subscribe(async ([currentState, event]) => {\n if (!canResolve(currentState) || !shouldResolve(currentState, event) || resolving) return;\n\n try {\n // This along with the resolving finite state (or more complex) could be pulled into its own abstraction.\n // Set flag before async work\n resolving = true;\n abortController = new AbortController();\n\n const { presentation } = currentState;\n // Fetch and parse playlist\n const response = await fetchResolvable(presentation, { signal: abortController.signal });\n const text = await getResponseText(response);\n const parsed = parseMultivariantPlaylist(text, presentation);\n\n // Update state with resolved presentation\n state.patch({\n presentation: parsed,\n });\n } catch (error) {\n // Ignore AbortError - this is expected when cleanup happens\n if (error instanceof Error && error.name === 'AbortError') {\n return;\n }\n throw error;\n } finally {\n // Always clear flag\n resolving = false;\n abortController = null;\n }\n });\n\n // Return cleanup function that aborts pending fetches\n return () => {\n abortController?.abort();\n cleanup();\n };\n}\n"],"mappings":";;;;;;;;AA+BA,SAAgB,aACd,cACwC;AACxC,QAAO,iBAAiB,UAAa,SAAS,gBAAgB,EAAE,QAAQ;;AAG1E,SAAgB,WACd,OACuE;AACvE,QAAO,aAAa,MAAM,aAAa;;;;;;;;;;;;;AAczC,SAAgB,cAAc,OAA0B,OAAoC;CAC1F,MAAM,EAAE,YAAY;AACpB,QAEE,CAAC,QAAQ,WAAW,CAAC,SAAS,QAAe,IAE7C,MAAM,SAAS;;;;;;;;;;;;AAcnB,SAAgB,qBACd,OACA,QACY;AACZ,QAAO,OAAO,WAAW,YAAY;AAGnC,MAAI,MAAM,QAAQ,YAAY,OAAW;EACzC,MAAM,UAAU,QAAQ,cAAc,WAAW;AACjD,QAAM,MAAM,EAAW,SAAqD,CAAC;GAC7E;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCJ,SAAgB,oBAAoB,EAClC,OACA,UAIa;CAEb,IAAI,YAAY;CAChB,IAAI,kBAA0C;CAE9C,MAAM,UAAU,cAAc,CAAC,OAAO,OAAO,CAAC,CAAC,UAAU,OAAO,CAAC,cAAc,WAAW;AACxF,MAAI,CAAC,WAAW,aAAa,IAAI,CAAC,cAAc,cAAc,MAAM,IAAI,UAAW;AAEnF,MAAI;AAGF,eAAY;AACZ,qBAAkB,IAAI,iBAAiB;GAEvC,MAAM,EAAE,iBAAiB;GAIzB,MAAM,SAAS,0BADF,MAAM,gBADF,MAAM,gBAAgB,cAAc,EAAE,QAAQ,gBAAgB,QAAQ,CAAC,CAC5C,EACG,aAAa;AAG5D,SAAM,MAAM,EACV,cAAc,QACf,CAAC;WACK,OAAO;AAEd,OAAI,iBAAiB,SAAS,MAAM,SAAS,aAC3C;AAEF,SAAM;YACE;AAER,eAAY;AACZ,qBAAkB;;GAEpB;AAGF,cAAa;AACX,mBAAiB,OAAO;AACxB,WAAS"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseMediaPlaylist } from "../hls/parse-media-playlist.js";
|
|
2
|
+
import { isResolvedTrack } from "../types/index.js";
|
|
3
|
+
import { fetchResolvable, getResponseText } from "../../dom/network/fetch.js";
|
|
4
|
+
import { combineLatest } from "../reactive/combine-latest.js";
|
|
5
|
+
import { getSelectedTrack } from "../utils/track-selection.js";
|
|
6
|
+
import { ConcurrentRunner, Task } from "../task.js";
|
|
7
|
+
|
|
8
|
+
//#region src/core/features/resolve-track.ts
|
|
9
|
+
function canResolve(state, config) {
|
|
10
|
+
const track = getSelectedTrack(state, config.type);
|
|
11
|
+
if (!track) return false;
|
|
12
|
+
return !isResolvedTrack(track);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Determines if track resolution conditions are met.
|
|
16
|
+
*
|
|
17
|
+
* Currently always returns true - conditions are checked by canResolveTrack()
|
|
18
|
+
* and resolving flag. Kept as placeholder for future conditional logic.
|
|
19
|
+
*
|
|
20
|
+
* @param state - Current track resolution state
|
|
21
|
+
* @param event - Current action/event
|
|
22
|
+
* @returns true (conditions checked elsewhere)
|
|
23
|
+
*/
|
|
24
|
+
function shouldResolve(_state, _event) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Updates a track within a presentation (immutably).
|
|
29
|
+
* Generic - works for video, audio, or text tracks.
|
|
30
|
+
*/
|
|
31
|
+
function updateTrackInPresentation(presentation, resolvedTrack) {
|
|
32
|
+
const trackId = resolvedTrack.id;
|
|
33
|
+
return {
|
|
34
|
+
...presentation,
|
|
35
|
+
selectionSets: presentation.selectionSets.map((selectionSet) => ({
|
|
36
|
+
...selectionSet,
|
|
37
|
+
switchingSets: selectionSet.switchingSets.map((switchingSet) => ({
|
|
38
|
+
...switchingSet,
|
|
39
|
+
tracks: switchingSet.tracks.map((track) => track.id === trackId ? resolvedTrack : track)
|
|
40
|
+
}))
|
|
41
|
+
}))
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolves unresolved tracks using reactive composition.
|
|
46
|
+
*
|
|
47
|
+
* The subscribe closure is pure scheduling logic: it checks conditions and
|
|
48
|
+
* creates a Task for the selected track when appropriate. The ConcurrentRunner
|
|
49
|
+
* handles all concurrency concerns — deduplication, parallel execution, and
|
|
50
|
+
* cleanup.
|
|
51
|
+
*
|
|
52
|
+
* Generic version that works for video, audio, or text tracks based on config.
|
|
53
|
+
* Type parameter T is inferred from config.type (use 'as const' for inference).
|
|
54
|
+
*/
|
|
55
|
+
function resolveTrack({ state, events }, config) {
|
|
56
|
+
const runner = new ConcurrentRunner();
|
|
57
|
+
const cleanup = combineLatest([state, events]).subscribe(([currentState, event]) => {
|
|
58
|
+
if (!canResolve(currentState, config) || !shouldResolve(currentState, event)) return;
|
|
59
|
+
const track = getSelectedTrack(currentState, config.type);
|
|
60
|
+
if (!track) return;
|
|
61
|
+
const resolvedTrack = track;
|
|
62
|
+
runner.schedule(new Task(async (signal) => {
|
|
63
|
+
const mediaTrack = parseMediaPlaylist(await getResponseText(await fetchResolvable(resolvedTrack, { signal })), resolvedTrack);
|
|
64
|
+
const latestPresentation = state.current.presentation;
|
|
65
|
+
const updatedPresentation = updateTrackInPresentation(latestPresentation, mediaTrack);
|
|
66
|
+
state.patch({ presentation: updatedPresentation });
|
|
67
|
+
}, { id: track.id }));
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
runner.abortAll();
|
|
71
|
+
cleanup();
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//#endregion
|
|
76
|
+
export { resolveTrack };
|
|
77
|
+
//# sourceMappingURL=resolve-track.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve-track.js","names":[],"sources":["../../../../src/core/features/resolve-track.ts"],"sourcesContent":["import { fetchResolvable, getResponseText } from '../../dom/network/fetch';\nimport type { EventStream } from '../events/create-event-stream';\nimport { parseMediaPlaylist } from '../hls/parse-media-playlist';\nimport { combineLatest } from '../reactive/combine-latest';\nimport type { WritableState } from '../state/create-state';\nimport { ConcurrentRunner, Task } from '../task';\nimport type { Presentation, ResolvedTrack, TrackType } from '../types';\nimport { isResolvedTrack } from '../types';\nimport { getSelectedTrack, type TrackSelectionState } from '../utils/track-selection';\n\n/**\n * State shape for track resolution.\n */\nexport interface TrackResolutionState extends TrackSelectionState {\n presentation?: Presentation | undefined;\n selectedVideoTrackId?: string | undefined;\n selectedAudioTrackId?: string | undefined;\n selectedTextTrackId?: string | undefined;\n}\n\nexport function canResolve<T extends TrackType>(\n state: TrackResolutionState,\n config: TrackResolutionConfig<T>\n): boolean {\n const track = getSelectedTrack(state, config.type);\n if (!track) return false;\n\n return !isResolvedTrack(track);\n}\n\n/**\n * Determines if track resolution conditions are met.\n *\n * Currently always returns true - conditions are checked by canResolveTrack()\n * and resolving flag. Kept as placeholder for future conditional logic.\n *\n * @param state - Current track resolution state\n * @param event - Current action/event\n * @returns true (conditions checked elsewhere)\n */\nexport function shouldResolve(_state: TrackResolutionState, _event: TrackResolutionAction): boolean {\n return true;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Updates a track within a presentation (immutably).\n * Generic - works for video, audio, or text tracks.\n */\nexport function updateTrackInPresentation<T extends ResolvedTrack>(\n presentation: Presentation,\n resolvedTrack: T\n): Presentation {\n const trackId = resolvedTrack.id;\n return {\n ...presentation,\n selectionSets: presentation.selectionSets.map((selectionSet) => ({\n ...selectionSet,\n switchingSets: selectionSet.switchingSets.map((switchingSet) => ({\n ...switchingSet,\n tracks: switchingSet.tracks.map((track) => (track.id === trackId ? resolvedTrack : track)),\n })),\n })),\n } as Presentation;\n}\n\n/**\n * Action types for track resolution.\n * Event names match HTMLMediaElement events (lowercase).\n */\nexport type TrackResolutionAction = { type: 'play' } | { type: 'pause' };\n\n/**\n * Configuration for track resolution.\n */\nexport interface TrackResolutionConfig<T extends TrackType = TrackType> {\n type: T;\n}\n\n/**\n * Resolves unresolved tracks using reactive composition.\n *\n * The subscribe closure is pure scheduling logic: it checks conditions and\n * creates a Task for the selected track when appropriate. The ConcurrentRunner\n * handles all concurrency concerns — deduplication, parallel execution, and\n * cleanup.\n *\n * Generic version that works for video, audio, or text tracks based on config.\n * Type parameter T is inferred from config.type (use 'as const' for inference).\n */\nexport function resolveTrack<T extends TrackType>(\n {\n state,\n events,\n }: {\n state: WritableState<TrackResolutionState>;\n events: EventStream<TrackResolutionAction>;\n },\n config: TrackResolutionConfig<T>\n): () => void {\n // NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createTaskRunner() with args TBD),\n // likely eventually passed down via config or a new \"definitions\" argument. This will allow us to decide if we want our task runner/scheduler\n // to e.g. run concurrently (like we currently are), serially with a queue, or abort the previous task and replace it with the newly scheduled one. (CJP).\n const runner = new ConcurrentRunner();\n\n const cleanup = combineLatest([state, events]).subscribe(([currentState, event]) => {\n if (!canResolve(currentState, config) || !shouldResolve(currentState, event)) return;\n\n const track = getSelectedTrack(currentState, config.type);\n if (!track) return;\n\n const resolvedTrack = track;\n\n runner.schedule(\n // NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createResolveTrackTask(track, context, config)),\n // likely eventually passed down via config or a new \"definitions\" argument (CJP).\n new Task(\n async (signal) => {\n const response = await fetchResolvable(resolvedTrack, { signal });\n const text = await getResponseText(response);\n const mediaTrack = parseMediaPlaylist(text, resolvedTrack);\n\n // IMPORTANT: Read state.current.presentation at patch time, not from the\n // captured currentState snapshot. Multiple Tasks may be running concurrently\n // (one per track being resolved), so the snapshot is likely already stale by\n // the time a task completes — a sibling task may have already patched the\n // presentation with its own resolved track. Using the snapshot would overwrite\n // that update and lose the other track's resolution. Reading live state ensures\n // each task builds on top of whatever has been committed so far.\n //\n // This is a limitation of the current architecture: tasks receive a snapshot\n // at scheduling time but need live state at write time. A future improvement\n // could dispatch resolved tracks through a single serialised writer (e.g. a\n // reducer pattern) to eliminate this hazard entirely.\n const latestPresentation = state.current.presentation!;\n const updatedPresentation = updateTrackInPresentation(latestPresentation, mediaTrack);\n state.patch({ presentation: updatedPresentation });\n },\n { id: track.id }\n )\n );\n });\n\n return () => {\n runner.abortAll();\n cleanup();\n };\n}\n"],"mappings":";;;;;;;;AAoBA,SAAgB,WACd,OACA,QACS;CACT,MAAM,QAAQ,iBAAiB,OAAO,OAAO,KAAK;AAClD,KAAI,CAAC,MAAO,QAAO;AAEnB,QAAO,CAAC,gBAAgB,MAAM;;;;;;;;;;;;AAahC,SAAgB,cAAc,QAA8B,QAAwC;AAClG,QAAO;;;;;;AAWT,SAAgB,0BACd,cACA,eACc;CACd,MAAM,UAAU,cAAc;AAC9B,QAAO;EACL,GAAG;EACH,eAAe,aAAa,cAAc,KAAK,kBAAkB;GAC/D,GAAG;GACH,eAAe,aAAa,cAAc,KAAK,kBAAkB;IAC/D,GAAG;IACH,QAAQ,aAAa,OAAO,KAAK,UAAW,MAAM,OAAO,UAAU,gBAAgB,MAAO;IAC3F,EAAE;GACJ,EAAE;EACJ;;;;;;;;;;;;;AA2BH,SAAgB,aACd,EACE,OACA,UAKF,QACY;CAIZ,MAAM,SAAS,IAAI,kBAAkB;CAErC,MAAM,UAAU,cAAc,CAAC,OAAO,OAAO,CAAC,CAAC,WAAW,CAAC,cAAc,WAAW;AAClF,MAAI,CAAC,WAAW,cAAc,OAAO,IAAI,CAAC,cAAc,cAAc,MAAM,CAAE;EAE9E,MAAM,QAAQ,iBAAiB,cAAc,OAAO,KAAK;AACzD,MAAI,CAAC,MAAO;EAEZ,MAAM,gBAAgB;AAEtB,SAAO,SAGL,IAAI,KACF,OAAO,WAAW;GAGhB,MAAM,aAAa,mBADN,MAAM,gBADF,MAAM,gBAAgB,eAAe,EAAE,QAAQ,CAAC,CACrB,EACA,cAAc;GAc1D,MAAM,qBAAqB,MAAM,QAAQ;GACzC,MAAM,sBAAsB,0BAA0B,oBAAoB,WAAW;AACrF,SAAM,MAAM,EAAE,cAAc,qBAAqB,CAAC;KAEpD,EAAE,IAAI,MAAM,IAAI,CACjB,CACF;GACD;AAEF,cAAa;AACX,SAAO,UAAU;AACjB,WAAS"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { SelectedTrackIdKeyByType } from "../utils/track-selection.js";
|
|
2
|
+
|
|
3
|
+
//#region src/core/features/select-tracks.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pick text track to activate.
|
|
6
|
+
*
|
|
7
|
+
* Selection priority (if enabled):
|
|
8
|
+
* 1. User preference (preferredSubtitleLanguage)
|
|
9
|
+
* 2. DEFAULT track (if enableDefaultTrack is true and track has DEFAULT=YES + AUTOSELECT=YES)
|
|
10
|
+
* 3. No auto-selection (user opt-in)
|
|
11
|
+
*
|
|
12
|
+
* By default, FORCED tracks are excluded per Apple's HLS spec.
|
|
13
|
+
*
|
|
14
|
+
* @param presentation - Presentation with text tracks
|
|
15
|
+
* @param config - Selection configuration
|
|
16
|
+
* @returns Track ID or undefined (no auto-selection)
|
|
17
|
+
*/
|
|
18
|
+
function pickTextTrack(presentation, config) {
|
|
19
|
+
const textSet = presentation.selectionSets.find((set) => set.type === "text");
|
|
20
|
+
if (!textSet?.switchingSets?.[0]?.tracks.length) return void 0;
|
|
21
|
+
const tracks = textSet.switchingSets[0].tracks;
|
|
22
|
+
const availableTracks = config.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);
|
|
23
|
+
if (availableTracks.length === 0) return void 0;
|
|
24
|
+
const { preferredSubtitleLanguage, enableDefaultTrack = false } = config;
|
|
25
|
+
if (preferredSubtitleLanguage) {
|
|
26
|
+
const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);
|
|
27
|
+
if (languageMatch) return languageMatch.id;
|
|
28
|
+
}
|
|
29
|
+
if (enableDefaultTrack) {
|
|
30
|
+
const defaultTrack = availableTracks.find((track) => track.default === true);
|
|
31
|
+
if (defaultTrack) return defaultTrack.id;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Check if we can select a track of the given type.
|
|
36
|
+
*
|
|
37
|
+
* Returns true when:
|
|
38
|
+
* - Presentation exists
|
|
39
|
+
* - Has tracks of the specified type
|
|
40
|
+
*
|
|
41
|
+
* Generic over track type - works for video, audio, or text.
|
|
42
|
+
*/
|
|
43
|
+
function canSelectTrack(state, config) {
|
|
44
|
+
return !!state?.presentation?.selectionSets?.find(({ type }) => type === config.type)?.switchingSets?.[0]?.tracks.length;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if we should select a track of the given type.
|
|
48
|
+
*
|
|
49
|
+
* Returns true when:
|
|
50
|
+
* - Track of this type is not already selected
|
|
51
|
+
*
|
|
52
|
+
* Generic over track type - works for video, audio, or text.
|
|
53
|
+
*
|
|
54
|
+
* @TODO figure out reactive model for ABR cases - right now we're only selecting
|
|
55
|
+
* if we have nothing selected (CJP)
|
|
56
|
+
*/
|
|
57
|
+
function shouldSelectTrack(state, config) {
|
|
58
|
+
return !state[SelectedTrackIdKeyByType[config.type]];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Select video track orchestration.
|
|
62
|
+
*
|
|
63
|
+
* Selects video track when:
|
|
64
|
+
* - Presentation exists
|
|
65
|
+
* - No video track is selected yet
|
|
66
|
+
*
|
|
67
|
+
* Uses bandwidth-based quality selection algorithm.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const cleanup = selectVideoTrack(
|
|
71
|
+
* { state, owners, events },
|
|
72
|
+
* { initialBandwidth: 2_000_000 }
|
|
73
|
+
* );
|
|
74
|
+
*/
|
|
75
|
+
function selectVideoTrack({ state }, config = { type: "video" }) {
|
|
76
|
+
let selecting = false;
|
|
77
|
+
return state.subscribe(async (currentState) => {
|
|
78
|
+
if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;
|
|
79
|
+
try {
|
|
80
|
+
selecting = true;
|
|
81
|
+
const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === config.type)?.switchingSets[0]?.tracks[0]?.id;
|
|
82
|
+
if (selectedTrackId) {
|
|
83
|
+
const selectedTrackKey = SelectedTrackIdKeyByType[config.type];
|
|
84
|
+
state.patch({ [selectedTrackKey]: selectedTrackId });
|
|
85
|
+
}
|
|
86
|
+
} finally {
|
|
87
|
+
selecting = false;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Select audio track orchestration.
|
|
93
|
+
*
|
|
94
|
+
* Selects audio track when:
|
|
95
|
+
* - Presentation exists
|
|
96
|
+
* - No audio track is selected yet
|
|
97
|
+
*
|
|
98
|
+
* Uses language and preference-based selection.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const cleanup = selectAudioTrack(
|
|
102
|
+
* { state, owners, events },
|
|
103
|
+
* { preferredAudioLanguage: 'en' }
|
|
104
|
+
* );
|
|
105
|
+
*/
|
|
106
|
+
function selectAudioTrack({ state }, config = { type: "audio" }) {
|
|
107
|
+
let selecting = false;
|
|
108
|
+
return state.subscribe(async (currentState) => {
|
|
109
|
+
if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;
|
|
110
|
+
try {
|
|
111
|
+
selecting = true;
|
|
112
|
+
const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === "audio")?.switchingSets[0]?.tracks[0]?.id;
|
|
113
|
+
if (selectedTrackId) state.patch({ selectedAudioTrackId: selectedTrackId });
|
|
114
|
+
} finally {
|
|
115
|
+
selecting = false;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Select text track orchestration.
|
|
121
|
+
*
|
|
122
|
+
* Selects text track when:
|
|
123
|
+
* - Presentation exists
|
|
124
|
+
* - No text track is selected yet
|
|
125
|
+
*
|
|
126
|
+
* Note: Currently does not auto-select (user opt-in).
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* const cleanup = selectTextTrack({ state, owners, events }, {});
|
|
130
|
+
*/
|
|
131
|
+
function selectTextTrack({ state }, config = { type: "text" }) {
|
|
132
|
+
let selecting = false;
|
|
133
|
+
return state.subscribe(async (currentState) => {
|
|
134
|
+
if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;
|
|
135
|
+
try {
|
|
136
|
+
selecting = true;
|
|
137
|
+
const selectedTextTrackId = pickTextTrack(currentState.presentation, config);
|
|
138
|
+
if (selectedTextTrackId) state.patch({ selectedTextTrackId });
|
|
139
|
+
} finally {
|
|
140
|
+
selecting = false;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { selectAudioTrack, selectTextTrack, selectVideoTrack };
|
|
147
|
+
//# sourceMappingURL=select-tracks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"select-tracks.js","names":[],"sources":["../../../../src/core/features/select-tracks.ts"],"sourcesContent":["import { DEFAULT_QUALITY_CONFIG, selectQuality } from '../abr/quality-selection';\nimport type { EventStream } from '../events/create-event-stream';\nimport type { WritableState } from '../state/create-state';\nimport type { AudioSelectionSet, Presentation, TrackType, VideoSelectionSet } from '../types';\nimport { SelectedTrackIdKeyByType } from '../utils/track-selection';\n\n/**\n * Default initial bandwidth estimate for cold start (bits per second).\n * Conservative 1 Mbps to avoid over-selecting on slow connections.\n */\nexport const DEFAULT_INITIAL_BANDWIDTH = 1_000_000;\n\n/**\n * State shape for track selection.\n */\nexport interface TrackSelectionState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n selectedTextTrackId?: string;\n}\n\n/**\n * Owners shape for track selection.\n * Currently empty - reserved for future use (e.g., bandwidth estimator).\n */\nexport type TrackSelectionOwners = Record<string, never>;\n\n/**\n * Action types for track selection.\n * Reserved for future event-driven selection triggers.\n */\nexport type TrackSelectionAction = { type: 'presentation-loaded' };\n\n/**\n * Base configuration for track selection.\n * Generic over track type with discriminant `type` field.\n */\nexport interface TrackSelectionConfig<T extends TrackType = TrackType> {\n type: T;\n}\n\n/**\n * Configuration for video track selection.\n * Generic with default to 'video' for convenience.\n */\nexport interface VideoSelectionConfig<T extends TrackType = 'video'> extends TrackSelectionConfig<T> {\n /**\n * Initial bandwidth estimate for cold start (bits per second).\n * Used to select video quality before we have real measurements.\n * Default: 1 Mbps (conservative).\n */\n initialBandwidth?: number;\n\n /**\n * Safety margin for quality selection (0-1).\n * Default: 0.85 (15% headroom).\n */\n safetyMargin?: number;\n}\n\n/**\n * Configuration for audio track selection.\n * Generic with default to 'audio' for convenience.\n */\nexport interface AudioSelectionConfig<T extends TrackType = 'audio'> extends TrackSelectionConfig<T> {\n /**\n * Preferred audio language (ISO 639 code, e.g., \"en\", \"es\").\n * If not specified, selects first audio track.\n */\n preferredAudioLanguage?: string;\n}\n\n/**\n * Configuration for text track selection.\n * Generic with default to 'text' for convenience.\n */\nexport interface TextSelectionConfig<T extends TrackType = 'text'> extends TrackSelectionConfig<T> {\n /**\n * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching track if available.\n */\n preferredSubtitleLanguage?: string;\n\n /**\n * Include FORCED subtitle tracks in selection.\n * Default: false (follows hls.js/http-streaming pattern)\n *\n * Note: Per Apple's HLS spec, if content has forced and regular subtitles\n * in the same language, the regular track MUST contain both forced and\n * regular content. Therefore, forced-only tracks are redundant and excluded\n * by default.\n */\n includeForcedTracks?: boolean;\n\n /**\n * Auto-select DEFAULT track (requires DEFAULT=YES + AUTOSELECT=YES in HLS).\n * Default: false (user opt-in, matches hls.js/http-streaming)\n *\n * When enabled, tracks marked with both DEFAULT=YES and AUTOSELECT=YES\n * will be automatically selected if no user preference matches.\n */\n enableDefaultTrack?: boolean;\n}\n\n// =============================================================================\n// Helper Functions (Pure Selection Logic)\n// =============================================================================\n\n/**\n * Pick video track using quality selection algorithm.\n *\n * Uses bandwidth-based selection with safety margin to pick\n * the highest quality track that fits available bandwidth.\n *\n * @param presentation - Presentation with video tracks\n * @param config - Selection configuration (bandwidth, safety margin)\n * @returns Selected video track ID, or undefined if no video tracks\n */\nexport function pickVideoTrack(presentation: Presentation, config: VideoSelectionConfig): string | undefined {\n const videoSet = presentation.selectionSets.find((set) => set.type === 'video') as VideoSelectionSet | undefined;\n\n if (!videoSet || videoSet.switchingSets.length === 0) {\n return undefined;\n }\n\n // Get first switching set's tracks (HLS typically has one switching set per type)\n const switchingSet = videoSet.switchingSets[0];\n if (!switchingSet || switchingSet.tracks.length === 0) {\n return undefined;\n }\n\n const initialBandwidth = config.initialBandwidth ?? DEFAULT_INITIAL_BANDWIDTH;\n const safetyMargin = config.safetyMargin ?? DEFAULT_QUALITY_CONFIG.safetyMargin;\n\n // selectQuality works with both partially resolved and resolved tracks\n const selected = selectQuality(switchingSet.tracks as any, initialBandwidth, { safetyMargin });\n\n return selected?.id;\n}\n\n/**\n * Pick audio track.\n *\n * Selection priority:\n * 1. First track matching preferred language (if specified)\n * 2. First default track\n * 3. First audio track\n *\n * @param presentation - Presentation with audio tracks\n * @param config - Selection configuration (preferred language)\n * @returns Selected audio track ID, or undefined if no audio tracks\n */\nexport function pickAudioTrack(presentation: Presentation, config: AudioSelectionConfig): string | undefined {\n const audioSet = presentation.selectionSets.find((set) => set.type === 'audio') as AudioSelectionSet | undefined;\n\n if (!audioSet || audioSet.switchingSets.length === 0) {\n return undefined;\n }\n\n // Get first switching set's tracks\n const switchingSet = audioSet.switchingSets[0];\n if (!switchingSet || switchingSet.tracks.length === 0) {\n return undefined;\n }\n\n const tracks = switchingSet.tracks;\n\n // Try preferred language first\n if (config.preferredAudioLanguage) {\n const languageMatch = tracks.find((track) => track.language === config.preferredAudioLanguage);\n if (languageMatch) {\n return languageMatch.id;\n }\n }\n\n // Try default track\n const defaultTrack = tracks.find((track) => track.default === true);\n if (defaultTrack) {\n return defaultTrack.id;\n }\n\n // Fall back to first track\n return tracks[0]?.id;\n}\n\n/**\n * Pick text track to activate.\n *\n * Selection priority (if enabled):\n * 1. User preference (preferredSubtitleLanguage)\n * 2. DEFAULT track (if enableDefaultTrack is true and track has DEFAULT=YES + AUTOSELECT=YES)\n * 3. No auto-selection (user opt-in)\n *\n * By default, FORCED tracks are excluded per Apple's HLS spec.\n *\n * @param presentation - Presentation with text tracks\n * @param config - Selection configuration\n * @returns Track ID or undefined (no auto-selection)\n */\nexport function pickTextTrack(presentation: Presentation, config: TextSelectionConfig): string | undefined {\n const textSet = presentation.selectionSets.find((set) => set.type === 'text');\n if (!textSet?.switchingSets?.[0]?.tracks.length) return undefined;\n\n const tracks = textSet.switchingSets[0].tracks;\n\n // Filter out FORCED tracks by default (following hls.js/http-streaming pattern)\n // Per Apple spec: regular tracks MUST contain forced content when both exist\n const availableTracks = config.includeForcedTracks ? tracks : tracks.filter((track) => !track.forced);\n\n if (availableTracks.length === 0) return undefined;\n\n const { preferredSubtitleLanguage, enableDefaultTrack = false } = config;\n\n // 1. Preferred language match (if specified)\n if (preferredSubtitleLanguage) {\n const languageMatch = availableTracks.find((track) => track.language === preferredSubtitleLanguage);\n if (languageMatch) return languageMatch.id;\n }\n\n // 2. DEFAULT track (if enabled AND track has both DEFAULT=YES + AUTOSELECT=YES)\n // Note: Parser only sets default=true when BOTH attributes present\n if (enableDefaultTrack) {\n const defaultTrack = availableTracks.find((track) => track.default === true);\n if (defaultTrack) return defaultTrack.id;\n }\n\n // 3. User opt-in (no auto-selection)\n return undefined;\n}\n\n/**\n * Check if we can select a track of the given type.\n *\n * Returns true when:\n * - Presentation exists\n * - Has tracks of the specified type\n *\n * Generic over track type - works for video, audio, or text.\n */\nexport function canSelectTrack<T extends TrackType>(\n state: TrackSelectionState,\n config: TrackSelectionConfig<T>\n): boolean {\n return !!state?.presentation?.selectionSets?.find(({ type }) => type === config.type)?.switchingSets?.[0]?.tracks\n .length;\n}\n\n/**\n * Check if we should select a track of the given type.\n *\n * Returns true when:\n * - Track of this type is not already selected\n *\n * Generic over track type - works for video, audio, or text.\n *\n * @TODO figure out reactive model for ABR cases - right now we're only selecting\n * if we have nothing selected (CJP)\n */\nexport function shouldSelectTrack<T extends TrackType>(\n state: TrackSelectionState,\n config: TrackSelectionConfig<T>\n): boolean {\n return !state[SelectedTrackIdKeyByType[config.type]];\n}\n\n// =============================================================================\n// Orchestrations\n// =============================================================================\n\n/**\n * Select video track orchestration.\n *\n * Selects video track when:\n * - Presentation exists\n * - No video track is selected yet\n *\n * Uses bandwidth-based quality selection algorithm.\n *\n * @example\n * const cleanup = selectVideoTrack(\n * { state, owners, events },\n * { initialBandwidth: 2_000_000 }\n * );\n */\nexport function selectVideoTrack(\n {\n state,\n }: {\n state: WritableState<TrackSelectionState>;\n owners: WritableState<TrackSelectionOwners>;\n events: EventStream<TrackSelectionAction>;\n },\n config: VideoSelectionConfig = { type: 'video' }\n): () => void {\n let selecting = false;\n\n return state.subscribe(async (currentState: TrackSelectionState) => {\n if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;\n\n try {\n selecting = true;\n\n // Just to have basic functionality/POC, simply selecting the first track\n const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === config.type)\n ?.switchingSets[0]?.tracks[0]?.id;\n\n if (selectedTrackId) {\n const selectedTrackKey = SelectedTrackIdKeyByType[config.type];\n state.patch({ [selectedTrackKey]: selectedTrackId });\n }\n } finally {\n selecting = false;\n }\n });\n}\n\n/**\n * Select audio track orchestration.\n *\n * Selects audio track when:\n * - Presentation exists\n * - No audio track is selected yet\n *\n * Uses language and preference-based selection.\n *\n * @example\n * const cleanup = selectAudioTrack(\n * { state, owners, events },\n * { preferredAudioLanguage: 'en' }\n * );\n */\nexport function selectAudioTrack(\n {\n state,\n }: {\n state: WritableState<TrackSelectionState>;\n owners: WritableState<TrackSelectionOwners>;\n events: EventStream<TrackSelectionAction>;\n },\n config: AudioSelectionConfig = { type: 'audio' }\n): () => void {\n let selecting = false;\n\n return state.subscribe(async (currentState: TrackSelectionState) => {\n if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;\n\n try {\n selecting = true;\n\n // Just to have basic functionality/POC, simply selecting the first track\n const selectedTrackId = currentState.presentation?.selectionSets.find(({ type }) => type === 'audio')\n ?.switchingSets[0]?.tracks[0]?.id;\n\n if (selectedTrackId) {\n state.patch({ selectedAudioTrackId: selectedTrackId });\n }\n } finally {\n selecting = false;\n }\n });\n}\n\n/**\n * Select text track orchestration.\n *\n * Selects text track when:\n * - Presentation exists\n * - No text track is selected yet\n *\n * Note: Currently does not auto-select (user opt-in).\n *\n * @example\n * const cleanup = selectTextTrack({ state, owners, events }, {});\n */\nexport function selectTextTrack(\n {\n state,\n }: {\n state: WritableState<TrackSelectionState>;\n owners: WritableState<TrackSelectionOwners>;\n events: EventStream<TrackSelectionAction>;\n },\n config: TextSelectionConfig = { type: 'text' }\n): () => void {\n let selecting = false;\n\n return state.subscribe(async (currentState: TrackSelectionState) => {\n if (!canSelectTrack(currentState, config) || !shouldSelectTrack(currentState, config) || selecting) return;\n\n try {\n selecting = true;\n\n // Text tracks are user opt-in - don't auto-select\n const selectedTextTrackId = pickTextTrack(currentState.presentation!, config);\n\n if (selectedTextTrackId) {\n state.patch({ selectedTextTrackId });\n }\n } finally {\n selecting = false;\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAwMA,SAAgB,cAAc,cAA4B,QAAiD;CACzG,MAAM,UAAU,aAAa,cAAc,MAAM,QAAQ,IAAI,SAAS,OAAO;AAC7E,KAAI,CAAC,SAAS,gBAAgB,IAAI,OAAO,OAAQ,QAAO;CAExD,MAAM,SAAS,QAAQ,cAAc,GAAG;CAIxC,MAAM,kBAAkB,OAAO,sBAAsB,SAAS,OAAO,QAAQ,UAAU,CAAC,MAAM,OAAO;AAErG,KAAI,gBAAgB,WAAW,EAAG,QAAO;CAEzC,MAAM,EAAE,2BAA2B,qBAAqB,UAAU;AAGlE,KAAI,2BAA2B;EAC7B,MAAM,gBAAgB,gBAAgB,MAAM,UAAU,MAAM,aAAa,0BAA0B;AACnG,MAAI,cAAe,QAAO,cAAc;;AAK1C,KAAI,oBAAoB;EACtB,MAAM,eAAe,gBAAgB,MAAM,UAAU,MAAM,YAAY,KAAK;AAC5E,MAAI,aAAc,QAAO,aAAa;;;;;;;;;;;;AAgB1C,SAAgB,eACd,OACA,QACS;AACT,QAAO,CAAC,CAAC,OAAO,cAAc,eAAe,MAAM,EAAE,WAAW,SAAS,OAAO,KAAK,EAAE,gBAAgB,IAAI,OACxG;;;;;;;;;;;;;AAcL,SAAgB,kBACd,OACA,QACS;AACT,QAAO,CAAC,MAAM,yBAAyB,OAAO;;;;;;;;;;;;;;;;;AAsBhD,SAAgB,iBACd,EACE,SAMF,SAA+B,EAAE,MAAM,SAAS,EACpC;CACZ,IAAI,YAAY;AAEhB,QAAO,MAAM,UAAU,OAAO,iBAAsC;AAClE,MAAI,CAAC,eAAe,cAAc,OAAO,IAAI,CAAC,kBAAkB,cAAc,OAAO,IAAI,UAAW;AAEpG,MAAI;AACF,eAAY;GAGZ,MAAM,kBAAkB,aAAa,cAAc,cAAc,MAAM,EAAE,WAAW,SAAS,OAAO,KAAK,EACrG,cAAc,IAAI,OAAO,IAAI;AAEjC,OAAI,iBAAiB;IACnB,MAAM,mBAAmB,yBAAyB,OAAO;AACzD,UAAM,MAAM,GAAG,mBAAmB,iBAAiB,CAAC;;YAE9C;AACR,eAAY;;GAEd;;;;;;;;;;;;;;;;;AAkBJ,SAAgB,iBACd,EACE,SAMF,SAA+B,EAAE,MAAM,SAAS,EACpC;CACZ,IAAI,YAAY;AAEhB,QAAO,MAAM,UAAU,OAAO,iBAAsC;AAClE,MAAI,CAAC,eAAe,cAAc,OAAO,IAAI,CAAC,kBAAkB,cAAc,OAAO,IAAI,UAAW;AAEpG,MAAI;AACF,eAAY;GAGZ,MAAM,kBAAkB,aAAa,cAAc,cAAc,MAAM,EAAE,WAAW,SAAS,QAAQ,EACjG,cAAc,IAAI,OAAO,IAAI;AAEjC,OAAI,gBACF,OAAM,MAAM,EAAE,sBAAsB,iBAAiB,CAAC;YAEhD;AACR,eAAY;;GAEd;;;;;;;;;;;;;;AAeJ,SAAgB,gBACd,EACE,SAMF,SAA8B,EAAE,MAAM,QAAQ,EAClC;CACZ,IAAI,YAAY;AAEhB,QAAO,MAAM,UAAU,OAAO,iBAAsC;AAClE,MAAI,CAAC,eAAe,cAAc,OAAO,IAAI,CAAC,kBAAkB,cAAc,OAAO,IAAI,UAAW;AAEpG,MAAI;AACF,eAAY;GAGZ,MAAM,sBAAsB,cAAc,aAAa,cAAe,OAAO;AAE7E,OAAI,oBACF,OAAM,MAAM,EAAE,qBAAqB,CAAC;YAE9B;AACR,eAAY;;GAEd"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
//#region src/core/hls/parse-attributes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Parse HLS attribute list from a tag line.
|
|
4
|
+
* Handles both quoted and unquoted values.
|
|
5
|
+
*/
|
|
6
|
+
function parseAttributeList(line) {
|
|
7
|
+
const attributes = /* @__PURE__ */ new Map();
|
|
8
|
+
for (const match of line.matchAll(/([A-Z0-9-]+)=(?:"([^"]*)"|([^,]*))/g)) {
|
|
9
|
+
const key = match[1];
|
|
10
|
+
const value = match[2] ?? match[3] ?? "";
|
|
11
|
+
if (key) attributes.set(key, value);
|
|
12
|
+
}
|
|
13
|
+
return attributes;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse RESOLUTION attribute value (WIDTHxHEIGHT).
|
|
17
|
+
*/
|
|
18
|
+
function parseResolution(value) {
|
|
19
|
+
const match = /^(\d+)x(\d+)$/.exec(value);
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
return {
|
|
22
|
+
width: Number.parseInt(match[1], 10),
|
|
23
|
+
height: Number.parseInt(match[2], 10)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse FRAME-RATE attribute to rational frame rate.
|
|
28
|
+
*/
|
|
29
|
+
function parseFrameRate(value) {
|
|
30
|
+
const fps = Number.parseFloat(value);
|
|
31
|
+
if (Number.isNaN(fps) || fps <= 0) return void 0;
|
|
32
|
+
if (Math.abs(fps - 23.976) < .01) return {
|
|
33
|
+
frameRateNumerator: 24e3,
|
|
34
|
+
frameRateDenominator: 1001
|
|
35
|
+
};
|
|
36
|
+
if (Math.abs(fps - 29.97) < .01) return {
|
|
37
|
+
frameRateNumerator: 3e4,
|
|
38
|
+
frameRateDenominator: 1001
|
|
39
|
+
};
|
|
40
|
+
if (Math.abs(fps - 59.94) < .01) return {
|
|
41
|
+
frameRateNumerator: 6e4,
|
|
42
|
+
frameRateDenominator: 1001
|
|
43
|
+
};
|
|
44
|
+
if (fps % 1 === 0) return { frameRateNumerator: Math.round(fps) };
|
|
45
|
+
return { frameRateNumerator: Math.round(fps) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse CODECS attribute into separate video and audio codecs.
|
|
49
|
+
*/
|
|
50
|
+
function parseCodecs(codecs) {
|
|
51
|
+
const parts = codecs.split(",").map((s) => s.trim());
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const codec of parts) if (codec.startsWith("avc1.") || codec.startsWith("hvc1.") || codec.startsWith("hev1.")) result.video = codec;
|
|
54
|
+
else if (codec.startsWith("mp4a.")) result.audio = codec;
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse #EXTINF duration value.
|
|
59
|
+
*/
|
|
60
|
+
function parseExtInfDuration(value) {
|
|
61
|
+
const durationPart = value.split(",")[0] ?? value;
|
|
62
|
+
const duration = Number.parseFloat(durationPart);
|
|
63
|
+
return Number.isNaN(duration) ? 0 : duration;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parse BYTERANGE attribute value.
|
|
67
|
+
* Format: "length[@offset]"
|
|
68
|
+
* If offset is omitted, it continues from the previous byte range end.
|
|
69
|
+
*/
|
|
70
|
+
function parseByteRange(value, previousEnd) {
|
|
71
|
+
const match = /^(\d+)(?:@(\d+))?$/.exec(value);
|
|
72
|
+
if (!match) return null;
|
|
73
|
+
const length = Number.parseInt(match[1], 10);
|
|
74
|
+
if (Number.isNaN(length)) return null;
|
|
75
|
+
let start;
|
|
76
|
+
if (match[2] !== void 0) {
|
|
77
|
+
start = Number.parseInt(match[2], 10);
|
|
78
|
+
if (Number.isNaN(start)) return null;
|
|
79
|
+
} else if (previousEnd !== void 0) start = previousEnd;
|
|
80
|
+
else return null;
|
|
81
|
+
return {
|
|
82
|
+
start,
|
|
83
|
+
end: start + length - 1
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create AttributeList from raw attribute string.
|
|
88
|
+
*/
|
|
89
|
+
function createAttributeList(line) {
|
|
90
|
+
const map = parseAttributeList(line);
|
|
91
|
+
return {
|
|
92
|
+
get(key) {
|
|
93
|
+
return map.get(key);
|
|
94
|
+
},
|
|
95
|
+
getInt(key, defaultValue) {
|
|
96
|
+
const value = map.get(key);
|
|
97
|
+
if (value === void 0) return defaultValue;
|
|
98
|
+
const parsed = Number.parseInt(value, 10);
|
|
99
|
+
return Number.isNaN(parsed) ? defaultValue : parsed;
|
|
100
|
+
},
|
|
101
|
+
getFloat(key, defaultValue) {
|
|
102
|
+
const value = map.get(key);
|
|
103
|
+
if (value === void 0) return defaultValue;
|
|
104
|
+
const parsed = Number.parseFloat(value);
|
|
105
|
+
return Number.isNaN(parsed) ? defaultValue : parsed;
|
|
106
|
+
},
|
|
107
|
+
getBool(key) {
|
|
108
|
+
return map.get(key) === "YES";
|
|
109
|
+
},
|
|
110
|
+
getResolution(key) {
|
|
111
|
+
const value = map.get(key);
|
|
112
|
+
if (!value) return void 0;
|
|
113
|
+
return parseResolution(value) ?? void 0;
|
|
114
|
+
},
|
|
115
|
+
getFrameRate(key) {
|
|
116
|
+
const value = map.get(key);
|
|
117
|
+
if (!value) return void 0;
|
|
118
|
+
return parseFrameRate(value);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Match a tag and extract its attributes.
|
|
124
|
+
* Returns null if the line doesn't match the tag.
|
|
125
|
+
*/
|
|
126
|
+
function matchTag(line, tag) {
|
|
127
|
+
const prefix = `#${tag}:`;
|
|
128
|
+
if (!line.startsWith(prefix)) return null;
|
|
129
|
+
return createAttributeList(line.slice(prefix.length));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//#endregion
|
|
133
|
+
export { matchTag, parseByteRange, parseCodecs, parseExtInfDuration };
|
|
134
|
+
//# sourceMappingURL=parse-attributes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-attributes.js","names":[],"sources":["../../../../src/core/hls/parse-attributes.ts"],"sourcesContent":["import type { FrameRate } from '../types';\n\n/**\n * Parse HLS attribute list from a tag line.\n * Handles both quoted and unquoted values.\n */\nexport function parseAttributeList(line: string): Map<string, string> {\n const attributes = new Map<string, string>();\n const regex = /([A-Z0-9-]+)=(?:\"([^\"]*)\"|([^,]*))/g;\n\n for (const match of line.matchAll(regex)) {\n const key = match[1];\n const value = match[2] ?? match[3] ?? '';\n if (key) {\n attributes.set(key, value);\n }\n }\n\n return attributes;\n}\n\n/**\n * Parse RESOLUTION attribute value (WIDTHxHEIGHT).\n */\nexport function parseResolution(value: string): { width: number; height: number } | null {\n const match = /^(\\d+)x(\\d+)$/.exec(value);\n if (!match) return null;\n\n const width = Number.parseInt(match[1]!, 10);\n const height = Number.parseInt(match[2]!, 10);\n\n return { width, height };\n}\n\n/**\n * Parse FRAME-RATE attribute to rational frame rate.\n */\nexport function parseFrameRate(value: string): FrameRate | undefined {\n const fps = Number.parseFloat(value);\n if (Number.isNaN(fps) || fps <= 0) return undefined;\n\n // Common frame rates with tolerance for floating point precision\n if (Math.abs(fps - 23.976) < 0.01) {\n return { frameRateNumerator: 24000, frameRateDenominator: 1001 };\n }\n if (Math.abs(fps - 29.97) < 0.01) {\n return { frameRateNumerator: 30000, frameRateDenominator: 1001 };\n }\n if (Math.abs(fps - 59.94) < 0.01) {\n return { frameRateNumerator: 60000, frameRateDenominator: 1001 };\n }\n\n // Integer frame rates\n if (fps % 1 === 0) {\n return { frameRateNumerator: Math.round(fps) };\n }\n\n // Default: use rounded value\n return { frameRateNumerator: Math.round(fps) };\n}\n\n/**\n * Parse CODECS attribute into separate video and audio codecs.\n */\nexport function parseCodecs(codecs: string): { video?: string; audio?: string } {\n const parts = codecs.split(',').map((s) => s.trim());\n const result: { video?: string; audio?: string } = {};\n\n for (const codec of parts) {\n if (codec.startsWith('avc1.') || codec.startsWith('hvc1.') || codec.startsWith('hev1.')) {\n result.video = codec;\n } else if (codec.startsWith('mp4a.')) {\n result.audio = codec;\n }\n }\n\n return result;\n}\n\n/**\n * Parse #EXTINF duration value.\n */\nexport function parseExtInfDuration(value: string): number {\n const durationPart = value.split(',')[0] ?? value;\n const duration = Number.parseFloat(durationPart);\n return Number.isNaN(duration) ? 0 : duration;\n}\n\n/**\n * Parse BYTERANGE attribute value.\n * Format: \"length[@offset]\"\n * If offset is omitted, it continues from the previous byte range end.\n */\nexport function parseByteRange(value: string, previousEnd?: number): { start: number; end: number } | null {\n const match = /^(\\d+)(?:@(\\d+))?$/.exec(value);\n if (!match) return null;\n\n const length = Number.parseInt(match[1]!, 10);\n if (Number.isNaN(length)) return null;\n\n let start: number;\n if (match[2] !== undefined) {\n start = Number.parseInt(match[2], 10);\n if (Number.isNaN(start)) return null;\n } else if (previousEnd !== undefined) {\n start = previousEnd;\n } else {\n return null;\n }\n\n return { start, end: start + length - 1 };\n}\n\n/**\n * AttributeList - Typed attribute access wrapper.\n */\nexport interface AttributeList {\n get: (key: string) => string | undefined;\n getInt: (key: string, defaultValue?: number) => number | undefined;\n getFloat: (key: string, defaultValue?: number) => number | undefined;\n getBool: (key: string) => boolean;\n getResolution: (key: string) => { width: number; height: number } | undefined;\n getFrameRate: (key: string) => FrameRate | undefined;\n}\n\n/**\n * Create AttributeList from raw attribute string.\n */\nexport function createAttributeList(line: string): AttributeList {\n const map = parseAttributeList(line);\n\n return {\n get(key: string): string | undefined {\n return map.get(key);\n },\n\n getInt(key: string, defaultValue?: number): number | undefined {\n const value = map.get(key);\n if (value === undefined) return defaultValue;\n const parsed = Number.parseInt(value, 10);\n return Number.isNaN(parsed) ? defaultValue : parsed;\n },\n\n getFloat(key: string, defaultValue?: number): number | undefined {\n const value = map.get(key);\n if (value === undefined) return defaultValue;\n const parsed = Number.parseFloat(value);\n return Number.isNaN(parsed) ? defaultValue : parsed;\n },\n\n getBool(key: string): boolean {\n return map.get(key) === 'YES';\n },\n\n getResolution(key: string): { width: number; height: number } | undefined {\n const value = map.get(key);\n if (!value) return undefined;\n return parseResolution(value) ?? undefined;\n },\n\n getFrameRate(key: string): FrameRate | undefined {\n const value = map.get(key);\n if (!value) return undefined;\n return parseFrameRate(value);\n },\n };\n}\n\n/**\n * Match a tag and extract its attributes.\n * Returns null if the line doesn't match the tag.\n */\nexport function matchTag(line: string, tag: string): AttributeList | null {\n const prefix = `#${tag}:`;\n if (!line.startsWith(prefix)) return null;\n return createAttributeList(line.slice(prefix.length));\n}\n"],"mappings":";;;;;AAMA,SAAgB,mBAAmB,MAAmC;CACpE,MAAM,6BAAa,IAAI,KAAqB;AAG5C,MAAK,MAAM,SAAS,KAAK,SAFX,sCAE0B,EAAE;EACxC,MAAM,MAAM,MAAM;EAClB,MAAM,QAAQ,MAAM,MAAM,MAAM,MAAM;AACtC,MAAI,IACF,YAAW,IAAI,KAAK,MAAM;;AAI9B,QAAO;;;;;AAMT,SAAgB,gBAAgB,OAAyD;CACvF,MAAM,QAAQ,gBAAgB,KAAK,MAAM;AACzC,KAAI,CAAC,MAAO,QAAO;AAKnB,QAAO;EAAE,OAHK,OAAO,SAAS,MAAM,IAAK,GAAG;EAG5B,QAFD,OAAO,SAAS,MAAM,IAAK,GAAG;EAErB;;;;;AAM1B,SAAgB,eAAe,OAAsC;CACnE,MAAM,MAAM,OAAO,WAAW,MAAM;AACpC,KAAI,OAAO,MAAM,IAAI,IAAI,OAAO,EAAG,QAAO;AAG1C,KAAI,KAAK,IAAI,MAAM,OAAO,GAAG,IAC3B,QAAO;EAAE,oBAAoB;EAAO,sBAAsB;EAAM;AAElE,KAAI,KAAK,IAAI,MAAM,MAAM,GAAG,IAC1B,QAAO;EAAE,oBAAoB;EAAO,sBAAsB;EAAM;AAElE,KAAI,KAAK,IAAI,MAAM,MAAM,GAAG,IAC1B,QAAO;EAAE,oBAAoB;EAAO,sBAAsB;EAAM;AAIlE,KAAI,MAAM,MAAM,EACd,QAAO,EAAE,oBAAoB,KAAK,MAAM,IAAI,EAAE;AAIhD,QAAO,EAAE,oBAAoB,KAAK,MAAM,IAAI,EAAE;;;;;AAMhD,SAAgB,YAAY,QAAoD;CAC9E,MAAM,QAAQ,OAAO,MAAM,IAAI,CAAC,KAAK,MAAM,EAAE,MAAM,CAAC;CACpD,MAAM,SAA6C,EAAE;AAErD,MAAK,MAAM,SAAS,MAClB,KAAI,MAAM,WAAW,QAAQ,IAAI,MAAM,WAAW,QAAQ,IAAI,MAAM,WAAW,QAAQ,CACrF,QAAO,QAAQ;UACN,MAAM,WAAW,QAAQ,CAClC,QAAO,QAAQ;AAInB,QAAO;;;;;AAMT,SAAgB,oBAAoB,OAAuB;CACzD,MAAM,eAAe,MAAM,MAAM,IAAI,CAAC,MAAM;CAC5C,MAAM,WAAW,OAAO,WAAW,aAAa;AAChD,QAAO,OAAO,MAAM,SAAS,GAAG,IAAI;;;;;;;AAQtC,SAAgB,eAAe,OAAe,aAA6D;CACzG,MAAM,QAAQ,qBAAqB,KAAK,MAAM;AAC9C,KAAI,CAAC,MAAO,QAAO;CAEnB,MAAM,SAAS,OAAO,SAAS,MAAM,IAAK,GAAG;AAC7C,KAAI,OAAO,MAAM,OAAO,CAAE,QAAO;CAEjC,IAAI;AACJ,KAAI,MAAM,OAAO,QAAW;AAC1B,UAAQ,OAAO,SAAS,MAAM,IAAI,GAAG;AACrC,MAAI,OAAO,MAAM,MAAM,CAAE,QAAO;YACvB,gBAAgB,OACzB,SAAQ;KAER,QAAO;AAGT,QAAO;EAAE;EAAO,KAAK,QAAQ,SAAS;EAAG;;;;;AAkB3C,SAAgB,oBAAoB,MAA6B;CAC/D,MAAM,MAAM,mBAAmB,KAAK;AAEpC,QAAO;EACL,IAAI,KAAiC;AACnC,UAAO,IAAI,IAAI,IAAI;;EAGrB,OAAO,KAAa,cAA2C;GAC7D,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,OAAI,UAAU,OAAW,QAAO;GAChC,MAAM,SAAS,OAAO,SAAS,OAAO,GAAG;AACzC,UAAO,OAAO,MAAM,OAAO,GAAG,eAAe;;EAG/C,SAAS,KAAa,cAA2C;GAC/D,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,OAAI,UAAU,OAAW,QAAO;GAChC,MAAM,SAAS,OAAO,WAAW,MAAM;AACvC,UAAO,OAAO,MAAM,OAAO,GAAG,eAAe;;EAG/C,QAAQ,KAAsB;AAC5B,UAAO,IAAI,IAAI,IAAI,KAAK;;EAG1B,cAAc,KAA4D;GACxE,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,OAAI,CAAC,MAAO,QAAO;AACnB,UAAO,gBAAgB,MAAM,IAAI;;EAGnC,aAAa,KAAoC;GAC/C,MAAM,QAAQ,IAAI,IAAI,IAAI;AAC1B,OAAI,CAAC,MAAO,QAAO;AACnB,UAAO,eAAe,MAAM;;EAE/B;;;;;;AAOH,SAAgB,SAAS,MAAc,KAAmC;CACxE,MAAM,SAAS,IAAI,IAAI;AACvB,KAAI,CAAC,KAAK,WAAW,OAAO,CAAE,QAAO;AACrC,QAAO,oBAAoB,KAAK,MAAM,OAAO,OAAO,CAAC"}
|