@videojs/spf 10.0.0-beta.11 → 10.0.0-beta.13

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.
Files changed (211) hide show
  1. package/dist/default/core/abr/bandwidth-estimator.js +2 -3
  2. package/dist/default/core/abr/bandwidth-estimator.js.map +1 -1
  3. package/dist/default/core/abr/ewma.js +1 -1
  4. package/dist/default/core/abr/quality-selection.js +2 -2
  5. package/dist/default/core/abr/quality-selection.js.map +1 -1
  6. package/dist/default/core/buffer/back-buffer.js +2 -2
  7. package/dist/default/core/buffer/forward-buffer.js +1 -1
  8. package/dist/default/core/features/calculate-presentation-duration.js +11 -9
  9. package/dist/default/core/features/calculate-presentation-duration.js.map +1 -1
  10. package/dist/default/core/features/quality-switching.js +7 -6
  11. package/dist/default/core/features/quality-switching.js.map +1 -1
  12. package/dist/default/core/features/resolve-presentation.js +30 -52
  13. package/dist/default/core/features/resolve-presentation.js.map +1 -1
  14. package/dist/default/core/features/resolve-track.js +17 -28
  15. package/dist/default/core/features/resolve-track.js.map +1 -1
  16. package/dist/default/core/features/select-tracks.js +18 -35
  17. package/dist/default/core/features/select-tracks.js.map +1 -1
  18. package/dist/default/core/features/sync-preload-attribute.js +26 -0
  19. package/dist/default/core/features/sync-preload-attribute.js.map +1 -0
  20. package/dist/default/core/hls/parse-attributes.js +1 -1
  21. package/dist/default/core/hls/parse-attributes.js.map +1 -1
  22. package/dist/default/core/hls/parse-media-playlist.js +1 -2
  23. package/dist/default/core/hls/parse-media-playlist.js.map +1 -1
  24. package/dist/default/core/hls/parse-multivariant.js +2 -3
  25. package/dist/default/core/hls/parse-multivariant.js.map +1 -1
  26. package/dist/default/core/hls/resolve-url.js +1 -1
  27. package/dist/default/core/signals/effect.js +41 -0
  28. package/dist/default/core/signals/effect.js.map +1 -0
  29. package/dist/default/core/signals/primitives.js +32 -0
  30. package/dist/default/core/signals/primitives.js.map +1 -0
  31. package/dist/default/core/task.js +3 -3
  32. package/dist/default/core/task.js.map +1 -1
  33. package/dist/default/core/types/index.js +1 -1
  34. package/dist/default/core/types/index.js.map +1 -1
  35. package/dist/default/core/utils/generate-id.js +1 -1
  36. package/dist/default/core/utils/track-selection.js +10 -2
  37. package/dist/default/core/utils/track-selection.js.map +1 -1
  38. package/dist/default/dom/features/end-of-stream.js +24 -48
  39. package/dist/default/dom/features/end-of-stream.js.map +1 -1
  40. package/dist/default/dom/features/load-segments.js +40 -36
  41. package/dist/default/dom/features/load-segments.js.map +1 -1
  42. package/dist/default/dom/features/load-text-track-cues.js +36 -26
  43. package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
  44. package/dist/default/dom/features/segment-loader-actor.js +4 -5
  45. package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
  46. package/dist/default/dom/features/setup-mediasource.js +29 -36
  47. package/dist/default/dom/features/setup-mediasource.js.map +1 -1
  48. package/dist/default/dom/features/setup-sourcebuffer.js +43 -35
  49. package/dist/default/dom/features/setup-sourcebuffer.js.map +1 -1
  50. package/dist/default/dom/features/setup-text-tracks.js +25 -46
  51. package/dist/default/dom/features/setup-text-tracks.js.map +1 -1
  52. package/dist/default/dom/features/sync-selected-text-track-from-dom.js +14 -23
  53. package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +1 -1
  54. package/dist/default/dom/features/sync-text-track-modes.js +12 -17
  55. package/dist/default/dom/features/sync-text-track-modes.js.map +1 -1
  56. package/dist/default/dom/features/track-current-time.js +10 -7
  57. package/dist/default/dom/features/track-current-time.js.map +1 -1
  58. package/dist/default/dom/features/track-playback-initiated.js +34 -36
  59. package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
  60. package/dist/default/dom/features/track-playback-rate.js +12 -16
  61. package/dist/default/dom/features/track-playback-rate.js.map +1 -1
  62. package/dist/default/dom/features/update-duration.js +19 -13
  63. package/dist/default/dom/features/update-duration.js.map +1 -1
  64. package/dist/default/dom/media/append-segment.js +1 -1
  65. package/dist/default/dom/media/buffer-flusher.js +1 -1
  66. package/dist/default/dom/media/mediasource-setup.js +32 -40
  67. package/dist/default/dom/media/mediasource-setup.js.map +1 -1
  68. package/dist/default/dom/media/source-buffer-actor.js +16 -26
  69. package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
  70. package/dist/default/dom/network/chunked-stream-iterable.js +1 -1
  71. package/dist/default/dom/network/chunked-stream-iterable.js.map +1 -1
  72. package/dist/default/dom/network/fetch.js +1 -2
  73. package/dist/default/dom/network/fetch.js.map +1 -1
  74. package/dist/default/dom/playback-engine/adapter.js +13 -13
  75. package/dist/default/dom/playback-engine/adapter.js.map +1 -1
  76. package/dist/default/dom/playback-engine/engine.js +24 -49
  77. package/dist/default/dom/playback-engine/engine.js.map +1 -1
  78. package/dist/default/dom/playback-engine.js +2 -2
  79. package/dist/default/dom/text/parse-vtt-segment.js +1 -1
  80. package/dist/default/dom.js +1 -2
  81. package/dist/default/index.js +1 -1
  82. package/dist/dev/core/abr/bandwidth-estimator.js +2 -3
  83. package/dist/dev/core/abr/bandwidth-estimator.js.map +1 -1
  84. package/dist/dev/core/abr/ewma.js +1 -1
  85. package/dist/dev/core/abr/quality-selection.js +2 -2
  86. package/dist/dev/core/abr/quality-selection.js.map +1 -1
  87. package/dist/dev/core/actor.d.ts +6 -19
  88. package/dist/dev/core/actor.d.ts.map +1 -1
  89. package/dist/dev/core/buffer/back-buffer.js +2 -2
  90. package/dist/dev/core/buffer/forward-buffer.js +1 -1
  91. package/dist/dev/core/features/calculate-presentation-duration.js +11 -9
  92. package/dist/dev/core/features/calculate-presentation-duration.js.map +1 -1
  93. package/dist/dev/core/features/quality-switching.js +7 -6
  94. package/dist/dev/core/features/quality-switching.js.map +1 -1
  95. package/dist/dev/core/features/resolve-presentation.js +30 -52
  96. package/dist/dev/core/features/resolve-presentation.js.map +1 -1
  97. package/dist/dev/core/features/resolve-track.js +17 -28
  98. package/dist/dev/core/features/resolve-track.js.map +1 -1
  99. package/dist/dev/core/features/select-tracks.js +18 -35
  100. package/dist/dev/core/features/select-tracks.js.map +1 -1
  101. package/dist/dev/core/features/sync-preload-attribute.js +26 -0
  102. package/dist/dev/core/features/sync-preload-attribute.js.map +1 -0
  103. package/dist/dev/core/hls/parse-attributes.js +1 -1
  104. package/dist/dev/core/hls/parse-attributes.js.map +1 -1
  105. package/dist/dev/core/hls/parse-media-playlist.js +1 -2
  106. package/dist/dev/core/hls/parse-media-playlist.js.map +1 -1
  107. package/dist/dev/core/hls/parse-multivariant.js +2 -3
  108. package/dist/dev/core/hls/parse-multivariant.js.map +1 -1
  109. package/dist/dev/core/hls/resolve-url.js +1 -1
  110. package/dist/dev/core/signals/effect.d.ts +15 -0
  111. package/dist/dev/core/signals/effect.d.ts.map +1 -0
  112. package/dist/dev/core/signals/effect.js +41 -0
  113. package/dist/dev/core/signals/effect.js.map +1 -0
  114. package/dist/dev/core/signals/primitives.d.ts +10 -0
  115. package/dist/dev/core/signals/primitives.d.ts.map +1 -0
  116. package/dist/dev/core/signals/primitives.js +32 -0
  117. package/dist/dev/core/signals/primitives.js.map +1 -0
  118. package/dist/dev/core/task.js +3 -3
  119. package/dist/dev/core/task.js.map +1 -1
  120. package/dist/dev/core/types/index.js +1 -1
  121. package/dist/dev/core/types/index.js.map +1 -1
  122. package/dist/dev/core/utils/generate-id.js +1 -1
  123. package/dist/dev/core/utils/track-selection.js +10 -2
  124. package/dist/dev/core/utils/track-selection.js.map +1 -1
  125. package/dist/dev/dom/features/end-of-stream.js +24 -48
  126. package/dist/dev/dom/features/end-of-stream.js.map +1 -1
  127. package/dist/dev/dom/features/load-segments.d.ts +4 -4
  128. package/dist/dev/dom/features/load-segments.d.ts.map +1 -1
  129. package/dist/dev/dom/features/load-segments.js +40 -36
  130. package/dist/dev/dom/features/load-segments.js.map +1 -1
  131. package/dist/dev/dom/features/load-text-track-cues.d.ts +4 -4
  132. package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
  133. package/dist/dev/dom/features/load-text-track-cues.js +36 -26
  134. package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
  135. package/dist/dev/dom/features/segment-loader-actor.d.ts +1 -0
  136. package/dist/dev/dom/features/segment-loader-actor.js +4 -5
  137. package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
  138. package/dist/dev/dom/features/setup-mediasource.js +29 -36
  139. package/dist/dev/dom/features/setup-mediasource.js.map +1 -1
  140. package/dist/dev/dom/features/setup-sourcebuffer.d.ts.map +1 -1
  141. package/dist/dev/dom/features/setup-sourcebuffer.js +43 -35
  142. package/dist/dev/dom/features/setup-sourcebuffer.js.map +1 -1
  143. package/dist/dev/dom/features/setup-text-tracks.js +25 -46
  144. package/dist/dev/dom/features/setup-text-tracks.js.map +1 -1
  145. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +14 -23
  146. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +1 -1
  147. package/dist/dev/dom/features/sync-text-track-modes.js +12 -17
  148. package/dist/dev/dom/features/sync-text-track-modes.js.map +1 -1
  149. package/dist/dev/dom/features/track-current-time.d.ts +4 -4
  150. package/dist/dev/dom/features/track-current-time.d.ts.map +1 -1
  151. package/dist/dev/dom/features/track-current-time.js +10 -7
  152. package/dist/dev/dom/features/track-current-time.js.map +1 -1
  153. package/dist/dev/dom/features/track-playback-initiated.d.ts +14 -23
  154. package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
  155. package/dist/dev/dom/features/track-playback-initiated.js +34 -36
  156. package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
  157. package/dist/dev/dom/features/track-playback-rate.d.ts +4 -4
  158. package/dist/dev/dom/features/track-playback-rate.d.ts.map +1 -1
  159. package/dist/dev/dom/features/track-playback-rate.js +12 -16
  160. package/dist/dev/dom/features/track-playback-rate.js.map +1 -1
  161. package/dist/dev/dom/features/update-duration.js +19 -13
  162. package/dist/dev/dom/features/update-duration.js.map +1 -1
  163. package/dist/dev/dom/media/append-segment.js +1 -1
  164. package/dist/dev/dom/media/buffer-flusher.js +1 -1
  165. package/dist/dev/dom/media/mediasource-setup.js +32 -40
  166. package/dist/dev/dom/media/mediasource-setup.js.map +1 -1
  167. package/dist/dev/dom/media/source-buffer-actor.d.ts +2 -2
  168. package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
  169. package/dist/dev/dom/media/source-buffer-actor.js +16 -26
  170. package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
  171. package/dist/dev/dom/network/chunked-stream-iterable.js +1 -1
  172. package/dist/dev/dom/network/chunked-stream-iterable.js.map +1 -1
  173. package/dist/dev/dom/network/fetch.js +1 -2
  174. package/dist/dev/dom/network/fetch.js.map +1 -1
  175. package/dist/dev/dom/playback-engine/adapter.d.ts.map +1 -1
  176. package/dist/dev/dom/playback-engine/adapter.js +13 -13
  177. package/dist/dev/dom/playback-engine/adapter.js.map +1 -1
  178. package/dist/dev/dom/playback-engine/engine.d.ts +14 -26
  179. package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
  180. package/dist/dev/dom/playback-engine/engine.js +24 -49
  181. package/dist/dev/dom/playback-engine/engine.js.map +1 -1
  182. package/dist/dev/dom/playback-engine.d.ts +3 -2
  183. package/dist/dev/dom/playback-engine.js +2 -2
  184. package/dist/dev/dom/text/parse-vtt-segment.js +1 -1
  185. package/dist/dev/dom.js +1 -2
  186. package/dist/dev/index.js +1 -1
  187. package/package.json +5 -4
  188. package/dist/default/all.js +0 -16
  189. package/dist/default/core/events/create-event-stream.js +0 -48
  190. package/dist/default/core/events/create-event-stream.js.map +0 -1
  191. package/dist/default/core/reactive/combine-latest.js +0 -72
  192. package/dist/default/core/reactive/combine-latest.js.map +0 -1
  193. package/dist/default/core/state/create-state.js +0 -143
  194. package/dist/default/core/state/create-state.js.map +0 -1
  195. package/dist/dev/all.js +0 -16
  196. package/dist/dev/core/events/create-event-stream.d.ts +0 -48
  197. package/dist/dev/core/events/create-event-stream.d.ts.map +0 -1
  198. package/dist/dev/core/events/create-event-stream.js +0 -48
  199. package/dist/dev/core/events/create-event-stream.js.map +0 -1
  200. package/dist/dev/core/features/resolve-presentation.d.ts +0 -16
  201. package/dist/dev/core/features/resolve-presentation.d.ts.map +0 -1
  202. package/dist/dev/core/features/resolve-track.d.ts +0 -13
  203. package/dist/dev/core/features/resolve-track.d.ts.map +0 -1
  204. package/dist/dev/core/features/select-tracks.d.ts +0 -11
  205. package/dist/dev/core/features/select-tracks.d.ts.map +0 -1
  206. package/dist/dev/core/reactive/combine-latest.js +0 -72
  207. package/dist/dev/core/reactive/combine-latest.js.map +0 -1
  208. package/dist/dev/core/state/create-state.d.ts +0 -104
  209. package/dist/dev/core/state/create-state.d.ts.map +0 -1
  210. package/dist/dev/core/state/create-state.js +0 -143
  211. package/dist/dev/core/state/create-state.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"engine.js","names":[],"sources":["../../../../src/dom/playback-engine/engine.ts"],"sourcesContent":["import type { BandwidthState } from '../../core/abr/bandwidth-estimator';\nimport { createEventStream } from '../../core/events/create-event-stream';\nimport { calculatePresentationDuration } from '../../core/features/calculate-presentation-duration';\nimport { switchQuality } from '../../core/features/quality-switching';\nimport {\n type PresentationAction,\n resolvePresentation,\n syncPreloadAttribute,\n} from '../../core/features/resolve-presentation';\nimport { resolveTrack, type TrackResolutionAction } from '../../core/features/resolve-track';\nimport {\n selectAudioTrack,\n selectTextTrack,\n selectVideoTrack,\n type TrackSelectionAction,\n} from '../../core/features/select-tracks';\nimport { createState } from '../../core/state/create-state';\nimport { endOfStream } from '../features/end-of-stream';\nimport { loadSegments } from '../features/load-segments';\nimport type { TextTrackBufferState } from '../features/load-text-track-cues';\nimport { loadTextTrackCues } from '../features/load-text-track-cues';\nimport { setupMediaSource } from '../features/setup-mediasource';\nimport { setupSourceBuffers } from '../features/setup-sourcebuffer';\nimport { setupTextTracks } from '../features/setup-text-tracks';\nimport { syncSelectedTextTrackFromDom } from '../features/sync-selected-text-track-from-dom';\nimport { syncTextTrackModes } from '../features/sync-text-track-modes';\nimport { trackCurrentTime } from '../features/track-current-time';\nimport { trackPlaybackInitiated } from '../features/track-playback-initiated';\nimport { updateDuration } from '../features/update-duration';\nimport type { SourceBufferActor } from '../media/source-buffer-actor';\nimport { destroyVttParser } from '../text/parse-vtt-segment';\n\n/**\n * Union of all action types used by playback engine orchestrations.\n * Includes synthetic @@INITIALIZE@@ event for combineLatest bootstrapping.\n */\nexport type PlaybackEngineAction =\n | PresentationAction\n | TrackResolutionAction\n | TrackSelectionAction\n | { type: '@@INITIALIZE@@' };\n\n/**\n * Configuration for the playback engine.\n */\nexport interface PlaybackEngineConfig {\n /**\n * Initial bandwidth estimate for cold start (bits per second).\n * Default: 1 Mbps (conservative).\n */\n initialBandwidth?: number;\n\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 * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching text 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 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 enableDefaultTrack?: boolean;\n}\n\n/**\n * Combined state shape for the playback engine.\n * Includes all state needed by orchestrations.\n */\nexport interface PlaybackEngineState {\n // Presentation state\n presentation?: any;\n preload?: string;\n\n // Track selection state\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n // NOTE: Text Tracks (subtitles/ccs) can be unselected\n selectedTextTrackId?: string;\n\n // Bandwidth estimation state\n bandwidthState?: BandwidthState;\n\n // ABR control — set to true to suppress automatic quality switching (manual selection mode).\n // TODO: replace with separate manualVideoTrackId / abrVideoTrackId fields so the two\n // concerns don't share a field; see quality-switching.ts for the full design note.\n abrDisabled?: boolean;\n\n // Text track buffer state (tracks loaded VTT segments per text track ID)\n textBufferState?: TextTrackBufferState;\n\n // Current playback position (mirrored from mediaElement via trackCurrentTime)\n currentTime?: number;\n\n // True once the user has initiated playback (enables segment loading regardless of preload)\n playbackInitiated?: boolean;\n}\n\n/**\n * Combined owners shape for the playback engine.\n * Includes all mutable platform objects.\n */\nexport interface PlaybackEngineOwners {\n // Media element\n mediaElement?: HTMLMediaElement | undefined;\n\n // MediaSource\n mediaSource?: MediaSource;\n\n // SourceBuffers and their actors (created together by setupSourceBuffer)\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n\n // Text tracks (track elements by ID)\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Playback engine instance.\n */\nexport interface PlaybackEngine {\n /**\n * Reactive state (for inspection/testing).\n */\n state: ReturnType<typeof createState<PlaybackEngineState>>;\n\n /**\n * Mutable owners (for inspection/testing).\n */\n owners: ReturnType<typeof createState<PlaybackEngineOwners>>;\n\n /**\n * Shared event stream (for inspection/testing/triggering events).\n */\n events: ReturnType<typeof createEventStream<PlaybackEngineAction>>;\n\n /**\n * Cleanup function to destroy all orchestrations.\n */\n destroy: () => void;\n}\n\n/**\n * Create a POC playback engine.\n *\n * Wires together all orchestrations to create a reactive playback pipeline:\n * 1. Resolve presentation (multivariant playlist)\n * 2. Select initial video and audio tracks\n * 3. Resolve selected tracks (media playlists)\n * 4. Setup MediaSource\n * 5. Setup SourceBuffers for video and audio\n *\n * Note: This is a POC - does not yet load/append segments.\n *\n * @param config - Playback engine configuration\n * @returns Playback engine instance with state, owners, and destroy function\n *\n * @example\n * const engine = createPlaybackEngine({\n * initialBandwidth: 2_000_000,\n * preferredAudioLanguage: 'en',\n * });\n *\n * // Initialize by patching state and owners\n * engine.owners.patch({ mediaElement: document.querySelector('video') });\n * engine.state.patch({\n * presentation: { url: 'https://example.com/playlist.m3u8' },\n * preload: 'auto',\n * });\n *\n * // Inspect state\n * console.log(engine.state.current);\n *\n * // Cleanup\n * engine.destroy();\n */\nexport function createPlaybackEngine(config: PlaybackEngineConfig = {}): PlaybackEngine {\n // Create reactive state and owners (initially empty)\n const state = createState<PlaybackEngineState>({\n bandwidthState: {\n fastEstimate: 0,\n fastTotalWeight: 0,\n slowEstimate: 0,\n slowTotalWeight: 0,\n bytesSampled: 0,\n },\n });\n const owners = createState<PlaybackEngineOwners>({});\n\n // Create single shared event stream for all orchestrations\n const events = createEventStream<PlaybackEngineAction>();\n\n // Wire up orchestrations (all share single event stream)\n // Note: @ts-expect-error needed due to EventStream invariance - each orchestration expects\n // specific event types, but shared stream has union of all types. Proper fix would\n // require making EventStream covariant or refactoring event system.\n const cleanups = [\n // 0a. Sync preload attribute from mediaElement → state.preload\n // Only re-reads when the mediaElement reference changes (lastMediaElement guard).\n // Normalises '' (absent attribute) to 'auto' (browser default).\n // @ts-expect-error - WritableState type variance\n syncPreloadAttribute(state, owners),\n\n // 0b. Bridge media element play event → state.playbackInitiated + event stream\n // Enables preload=\"none\"/\"metadata\" resolution via native controls / element.play()\n // @ts-expect-error - EventStream type variance\n trackPlaybackInitiated({ state, owners, events }),\n\n // 1. Resolve presentation (URL already in state)\n // @ts-expect-error - EventStream type variance\n resolvePresentation({ state, events }),\n\n // 2. Select initial tracks (when presentation loads)\n selectVideoTrack(\n // @ts-expect-error - Owners and EventStream type compatibility\n { state, owners, events },\n {\n type: 'video',\n ...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }),\n }\n ),\n selectAudioTrack(\n // @ts-expect-error - Owners and EventStream type compatibility\n { state, owners, events },\n {\n type: 'audio',\n ...(config.preferredAudioLanguage !== undefined && { preferredAudioLanguage: config.preferredAudioLanguage }),\n }\n ),\n selectTextTrack(\n // @ts-expect-error - Owners and EventStream type compatibility\n { state, owners, events },\n {\n type: 'text',\n ...(config.preferredSubtitleLanguage !== undefined && {\n preferredSubtitleLanguage: config.preferredSubtitleLanguage,\n }),\n ...(config.includeForcedTracks !== undefined && { includeForcedTracks: config.includeForcedTracks }),\n ...(config.enableDefaultTrack !== undefined && { enableDefaultTrack: config.enableDefaultTrack }),\n }\n ),\n\n // 3. Resolve selected tracks (fetch media playlists)\n // @ts-expect-error - EventStream type variance\n resolveTrack({ state, events }, { type: 'video' as const }),\n // @ts-expect-error - EventStream type variance\n resolveTrack({ state, events }, { type: 'audio' as const }),\n // @ts-expect-error - EventStream type variance\n resolveTrack({ state, events }, { type: 'text' as const }),\n\n // 3.5. Calculate presentation duration from resolved tracks\n calculatePresentationDuration({ state }),\n\n // 4. Setup MediaSource (when presentation loaded)\n setupMediaSource({ state, owners }),\n\n // 4.5. Update MediaSource duration (when presentation duration available)\n updateDuration({ state, owners }),\n\n // 5. Setup SourceBuffers (when MediaSource ready and all selected tracks resolved)\n // Both SourceBuffers are created in a single synchronous operation to guarantee\n // neither is visible to loadSegments before the other exists — preventing the\n // Firefox bug where appending video data before audio SB is created causes\n // mozHasAudio to be permanently false.\n setupSourceBuffers({ state, owners }),\n\n // 5.5. Track currentTime from mediaElement (feeds forward buffer management)\n //\n // NOTE: SourceBufferActor wiring is intentionally absent here in Phase 1.\n //\n // Attempting to wire actors into the engine at this layer revealed a\n // brittleness in the current architecture: every patch to `owners` —\n // regardless of which field changed — wakes up ALL combineLatest subscribers\n // (loadSegments, endOfStream, etc.). Storing actor references in owners\n // caused loadSegments to re-evaluate mid-task, store a spurious pending\n // state, and run a second loading cycle on completion.\n //\n // This means any future feature that needs to introduce new reactive state\n // alongside SourceBuffers faces the same hazard. The right fix is for\n // loadSegments (and other orchestrations) to route their MSE operations\n // through the actor directly, at which point the actor lifecycle is\n // co-located with its consumer rather than managed centrally here.\n //\n // Actor wiring will be introduced in Phase 2 as part of the loadSegments\n // refactor. See .claude/plans/spf/buffer-state-shadow-actual-model.md.\n trackCurrentTime({ state, owners }),\n\n // 5.75. ABR quality switching (reacts to bandwidth samples from loadSegments)\n switchQuality({ state }),\n\n // 6. Load segments (when SourceBuffer ready and track resolved)\n loadSegments({ state, owners }, { type: 'video' }),\n loadSegments({ state, owners }, { type: 'audio' }),\n\n // 6.5. Signal end of stream when all segments loaded\n endOfStream({ state, owners }),\n\n // 7. Setup text tracks (when mediaElement and presentation ready)\n setupTextTracks({ state, owners }),\n\n // 8. Sync text track modes (when track selected and track elements created)\n syncTextTrackModes({ state, owners }),\n\n // 8.5. Bridge DOM text track mode changes → selectedTextTrackId\n // Detects when external code (e.g. captions button via toggleSubtitles())\n // sets a subtitle/caption track to 'showing' and reflects that into SPF\n // state, which in turn drives loadTextTrackCues.\n syncSelectedTextTrackFromDom({ state, owners }),\n\n // 9. Load text track cues (when track resolved and mode set)\n loadTextTrackCues({ state, owners }),\n ];\n\n // Dispatch synthetic initialize event to satisfy combineLatest\n // (combineLatest waits for all sources to emit before triggering listeners)\n events.dispatch({ type: '@@INITIALIZE@@' });\n\n // Return engine instance\n return {\n state,\n owners,\n events,\n destroy: () => {\n cleanups.forEach((cleanup) => cleanup());\n destroyVttParser();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8LA,SAAgB,qBAAqB,SAA+B,EAAE,EAAkB;CAEtF,MAAM,QAAQ,YAAiC,EAC7C,gBAAgB;EACd,cAAc;EACd,iBAAiB;EACjB,cAAc;EACd,iBAAiB;EACjB,cAAc;EACf,EACF,CAAC;CACF,MAAM,SAAS,YAAkC,EAAE,CAAC;CAGpD,MAAM,SAAS,mBAAyC;CAMxD,MAAM,WAAW;EAKf,qBAAqB,OAAO,OAAO;EAKnC,uBAAuB;GAAE;GAAO;GAAQ;GAAQ,CAAC;EAIjD,oBAAoB;GAAE;GAAO;GAAQ,CAAC;EAGtC,iBAEE;GAAE;GAAO;GAAQ;GAAQ,EACzB;GACE,MAAM;GACN,GAAI,OAAO,qBAAqB,UAAa,EAAE,kBAAkB,OAAO,kBAAkB;GAC3F,CACF;EACD,iBAEE;GAAE;GAAO;GAAQ;GAAQ,EACzB;GACE,MAAM;GACN,GAAI,OAAO,2BAA2B,UAAa,EAAE,wBAAwB,OAAO,wBAAwB;GAC7G,CACF;EACD,gBAEE;GAAE;GAAO;GAAQ;GAAQ,EACzB;GACE,MAAM;GACN,GAAI,OAAO,8BAA8B,UAAa,EACpD,2BAA2B,OAAO,2BACnC;GACD,GAAI,OAAO,wBAAwB,UAAa,EAAE,qBAAqB,OAAO,qBAAqB;GACnG,GAAI,OAAO,uBAAuB,UAAa,EAAE,oBAAoB,OAAO,oBAAoB;GACjG,CACF;EAID,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAkB,CAAC;EAE3D,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAkB,CAAC;EAE3D,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,QAAiB,CAAC;EAG1D,8BAA8B,EAAE,OAAO,CAAC;EAGxC,iBAAiB;GAAE;GAAO;GAAQ,CAAC;EAGnC,eAAe;GAAE;GAAO;GAAQ,CAAC;EAOjC,mBAAmB;GAAE;GAAO;GAAQ,CAAC;EAqBrC,iBAAiB;GAAE;GAAO;GAAQ,CAAC;EAGnC,cAAc,EAAE,OAAO,CAAC;EAGxB,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAS,CAAC;EAClD,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAS,CAAC;EAGlD,YAAY;GAAE;GAAO;GAAQ,CAAC;EAG9B,gBAAgB;GAAE;GAAO;GAAQ,CAAC;EAGlC,mBAAmB;GAAE;GAAO;GAAQ,CAAC;EAMrC,6BAA6B;GAAE;GAAO;GAAQ,CAAC;EAG/C,kBAAkB;GAAE;GAAO;GAAQ,CAAC;EACrC;AAID,QAAO,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAG3C,QAAO;EACL;EACA;EACA;EACA,eAAe;AACb,YAAS,SAAS,YAAY,SAAS,CAAC;AACxC,qBAAkB;;EAErB"}
1
+ {"version":3,"file":"engine.js","names":[],"sources":["../../../../src/dom/playback-engine/engine.ts"],"sourcesContent":["import type { BandwidthState } from '../../core/abr/bandwidth-estimator';\nimport { calculatePresentationDuration } from '../../core/features/calculate-presentation-duration';\nimport { switchQuality } from '../../core/features/quality-switching';\nimport { resolvePresentation } from '../../core/features/resolve-presentation';\nimport { resolveTrack } from '../../core/features/resolve-track';\nimport { selectAudioTrack, selectTextTrack, selectVideoTrack } from '../../core/features/select-tracks';\nimport { syncPreloadAttribute } from '../../core/features/sync-preload-attribute';\nimport type { ReadonlySignal, Signal } from '../../core/signals/primitives';\nimport { signal } from '../../core/signals/primitives';\nimport { endOfStream } from '../features/end-of-stream';\nimport { loadSegments } from '../features/load-segments';\nimport type { TextTrackBufferState } from '../features/load-text-track-cues';\nimport { loadTextTrackCues } from '../features/load-text-track-cues';\nimport { setupMediaSource } from '../features/setup-mediasource';\nimport { setupSourceBuffers } from '../features/setup-sourcebuffer';\nimport { setupTextTracks } from '../features/setup-text-tracks';\nimport { syncSelectedTextTrackFromDom } from '../features/sync-selected-text-track-from-dom';\nimport { syncTextTrackModes } from '../features/sync-text-track-modes';\nimport { trackCurrentTime } from '../features/track-current-time';\nimport { trackPlaybackInitiated } from '../features/track-playback-initiated';\nimport { updateDuration } from '../features/update-duration';\nimport type { SourceBufferActor } from '../media/source-buffer-actor';\nimport { destroyVttParser } from '../text/parse-vtt-segment';\n\n/**\n * Configuration for the playback engine.\n */\nexport interface PlaybackEngineConfig {\n /**\n * Initial bandwidth estimate for cold start (bits per second).\n * Default: 1 Mbps (conservative).\n */\n initialBandwidth?: number;\n\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 * Preferred subtitle language (ISO 639 code, e.g., \"en\", \"es\").\n * If specified, selects matching text 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 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 enableDefaultTrack?: boolean;\n}\n\n/**\n * Combined state shape for the playback engine.\n * Includes all state needed by orchestrations.\n */\nexport interface PlaybackEngineState {\n // Presentation state\n presentation?: any;\n preload?: 'auto' | 'metadata' | 'none';\n\n // Track selection state\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n // NOTE: Text Tracks (subtitles/ccs) can be unselected\n selectedTextTrackId?: string;\n\n // Bandwidth estimation state\n bandwidthState?: BandwidthState;\n\n // ABR control — set to true to suppress automatic quality switching (manual selection mode).\n // TODO: replace with separate manualVideoTrackId / abrVideoTrackId fields so the two\n // concerns don't share a field; see quality-switching.ts for the full design note.\n abrDisabled?: boolean;\n\n // Text track buffer state (tracks loaded VTT segments per text track ID)\n textBufferState?: TextTrackBufferState;\n\n // Current playback position (mirrored from mediaElement via trackCurrentTime)\n currentTime?: number;\n\n // True once the user has initiated playback (enables segment loading regardless of preload)\n playbackInitiated?: boolean;\n}\n\n/**\n * Combined owners shape for the playback engine.\n * Includes all mutable platform objects.\n */\nexport interface PlaybackEngineOwners {\n // Media element\n mediaElement?: HTMLMediaElement | undefined;\n\n // MediaSource\n mediaSource?: MediaSource;\n /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */\n mediaSourceReadyState?: ReadonlySignal<MediaSource['readyState']>;\n\n // SourceBuffers and their actors (created together by setupSourceBuffer)\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n\n // Text tracks (track elements by ID)\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Playback engine instance.\n */\nexport interface PlaybackEngine {\n /**\n * Reactive state signal (for inspection/testing).\n */\n state: Signal<PlaybackEngineState>;\n\n /**\n * Mutable owners signal (for inspection/testing).\n */\n owners: Signal<PlaybackEngineOwners>;\n\n /**\n * Cleanup function to destroy all orchestrations.\n */\n destroy: () => void;\n}\n\n/**\n * Create a POC playback engine.\n *\n * Wires together all orchestrations to create a reactive playback pipeline:\n * 1. Resolve presentation (multivariant playlist)\n * 2. Select initial video and audio tracks\n * 3. Resolve selected tracks (media playlists)\n * 4. Setup MediaSource\n * 5. Setup SourceBuffers for video and audio\n *\n * Note: This is a POC - does not yet load/append segments.\n *\n * @param config - Playback engine configuration\n * @returns Playback engine instance with state, owners, and destroy function\n *\n * @example\n * const engine = createPlaybackEngine({\n * initialBandwidth: 2_000_000,\n * preferredAudioLanguage: 'en',\n * });\n *\n * // Initialize by setting state and owners\n * engine.owners.set({ ...engine.owners.get(), mediaElement: document.querySelector('video') });\n * engine.state.set({\n * ...engine.state.get(),\n * presentation: { url: 'https://example.com/playlist.m3u8' },\n * preload: 'auto',\n * });\n *\n * // Inspect state\n * console.log(engine.state.get());\n *\n * // Cleanup\n * engine.destroy();\n */\nexport function createPlaybackEngine(config: PlaybackEngineConfig = {}): PlaybackEngine {\n // Create reactive state and owners as signals\n const state = signal<PlaybackEngineState>({\n bandwidthState: {\n fastEstimate: 0,\n fastTotalWeight: 0,\n slowEstimate: 0,\n slowTotalWeight: 0,\n bytesSampled: 0,\n },\n });\n const owners = signal<PlaybackEngineOwners>({});\n\n // Wire up orchestrations\n const cleanups = [\n // 0a. Sync preload attribute from mediaElement → state.preload\n // Normalises '' (absent attribute) to 'auto' (browser default).\n syncPreloadAttribute({ state, owners }),\n\n // 0b. Track media element play event → state.playbackInitiated\n // Enables preload=\"none\" resolution via native controls / element.play()\n trackPlaybackInitiated({ state, owners }),\n\n // 1. Resolve presentation (URL already in state)\n resolvePresentation({ state }),\n\n // 2. Select initial tracks (when presentation loads)\n selectVideoTrack(\n { state },\n {\n type: 'video',\n ...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }),\n }\n ),\n selectAudioTrack(\n { state },\n {\n type: 'audio',\n ...(config.preferredAudioLanguage !== undefined && { preferredAudioLanguage: config.preferredAudioLanguage }),\n }\n ),\n selectTextTrack(\n { state },\n {\n type: 'text',\n ...(config.preferredSubtitleLanguage !== undefined && {\n preferredSubtitleLanguage: config.preferredSubtitleLanguage,\n }),\n ...(config.includeForcedTracks !== undefined && { includeForcedTracks: config.includeForcedTracks }),\n ...(config.enableDefaultTrack !== undefined && { enableDefaultTrack: config.enableDefaultTrack }),\n }\n ),\n\n // 3. Resolve selected tracks (fetch media playlists)\n resolveTrack({ state }, { type: 'video' as const }),\n resolveTrack({ state }, { type: 'audio' as const }),\n resolveTrack({ state }, { type: 'text' as const }),\n\n // 3.5. Calculate presentation duration from resolved tracks\n calculatePresentationDuration({ state }),\n\n // 4. Setup MediaSource (when presentation loaded)\n setupMediaSource({ state, owners }),\n\n // 4.5. Update MediaSource duration (when presentation duration available)\n updateDuration({ state, owners }),\n\n // 5. Setup SourceBuffers (when MediaSource ready and all selected tracks resolved)\n // Both SourceBuffers are created in a single synchronous operation to guarantee\n // neither is visible to loadSegments before the other exists — preventing the\n // Firefox bug where appending video data before audio SB is created causes\n // mozHasAudio to be permanently false.\n setupSourceBuffers({ state, owners }),\n\n // 5.5. Track currentTime from mediaElement (feeds forward buffer management)\n //\n // NOTE: SourceBufferActor wiring is intentionally absent here in Phase 1.\n //\n // Attempting to wire actors into the engine at this layer revealed a\n // brittleness in the current architecture: every patch to `owners` —\n // regardless of which field changed — wakes up ALL combineLatest subscribers\n // (loadSegments, endOfStream, etc.). Storing actor references in owners\n // caused loadSegments to re-evaluate mid-task, store a spurious pending\n // state, and run a second loading cycle on completion.\n //\n // This means any future feature that needs to introduce new reactive state\n // alongside SourceBuffers faces the same hazard. The right fix is for\n // loadSegments (and other orchestrations) to route their MSE operations\n // through the actor directly, at which point the actor lifecycle is\n // co-located with its consumer rather than managed centrally here.\n //\n // Actor wiring will be introduced in Phase 2 as part of the loadSegments\n // refactor. See .claude/plans/spf/buffer-state-shadow-actual-model.md.\n trackCurrentTime({ state, owners }),\n\n // 5.75. ABR quality switching (reacts to bandwidth samples from loadSegments)\n switchQuality(\n { state },\n config.initialBandwidth !== undefined ? { defaultBandwidth: config.initialBandwidth } : {}\n ),\n\n // 6. Load segments (when SourceBuffer ready and track resolved)\n loadSegments({ state, owners }, { type: 'video' }),\n loadSegments({ state, owners }, { type: 'audio' }),\n\n // 6.5. Signal end of stream when all segments loaded\n endOfStream({ state, owners }),\n\n // 7. Setup text tracks (when mediaElement and presentation ready)\n setupTextTracks({ state, owners }),\n\n // 8. Sync text track modes (when track selected and track elements created)\n syncTextTrackModes({ state, owners }),\n\n // 8.5. Bridge DOM text track mode changes → selectedTextTrackId\n // Detects when external code (e.g. captions button via toggleSubtitles())\n // sets a subtitle/caption track to 'showing' and reflects that into SPF\n // state, which in turn drives loadTextTrackCues.\n syncSelectedTextTrackFromDom({ state, owners }),\n\n // 9. Load text track cues (when track resolved and mode set)\n loadTextTrackCues({ state, owners }),\n ];\n\n // Return engine instance\n return {\n state,\n owners,\n destroy: () => {\n cleanups.forEach((cleanup) => cleanup());\n destroyVttParser();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0KA,SAAgB,qBAAqB,SAA+B,EAAE,EAAkB;CAEtF,MAAM,QAAQ,OAA4B,EACxC,gBAAgB;EACd,cAAc;EACd,iBAAiB;EACjB,cAAc;EACd,iBAAiB;EACjB,cAAc;EACf,EACF,CAAC;CACF,MAAM,SAAS,OAA6B,EAAE,CAAC;CAG/C,MAAM,WAAW;EAGf,qBAAqB;GAAE;GAAO;GAAQ,CAAC;EAIvC,uBAAuB;GAAE;GAAO;GAAQ,CAAC;EAGzC,oBAAoB,EAAE,OAAO,CAAC;EAG9B,iBACE,EAAE,OAAO,EACT;GACE,MAAM;GACN,GAAI,OAAO,qBAAqB,KAAA,KAAa,EAAE,kBAAkB,OAAO,kBAAkB;GAC3F,CACF;EACD,iBACE,EAAE,OAAO,EACT;GACE,MAAM;GACN,GAAI,OAAO,2BAA2B,KAAA,KAAa,EAAE,wBAAwB,OAAO,wBAAwB;GAC7G,CACF;EACD,gBACE,EAAE,OAAO,EACT;GACE,MAAM;GACN,GAAI,OAAO,8BAA8B,KAAA,KAAa,EACpD,2BAA2B,OAAO,2BACnC;GACD,GAAI,OAAO,wBAAwB,KAAA,KAAa,EAAE,qBAAqB,OAAO,qBAAqB;GACnG,GAAI,OAAO,uBAAuB,KAAA,KAAa,EAAE,oBAAoB,OAAO,oBAAoB;GACjG,CACF;EAGD,aAAa,EAAE,OAAO,EAAE,EAAE,MAAM,SAAkB,CAAC;EACnD,aAAa,EAAE,OAAO,EAAE,EAAE,MAAM,SAAkB,CAAC;EACnD,aAAa,EAAE,OAAO,EAAE,EAAE,MAAM,QAAiB,CAAC;EAGlD,8BAA8B,EAAE,OAAO,CAAC;EAGxC,iBAAiB;GAAE;GAAO;GAAQ,CAAC;EAGnC,eAAe;GAAE;GAAO;GAAQ,CAAC;EAOjC,mBAAmB;GAAE;GAAO;GAAQ,CAAC;EAqBrC,iBAAiB;GAAE;GAAO;GAAQ,CAAC;EAGnC,cACE,EAAE,OAAO,EACT,OAAO,qBAAqB,KAAA,IAAY,EAAE,kBAAkB,OAAO,kBAAkB,GAAG,EAAE,CAC3F;EAGD,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAS,CAAC;EAClD,aAAa;GAAE;GAAO;GAAQ,EAAE,EAAE,MAAM,SAAS,CAAC;EAGlD,YAAY;GAAE;GAAO;GAAQ,CAAC;EAG9B,gBAAgB;GAAE;GAAO;GAAQ,CAAC;EAGlC,mBAAmB;GAAE;GAAO;GAAQ,CAAC;EAMrC,6BAA6B;GAAE;GAAO;GAAQ,CAAC;EAG/C,kBAAkB;GAAE;GAAO;GAAQ,CAAC;EACrC;AAGD,QAAO;EACL;EACA;EACA,eAAe;AACb,YAAS,SAAS,YAAY,SAAS,CAAC;AACxC,qBAAkB;;EAErB"}
@@ -1,4 +1,4 @@
1
+ import { effect } from "../core/signals/effect.js";
1
2
  import { createPlaybackEngine } from "./playback-engine/engine.js";
2
3
  import { SpfMedia } from "./playback-engine/adapter.js";
3
-
4
- export { SpfMedia, createPlaybackEngine };
4
+ export { SpfMedia, createPlaybackEngine, effect };
@@ -50,7 +50,7 @@ function parseVttSegment(url) {
50
50
  function destroyVttParser() {
51
51
  dummyVideo = null;
52
52
  }
53
-
54
53
  //#endregion
55
54
  export { destroyVttParser, parseVttSegment };
55
+
56
56
  //# sourceMappingURL=parse-vtt-segment.js.map
@@ -7,5 +7,4 @@ import { trackPlaybackRate } from "./dom/features/track-playback-rate.js";
7
7
  import { appendSegment } from "./dom/media/append-segment.js";
8
8
  import { flushBuffer } from "./dom/media/buffer-flusher.js";
9
9
  import { SpfMedia } from "./dom/playback-engine/adapter.js";
10
-
11
- export { SpfMedia, appendSegment, destroyVttParser, flushBuffer, loadSegments, loadTextTrackCues, parseVttSegment, trackCurrentTime, trackPlaybackInitiated, trackPlaybackRate };
10
+ export { SpfMedia, appendSegment, destroyVttParser, flushBuffer, loadSegments, loadTextTrackCues, parseVttSegment, trackCurrentTime, trackPlaybackInitiated, trackPlaybackRate };
@@ -8,7 +8,7 @@
8
8
  * @packageDocumentation
9
9
  */
10
10
  const VERSION = "0.1.0";
11
-
12
11
  //#endregion
13
12
  export { VERSION };
13
+
14
14
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,4 @@
1
1
  import { applyZeroFactor, calculateEwma } from "./ewma.js";
2
-
3
2
  //#region src/core/abr/bandwidth-estimator.ts
4
3
  /**
5
4
  * Dual EWMA Bandwidth Estimator
@@ -90,7 +89,7 @@ function getBandwidthEstimate(state, defaultEstimate, config = DEFAULT_BANDWIDTH
90
89
  const slowEstimate = applyZeroFactor(state.slowEstimate, state.slowTotalWeight, config.slowHalfLife);
91
90
  return Math.min(fastEstimate, slowEstimate);
92
91
  }
93
-
94
92
  //#endregion
95
- export { DEFAULT_BANDWIDTH_CONFIG, getBandwidthEstimate, sampleBandwidth };
93
+ export { getBandwidthEstimate, sampleBandwidth };
94
+
96
95
  //# sourceMappingURL=bandwidth-estimator.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"bandwidth-estimator.js","names":[],"sources":["../../../../src/core/abr/bandwidth-estimator.ts"],"sourcesContent":["/**\n * Dual EWMA Bandwidth Estimator\n *\n * Estimates available bandwidth using two EWMA calculations with different\n * half-lives, taking the minimum of both. This approach (from Shaka Player):\n *\n * - **Fast EWMA** (2s half-life): Reacts quickly to bandwidth drops\n * - **Slow EWMA** (5s half-life): Provides stability during fluctuations\n * - **min(fast, slow)**: Adapts down quickly, up slowly\n *\n * This naturally provides asymmetric behavior needed for good QoE:\n * avoiding stalls (quick downgrade) while preventing oscillation (slow upgrade).\n */\n\nimport { applyZeroFactor, calculateEwma } from './ewma';\n\n/**\n * Bandwidth estimator state.\n *\n * This state structure will be managed by O1 (State Container).\n * Functions in this module operate on this state immutably.\n */\nexport interface BandwidthState {\n /** Fast-moving EWMA estimate (raw, uncorrected). */\n fastEstimate: number;\n /** Total weight accumulated in fast EWMA. */\n fastTotalWeight: number;\n /** Slow-moving EWMA estimate (raw, uncorrected). */\n slowEstimate: number;\n /** Total weight accumulated in slow EWMA. */\n slowTotalWeight: number;\n /** Total bytes sampled across all valid samples. */\n bytesSampled: number;\n}\n\n/**\n * Configuration for bandwidth estimation.\n */\nexport interface BandwidthConfig {\n /** Half-life for fast EWMA in seconds. */\n fastHalfLife: number;\n /** Half-life for slow EWMA in seconds. */\n slowHalfLife: number;\n /** Minimum total bytes before trusting the estimate. */\n minTotalBytes: number;\n /** Minimum bytes per sample to count (filters TTFB-dominated samples). */\n minBytes: number;\n /** Minimum sample duration in ms (filters cached responses). */\n minDuration: number;\n}\n\n/**\n * Default bandwidth estimator configuration.\n *\n * Values match Shaka Player defaults based on experimentation.\n */\nexport const DEFAULT_BANDWIDTH_CONFIG: BandwidthConfig = {\n fastHalfLife: 2, // 2 seconds\n slowHalfLife: 5, // 5 seconds\n minTotalBytes: 128_000, // 128 KB\n minBytes: 16_000, // 16 KB\n minDuration: 5, // 5 ms\n};\n\n/**\n * Add a bandwidth sample from a segment download.\n *\n * Samples are filtered based on:\n * - Minimum bytes (filters TTFB-dominated small segments)\n * - Minimum duration (filters cached responses)\n *\n * Valid samples update both fast and slow EWMA estimates.\n *\n * @param state - Current estimator state\n * @param durationMs - Download duration in milliseconds\n * @param numBytes - Number of bytes downloaded\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns New estimator state with sample incorporated (or unchanged if filtered)\n *\n * @example\n * let state = { fastEstimate: 0, fastTotalWeight: 0, ... };\n * // Sample: 1MB in 1 second\n * state = sampleBandwidth(state, 1000, 1_000_000);\n */\nexport function sampleBandwidth(\n state: BandwidthState,\n durationMs: number,\n numBytes: number,\n config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG\n): BandwidthState {\n // Always track bytes for startup phase calculation\n const updatedBytesSampled = state.bytesSampled + numBytes;\n\n // Filter: Ignore samples below minimum bytes (TTFB-dominated)\n // Small segments' download time is mostly connection setup latency,\n // which would artificially lower our bandwidth estimate\n if (numBytes < config.minBytes) {\n return { ...state, bytesSampled: updatedBytesSampled };\n }\n\n // Filter: Ignore samples faster than minimum duration (cached responses)\n // Cached responses load nearly instantly and would artificially inflate\n // our bandwidth estimate\n if (durationMs < config.minDuration) {\n return { ...state, bytesSampled: updatedBytesSampled };\n }\n\n // Calculate bandwidth in bits per second\n // Formula: (bytes * 8 bits/byte * 1000 ms/s) / ms = bits/s\n const bandwidth = (8000 * numBytes) / durationMs;\n\n // Weight by duration in seconds\n // Longer downloads are more reliable indicators of true bandwidth\n const weight = durationMs / 1000;\n\n return {\n fastEstimate: calculateEwma(state.fastEstimate, bandwidth, weight, config.fastHalfLife),\n fastTotalWeight: state.fastTotalWeight + weight,\n slowEstimate: calculateEwma(state.slowEstimate, bandwidth, weight, config.slowHalfLife),\n slowTotalWeight: state.slowTotalWeight + weight,\n bytesSampled: updatedBytesSampled,\n };\n}\n\n/**\n * Get the current bandwidth estimate.\n *\n * Returns the **minimum** of the fast and slow EWMA estimates.\n * This provides the key asymmetric behavior:\n * - When bandwidth drops, fast EWMA reacts first and dominates (quick adaptation)\n * - When bandwidth rises, slow EWMA lags behind and dominates (slow adaptation)\n *\n * Uses default estimate until enough data has been sampled.\n *\n * @param state - Current estimator state\n * @param defaultEstimate - Fallback estimate before sufficient samples (bps)\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns Bandwidth estimate in bits per second\n *\n * @example\n * const estimate = getBandwidthEstimate(state, 5_000_000); // 5 Mbps default\n */\nexport function getBandwidthEstimate(\n state: BandwidthState,\n defaultEstimate: number,\n config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG\n): number {\n // Use default until we have enough samples to trust our estimate\n if (state.bytesSampled < config.minTotalBytes) {\n return defaultEstimate;\n }\n\n // Apply zero-factor correction to both estimates\n const fastEstimate = applyZeroFactor(state.fastEstimate, state.fastTotalWeight, config.fastHalfLife);\n\n const slowEstimate = applyZeroFactor(state.slowEstimate, state.slowTotalWeight, config.slowHalfLife);\n\n // Take the minimum - this is the key insight from Shaka Player\n // It naturally provides \"down quickly, up slowly\" behavior\n return Math.min(fastEstimate, slowEstimate);\n}\n\n/**\n * Check if the estimator has enough data to provide a reliable estimate.\n *\n * Requires both:\n * - Enough total bytes sampled (minTotalBytes threshold)\n * - At least one valid EWMA sample (totalWeight > 0)\n *\n * @param state - Current estimator state\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns True if we've sampled enough bytes to trust the estimate\n *\n * @example\n * if (hasGoodEstimate(state)) {\n * const estimate = getBandwidthEstimate(state, 5_000_000);\n * }\n */\nexport function hasGoodEstimate(state: BandwidthState, config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG): boolean {\n // Need enough total bytes AND at least one valid EWMA sample\n return state.bytesSampled >= config.minTotalBytes && state.fastTotalWeight > 0 && state.slowTotalWeight > 0;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,2BAA4C;CACvD,cAAc;CACd,cAAc;CACd,eAAe;CACf,UAAU;CACV,aAAa;CACd;;;;;;;;;;;;;;;;;;;;;AAsBD,SAAgB,gBACd,OACA,YACA,UACA,SAA0B,0BACV;CAEhB,MAAM,sBAAsB,MAAM,eAAe;AAKjD,KAAI,WAAW,OAAO,SACpB,QAAO;EAAE,GAAG;EAAO,cAAc;EAAqB;AAMxD,KAAI,aAAa,OAAO,YACtB,QAAO;EAAE,GAAG;EAAO,cAAc;EAAqB;CAKxD,MAAM,YAAa,MAAO,WAAY;CAItC,MAAM,SAAS,aAAa;AAE5B,QAAO;EACL,cAAc,cAAc,MAAM,cAAc,WAAW,QAAQ,OAAO,aAAa;EACvF,iBAAiB,MAAM,kBAAkB;EACzC,cAAc,cAAc,MAAM,cAAc,WAAW,QAAQ,OAAO,aAAa;EACvF,iBAAiB,MAAM,kBAAkB;EACzC,cAAc;EACf;;;;;;;;;;;;;;;;;;;;AAqBH,SAAgB,qBACd,OACA,iBACA,SAA0B,0BAClB;AAER,KAAI,MAAM,eAAe,OAAO,cAC9B,QAAO;CAIT,MAAM,eAAe,gBAAgB,MAAM,cAAc,MAAM,iBAAiB,OAAO,aAAa;CAEpG,MAAM,eAAe,gBAAgB,MAAM,cAAc,MAAM,iBAAiB,OAAO,aAAa;AAIpG,QAAO,KAAK,IAAI,cAAc,aAAa"}
1
+ {"version":3,"file":"bandwidth-estimator.js","names":[],"sources":["../../../../src/core/abr/bandwidth-estimator.ts"],"sourcesContent":["/**\n * Dual EWMA Bandwidth Estimator\n *\n * Estimates available bandwidth using two EWMA calculations with different\n * half-lives, taking the minimum of both. This approach (from Shaka Player):\n *\n * - **Fast EWMA** (2s half-life): Reacts quickly to bandwidth drops\n * - **Slow EWMA** (5s half-life): Provides stability during fluctuations\n * - **min(fast, slow)**: Adapts down quickly, up slowly\n *\n * This naturally provides asymmetric behavior needed for good QoE:\n * avoiding stalls (quick downgrade) while preventing oscillation (slow upgrade).\n */\n\nimport { applyZeroFactor, calculateEwma } from './ewma';\n\n/**\n * Bandwidth estimator state.\n *\n * This state structure will be managed by O1 (State Container).\n * Functions in this module operate on this state immutably.\n */\nexport interface BandwidthState {\n /** Fast-moving EWMA estimate (raw, uncorrected). */\n fastEstimate: number;\n /** Total weight accumulated in fast EWMA. */\n fastTotalWeight: number;\n /** Slow-moving EWMA estimate (raw, uncorrected). */\n slowEstimate: number;\n /** Total weight accumulated in slow EWMA. */\n slowTotalWeight: number;\n /** Total bytes sampled across all valid samples. */\n bytesSampled: number;\n}\n\n/**\n * Configuration for bandwidth estimation.\n */\nexport interface BandwidthConfig {\n /** Half-life for fast EWMA in seconds. */\n fastHalfLife: number;\n /** Half-life for slow EWMA in seconds. */\n slowHalfLife: number;\n /** Minimum total bytes before trusting the estimate. */\n minTotalBytes: number;\n /** Minimum bytes per sample to count (filters TTFB-dominated samples). */\n minBytes: number;\n /** Minimum sample duration in ms (filters cached responses). */\n minDuration: number;\n}\n\n/**\n * Default bandwidth estimator configuration.\n *\n * Values match Shaka Player defaults based on experimentation.\n */\nexport const DEFAULT_BANDWIDTH_CONFIG: BandwidthConfig = {\n fastHalfLife: 2, // 2 seconds\n slowHalfLife: 5, // 5 seconds\n minTotalBytes: 128_000, // 128 KB\n minBytes: 16_000, // 16 KB\n minDuration: 5, // 5 ms\n};\n\n/**\n * Add a bandwidth sample from a segment download.\n *\n * Samples are filtered based on:\n * - Minimum bytes (filters TTFB-dominated small segments)\n * - Minimum duration (filters cached responses)\n *\n * Valid samples update both fast and slow EWMA estimates.\n *\n * @param state - Current estimator state\n * @param durationMs - Download duration in milliseconds\n * @param numBytes - Number of bytes downloaded\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns New estimator state with sample incorporated (or unchanged if filtered)\n *\n * @example\n * let state = { fastEstimate: 0, fastTotalWeight: 0, ... };\n * // Sample: 1MB in 1 second\n * state = sampleBandwidth(state, 1000, 1_000_000);\n */\nexport function sampleBandwidth(\n state: BandwidthState,\n durationMs: number,\n numBytes: number,\n config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG\n): BandwidthState {\n // Always track bytes for startup phase calculation\n const updatedBytesSampled = state.bytesSampled + numBytes;\n\n // Filter: Ignore samples below minimum bytes (TTFB-dominated)\n // Small segments' download time is mostly connection setup latency,\n // which would artificially lower our bandwidth estimate\n if (numBytes < config.minBytes) {\n return { ...state, bytesSampled: updatedBytesSampled };\n }\n\n // Filter: Ignore samples faster than minimum duration (cached responses)\n // Cached responses load nearly instantly and would artificially inflate\n // our bandwidth estimate\n if (durationMs < config.minDuration) {\n return { ...state, bytesSampled: updatedBytesSampled };\n }\n\n // Calculate bandwidth in bits per second\n // Formula: (bytes * 8 bits/byte * 1000 ms/s) / ms = bits/s\n const bandwidth = (8000 * numBytes) / durationMs;\n\n // Weight by duration in seconds\n // Longer downloads are more reliable indicators of true bandwidth\n const weight = durationMs / 1000;\n\n return {\n fastEstimate: calculateEwma(state.fastEstimate, bandwidth, weight, config.fastHalfLife),\n fastTotalWeight: state.fastTotalWeight + weight,\n slowEstimate: calculateEwma(state.slowEstimate, bandwidth, weight, config.slowHalfLife),\n slowTotalWeight: state.slowTotalWeight + weight,\n bytesSampled: updatedBytesSampled,\n };\n}\n\n/**\n * Get the current bandwidth estimate.\n *\n * Returns the **minimum** of the fast and slow EWMA estimates.\n * This provides the key asymmetric behavior:\n * - When bandwidth drops, fast EWMA reacts first and dominates (quick adaptation)\n * - When bandwidth rises, slow EWMA lags behind and dominates (slow adaptation)\n *\n * Uses default estimate until enough data has been sampled.\n *\n * @param state - Current estimator state\n * @param defaultEstimate - Fallback estimate before sufficient samples (bps)\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns Bandwidth estimate in bits per second\n *\n * @example\n * const estimate = getBandwidthEstimate(state, 5_000_000); // 5 Mbps default\n */\nexport function getBandwidthEstimate(\n state: BandwidthState,\n defaultEstimate: number,\n config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG\n): number {\n // Use default until we have enough samples to trust our estimate\n if (state.bytesSampled < config.minTotalBytes) {\n return defaultEstimate;\n }\n\n // Apply zero-factor correction to both estimates\n const fastEstimate = applyZeroFactor(state.fastEstimate, state.fastTotalWeight, config.fastHalfLife);\n\n const slowEstimate = applyZeroFactor(state.slowEstimate, state.slowTotalWeight, config.slowHalfLife);\n\n // Take the minimum - this is the key insight from Shaka Player\n // It naturally provides \"down quickly, up slowly\" behavior\n return Math.min(fastEstimate, slowEstimate);\n}\n\n/**\n * Check if the estimator has enough data to provide a reliable estimate.\n *\n * Requires both:\n * - Enough total bytes sampled (minTotalBytes threshold)\n * - At least one valid EWMA sample (totalWeight > 0)\n *\n * @param state - Current estimator state\n * @param config - Optional estimator configuration (uses defaults if not provided)\n * @returns True if we've sampled enough bytes to trust the estimate\n *\n * @example\n * if (hasGoodEstimate(state)) {\n * const estimate = getBandwidthEstimate(state, 5_000_000);\n * }\n */\nexport function hasGoodEstimate(state: BandwidthState, config: BandwidthConfig = DEFAULT_BANDWIDTH_CONFIG): boolean {\n // Need enough total bytes AND at least one valid EWMA sample\n return state.bytesSampled >= config.minTotalBytes && state.fastTotalWeight > 0 && state.slowTotalWeight > 0;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,2BAA4C;CACvD,cAAc;CACd,cAAc;CACd,eAAe;CACf,UAAU;CACV,aAAa;CACd;;;;;;;;;;;;;;;;;;;;;AAsBD,SAAgB,gBACd,OACA,YACA,UACA,SAA0B,0BACV;CAEhB,MAAM,sBAAsB,MAAM,eAAe;AAKjD,KAAI,WAAW,OAAO,SACpB,QAAO;EAAE,GAAG;EAAO,cAAc;EAAqB;AAMxD,KAAI,aAAa,OAAO,YACtB,QAAO;EAAE,GAAG;EAAO,cAAc;EAAqB;CAKxD,MAAM,YAAa,MAAO,WAAY;CAItC,MAAM,SAAS,aAAa;AAE5B,QAAO;EACL,cAAc,cAAc,MAAM,cAAc,WAAW,QAAQ,OAAO,aAAa;EACvF,iBAAiB,MAAM,kBAAkB;EACzC,cAAc,cAAc,MAAM,cAAc,WAAW,QAAQ,OAAO,aAAa;EACvF,iBAAiB,MAAM,kBAAkB;EACzC,cAAc;EACf;;;;;;;;;;;;;;;;;;;;AAqBH,SAAgB,qBACd,OACA,iBACA,SAA0B,0BAClB;AAER,KAAI,MAAM,eAAe,OAAO,cAC9B,QAAO;CAIT,MAAM,eAAe,gBAAgB,MAAM,cAAc,MAAM,iBAAiB,OAAO,aAAa;CAEpG,MAAM,eAAe,gBAAgB,MAAM,cAAc,MAAM,iBAAiB,OAAO,aAAa;AAIpG,QAAO,KAAK,IAAI,cAAc,aAAa"}
@@ -65,7 +65,7 @@ function applyZeroFactor(estimate, totalWeight, halfLife) {
65
65
  if (totalWeight === 0) return 0;
66
66
  return estimate / (1 - calculateAlpha(halfLife) ** totalWeight);
67
67
  }
68
-
69
68
  //#endregion
70
69
  export { applyZeroFactor, calculateEwma };
70
+
71
71
  //# sourceMappingURL=ewma.js.map
@@ -47,7 +47,7 @@ function selectQuality(tracks, currentBandwidth, config = DEFAULT_QUALITY_CONFIG
47
47
  function hasHigherResolution(trackA, trackB) {
48
48
  return (trackA.width ?? 0) * (trackA.height ?? 0) > (trackB.width ?? 0) * (trackB.height ?? 0);
49
49
  }
50
-
51
50
  //#endregion
52
- export { DEFAULT_QUALITY_CONFIG, selectQuality };
51
+ export { selectQuality };
52
+
53
53
  //# sourceMappingURL=quality-selection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"quality-selection.js","names":[],"sources":["../../../../src/core/abr/quality-selection.ts"],"sourcesContent":["/**\n * Quality Selection Algorithm\n *\n * Selects optimal video track based on bandwidth estimate with safety margin.\n * Stateless selection - picks highest quality that fits bandwidth.\n *\n * Key concepts:\n * - **Safety margin** (0.85): Pick track where bandwidth >= track.bandwidth / 0.85\n * - This ensures 15% headroom to avoid buffering\n * - At same bandwidth, prefer higher resolution\n */\n\nimport type { PartiallyResolvedVideoTrack } from '../types';\n\n/**\n * Quality selection configuration.\n */\nexport interface QualityConfig {\n /**\n * Safety margin (0-1).\n * To select a track, need: currentBandwidth >= track.bandwidth / safetyMargin.\n * Default 0.85 means track must use ≤85% of available bandwidth (15% headroom).\n */\n safetyMargin: number;\n}\n\n/**\n * Default quality selection configuration.\n * Values match Shaka Player upgrade threshold (0.85 = 15% headroom).\n */\nexport const DEFAULT_QUALITY_CONFIG: QualityConfig = {\n safetyMargin: 0.85,\n};\n\n/**\n * Select the best video track based on current bandwidth estimate.\n *\n * Selects the highest quality track where bandwidth is sufficient with safety margin:\n * - currentBandwidth >= track.bandwidth / safetyMargin\n * - Default safetyMargin 0.85 means track uses ≤85% of bandwidth (15% headroom)\n * - At same bandwidth, prefers higher resolution\n *\n * @param tracks - Available video tracks (can be unsorted)\n * @param currentBandwidth - Current bandwidth estimate in bits per second\n * @param config - Optional quality selection configuration\n * @returns Selected track, or undefined if no tracks available\n *\n * @example\n * const tracks = [\n * { id: '360p', bandwidth: 500_000, ... },\n * { id: '720p', bandwidth: 2_000_000, ... },\n * { id: '1080p', bandwidth: 4_000_000, ... },\n * ];\n *\n * // With 2.5 Mbps, selects 720p (1080p needs 4M/0.85 = 4.7 Mbps)\n * const selected = selectQuality(tracks, 2_500_000);\n */\nexport function selectQuality(\n tracks: PartiallyResolvedVideoTrack[],\n currentBandwidth: number,\n config: QualityConfig = DEFAULT_QUALITY_CONFIG\n): PartiallyResolvedVideoTrack | undefined {\n if (tracks.length === 0) {\n return undefined;\n }\n\n // Sort tracks by bandwidth (lowest first)\n const sortedTracks = tracks.slice().sort((a, b) => a.bandwidth - b.bandwidth);\n\n // Start with no selection\n let chosen: PartiallyResolvedVideoTrack | undefined;\n\n for (const track of sortedTracks) {\n // Check if we have enough bandwidth for this track with safety margin\n // Required bandwidth = track.bandwidth / safetyMargin\n const requiredBandwidth = track.bandwidth / config.safetyMargin;\n\n if (currentBandwidth >= requiredBandwidth) {\n // We can support this track - prefer it if better than current choice\n if (\n !chosen ||\n track.bandwidth > chosen.bandwidth ||\n (track.bandwidth === chosen.bandwidth && hasHigherResolution(track, chosen))\n ) {\n chosen = track;\n }\n }\n }\n\n // If no track fits with safety margin, fall back to lowest quality\n return chosen ?? sortedTracks[0];\n}\n\n/**\n * Check if track A has higher resolution than track B.\n * Compares by total pixel count (width × height).\n *\n * @param trackA - First track to compare\n * @param trackB - Second track to compare\n * @returns True if trackA has more pixels than trackB\n */\nfunction hasHigherResolution(trackA: PartiallyResolvedVideoTrack, trackB: PartiallyResolvedVideoTrack): boolean {\n const pixelsA = (trackA.width ?? 0) * (trackA.height ?? 0);\n const pixelsB = (trackB.width ?? 0) * (trackB.height ?? 0);\n return pixelsA > pixelsB;\n}\n"],"mappings":";;;;;AA8BA,MAAa,yBAAwC,EACnD,cAAc,KACf;;;;;;;;;;;;;;;;;;;;;;;;AAyBD,SAAgB,cACd,QACA,kBACA,SAAwB,wBACiB;AACzC,KAAI,OAAO,WAAW,EACpB;CAIF,MAAM,eAAe,OAAO,OAAO,CAAC,MAAM,GAAG,MAAM,EAAE,YAAY,EAAE,UAAU;CAG7E,IAAI;AAEJ,MAAK,MAAM,SAAS,aAKlB,KAAI,oBAFsB,MAAM,YAAY,OAAO,cAIjD;MACE,CAAC,UACD,MAAM,YAAY,OAAO,aACxB,MAAM,cAAc,OAAO,aAAa,oBAAoB,OAAO,OAAO,CAE3E,UAAS;;AAMf,QAAO,UAAU,aAAa;;;;;;;;;;AAWhC,SAAS,oBAAoB,QAAqC,QAA8C;AAG9G,SAFiB,OAAO,SAAS,MAAM,OAAO,UAAU,MACvC,OAAO,SAAS,MAAM,OAAO,UAAU"}
1
+ {"version":3,"file":"quality-selection.js","names":[],"sources":["../../../../src/core/abr/quality-selection.ts"],"sourcesContent":["/**\n * Quality Selection Algorithm\n *\n * Selects optimal video track based on bandwidth estimate with safety margin.\n * Stateless selection - picks highest quality that fits bandwidth.\n *\n * Key concepts:\n * - **Safety margin** (0.85): Pick track where bandwidth >= track.bandwidth / 0.85\n * - This ensures 15% headroom to avoid buffering\n * - At same bandwidth, prefer higher resolution\n */\n\nimport type { PartiallyResolvedVideoTrack } from '../types';\n\n/**\n * Quality selection configuration.\n */\nexport interface QualityConfig {\n /**\n * Safety margin (0-1).\n * To select a track, need: currentBandwidth >= track.bandwidth / safetyMargin.\n * Default 0.85 means track must use ≤85% of available bandwidth (15% headroom).\n */\n safetyMargin: number;\n}\n\n/**\n * Default quality selection configuration.\n * Values match Shaka Player upgrade threshold (0.85 = 15% headroom).\n */\nexport const DEFAULT_QUALITY_CONFIG: QualityConfig = {\n safetyMargin: 0.85,\n};\n\n/**\n * Select the best video track based on current bandwidth estimate.\n *\n * Selects the highest quality track where bandwidth is sufficient with safety margin:\n * - currentBandwidth >= track.bandwidth / safetyMargin\n * - Default safetyMargin 0.85 means track uses ≤85% of bandwidth (15% headroom)\n * - At same bandwidth, prefers higher resolution\n *\n * @param tracks - Available video tracks (can be unsorted)\n * @param currentBandwidth - Current bandwidth estimate in bits per second\n * @param config - Optional quality selection configuration\n * @returns Selected track, or undefined if no tracks available\n *\n * @example\n * const tracks = [\n * { id: '360p', bandwidth: 500_000, ... },\n * { id: '720p', bandwidth: 2_000_000, ... },\n * { id: '1080p', bandwidth: 4_000_000, ... },\n * ];\n *\n * // With 2.5 Mbps, selects 720p (1080p needs 4M/0.85 = 4.7 Mbps)\n * const selected = selectQuality(tracks, 2_500_000);\n */\nexport function selectQuality(\n tracks: PartiallyResolvedVideoTrack[],\n currentBandwidth: number,\n config: QualityConfig = DEFAULT_QUALITY_CONFIG\n): PartiallyResolvedVideoTrack | undefined {\n if (tracks.length === 0) {\n return undefined;\n }\n\n // Sort tracks by bandwidth (lowest first)\n const sortedTracks = tracks.slice().sort((a, b) => a.bandwidth - b.bandwidth);\n\n // Start with no selection\n let chosen: PartiallyResolvedVideoTrack | undefined;\n\n for (const track of sortedTracks) {\n // Check if we have enough bandwidth for this track with safety margin\n // Required bandwidth = track.bandwidth / safetyMargin\n const requiredBandwidth = track.bandwidth / config.safetyMargin;\n\n if (currentBandwidth >= requiredBandwidth) {\n // We can support this track - prefer it if better than current choice\n if (\n !chosen ||\n track.bandwidth > chosen.bandwidth ||\n (track.bandwidth === chosen.bandwidth && hasHigherResolution(track, chosen))\n ) {\n chosen = track;\n }\n }\n }\n\n // If no track fits with safety margin, fall back to lowest quality\n return chosen ?? sortedTracks[0];\n}\n\n/**\n * Check if track A has higher resolution than track B.\n * Compares by total pixel count (width × height).\n *\n * @param trackA - First track to compare\n * @param trackB - Second track to compare\n * @returns True if trackA has more pixels than trackB\n */\nfunction hasHigherResolution(trackA: PartiallyResolvedVideoTrack, trackB: PartiallyResolvedVideoTrack): boolean {\n const pixelsA = (trackA.width ?? 0) * (trackA.height ?? 0);\n const pixelsB = (trackB.width ?? 0) * (trackB.height ?? 0);\n return pixelsA > pixelsB;\n}\n"],"mappings":";;;;;AA8BA,MAAa,yBAAwC,EACnD,cAAc,KACf;;;;;;;;;;;;;;;;;;;;;;;;AAyBD,SAAgB,cACd,QACA,kBACA,SAAwB,wBACiB;AACzC,KAAI,OAAO,WAAW,EACpB;CAIF,MAAM,eAAe,OAAO,OAAO,CAAC,MAAM,GAAG,MAAM,EAAE,YAAY,EAAE,UAAU;CAG7E,IAAI;AAEJ,MAAK,MAAM,SAAS,aAKlB,KAAI,oBAFsB,MAAM,YAAY,OAAO;MAK/C,CAAC,UACD,MAAM,YAAY,OAAO,aACxB,MAAM,cAAc,OAAO,aAAa,oBAAoB,OAAO,OAAO,CAE3E,UAAS;;AAMf,QAAO,UAAU,aAAa;;;;;;;;;;AAWhC,SAAS,oBAAoB,QAAqC,QAA8C;AAG9G,SAFiB,OAAO,SAAS,MAAM,OAAO,UAAU,MACvC,OAAO,SAAS,MAAM,OAAO,UAAU"}
@@ -1,31 +1,18 @@
1
+ import { ReadonlySignal } from "./signals/primitives.js";
2
+
1
3
  //#region src/core/actor.d.ts
2
- /**
3
- * Generic actor types.
4
- *
5
- * An actor owns its snapshot (finite status + non-finite context) and
6
- * notifies observers when it changes. Mirrors the XState snapshot model:
7
- * `snapshot.status` is the bounded operational mode, `snapshot.context`
8
- * holds arbitrary non-finite data.
9
- */
10
4
  /** Complete actor snapshot: finite status + non-finite context. */
11
5
  interface ActorSnapshot<Status extends string, Context> {
12
6
  status: Status;
13
7
  context: Context;
14
8
  }
15
9
  /** Generic actor interface: owns its snapshot and notifies observers. */
16
- interface Actor<Status extends string, Context> {
17
- /** Current snapshot. */
18
- readonly snapshot: ActorSnapshot<Status, Context>;
19
- /**
20
- * Subscribe to snapshot changes. Fires immediately with the current
21
- * snapshot, then on every subsequent change.
22
- *
23
- * @returns Unsubscribe function.
24
- */
25
- subscribe(listener: (snapshot: ActorSnapshot<Status, Context>) => void): () => void;
10
+ interface SignalActor<Status extends string, Context> {
11
+ /** Current snapshot. Readable and reactive; not writable by consumers. */
12
+ readonly snapshot: ReadonlySignal<ActorSnapshot<Status, Context>>;
26
13
  /** Tear down the actor. */
27
14
  destroy(): void;
28
15
  }
29
16
  //#endregion
30
- export { Actor };
17
+ export { SignalActor };
31
18
  //# sourceMappingURL=actor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"actor.d.ts","names":[],"sources":["../../../src/core/actor.ts"],"mappings":";;AAUA;;;;;;;;UAAiB,aAAA;EACf,MAAA,EAAQ,MAAA;EACR,OAAA,EAAS,OAAA;AAAA;AAIX;AAAA,UAAiB,KAAA;EAAK;EAAA,SAEX,QAAA,EAAU,aAAA,CAAc,MAAA,EAAQ,OAAA;EAAA;;;;;;EAQzC,SAAA,CAAU,QAAA,GAAW,QAAA,EAAU,aAAA,CAAc,MAAA,EAAQ,OAAA;EAVhC;EAarB,OAAA;AAAA"}
1
+ {"version":3,"file":"actor.d.ts","names":[],"sources":["../../../src/core/actor.ts"],"mappings":";;;;UAYiB,aAAA;EACf,MAAA,EAAQ,MAAA;EACR,OAAA,EAAS,OAAA;AAAA;;UAqBM,WAAA;EAEyC;EAAA,SAA/C,QAAA,EAAU,cAAA,CAAe,aAAA,CAAc,MAAA,EAAQ,OAAA;EAEjD;EAAP,OAAA;AAAA"}
@@ -40,7 +40,7 @@ function calculateBackBufferFlushPoint(segments, currentTime, config = DEFAULT_B
40
40
  if (segmentsToFlush >= segmentsBefore.length) return currentTime;
41
41
  return segmentsBefore[segmentsToFlush].startTime;
42
42
  }
43
-
44
43
  //#endregion
45
- export { DEFAULT_BACK_BUFFER_CONFIG, calculateBackBufferFlushPoint };
44
+ export { calculateBackBufferFlushPoint };
45
+
46
46
  //# sourceMappingURL=back-buffer.js.map
@@ -67,7 +67,7 @@ function getSegmentsToLoad(segments, bufferedSegments, currentTime, config = DEF
67
67
  return isInRange && isNotBuffered;
68
68
  });
69
69
  }
70
-
71
70
  //#endregion
72
71
  export { DEFAULT_FORWARD_BUFFER_CONFIG, calculateForwardFlushPoint, getSegmentsToLoad };
72
+
73
73
  //# sourceMappingURL=forward-buffer.js.map
@@ -1,7 +1,6 @@
1
+ import { effect } from "../signals/effect.js";
1
2
  import { isResolvedTrack } from "../types/index.js";
2
- import { combineLatest } from "../reactive/combine-latest.js";
3
3
  import { getSelectedTrack } from "../utils/track-selection.js";
4
-
5
4
  //#region src/core/features/calculate-presentation-duration.ts
6
5
  /**
7
6
  * Check if we can calculate presentation duration (have required data).
@@ -34,18 +33,21 @@ function getDurationFromResolvedTracks(state) {
34
33
  * Calculate and set presentation duration from resolved tracks.
35
34
  */
36
35
  function calculatePresentationDuration({ state }) {
37
- return combineLatest([state]).subscribe(([currentState]) => {
36
+ return effect(() => {
37
+ const currentState = state.get();
38
38
  if (!shouldCalculateDuration(currentState)) return;
39
39
  const duration = getDurationFromResolvedTracks(currentState);
40
40
  if (duration === void 0 || !Number.isFinite(duration)) return;
41
- const { presentation } = currentState;
42
- state.patch({ presentation: {
43
- ...presentation,
44
- duration
45
- } });
41
+ state.set({
42
+ ...currentState,
43
+ presentation: {
44
+ ...currentState.presentation,
45
+ duration
46
+ }
47
+ });
46
48
  });
47
49
  }
48
-
49
50
  //#endregion
50
51
  export { calculatePresentationDuration };
52
+
51
53
  //# sourceMappingURL=calculate-presentation-duration.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"calculate-presentation-duration.js","names":[],"sources":["../../../../src/core/features/calculate-presentation-duration.ts"],"sourcesContent":["import { combineLatest } from '../reactive/combine-latest';\nimport type { WritableState } from '../state/create-state';\nimport type { AudioTrack, Presentation, VideoTrack } from '../types';\nimport { isResolvedTrack } from '../types';\nimport { getSelectedTrack } from '../utils/track-selection';\n\nexport interface PresentationDurationState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * Check if we can calculate presentation duration (have required data).\n */\nexport function canCalculateDuration(state: PresentationDurationState): boolean {\n if (!state.presentation) return false;\n // Need at least one selected track\n return !!(state.selectedVideoTrackId || state.selectedAudioTrackId);\n}\n\n/**\n * Check if we should calculate presentation duration (conditions met).\n */\nexport function shouldCalculateDuration(state: PresentationDurationState): boolean {\n if (!canCalculateDuration(state)) return false;\n\n const { presentation } = state;\n\n // Don't recalculate if already set\n if (presentation!.duration !== undefined) return false;\n\n // Check if any selected track is resolved\n const videoTrack = state.selectedVideoTrackId ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = state.selectedAudioTrackId ? getSelectedTrack(state, 'audio') : undefined;\n\n // At least one track must be resolved (has segments and duration)\n return !!((videoTrack && isResolvedTrack(videoTrack)) || (audioTrack && isResolvedTrack(audioTrack)));\n}\n\n/**\n * Get duration from the first resolved track (prefer video, fallback to audio).\n */\nexport function getDurationFromResolvedTracks(state: PresentationDurationState): number | undefined {\n // Try video track first\n const videoTrack = state.selectedVideoTrackId\n ? (getSelectedTrack(state, 'video') as VideoTrack | undefined)\n : undefined;\n if (videoTrack && isResolvedTrack(videoTrack)) {\n return videoTrack.duration;\n }\n\n // Fallback to audio track\n const audioTrack = state.selectedAudioTrackId\n ? (getSelectedTrack(state, 'audio') as AudioTrack | undefined)\n : undefined;\n if (audioTrack && isResolvedTrack(audioTrack)) {\n return audioTrack.duration;\n }\n\n return undefined;\n}\n\n/**\n * Calculate and set presentation duration from resolved tracks.\n */\nexport function calculatePresentationDuration({\n state,\n}: {\n state: WritableState<PresentationDurationState>;\n}): () => void {\n return combineLatest([state]).subscribe(([currentState]: [PresentationDurationState]) => {\n if (!shouldCalculateDuration(currentState)) return;\n\n const duration = getDurationFromResolvedTracks(currentState);\n if (duration === undefined || !Number.isFinite(duration)) return;\n\n const { presentation } = currentState;\n\n // Patch presentation with duration\n state.patch({\n presentation: {\n ...presentation!,\n duration,\n },\n });\n });\n}\n"],"mappings":";;;;;;;;AAeA,SAAgB,qBAAqB,OAA2C;AAC9E,KAAI,CAAC,MAAM,aAAc,QAAO;AAEhC,QAAO,CAAC,EAAE,MAAM,wBAAwB,MAAM;;;;;AAMhD,SAAgB,wBAAwB,OAA2C;AACjF,KAAI,CAAC,qBAAqB,MAAM,CAAE,QAAO;CAEzC,MAAM,EAAE,iBAAiB;AAGzB,KAAI,aAAc,aAAa,OAAW,QAAO;CAGjD,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG;CACnF,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG;AAGnF,QAAO,CAAC,EAAG,cAAc,gBAAgB,WAAW,IAAM,cAAc,gBAAgB,WAAW;;;;;AAMrG,SAAgB,8BAA8B,OAAsD;CAElG,MAAM,aAAa,MAAM,uBACpB,iBAAiB,OAAO,QAAQ,GACjC;AACJ,KAAI,cAAc,gBAAgB,WAAW,CAC3C,QAAO,WAAW;CAIpB,MAAM,aAAa,MAAM,uBACpB,iBAAiB,OAAO,QAAQ,GACjC;AACJ,KAAI,cAAc,gBAAgB,WAAW,CAC3C,QAAO,WAAW;;;;;AAStB,SAAgB,8BAA8B,EAC5C,SAGa;AACb,QAAO,cAAc,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,kBAA+C;AACvF,MAAI,CAAC,wBAAwB,aAAa,CAAE;EAE5C,MAAM,WAAW,8BAA8B,aAAa;AAC5D,MAAI,aAAa,UAAa,CAAC,OAAO,SAAS,SAAS,CAAE;EAE1D,MAAM,EAAE,iBAAiB;AAGzB,QAAM,MAAM,EACV,cAAc;GACZ,GAAG;GACH;GACD,EACF,CAAC;GACF"}
1
+ {"version":3,"file":"calculate-presentation-duration.js","names":[],"sources":["../../../../src/core/features/calculate-presentation-duration.ts"],"sourcesContent":["import { effect } from '../signals/effect';\nimport type { Signal } from '../signals/primitives';\nimport type { AudioTrack, Presentation, VideoTrack } from '../types';\nimport { isResolvedTrack } from '../types';\nimport { getSelectedTrack } from '../utils/track-selection';\n\nexport interface PresentationDurationState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * Check if we can calculate presentation duration (have required data).\n */\nexport function canCalculateDuration(state: PresentationDurationState): boolean {\n if (!state.presentation) return false;\n // Need at least one selected track\n return !!(state.selectedVideoTrackId || state.selectedAudioTrackId);\n}\n\n/**\n * Check if we should calculate presentation duration (conditions met).\n */\nexport function shouldCalculateDuration(state: PresentationDurationState): boolean {\n if (!canCalculateDuration(state)) return false;\n\n const { presentation } = state;\n\n // Don't recalculate if already set\n if (presentation!.duration !== undefined) return false;\n\n // Check if any selected track is resolved\n const videoTrack = state.selectedVideoTrackId ? getSelectedTrack(state, 'video') : undefined;\n const audioTrack = state.selectedAudioTrackId ? getSelectedTrack(state, 'audio') : undefined;\n\n // At least one track must be resolved (has segments and duration)\n return !!((videoTrack && isResolvedTrack(videoTrack)) || (audioTrack && isResolvedTrack(audioTrack)));\n}\n\n/**\n * Get duration from the first resolved track (prefer video, fallback to audio).\n */\nexport function getDurationFromResolvedTracks(state: PresentationDurationState): number | undefined {\n // Try video track first\n const videoTrack = state.selectedVideoTrackId\n ? (getSelectedTrack(state, 'video') as VideoTrack | undefined)\n : undefined;\n if (videoTrack && isResolvedTrack(videoTrack)) {\n return videoTrack.duration;\n }\n\n // Fallback to audio track\n const audioTrack = state.selectedAudioTrackId\n ? (getSelectedTrack(state, 'audio') as AudioTrack | undefined)\n : undefined;\n if (audioTrack && isResolvedTrack(audioTrack)) {\n return audioTrack.duration;\n }\n\n return undefined;\n}\n\n/**\n * Calculate and set presentation duration from resolved tracks.\n */\nexport function calculatePresentationDuration<S extends PresentationDurationState>({\n state,\n}: {\n state: Signal<S>;\n}): () => void {\n return effect(() => {\n const currentState = state.get();\n if (!shouldCalculateDuration(currentState)) return;\n\n const duration = getDurationFromResolvedTracks(currentState);\n if (duration === undefined || !Number.isFinite(duration)) return;\n\n state.set({\n ...currentState,\n presentation: {\n ...currentState.presentation!,\n duration,\n },\n } as S);\n });\n}\n"],"mappings":";;;;;;;AAeA,SAAgB,qBAAqB,OAA2C;AAC9E,KAAI,CAAC,MAAM,aAAc,QAAO;AAEhC,QAAO,CAAC,EAAE,MAAM,wBAAwB,MAAM;;;;;AAMhD,SAAgB,wBAAwB,OAA2C;AACjF,KAAI,CAAC,qBAAqB,MAAM,CAAE,QAAO;CAEzC,MAAM,EAAE,iBAAiB;AAGzB,KAAI,aAAc,aAAa,KAAA,EAAW,QAAO;CAGjD,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;CACnF,MAAM,aAAa,MAAM,uBAAuB,iBAAiB,OAAO,QAAQ,GAAG,KAAA;AAGnF,QAAO,CAAC,EAAG,cAAc,gBAAgB,WAAW,IAAM,cAAc,gBAAgB,WAAW;;;;;AAMrG,SAAgB,8BAA8B,OAAsD;CAElG,MAAM,aAAa,MAAM,uBACpB,iBAAiB,OAAO,QAAQ,GACjC,KAAA;AACJ,KAAI,cAAc,gBAAgB,WAAW,CAC3C,QAAO,WAAW;CAIpB,MAAM,aAAa,MAAM,uBACpB,iBAAiB,OAAO,QAAQ,GACjC,KAAA;AACJ,KAAI,cAAc,gBAAgB,WAAW,CAC3C,QAAO,WAAW;;;;;AAStB,SAAgB,8BAAmE,EACjF,SAGa;AACb,QAAO,aAAa;EAClB,MAAM,eAAe,MAAM,KAAK;AAChC,MAAI,CAAC,wBAAwB,aAAa,CAAE;EAE5C,MAAM,WAAW,8BAA8B,aAAa;AAC5D,MAAI,aAAa,KAAA,KAAa,CAAC,OAAO,SAAS,SAAS,CAAE;AAE1D,QAAM,IAAI;GACR,GAAG;GACH,cAAc;IACZ,GAAG,aAAa;IAChB;IACD;GACF,CAAM;GACP"}
@@ -1,6 +1,7 @@
1
1
  import { getBandwidthEstimate } from "../abr/bandwidth-estimator.js";
2
+ import { effect } from "../signals/effect.js";
3
+ import { update } from "../signals/primitives.js";
2
4
  import { selectQuality } from "../abr/quality-selection.js";
3
-
4
5
  //#region src/core/features/quality-switching.ts
5
6
  /**
6
7
  * Default quality switching configuration.
@@ -43,8 +44,8 @@ function switchQuality({ state }, config = {}) {
43
44
  const defaultBandwidth = config.defaultBandwidth ?? DEFAULT_SWITCHING_CONFIG.defaultBandwidth;
44
45
  let lastUpgradeTime = Date.now();
45
46
  let firstMeaningfulFire = true;
46
- return state.subscribe((currentState) => {
47
- const { presentation, bandwidthState, selectedVideoTrackId, abrDisabled } = currentState;
47
+ return effect(() => {
48
+ const { presentation, bandwidthState, selectedVideoTrackId, abrDisabled } = state.get();
48
49
  if (abrDisabled === true) return;
49
50
  if (!presentation || !bandwidthState) return;
50
51
  const videoTracks = getVideoTracks(presentation);
@@ -59,10 +60,10 @@ function switchQuality({ state }, config = {}) {
59
60
  if (!isFirst && now - lastUpgradeTime < minUpgradeInterval) return;
60
61
  lastUpgradeTime = now;
61
62
  }
62
- state.patch({ selectedVideoTrackId: optimal.id });
63
+ update(state, { selectedVideoTrackId: optimal.id });
63
64
  });
64
65
  }
65
-
66
66
  //#endregion
67
- export { DEFAULT_SWITCHING_CONFIG, switchQuality };
67
+ export { switchQuality };
68
+
68
69
  //# sourceMappingURL=quality-switching.js.map
@@ -1 +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"}
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 { effect } from '../signals/effect';\nimport { type Signal, update } from '../signals/primitives';\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<S extends QualitySwitchingState>(\n { state }: { state: Signal<S> },\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 effect(() => {\n const currentState = state.get();\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 const patch: Partial<QualitySwitchingState> = { selectedVideoTrackId: optimal.id };\n update(state, patch);\n });\n}\n"],"mappings":";;;;;;;;AAoDA,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,aAAa;EAElB,MAAM,EAAE,cAAc,gBAAgB,sBAAsB,gBADvC,MAAM,KAAK;AAGhC,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;;AAIpB,SAAO,OADuC,EAAE,sBAAsB,QAAQ,IAAI,CAC9D;GACpB"}
@@ -1,7 +1,7 @@
1
- import { parseMultivariantPlaylist } from "../hls/parse-multivariant.js";
1
+ import { effect } from "../signals/effect.js";
2
+ import { computed, update } from "../signals/primitives.js";
2
3
  import { fetchResolvable, getResponseText } from "../../dom/network/fetch.js";
3
- import { combineLatest } from "../reactive/combine-latest.js";
4
-
4
+ import { parseMultivariantPlaylist } from "../hls/parse-multivariant.js";
5
5
  //#region src/core/features/resolve-presentation.ts
6
6
  /**
7
7
  * Type guard to check if presentation is unresolved.
@@ -13,87 +13,65 @@ function canResolve(state) {
13
13
  return isUnresolved(state.presentation);
14
14
  }
15
15
  /**
16
- * Determines if resolution conditions are met based on preload policy and event.
16
+ * Determines if resolution conditions are met based on preload policy and playback state.
17
17
  *
18
18
  * Resolution conditions:
19
19
  * - State-driven: preload is 'auto' or 'metadata'
20
- * - Event-driven: play event
20
+ * - Playback-driven: playbackInitiated is true
21
21
  *
22
22
  * @param state - Current presentation state
23
- * @param event - Current action/event
24
23
  * @returns true if resolution conditions are met
25
24
  */
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
- });
25
+ function shouldResolve(state) {
26
+ const { preload, playbackInitiated } = state;
27
+ return ["auto", "metadata"].includes(preload) || !!playbackInitiated;
46
28
  }
47
29
  /**
48
30
  * Resolves unresolved presentations using reactive composition.
49
31
  *
50
- * Uses combineLatest to compose state + events, enabling both state-driven
51
- * and event-driven resolution triggers.
52
- *
53
32
  * Triggers resolution when:
54
33
  * - State-driven: Unresolved presentation + preload allows (auto/metadata)
55
- * - Event-driven: PLAY event when preload="none"
34
+ * - Playback-driven: playbackInitiated is true
56
35
  *
57
36
  * @example
58
37
  * ```ts
59
- * const state = createState({ presentation: undefined, preload: 'auto' });
60
- * const events = createEventStream<PresentationAction>();
38
+ * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false });
61
39
  *
62
- * const cleanup = resolvePresentation({ state, events });
40
+ * const cleanup = resolvePresentation({ state });
63
41
  *
64
42
  * // State-driven: resolves immediately when preload allows
65
- * state.patch({ presentation: { url: 'http://example.com/playlist.m3u8' } });
43
+ * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } });
66
44
  *
67
- * // Event-driven: resolves on PLAY when preload="none"
68
- * state.patch({ preload: 'none', presentation: { url: '...' } });
69
- * events.dispatch({ type: 'PLAY' });
45
+ * // Playback-driven: resolves when playbackInitiated is set
46
+ * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true });
70
47
  * ```
71
48
  */
72
- function resolvePresentation({ state, events }) {
49
+ function resolvePresentation({ state }) {
50
+ const canResolveSignal = computed(() => canResolve(state.get()));
51
+ const shouldResolveSignal = computed(() => shouldResolve(state.get()));
73
52
  let resolving = false;
74
53
  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) {
54
+ const cleanupEffect = effect(() => {
55
+ if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return;
56
+ const presentation = state.get().presentation;
57
+ resolving = true;
58
+ abortController = new AbortController();
59
+ fetchResolvable(presentation, { signal: abortController.signal }).then((response) => getResponseText(response)).then((text) => {
60
+ update(state, { presentation: parseMultivariantPlaylist(text, presentation) });
61
+ }).catch((error) => {
84
62
  if (error instanceof Error && error.name === "AbortError") return;
85
63
  throw error;
86
- } finally {
64
+ }).finally(() => {
87
65
  resolving = false;
88
66
  abortController = null;
89
- }
67
+ });
90
68
  });
91
69
  return () => {
92
70
  abortController?.abort();
93
- cleanup();
71
+ cleanupEffect();
94
72
  };
95
73
  }
96
-
97
74
  //#endregion
98
- export { canResolve, isUnresolved, resolvePresentation, shouldResolve, syncPreloadAttribute };
75
+ export { resolvePresentation };
76
+
99
77
  //# sourceMappingURL=resolve-presentation.js.map
@@ -1 +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"}
1
+ {"version":3,"file":"resolve-presentation.js","names":[],"sources":["../../../../src/core/features/resolve-presentation.ts"],"sourcesContent":["import { fetchResolvable, getResponseText } from '../../dom/network/fetch';\nimport { parseMultivariantPlaylist } from '../hls/parse-multivariant';\nimport { effect } from '../signals/effect';\nimport { computed, type Signal, update } from '../signals/primitives';\nimport type { AddressableObject, 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 /** True once the user has initiated playback — enables resolution regardless of preload. */\n playbackInitiated?: boolean;\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 playback state.\n *\n * Resolution conditions:\n * - State-driven: preload is 'auto' or 'metadata'\n * - Playback-driven: playbackInitiated is true\n *\n * @param state - Current presentation state\n * @returns true if resolution conditions are met\n */\nexport function shouldResolve(state: PresentationState): boolean {\n const { preload, playbackInitiated } = state;\n return (\n // State-driven: preload allows (auto/metadata)\n ['auto', 'metadata'].includes(preload as any) ||\n // Playback-driven: user has initiated playback\n !!playbackInitiated\n );\n}\n\n/**\n * Resolves unresolved presentations using reactive composition.\n *\n * Triggers resolution when:\n * - State-driven: Unresolved presentation + preload allows (auto/metadata)\n * - Playback-driven: playbackInitiated is true\n *\n * @example\n * ```ts\n * const state = signal({ presentation: undefined, preload: 'auto', playbackInitiated: false });\n *\n * const cleanup = resolvePresentation({ state });\n *\n * // State-driven: resolves immediately when preload allows\n * state.set({ ...state.get(), presentation: { url: 'http://example.com/playlist.m3u8' } });\n *\n * // Playback-driven: resolves when playbackInitiated is set\n * state.set({ ...state.get(), preload: 'none', presentation: { url: '...' }, playbackInitiated: true });\n * ```\n */\nexport function resolvePresentation<S extends PresentationState>({ state }: { state: Signal<S> }): () => void {\n const canResolveSignal = computed(() => canResolve(state.get()));\n const shouldResolveSignal = computed(() => shouldResolve(state.get()));\n\n let resolving = false;\n let abortController: AbortController | null = null;\n\n const cleanupEffect = effect(() => {\n if (!canResolveSignal.get() || !shouldResolveSignal.get() || resolving) return;\n\n const presentation = state.get().presentation as UnresolvedPresentation;\n resolving = true;\n abortController = new AbortController();\n\n fetchResolvable(presentation, { signal: abortController.signal })\n .then((response) => getResponseText(response))\n .then((text) => {\n const parsed = parseMultivariantPlaylist(text, presentation);\n const patch: Partial<PresentationState> = { presentation: parsed };\n update(state, patch);\n })\n .catch((error) => {\n if (error instanceof Error && error.name === 'AbortError') return;\n throw error;\n })\n .finally(() => {\n resolving = false;\n abortController = null;\n });\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;AAyBA,SAAgB,aACd,cACwC;AACxC,QAAO,iBAAiB,KAAA,KAAa,SAAS,gBAAgB,EAAE,QAAQ;;AAG1E,SAAgB,WACd,OACuE;AACvE,QAAO,aAAa,MAAM,aAAa;;;;;;;;;;;;AAazC,SAAgB,cAAc,OAAmC;CAC/D,MAAM,EAAE,SAAS,sBAAsB;AACvC,QAEE,CAAC,QAAQ,WAAW,CAAC,SAAS,QAAe,IAE7C,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;AAwBN,SAAgB,oBAAiD,EAAE,SAA2C;CAC5G,MAAM,mBAAmB,eAAe,WAAW,MAAM,KAAK,CAAC,CAAC;CAChE,MAAM,sBAAsB,eAAe,cAAc,MAAM,KAAK,CAAC,CAAC;CAEtE,IAAI,YAAY;CAChB,IAAI,kBAA0C;CAE9C,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,iBAAiB,KAAK,IAAI,CAAC,oBAAoB,KAAK,IAAI,UAAW;EAExE,MAAM,eAAe,MAAM,KAAK,CAAC;AACjC,cAAY;AACZ,oBAAkB,IAAI,iBAAiB;AAEvC,kBAAgB,cAAc,EAAE,QAAQ,gBAAgB,QAAQ,CAAC,CAC9D,MAAM,aAAa,gBAAgB,SAAS,CAAC,CAC7C,MAAM,SAAS;AAGd,UAAO,OADmC,EAAE,cAD7B,0BAA0B,MAAM,aAAa,EACM,CAC9C;IACpB,CACD,OAAO,UAAU;AAChB,OAAI,iBAAiB,SAAS,MAAM,SAAS,aAAc;AAC3D,SAAM;IACN,CACD,cAAc;AACb,eAAY;AACZ,qBAAkB;IAClB;GACJ;AAEF,cAAa;AACX,mBAAiB,OAAO;AACxB,iBAAe"}