@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":"load-text-track-cues.js","names":[],"sources":["../../../../src/dom/features/load-text-track-cues.ts"],"sourcesContent":["import { getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport { combineLatest } from '../../core/reactive/combine-latest';\nimport type { WritableState } from '../../core/state/create-state';\nimport type { Presentation, Segment, TextTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { parseVttSegment } from '../text/parse-vtt-segment';\n\nfunction isDuplicateCue(cue: VTTCue, textTrack: globalThis.TextTrack): boolean {\n const { cues } = textTrack;\n if (!cues) return false;\n for (let i = 0; i < cues.length; i++) {\n const existing = cues[i] as VTTCue;\n if (existing.startTime === cue.startTime && existing.endTime === cue.endTime && existing.text === cue.text) {\n return true;\n }\n }\n return false;\n}\n\nconst loadVttSegmentTask = async (\n { segment }: { segment: Segment },\n context: { textTrack: globalThis.TextTrack }\n): Promise<void> => {\n const cues = await parseVttSegment(segment.url);\n for (const cue of cues) {\n if (!isDuplicateCue(cue, context.textTrack)) {\n context.textTrack.addCue(cue);\n }\n }\n};\n\n// ============================================================================\n// MAIN TASK (composite - orchestrates subtasks)\n// ============================================================================\n\n/**\n * Load text track cues task (composite - orchestrates VTT segment subtasks).\n */\nconst loadTextTrackCuesTask = async (\n { currentState }: { currentState: TextTrackCueLoadingState },\n context: {\n signal: AbortSignal;\n textTrack: globalThis.TextTrack;\n state: WritableState<TextTrackCueLoadingState>;\n }\n): Promise<void> => {\n const track = findSelectedTextTrack(currentState);\n if (!track || !isResolvedTrack(track)) return;\n\n const { segments } = track;\n if (segments.length === 0) return;\n\n const trackId = track.id;\n\n // Resolve segments already recorded in the state model for this track.\n // Keyed by track ID so multiple text tracks don't interfere with each other.\n const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id));\n const alreadyLoaded = segments.filter((s) => loadedIds.has(s.id));\n\n // Apply the same forward buffer window as audio/video segment loading.\n const currentTime = currentState.currentTime ?? 0;\n const segmentsToLoad = getSegmentsToLoad(segments, alreadyLoaded, currentTime).filter((s) => !loadedIds.has(s.id));\n\n if (segmentsToLoad.length === 0) return;\n\n // Execute subtasks sequentially, recording each loaded segment in state.\n for (const segment of segmentsToLoad) {\n if (context.signal.aborted) break;\n\n try {\n await loadVttSegmentTask({ segment }, { textTrack: context.textTrack });\n\n // Record the loaded segment in shared state — mirrors bufferState for\n // audio/video and supports N text tracks keyed by track ID.\n const latest = context.state.current.textBufferState ?? {};\n const trackState = latest[trackId] ?? { segments: [] };\n context.state.patch({\n textBufferState: {\n ...latest,\n [trackId]: { segments: [...trackState.segments, { id: segment.id }] },\n },\n });\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') break;\n console.error('Failed to load VTT segment:', error);\n // Continue to next segment (graceful degradation)\n }\n }\n\n // Chrome bug: after a track goes through mode='disabled' (which clears cues) and back\n // to 'showing', cues added to the track aren't activated. Re-adding all cues forces\n // Chrome to re-process them. Safe no-op in other browsers.\n // Mirrors the workaround in HlsMediaTextTracksMixin (packages/core/src/dom/media/hls/text-tracks.ts).\n if (context.textTrack.mode === 'showing' && context.textTrack.cues) {\n Array.from(context.textTrack.cues).forEach((cue) => {\n context.textTrack.addCue(cue);\n });\n }\n\n // Wait a frame before completing to allow state updates to flush\n await new Promise((resolve) => requestAnimationFrame(resolve));\n};\n\n// ============================================================================\n// STATE & OWNERS\n// ============================================================================\n\n/**\n * Loaded-segment record for a single text track.\n */\nexport interface TextTrackSegmentState {\n segments: Array<{ id: string }>;\n}\n\n/**\n * Buffer model for text track cues — keyed by track ID.\n *\n * Using a per-track-ID map (rather than fixed 'video'/'audio' keys) because\n * there can be N text tracks — one per language/subtitle variant.\n */\nexport type TextTrackBufferState = Record<string, TextTrackSegmentState>;\n\n/**\n * State shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingState {\n selectedTextTrackId?: string;\n presentation?: Presentation;\n /** Current playback position — used to gate VTT segment fetching to the forward buffer window. */\n currentTime?: number;\n /** Loaded-segment model for text tracks, keyed by track ID. */\n textBufferState?: TextTrackBufferState;\n}\n\n/**\n * Owners shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingOwners {\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Find the selected text track in the presentation.\n */\nfunction findSelectedTextTrack(state: TextTrackCueLoadingState): TextTrack | undefined {\n if (!state.presentation || !state.selectedTextTrackId) {\n return undefined;\n }\n\n const textSet = state.presentation.selectionSets.find((set) => set.type === 'text');\n if (!textSet?.switchingSets?.[0]?.tracks) {\n return undefined;\n }\n\n const track = textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId);\n\n return track as TextTrack | undefined;\n}\n\n/**\n * Get the browser's TextTrack object for the selected text track.\n *\n * Retrieves the live TextTrack interface from the track element in owners,\n * which is used for adding cues, checking mode, and managing track state.\n *\n * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track),\n * not the presentation Track metadata type.\n *\n * @param state - Current playback state (track selection)\n * @param owners - DOM owners containing track elements map\n * @returns DOM TextTrack interface or undefined if not found\n */\nfunction getSelectedTextTrackFromOwners(\n state: TextTrackCueLoadingState,\n owners: TextTrackCueLoadingOwners\n): globalThis.TextTrack | undefined {\n const trackId = state.selectedTextTrackId;\n if (!trackId || !owners.textTracks) {\n return undefined;\n }\n\n const trackElement = owners.textTracks.get(trackId);\n return trackElement?.track;\n}\n\n/**\n * Check if we can load text track cues.\n *\n * Requires:\n * - Selected text track ID exists\n * - Track elements map exists\n * - Track element exists for selected track\n */\nexport function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId);\n}\n\n/**\n * Check if we should load text track cues.\n *\n * Only load if:\n * - Track is resolved (has segments)\n * - Track has at least one segment\n * - Track element exists\n */\nexport function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n if (!canLoadTextTrackCues(state, owners)) {\n return false;\n }\n\n const track = findSelectedTextTrack(state);\n if (!track || !isResolvedTrack(track) || track.segments.length === 0) {\n return false;\n }\n\n const textTrack = getSelectedTextTrackFromOwners(state, owners);\n if (!textTrack) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Load text track cues orchestration.\n *\n * Triggers when:\n * - Text track is selected\n * - Track is resolved (has segments)\n * - Track element exists\n *\n * Fetches and parses VTT segments within the forward buffer window, then adds\n * cues to the track incrementally. Continues on segment errors to provide\n * partial subtitles.\n *\n * @example\n * const cleanup = loadTextTrackCues({ state, owners });\n */\nexport function loadTextTrackCues({\n state,\n owners,\n}: {\n state: WritableState<TextTrackCueLoadingState>;\n owners: WritableState<TextTrackCueLoadingOwners>;\n}): () => void {\n let currentTask: Promise<void> | null = null;\n let abortController: AbortController | null = null;\n let lastTrackId: string | undefined;\n\n const cleanup = combineLatest([state, owners]).subscribe(\n async ([currentState, currentOwners]: [TextTrackCueLoadingState, TextTrackCueLoadingOwners]) => {\n // Abort any in-progress task when the selected track changes.\n // The new track's textBufferState entry will be empty, so the task\n // naturally starts fresh without needing any explicit reset.\n if (currentState.selectedTextTrackId !== lastTrackId) {\n lastTrackId = currentState.selectedTextTrackId;\n abortController?.abort();\n currentTask = null;\n }\n\n if (currentTask) return; // Task already in progress\n if (!shouldLoadTextTrackCues(currentState, currentOwners)) return;\n\n const textTrack = getSelectedTextTrackFromOwners(currentState, currentOwners);\n if (!textTrack) return;\n\n // Create abort controller and invoke task\n abortController = new AbortController();\n currentTask = loadTextTrackCuesTask(\n { currentState },\n { signal: abortController.signal, textTrack, state }\n ).finally(() => {\n currentTask = null;\n });\n }\n );\n\n return () => {\n abortController?.abort();\n cleanup();\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"load-text-track-cues.js","names":[],"sources":["../../../../src/dom/features/load-text-track-cues.ts"],"sourcesContent":["import { getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport { effect } from '../../core/signals/effect';\nimport { computed, type Signal } from '../../core/signals/primitives';\nimport type { Presentation, Segment, TextTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { parseVttSegment } from '../text/parse-vtt-segment';\n\nconst CueKeys = ['startTime', 'endTime', 'text'] as const;\nfunction isDuplicateCue(cue: VTTCue, existingCues: globalThis.TextTrack['cues']): boolean {\n return Array.prototype.some.call(existingCues ?? [], (existingCue) => {\n return CueKeys.every((k) => existingCue[k] === cue[k]);\n });\n}\n\nconst loadVttSegmentTask = async (\n { segment }: { segment: Segment },\n { textTrack }: { textTrack: globalThis.TextTrack }\n): Promise<void> => {\n const cues = await parseVttSegment(segment.url);\n cues.forEach((cue) => {\n if (isDuplicateCue(cue, textTrack.cues)) return;\n textTrack.addCue(cue);\n });\n};\n\n// ============================================================================\n// MAIN TASK (composite - orchestrates subtasks)\n// ============================================================================\n\n/**\n * Load text track cues task (composite - orchestrates VTT segment subtasks).\n */\nconst loadTextTrackCuesTask = async <S extends TextTrackCueLoadingState>(\n { currentState }: { currentState: S },\n context: {\n signal: AbortSignal;\n textTrack: globalThis.TextTrack;\n state: Signal<S>;\n }\n): Promise<void> => {\n const track = findSelectedTextTrack(currentState);\n if (!track || !isResolvedTrack(track)) return;\n\n const { segments } = track;\n if (segments.length === 0) return;\n\n const trackId = track.id;\n\n // Resolve segments already recorded in the state model for this track.\n // Keyed by track ID so multiple text tracks don't interfere with each other.\n const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id));\n const alreadyLoaded = segments.filter((s) => loadedIds.has(s.id));\n\n // Apply the same forward buffer window as audio/video segment loading.\n const currentTime = currentState.currentTime ?? 0;\n const segmentsToLoad = getSegmentsToLoad(segments, alreadyLoaded, currentTime).filter((s) => !loadedIds.has(s.id));\n\n if (segmentsToLoad.length === 0) return;\n\n // Execute subtasks sequentially, recording each loaded segment in state.\n for (const segment of segmentsToLoad) {\n if (context.signal.aborted) break;\n\n try {\n await loadVttSegmentTask({ segment }, { textTrack: context.textTrack });\n\n // Record the loaded segment in shared state — mirrors bufferState for\n // audio/video and supports N text tracks keyed by track ID.\n const latestState = context.state.get();\n const latest = latestState.textBufferState ?? {};\n const trackState = latest[trackId] ?? { segments: [] };\n context.state.set({\n ...latestState,\n textBufferState: {\n ...latest,\n [trackId]: { segments: [...trackState.segments, { id: segment.id }] },\n },\n } as S);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') break;\n console.error('Failed to load VTT segment:', error);\n // Continue to next segment (graceful degradation)\n }\n }\n\n // Chrome bug: after a track goes through mode='disabled' (which clears cues) and back\n // to 'showing', cues added to the track aren't activated. Re-adding all cues forces\n // Chrome to re-process them. Safe no-op in other browsers.\n // Mirrors the workaround in HlsMediaTextTracksMixin (packages/core/src/dom/media/hls/text-tracks.ts).\n if (context.textTrack.mode === 'showing' && context.textTrack.cues) {\n Array.from(context.textTrack.cues).forEach((cue) => {\n context.textTrack.addCue(cue);\n });\n }\n\n // Wait a frame before completing to allow state updates to flush\n await new Promise((resolve) => requestAnimationFrame(resolve));\n};\n\n// ============================================================================\n// STATE & OWNERS\n// ============================================================================\n\n/**\n * Loaded-segment record for a single text track.\n */\nexport interface TextTrackSegmentState {\n segments: Array<{ id: string }>;\n}\n\n/**\n * Buffer model for text track cues — keyed by track ID.\n *\n * Using a per-track-ID map (rather than fixed 'video'/'audio' keys) because\n * there can be N text tracks — one per language/subtitle variant.\n */\nexport type TextTrackBufferState = Record<string, TextTrackSegmentState>;\n\n/**\n * State shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingState {\n selectedTextTrackId?: string;\n presentation?: Presentation;\n /** Current playback position — used to gate VTT segment fetching to the forward buffer window. */\n currentTime?: number;\n /** Loaded-segment model for text tracks, keyed by track ID. */\n textBufferState?: TextTrackBufferState;\n}\n\n/**\n * Owners shape for text track cue loading.\n */\nexport interface TextTrackCueLoadingOwners {\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Find the selected text track in the presentation.\n */\nfunction findSelectedTextTrack(state: TextTrackCueLoadingState): TextTrack | undefined {\n if (!state.presentation || !state.selectedTextTrackId) {\n return undefined;\n }\n\n const textSet = state.presentation.selectionSets.find((set) => set.type === 'text');\n if (!textSet?.switchingSets?.[0]?.tracks) {\n return undefined;\n }\n\n const track = textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId);\n\n return track as TextTrack | undefined;\n}\n\n/**\n * Get the browser's TextTrack object for the selected text track.\n *\n * Retrieves the live TextTrack interface from the track element in owners,\n * which is used for adding cues, checking mode, and managing track state.\n *\n * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track),\n * not the presentation Track metadata type.\n *\n * @param state - Current playback state (track selection)\n * @param owners - DOM owners containing track elements map\n * @returns DOM TextTrack interface or undefined if not found\n */\nfunction getSelectedTextTrackFromOwners(\n state: TextTrackCueLoadingState,\n owners: TextTrackCueLoadingOwners\n): globalThis.TextTrack | undefined {\n const trackId = state.selectedTextTrackId;\n if (!trackId || !owners.textTracks) {\n return undefined;\n }\n\n const trackElement = owners.textTracks.get(trackId);\n return trackElement?.track;\n}\n\n/**\n * Check if we can load text track cues.\n *\n * Requires:\n * - Selected text track ID exists\n * - Track elements map exists\n * - Track element exists for selected track\n */\nexport function canLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId);\n}\n\n/**\n * Check if we should load text track cues.\n *\n * Only load if:\n * - Track is resolved (has segments)\n * - Track has at least one segment\n * - Track element exists\n */\nexport function shouldLoadTextTrackCues(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): boolean {\n if (!canLoadTextTrackCues(state, owners)) {\n return false;\n }\n\n const track = findSelectedTextTrack(state);\n if (!track || !isResolvedTrack(track) || track.segments.length === 0) {\n return false;\n }\n\n const textTrack = getSelectedTextTrackFromOwners(state, owners);\n if (!textTrack) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Load text track cues orchestration.\n *\n * Triggers when:\n * - Text track is selected\n * - Track is resolved (has segments)\n * - Track element exists\n *\n * Fetches and parses VTT segments within the forward buffer window, then adds\n * cues to the track incrementally. Continues on segment errors to provide\n * partial subtitles.\n *\n * @example\n * const cleanup = loadTextTrackCues({ state, owners });\n */\nexport function loadTextTrackCues<S extends TextTrackCueLoadingState, O extends TextTrackCueLoadingOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n let currentTask: Promise<void> | null = null;\n let abortController: AbortController | null = null;\n let lastTrackId: string | undefined;\n\n const selectedTrackId = computed(() => state.get().selectedTextTrackId);\n\n const cleanupEffect = effect(() => {\n const s = state.get();\n const o = owners.get();\n\n // Abort any in-progress task when the selected track changes.\n // The new track's textBufferState entry will be empty, so the task\n // naturally starts fresh without needing any explicit reset.\n if (selectedTrackId.get() !== lastTrackId) {\n lastTrackId = selectedTrackId.get();\n abortController?.abort();\n currentTask = null;\n }\n\n if (currentTask) return;\n if (!shouldLoadTextTrackCues(s, o)) return;\n\n const textTrack = getSelectedTextTrackFromOwners(s, o);\n if (!textTrack) return;\n\n abortController = new AbortController();\n currentTask = loadTextTrackCuesTask(\n { currentState: s },\n { signal: abortController.signal, textTrack, state }\n ).finally(() => {\n currentTask = null;\n });\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":"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"}
@@ -1,6 +1,5 @@
1
- import { calculateBackBufferFlushPoint } from "../../core/buffer/back-buffer.js";
2
1
  import { calculateForwardFlushPoint, getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
3
-
2
+ import { calculateBackBufferFlushPoint } from "../../core/buffer/back-buffer.js";
4
3
  //#region src/dom/features/segment-loader-actor.ts
5
4
  /**
6
5
  * Creates a SegmentLoaderActor for one track type (video or audio).
@@ -27,7 +26,7 @@ function createSegmentLoaderActor(sourceBufferActor, fetchBytes) {
27
26
  let running = false;
28
27
  let destroyed = false;
29
28
  const getBufferedSegments = (allSegments) => {
30
- const bufferedIds = new Set(sourceBufferActor.snapshot.context.segments.filter((s) => !s.partial).map((s) => s.id));
29
+ const bufferedIds = new Set(sourceBufferActor.snapshot.get().context.segments.filter((s) => !s.partial).map((s) => s.id));
31
30
  return allSegments.filter((s) => bufferedIds.has(s.id));
32
31
  };
33
32
  /**
@@ -47,7 +46,7 @@ function createSegmentLoaderActor(sourceBufferActor, fetchBytes) {
47
46
  */
48
47
  const planTasks = (message) => {
49
48
  const { track, range } = message;
50
- const actorCtx = sourceBufferActor.snapshot.context;
49
+ const actorCtx = sourceBufferActor.snapshot.get().context;
51
50
  const bufferedSegments = getBufferedSegments(track.segments);
52
51
  const currentTime = range?.start ?? 0;
53
52
  const tasks = [];
@@ -191,7 +190,7 @@ function createSegmentLoaderActor(sourceBufferActor, fetchBytes) {
191
190
  }
192
191
  };
193
192
  }
194
-
195
193
  //#endregion
196
194
  export { createSegmentLoaderActor };
195
+
197
196
  //# sourceMappingURL=segment-loader-actor.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/segment-loader-actor.ts"],"sourcesContent":["import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer';\nimport { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types';\nimport type {\n AppendInitMessage,\n AppendSegmentMessage,\n RemoveMessage,\n SourceBufferActor,\n} from '../media/source-buffer-actor';\n\n// ============================================================================\n// BUFFER STATE TYPES\n// ============================================================================\n\n/**\n * Buffer state for a single SourceBuffer.\n * Tracks which init segment and media segments are loaded.\n */\nexport interface SourceBufferState {\n /** Track ID of the loaded init segment */\n initTrackId?: string;\n /** Loaded media segments (unordered - selectors derive ordering) */\n segments: Array<{ id: string; trackId: string }>;\n}\n\n/**\n * Buffer state for all SourceBuffers.\n */\nexport interface BufferState {\n video?: SourceBufferState;\n audio?: SourceBufferState;\n}\n\n// ============================================================================\n// MESSAGE PROTOCOL\n// ============================================================================\n\n/** Track types that have SourceBuffers (video and audio only). */\nexport type SegmentLoaderTrack = VideoTrack | AudioTrack;\n\n/**\n * Message sent to a SegmentLoaderActor.\n *\n * `range` is optional to distinguish loading modes:\n * - No range: load init segment only (metadata preload mode)\n * - With range: load init + all segments overlapping [start, end]\n *\n * `start` and `end` are raw time values — no segment snapping.\n * The actor maps them onto segment boundaries internally.\n */\nexport type SegmentLoaderMessage = {\n type: 'load';\n track: SegmentLoaderTrack;\n range?: { start: number; end: number };\n};\n\n// ============================================================================\n// LOAD TASK\n// ============================================================================\n\n/**\n * A LoadTask is the intent to perform one unit of SegmentLoader work.\n * Unlike SourceBufferMessage, fetch-based tasks carry a URL rather than\n * pre-fetched data — the runner fetches and appends them in sequence.\n *\n * Derived from SourceBufferMessage types by removing `data` and adding\n * a fetch URL via AddressableObject.\n *\n * @todo Rename — \"LoadTask\" risks confusion with the `Task` class used for\n * SourceBufferActor scheduling. These are closer to operation descriptors or\n * messages than tasks in that sense.\n */\nexport type LoadTask =\n | (Omit<AppendInitMessage, 'data'> & AddressableObject)\n | (Omit<AppendSegmentMessage, 'data'> & AddressableObject)\n | RemoveMessage;\n\n// ============================================================================\n// ACTOR INTERFACE\n// ============================================================================\n\nexport interface SegmentLoaderActor {\n send(message: SegmentLoaderMessage): void;\n destroy(): void;\n}\n\n// ============================================================================\n// IMPLEMENTATION\n// ============================================================================\n\n/**\n * Creates a SegmentLoaderActor for one track type (video or audio).\n *\n * Receives load assignments via `send()` and owns all execution: planning,\n * removes, fetches, and appends. Coordinates with the SourceBufferActor for\n * all physical SourceBuffer operations.\n *\n * Planning (Cases 1–3) happens in `send()` on every incoming message, producing\n * an ordered LoadTask list. The runner drains that list sequentially. When a new\n * message arrives mid-run, send() replans and either continues the in-flight\n * operation (if still needed) or preempts it.\n *\n * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)\n * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).\n * Accepts an optional `minChunkSize` in options; init segments pass `Infinity`\n * so the entire body accumulates as one chunk before appending.\n */\nexport function createSegmentLoaderActor(\n sourceBufferActor: SourceBufferActor,\n fetchBytes: (\n addressable: AddressableObject,\n options?: RequestInit & { minChunkSize?: number }\n ) => Promise<AsyncIterable<Uint8Array>>\n): SegmentLoaderActor {\n let pendingTasks: LoadTask[] | null = null;\n let inFlightInitTrackId: string | null = null;\n let inFlightSegmentId: string | null = null;\n let abortController: AbortController | null = null;\n let running = false;\n let destroyed = false;\n\n const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {\n // Exclude partial segments — they are still being streamed and must not be\n // treated as fully buffered for load planning or buffer window calculations.\n const bufferedIds = new Set(sourceBufferActor.snapshot.context.segments.filter((s) => !s.partial).map((s) => s.id));\n return allSegments.filter((s) => bufferedIds.has(s.id));\n };\n\n /**\n * Translate a load message into an ordered LoadTask list based on committed\n * actor state. In-flight awareness is handled separately in send().\n *\n * @todo Rename alongside LoadTask (e.g. planOps).\n *\n * Case 1 — Removes: forward and back buffer flush points, segment-aligned.\n * No flush on track switch: appending new content overwrites existing buffer\n * ranges, and the actor's time-aligned deduplication keeps the segment model\n * accurate as new segments arrive.\n *\n * Case 2 — Init: schedule if not yet committed for this track.\n *\n * Case 3 — Segments: all segments in the load window not yet committed.\n */\n const planTasks = (message: SegmentLoaderMessage): LoadTask[] => {\n const { track, range } = message;\n const actorCtx = sourceBufferActor.snapshot.context;\n const bufferedSegments = getBufferedSegments(track.segments);\n const currentTime = range?.start ?? 0;\n const tasks: LoadTask[] = [];\n\n // Case 1: Removes\n if (range) {\n const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);\n if (forwardFlushStart < Infinity) {\n tasks.push({ type: 'remove', start: forwardFlushStart, end: Infinity });\n }\n const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);\n if (backFlushEnd > 0) {\n tasks.push({ type: 'remove', start: 0, end: backFlushEnd });\n }\n }\n\n // Case 2: Init\n if (actorCtx.initTrackId !== track.id) {\n tasks.push({\n type: 'append-init',\n meta: { trackId: track.id },\n url: track.initialization.url,\n ...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),\n });\n }\n\n // Case 3: Segments\n if (range) {\n const EPSILON = 0.0001;\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {\n // Quality-aware filter: skip segments already covered by equal-or-higher-quality\n // content in the actor context. Preserves buffered high-quality content during\n // ABR downgrades; loads during upgrades and for uncovered positions.\n const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);\n // Partial segments are still streaming — treat as not buffered so they\n // are always re-planned (avoids relying on incomplete data).\n if (existing?.partial) return true;\n if (!existing?.trackBandwidth || !track.bandwidth) return true;\n return track.bandwidth > existing.trackBandwidth;\n });\n for (const segment of segmentsToLoad) {\n tasks.push({\n type: 'append-segment',\n meta: {\n id: segment.id,\n startTime: segment.startTime,\n duration: segment.duration,\n trackId: track.id,\n trackBandwidth: track.bandwidth,\n },\n url: segment.url,\n ...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),\n });\n }\n }\n\n return tasks;\n };\n\n /**\n * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor.\n * Sets/clears in-flight tracking around async operations so send() can make\n * accurate continue/preempt decisions at any point during execution.\n *\n * @todo Rename alongside LoadTask (e.g. executeOp).\n */\n const executeLoadTask = async (task: LoadTask): Promise<void> => {\n const signal = abortController!.signal;\n try {\n if (task.type === 'remove') {\n await sourceBufferActor.send(task, signal);\n return;\n }\n\n if (task.type === 'append-init') {\n inFlightInitTrackId = task.meta.trackId;\n if (!signal.aborted) {\n // Init segments are small and need the full body before the\n // same-track-seek vs track-switch commit decision can be made.\n // minChunkSize: Infinity causes ChunkedStreamIterable to accumulate\n // all chunks and yield exactly one — equivalent to arrayBuffer() but\n // through the same streaming path as media segments.\n const data = await fetchBytes(task, { signal, minChunkSize: Infinity });\n // For seeks on the same track: commit even if aborted — avoids re-fetching the\n // same init next time. For track switches: don't commit the old track's init;\n // the new track's init follows in pendingTasks.\n const isTrackSwitch = pendingTasks?.some(\n (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId\n );\n if (!signal.aborted || !isTrackSwitch) {\n const appendSignal = signal.aborted ? new AbortController().signal : signal;\n await sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal);\n }\n }\n return;\n }\n\n // append-segment: await headers eagerly (starts the HTTP connection and\n // records the fetch in observers like tests), then pass the body stream\n // directly to the actor so chunks are appended as they arrive.\n inFlightSegmentId = task.meta.id;\n if (!signal.aborted) {\n const stream = await fetchBytes(task, { signal });\n if (!signal.aborted) {\n await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal);\n }\n }\n } finally {\n inFlightInitTrackId = null;\n inFlightSegmentId = null;\n }\n };\n\n /**\n * Drain the scheduled task list sequentially.\n * After each task completes, checks for a pending replacement plan from send().\n * If the signal was aborted and no new plan arrived, stops immediately.\n */\n const runScheduled = async (initialTasks: LoadTask[]): Promise<void> => {\n running = true;\n abortController = new AbortController();\n let scheduled = initialTasks;\n\n while (scheduled.length > 0 && !destroyed) {\n const task = scheduled[0]!;\n scheduled = scheduled.slice(1);\n\n try {\n await executeLoadTask(task);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') {\n // Abort is handled in the post-task check below.\n } else {\n console.error('Unexpected error in segment loader:', error);\n // Non-abort error (e.g. network failure on init): don't continue with\n // remaining tasks in this sequence — they depend on the failed step.\n // A pending replacement plan (if any) will still be picked up below.\n scheduled = [];\n }\n }\n\n if (pendingTasks !== null) {\n // A new plan arrived while this task was running — switch to it.\n scheduled = pendingTasks;\n pendingTasks = null;\n abortController = new AbortController();\n } else if (abortController.signal.aborted) {\n // Aborted with no replacement plan — stop.\n break;\n }\n }\n\n abortController = null;\n running = false;\n };\n\n return {\n send(message: SegmentLoaderMessage) {\n if (destroyed) return;\n\n const allTasks = planTasks(message);\n\n if (!running) {\n if (allTasks.length === 0) return;\n runScheduled(allTasks);\n return;\n }\n\n // Determine whether the in-flight operation is still needed for the new plan.\n const inFlightStillNeeded =\n (inFlightSegmentId !== null &&\n allTasks.some((t) => t.type === 'append-segment' && t.meta.id === inFlightSegmentId)) ||\n (inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId));\n\n if (inFlightStillNeeded) {\n // Continue: the in-flight operation covers something the new plan needs.\n // Queue everything except the in-flight item — it will complete on its own.\n pendingTasks = allTasks.filter(\n (t) =>\n !(t.type === 'append-segment' && t.meta.id === inFlightSegmentId) &&\n !(t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId)\n );\n } else {\n // Preempt: in-flight work is not needed for the new plan. Abort and replan.\n pendingTasks = allTasks;\n abortController?.abort();\n }\n },\n\n destroy() {\n destroyed = true;\n abortController?.abort();\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/segment-loader-actor.ts"],"sourcesContent":["import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer';\nimport { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types';\nimport type {\n AppendInitMessage,\n AppendSegmentMessage,\n RemoveMessage,\n SourceBufferActor,\n} from '../media/source-buffer-actor';\n\n// ============================================================================\n// BUFFER STATE TYPES\n// ============================================================================\n\n/**\n * Buffer state for a single SourceBuffer.\n * Tracks which init segment and media segments are loaded.\n */\nexport interface SourceBufferState {\n /** Track ID of the loaded init segment */\n initTrackId?: string;\n /** Loaded media segments (unordered - selectors derive ordering) */\n segments: Array<{ id: string; trackId: string }>;\n}\n\n/**\n * Buffer state for all SourceBuffers.\n */\nexport interface BufferState {\n video?: SourceBufferState;\n audio?: SourceBufferState;\n}\n\n// ============================================================================\n// MESSAGE PROTOCOL\n// ============================================================================\n\n/** Track types that have SourceBuffers (video and audio only). */\nexport type SegmentLoaderTrack = VideoTrack | AudioTrack;\n\n/**\n * Message sent to a SegmentLoaderActor.\n *\n * `range` is optional to distinguish loading modes:\n * - No range: load init segment only (metadata preload mode)\n * - With range: load init + all segments overlapping [start, end]\n *\n * `start` and `end` are raw time values — no segment snapping.\n * The actor maps them onto segment boundaries internally.\n */\nexport type SegmentLoaderMessage = {\n type: 'load';\n track: SegmentLoaderTrack;\n range?: { start: number; end: number };\n};\n\n// ============================================================================\n// LOAD TASK\n// ============================================================================\n\n/**\n * A LoadTask is the intent to perform one unit of SegmentLoader work.\n * Unlike SourceBufferMessage, fetch-based tasks carry a URL rather than\n * pre-fetched data — the runner fetches and appends them in sequence.\n *\n * Derived from SourceBufferMessage types by removing `data` and adding\n * a fetch URL via AddressableObject.\n *\n * @todo Rename — \"LoadTask\" risks confusion with the `Task` class used for\n * SourceBufferActor scheduling. These are closer to operation descriptors or\n * messages than tasks in that sense.\n */\nexport type LoadTask =\n | (Omit<AppendInitMessage, 'data'> & AddressableObject)\n | (Omit<AppendSegmentMessage, 'data'> & AddressableObject)\n | RemoveMessage;\n\n// ============================================================================\n// ACTOR INTERFACE\n// ============================================================================\n\nexport interface SegmentLoaderActor {\n send(message: SegmentLoaderMessage): void;\n destroy(): void;\n}\n\n// ============================================================================\n// IMPLEMENTATION\n// ============================================================================\n\n/**\n * Creates a SegmentLoaderActor for one track type (video or audio).\n *\n * Receives load assignments via `send()` and owns all execution: planning,\n * removes, fetches, and appends. Coordinates with the SourceBufferActor for\n * all physical SourceBuffer operations.\n *\n * Planning (Cases 1–3) happens in `send()` on every incoming message, producing\n * an ordered LoadTask list. The runner drains that list sequentially. When a new\n * message arrives mid-run, send() replans and either continues the in-flight\n * operation (if still needed) or preempts it.\n *\n * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)\n * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).\n * Accepts an optional `minChunkSize` in options; init segments pass `Infinity`\n * so the entire body accumulates as one chunk before appending.\n */\nexport function createSegmentLoaderActor(\n sourceBufferActor: SourceBufferActor,\n fetchBytes: (\n addressable: AddressableObject,\n options?: RequestInit & { minChunkSize?: number }\n ) => Promise<AsyncIterable<Uint8Array>>\n): SegmentLoaderActor {\n let pendingTasks: LoadTask[] | null = null;\n let inFlightInitTrackId: string | null = null;\n let inFlightSegmentId: string | null = null;\n let abortController: AbortController | null = null;\n let running = false;\n let destroyed = false;\n\n const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {\n // Exclude partial segments — they are still being streamed and must not be\n // treated as fully buffered for load planning or buffer window calculations.\n const bufferedIds = new Set(\n sourceBufferActor.snapshot\n .get()\n .context.segments.filter((s) => !s.partial)\n .map((s) => s.id)\n );\n return allSegments.filter((s) => bufferedIds.has(s.id));\n };\n\n /**\n * Translate a load message into an ordered LoadTask list based on committed\n * actor state. In-flight awareness is handled separately in send().\n *\n * @todo Rename alongside LoadTask (e.g. planOps).\n *\n * Case 1 — Removes: forward and back buffer flush points, segment-aligned.\n * No flush on track switch: appending new content overwrites existing buffer\n * ranges, and the actor's time-aligned deduplication keeps the segment model\n * accurate as new segments arrive.\n *\n * Case 2 — Init: schedule if not yet committed for this track.\n *\n * Case 3 — Segments: all segments in the load window not yet committed.\n */\n const planTasks = (message: SegmentLoaderMessage): LoadTask[] => {\n const { track, range } = message;\n const actorCtx = sourceBufferActor.snapshot.get().context;\n const bufferedSegments = getBufferedSegments(track.segments);\n const currentTime = range?.start ?? 0;\n const tasks: LoadTask[] = [];\n\n // Case 1: Removes\n if (range) {\n const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);\n if (forwardFlushStart < Infinity) {\n tasks.push({ type: 'remove', start: forwardFlushStart, end: Infinity });\n }\n const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);\n if (backFlushEnd > 0) {\n tasks.push({ type: 'remove', start: 0, end: backFlushEnd });\n }\n }\n\n // Case 2: Init\n if (actorCtx.initTrackId !== track.id) {\n tasks.push({\n type: 'append-init',\n meta: { trackId: track.id },\n url: track.initialization.url,\n ...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),\n });\n }\n\n // Case 3: Segments\n if (range) {\n const EPSILON = 0.0001;\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {\n // Quality-aware filter: skip segments already covered by equal-or-higher-quality\n // content in the actor context. Preserves buffered high-quality content during\n // ABR downgrades; loads during upgrades and for uncovered positions.\n const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);\n // Partial segments are still streaming — treat as not buffered so they\n // are always re-planned (avoids relying on incomplete data).\n if (existing?.partial) return true;\n if (!existing?.trackBandwidth || !track.bandwidth) return true;\n return track.bandwidth > existing.trackBandwidth;\n });\n for (const segment of segmentsToLoad) {\n tasks.push({\n type: 'append-segment',\n meta: {\n id: segment.id,\n startTime: segment.startTime,\n duration: segment.duration,\n trackId: track.id,\n trackBandwidth: track.bandwidth,\n },\n url: segment.url,\n ...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),\n });\n }\n }\n\n return tasks;\n };\n\n /**\n * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor.\n * Sets/clears in-flight tracking around async operations so send() can make\n * accurate continue/preempt decisions at any point during execution.\n *\n * @todo Rename alongside LoadTask (e.g. executeOp).\n */\n const executeLoadTask = async (task: LoadTask): Promise<void> => {\n const signal = abortController!.signal;\n try {\n if (task.type === 'remove') {\n await sourceBufferActor.send(task, signal);\n return;\n }\n\n if (task.type === 'append-init') {\n inFlightInitTrackId = task.meta.trackId;\n if (!signal.aborted) {\n // Init segments are small and need the full body before the\n // same-track-seek vs track-switch commit decision can be made.\n // minChunkSize: Infinity causes ChunkedStreamIterable to accumulate\n // all chunks and yield exactly one — equivalent to arrayBuffer() but\n // through the same streaming path as media segments.\n const data = await fetchBytes(task, { signal, minChunkSize: Infinity });\n // For seeks on the same track: commit even if aborted — avoids re-fetching the\n // same init next time. For track switches: don't commit the old track's init;\n // the new track's init follows in pendingTasks.\n const isTrackSwitch = pendingTasks?.some(\n (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId\n );\n if (!signal.aborted || !isTrackSwitch) {\n const appendSignal = signal.aborted ? new AbortController().signal : signal;\n await sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal);\n }\n }\n return;\n }\n\n // append-segment: await headers eagerly (starts the HTTP connection and\n // records the fetch in observers like tests), then pass the body stream\n // directly to the actor so chunks are appended as they arrive.\n inFlightSegmentId = task.meta.id;\n if (!signal.aborted) {\n const stream = await fetchBytes(task, { signal });\n if (!signal.aborted) {\n await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal);\n }\n }\n } finally {\n inFlightInitTrackId = null;\n inFlightSegmentId = null;\n }\n };\n\n /**\n * Drain the scheduled task list sequentially.\n * After each task completes, checks for a pending replacement plan from send().\n * If the signal was aborted and no new plan arrived, stops immediately.\n */\n const runScheduled = async (initialTasks: LoadTask[]): Promise<void> => {\n running = true;\n abortController = new AbortController();\n let scheduled = initialTasks;\n\n while (scheduled.length > 0 && !destroyed) {\n const task = scheduled[0]!;\n scheduled = scheduled.slice(1);\n\n try {\n await executeLoadTask(task);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') {\n // Abort is handled in the post-task check below.\n } else {\n console.error('Unexpected error in segment loader:', error);\n // Non-abort error (e.g. network failure on init): don't continue with\n // remaining tasks in this sequence — they depend on the failed step.\n // A pending replacement plan (if any) will still be picked up below.\n scheduled = [];\n }\n }\n\n if (pendingTasks !== null) {\n // A new plan arrived while this task was running — switch to it.\n scheduled = pendingTasks;\n pendingTasks = null;\n abortController = new AbortController();\n } else if (abortController.signal.aborted) {\n // Aborted with no replacement plan — stop.\n break;\n }\n }\n\n abortController = null;\n running = false;\n };\n\n return {\n send(message: SegmentLoaderMessage) {\n if (destroyed) return;\n\n const allTasks = planTasks(message);\n\n if (!running) {\n if (allTasks.length === 0) return;\n runScheduled(allTasks);\n return;\n }\n\n // Determine whether the in-flight operation is still needed for the new plan.\n const inFlightStillNeeded =\n (inFlightSegmentId !== null &&\n allTasks.some((t) => t.type === 'append-segment' && t.meta.id === inFlightSegmentId)) ||\n (inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId));\n\n if (inFlightStillNeeded) {\n // Continue: the in-flight operation covers something the new plan needs.\n // Queue everything except the in-flight item — it will complete on its own.\n pendingTasks = allTasks.filter(\n (t) =>\n !(t.type === 'append-segment' && t.meta.id === inFlightSegmentId) &&\n !(t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId)\n );\n } else {\n // Preempt: in-flight work is not needed for the new plan. Abort and replan.\n pendingTasks = allTasks;\n abortController?.abort();\n }\n },\n\n destroy() {\n destroyed = true;\n abortController?.abort();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2GA,SAAgB,yBACd,mBACA,YAIoB;CACpB,IAAI,eAAkC;CACtC,IAAI,sBAAqC;CACzC,IAAI,oBAAmC;CACvC,IAAI,kBAA0C;CAC9C,IAAI,UAAU;CACd,IAAI,YAAY;CAEhB,MAAM,uBAAuB,gBAA+C;EAG1E,MAAM,cAAc,IAAI,IACtB,kBAAkB,SACf,KAAK,CACL,QAAQ,SAAS,QAAQ,MAAM,CAAC,EAAE,QAAQ,CAC1C,KAAK,MAAM,EAAE,GAAG,CACpB;AACD,SAAO,YAAY,QAAQ,MAAM,YAAY,IAAI,EAAE,GAAG,CAAC;;;;;;;;;;;;;;;;;CAkBzD,MAAM,aAAa,YAA8C;EAC/D,MAAM,EAAE,OAAO,UAAU;EACzB,MAAM,WAAW,kBAAkB,SAAS,KAAK,CAAC;EAClD,MAAM,mBAAmB,oBAAoB,MAAM,SAAS;EAC5D,MAAM,cAAc,OAAO,SAAS;EACpC,MAAM,QAAoB,EAAE;AAG5B,MAAI,OAAO;GACT,MAAM,oBAAoB,2BAA2B,kBAAkB,YAAY;AACnF,OAAI,oBAAoB,SACtB,OAAM,KAAK;IAAE,MAAM;IAAU,OAAO;IAAmB,KAAK;IAAU,CAAC;GAEzE,MAAM,eAAe,8BAA8B,kBAAkB,YAAY;AACjF,OAAI,eAAe,EACjB,OAAM,KAAK;IAAE,MAAM;IAAU,OAAO;IAAG,KAAK;IAAc,CAAC;;AAK/D,MAAI,SAAS,gBAAgB,MAAM,GACjC,OAAM,KAAK;GACT,MAAM;GACN,MAAM,EAAE,SAAS,MAAM,IAAI;GAC3B,KAAK,MAAM,eAAe;GAC1B,GAAI,MAAM,eAAe,cAAc,KAAA,KAAa,EAAE,WAAW,MAAM,eAAe,WAAW;GAClG,CAAC;AAIJ,MAAI,OAAO;GACT,MAAM,UAAU;GAChB,MAAM,iBAAiB,kBAAkB,MAAM,UAAU,kBAAkB,YAAY,CAAC,QAAQ,QAAQ;IAItG,MAAM,WAAW,SAAS,SAAS,MAAM,MAAM,KAAK,IAAI,EAAE,YAAY,IAAI,UAAU,GAAG,QAAQ;AAG/F,QAAI,UAAU,QAAS,QAAO;AAC9B,QAAI,CAAC,UAAU,kBAAkB,CAAC,MAAM,UAAW,QAAO;AAC1D,WAAO,MAAM,YAAY,SAAS;KAClC;AACF,QAAK,MAAM,WAAW,eACpB,OAAM,KAAK;IACT,MAAM;IACN,MAAM;KACJ,IAAI,QAAQ;KACZ,WAAW,QAAQ;KACnB,UAAU,QAAQ;KAClB,SAAS,MAAM;KACf,gBAAgB,MAAM;KACvB;IACD,KAAK,QAAQ;IACb,GAAI,QAAQ,cAAc,KAAA,KAAa,EAAE,WAAW,QAAQ,WAAW;IACxE,CAAC;;AAIN,SAAO;;;;;;;;;CAUT,MAAM,kBAAkB,OAAO,SAAkC;EAC/D,MAAM,SAAS,gBAAiB;AAChC,MAAI;AACF,OAAI,KAAK,SAAS,UAAU;AAC1B,UAAM,kBAAkB,KAAK,MAAM,OAAO;AAC1C;;AAGF,OAAI,KAAK,SAAS,eAAe;AAC/B,0BAAsB,KAAK,KAAK;AAChC,QAAI,CAAC,OAAO,SAAS;KAMnB,MAAM,OAAO,MAAM,WAAW,MAAM;MAAE;MAAQ,cAAc;MAAU,CAAC;KAIvE,MAAM,gBAAgB,cAAc,MACjC,MAAM,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,KAAK,KAAK,QACjE;AACD,SAAI,CAAC,OAAO,WAAW,CAAC,eAAe;MACrC,MAAM,eAAe,OAAO,UAAU,IAAI,iBAAiB,CAAC,SAAS;AACrE,YAAM,kBAAkB,KAAK;OAAE,MAAM;OAAe;OAAM,MAAM,KAAK;OAAM,EAAE,aAAa;;;AAG9F;;AAMF,uBAAoB,KAAK,KAAK;AAC9B,OAAI,CAAC,OAAO,SAAS;IACnB,MAAM,SAAS,MAAM,WAAW,MAAM,EAAE,QAAQ,CAAC;AACjD,QAAI,CAAC,OAAO,QACV,OAAM,kBAAkB,KAAK;KAAE,MAAM;KAAkB,MAAM;KAAQ,MAAM,KAAK;KAAM,EAAE,OAAO;;YAG3F;AACR,yBAAsB;AACtB,uBAAoB;;;;;;;;CASxB,MAAM,eAAe,OAAO,iBAA4C;AACtE,YAAU;AACV,oBAAkB,IAAI,iBAAiB;EACvC,IAAI,YAAY;AAEhB,SAAO,UAAU,SAAS,KAAK,CAAC,WAAW;GACzC,MAAM,OAAO,UAAU;AACvB,eAAY,UAAU,MAAM,EAAE;AAE9B,OAAI;AACF,UAAM,gBAAgB,KAAK;YACpB,OAAO;AACd,QAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc,QAEpD;AACL,aAAQ,MAAM,uCAAuC,MAAM;AAI3D,iBAAY,EAAE;;;AAIlB,OAAI,iBAAiB,MAAM;AAEzB,gBAAY;AACZ,mBAAe;AACf,sBAAkB,IAAI,iBAAiB;cAC9B,gBAAgB,OAAO,QAEhC;;AAIJ,oBAAkB;AAClB,YAAU;;AAGZ,QAAO;EACL,KAAK,SAA+B;AAClC,OAAI,UAAW;GAEf,MAAM,WAAW,UAAU,QAAQ;AAEnC,OAAI,CAAC,SAAS;AACZ,QAAI,SAAS,WAAW,EAAG;AAC3B,iBAAa,SAAS;AACtB;;AAUF,OALG,sBAAsB,QACrB,SAAS,MAAM,MAAM,EAAE,SAAS,oBAAoB,EAAE,KAAK,OAAO,kBAAkB,IACrF,wBAAwB,QACvB,SAAS,MAAM,MAAM,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,oBAAoB,CAK1F,gBAAe,SAAS,QACrB,MACC,EAAE,EAAE,SAAS,oBAAoB,EAAE,KAAK,OAAO,sBAC/C,EAAE,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,qBACpD;QACI;AAEL,mBAAe;AACf,qBAAiB,OAAO;;;EAI5B,UAAU;AACR,eAAY;AACZ,oBAAiB,OAAO;;EAE3B"}
@@ -1,22 +1,8 @@
1
- import { attachMediaSource, createMediaSource, waitForSourceOpen } from "../media/mediasource-setup.js";
2
- import { combineLatest } from "../../core/reactive/combine-latest.js";
3
- import { isNil } from "@videojs/utils/predicate";
4
-
1
+ import { effect } from "../../core/signals/effect.js";
2
+ import { computed } from "../../core/signals/primitives.js";
3
+ import { attachMediaSource, createMediaSource, observeMediaSourceReadyState } from "../media/mediasource-setup.js";
5
4
  //#region src/dom/features/setup-mediasource.ts
6
5
  /**
7
- * Check if we have the minimum requirements to create MediaSource.
8
- */
9
- function canSetup(state, owners) {
10
- return !isNil(owners.mediaElement) && !isNil(state.presentation?.url);
11
- }
12
- /**
13
- * Check if we should proceed with MediaSource creation.
14
- * Placeholder for future conditions (e.g., checking if already created).
15
- */
16
- function shouldSetup(_state, owners) {
17
- return isNil(owners.mediaSource);
18
- }
19
- /**
20
6
  * Setup MediaSource orchestration.
21
7
  *
22
8
  * Creates and attaches MediaSource when:
@@ -26,30 +12,37 @@ function shouldSetup(_state, owners) {
26
12
  * Updates owners.mediaSource after successful setup.
27
13
  */
28
14
  function setupMediaSource({ state, owners }) {
29
- let settingUp = false;
30
- let abortController = null;
31
- const unsubscribe = combineLatest([state, owners]).subscribe(async ([currentState, currentOwners]) => {
32
- if (!canSetup(currentState, currentOwners) || !shouldSetup(currentState, currentOwners) || settingUp) return;
33
- try {
34
- settingUp = true;
35
- abortController = new AbortController();
36
- const mediaSource = createMediaSource({ preferManaged: true });
37
- attachMediaSource(mediaSource, currentOwners.mediaElement);
38
- await waitForSourceOpen(mediaSource, abortController.signal);
39
- owners.patch({ mediaSource });
40
- } catch (error) {
41
- if (error instanceof DOMException && error.name === "AbortError") return;
42
- throw error;
43
- } finally {
44
- settingUp = false;
45
- }
15
+ const abortController = new AbortController();
16
+ const mediaElementSignal = computed(() => owners.get().mediaElement);
17
+ const presentationUrlSignal = computed(() => state.get().presentation?.url);
18
+ const canSetupSignal = computed(() => !!mediaElementSignal.get() && !!presentationUrlSignal.get());
19
+ const mediaElementSrcSignal = computed(() => mediaElementSignal.get()?.src);
20
+ const mediaSourceSignal = computed(() => owners.get().mediaSource);
21
+ const shouldSetupSignal = computed(() => !mediaElementSrcSignal.get());
22
+ const cleanupEffect = effect(() => {
23
+ if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;
24
+ const mediaElement = mediaElementSignal.get();
25
+ const { signal } = abortController;
26
+ const mediaSource = createMediaSource({ preferManaged: true });
27
+ const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, signal);
28
+ attachMediaSource(mediaSource, mediaElement);
29
+ const cleanupOwnersUpdateEffect = effect(() => {
30
+ if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== "open") return;
31
+ owners.set(Object.assign({}, owners.get(), {
32
+ mediaSource,
33
+ mediaSourceReadyState
34
+ }));
35
+ });
36
+ return () => {
37
+ cleanupOwnersUpdateEffect();
38
+ };
46
39
  });
47
40
  return () => {
48
41
  abortController?.abort();
49
- unsubscribe();
42
+ cleanupEffect();
50
43
  };
51
44
  }
52
-
53
45
  //#endregion
54
46
  export { setupMediaSource };
47
+
55
48
  //# sourceMappingURL=setup-mediasource.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"setup-mediasource.js","names":[],"sources":["../../../../src/dom/features/setup-mediasource.ts"],"sourcesContent":["import { isNil } from '@videojs/utils/predicate';\nimport { combineLatest } from '../../core/reactive/combine-latest';\nimport type { WritableState } from '../../core/state/create-state';\nimport type { Presentation } from '../../core/types';\nimport { attachMediaSource, createMediaSource, waitForSourceOpen } from '../media/mediasource-setup';\n\n/**\n * State shape required for MediaSource setup.\n */\nexport interface MediaSourceState {\n presentation?: Presentation;\n}\n\n/**\n * Owners shape for MediaSource setup.\n */\nexport interface MediaSourceOwners {\n mediaElement?: HTMLMediaElement | undefined;\n mediaSource?: MediaSource;\n}\n\n/**\n * Check if we have the minimum requirements to create MediaSource.\n */\nexport function canSetup(state: MediaSourceState, owners: MediaSourceOwners): boolean {\n return !isNil(owners.mediaElement) && !isNil(state.presentation?.url);\n}\n\n/**\n * Check if we should proceed with MediaSource creation.\n * Placeholder for future conditions (e.g., checking if already created).\n */\nexport function shouldSetup(_state: MediaSourceState, owners: MediaSourceOwners): boolean {\n // Don't create if we already have one\n return isNil(owners.mediaSource);\n}\n\n/**\n * Setup MediaSource orchestration.\n *\n * Creates and attaches MediaSource when:\n * - mediaElement exists in owners\n * - presentation.url exists in state\n *\n * Updates owners.mediaSource after successful setup.\n */\nexport function setupMediaSource({\n state,\n owners,\n}: {\n state: WritableState<MediaSourceState>;\n owners: WritableState<MediaSourceOwners>;\n}): () => void {\n let settingUp = false;\n let abortController: AbortController | null = null;\n\n const unsubscribe = combineLatest([state, owners]).subscribe(\n async ([currentState, currentOwners]: [MediaSourceState, MediaSourceOwners]) => {\n if (!canSetup(currentState, currentOwners) || !shouldSetup(currentState, currentOwners) || settingUp) return;\n\n try {\n settingUp = true;\n abortController = new AbortController();\n\n // Prefer ManagedMediaSource when available (Safari 17+). attachMediaSource\n // handles the srcObject path and sets disableRemotePlayback automatically.\n const mediaSource = createMediaSource({ preferManaged: true });\n\n // Attach to element\n attachMediaSource(mediaSource, currentOwners.mediaElement!);\n\n // Wait for sourceopen aborted if destroy() is called before it fires\n await waitForSourceOpen(mediaSource, abortController.signal);\n\n owners.patch({ mediaSource });\n } catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') return;\n throw error;\n } finally {\n settingUp = false;\n }\n }\n );\n\n return () => {\n abortController?.abort();\n unsubscribe();\n };\n}\n"],"mappings":";;;;;;;;AAwBA,SAAgB,SAAS,OAAyB,QAAoC;AACpF,QAAO,CAAC,MAAM,OAAO,aAAa,IAAI,CAAC,MAAM,MAAM,cAAc,IAAI;;;;;;AAOvE,SAAgB,YAAY,QAA0B,QAAoC;AAExF,QAAO,MAAM,OAAO,YAAY;;;;;;;;;;;AAYlC,SAAgB,iBAAiB,EAC/B,OACA,UAIa;CACb,IAAI,YAAY;CAChB,IAAI,kBAA0C;CAE9C,MAAM,cAAc,cAAc,CAAC,OAAO,OAAO,CAAC,CAAC,UACjD,OAAO,CAAC,cAAc,mBAA0D;AAC9E,MAAI,CAAC,SAAS,cAAc,cAAc,IAAI,CAAC,YAAY,cAAc,cAAc,IAAI,UAAW;AAEtG,MAAI;AACF,eAAY;AACZ,qBAAkB,IAAI,iBAAiB;GAIvC,MAAM,cAAc,kBAAkB,EAAE,eAAe,MAAM,CAAC;AAG9D,qBAAkB,aAAa,cAAc,aAAc;AAG3D,SAAM,kBAAkB,aAAa,gBAAgB,OAAO;AAE5D,UAAO,MAAM,EAAE,aAAa,CAAC;WACtB,OAAO;AACd,OAAI,iBAAiB,gBAAgB,MAAM,SAAS,aAAc;AAClE,SAAM;YACE;AACR,eAAY;;GAGjB;AAED,cAAa;AACX,mBAAiB,OAAO;AACxB,eAAa"}
1
+ {"version":3,"file":"setup-mediasource.js","names":[],"sources":["../../../../src/dom/features/setup-mediasource.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport type { Presentation } from '../../core/types';\nimport { attachMediaSource, createMediaSource, observeMediaSourceReadyState } from '../media/mediasource-setup';\n\n/**\n * State shape required for MediaSource setup.\n */\nexport interface MediaSourceState {\n presentation?: Presentation;\n}\n\n/**\n * Owners shape for MediaSource setup.\n */\nexport interface MediaSourceOwners {\n mediaElement?: HTMLMediaElement | undefined;\n mediaSource?: MediaSource;\n /** Reactive mirror of `mediaSource.readyState` updated via DOM events. */\n mediaSourceReadyState?: ReadonlySignal<MediaSource['readyState']>;\n}\n\n/**\n * Setup MediaSource orchestration.\n *\n * Creates and attaches MediaSource when:\n * - mediaElement exists in owners\n * - presentation.url exists in state\n *\n * Updates owners.mediaSource after successful setup.\n */\nexport function setupMediaSource<S extends MediaSourceState, O extends MediaSourceOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const abortController = new AbortController();\n\n // Get the latest mediaElement (even if nullish)\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n // Get the latest presentationUrl (even if nullish)\n const presentationUrlSignal = computed(() => state.get().presentation?.url);\n\n const canSetupSignal = computed(() => !!mediaElementSignal.get() && !!presentationUrlSignal.get());\n\n const mediaElementSrcSignal = computed(() => mediaElementSignal.get()?.src);\n const mediaSourceSignal = computed(() => owners.get().mediaSource);\n const shouldSetupSignal = computed(() => !mediaElementSrcSignal.get());\n\n // NOTE: This should be cleaner and less brittle if/when Reactors have their own internal finite state. This is planned as followup work.\n // (here, e.g. something like: \"preconditions_unmet\"|\"pending\"|\"setting_up\"|\"tearing_down\"|\"set_up\")\n // This should also avoid needing nested effect().\n const cleanupEffect = effect(() => {\n if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;\n const mediaElement = mediaElementSignal.get() as HTMLMediaElement;\n const { signal } = abortController;\n\n const mediaSource = createMediaSource({ preferManaged: true });\n // NOTE: Consider making MediaSource an Actor and using this in it.\n const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, signal);\n attachMediaSource(mediaSource, mediaElement);\n\n const cleanupOwnersUpdateEffect = effect(() => {\n // If we already have a MediaSource or the *internal* mediaSource is not yet fully attached, wait to add it to owners;\n if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== 'open') return;\n owners.set(Object.assign({}, owners.get(), { mediaSource, mediaSourceReadyState }) as O);\n });\n\n return () => {\n cleanupOwnersUpdateEffect();\n };\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;;;;;;AA+BA,SAAgB,iBAA0E,EACxF,OACA,UAIa;CACb,MAAM,kBAAkB,IAAI,iBAAiB;CAG7C,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CAEpE,MAAM,wBAAwB,eAAe,MAAM,KAAK,CAAC,cAAc,IAAI;CAE3E,MAAM,iBAAiB,eAAe,CAAC,CAAC,mBAAmB,KAAK,IAAI,CAAC,CAAC,sBAAsB,KAAK,CAAC;CAElG,MAAM,wBAAwB,eAAe,mBAAmB,KAAK,EAAE,IAAI;CAC3E,MAAM,oBAAoB,eAAe,OAAO,KAAK,CAAC,YAAY;CAClE,MAAM,oBAAoB,eAAe,CAAC,sBAAsB,KAAK,CAAC;CAKtE,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,eAAe,KAAK,IAAI,CAAC,kBAAkB,KAAK,CAAE;EACvD,MAAM,eAAe,mBAAmB,KAAK;EAC7C,MAAM,EAAE,WAAW;EAEnB,MAAM,cAAc,kBAAkB,EAAE,eAAe,MAAM,CAAC;EAE9D,MAAM,wBAAwB,6BAA6B,aAAa,OAAO;AAC/E,oBAAkB,aAAa,aAAa;EAE5C,MAAM,4BAA4B,aAAa;AAE7C,OAAI,CAAC,CAAC,kBAAkB,KAAK,IAAI,sBAAsB,KAAK,KAAK,OAAQ;AACzE,UAAO,IAAI,OAAO,OAAO,EAAE,EAAE,OAAO,KAAK,EAAE;IAAE;IAAa;IAAuB,CAAC,CAAM;IACxF;AAEF,eAAa;AACX,8BAA2B;;GAE7B;AAEF,cAAa;AACX,mBAAiB,OAAO;AACxB,iBAAe"}
@@ -1,11 +1,14 @@
1
+ import { effect } from "../../core/signals/effect.js";
2
+ import { computed, update } from "../../core/signals/primitives.js";
1
3
  import { isResolvedTrack } from "../../core/types/index.js";
4
+ import { BufferKeyByType, getSelectedTrack } from "../../core/utils/track-selection.js";
2
5
  import { createSourceBuffer } from "../media/mediasource-setup.js";
3
- import { combineLatest } from "../../core/reactive/combine-latest.js";
4
- import { getSelectedTrack } from "../../core/utils/track-selection.js";
5
6
  import { createSourceBufferActor } from "../media/source-buffer-actor.js";
6
- import "@videojs/utils/predicate";
7
-
8
7
  //#region src/dom/features/setup-sourcebuffer.ts
8
+ const ActorKeyByType = {
9
+ video: "videoBufferActor",
10
+ audio: "audioBufferActor"
11
+ };
9
12
  /**
10
13
  * Build MIME codec string from track metadata.
11
14
  *
@@ -23,48 +26,53 @@ function buildMimeCodec(track) {
23
26
  /**
24
27
  * Setup all needed SourceBuffers as a single coordinated operation.
25
28
  *
26
- * Waits until ALL selected tracks (video and/or audio) are resolved with
27
- * codecs, then creates every SourceBuffer in one synchronous block before
28
- * patching owners. This guarantees that downstream consumers (e.g.
29
- * loadSegments) never see a partial set of SourceBuffers — preventing the
30
- * Firefox bug where appending to a video SourceBuffer before the audio
31
- * SourceBuffer exists causes mozHasAudio to be permanently false.
29
+ * Waits until ALL media tracks in the presentation are resolved with codecs,
30
+ * then creates every SourceBuffer in one synchronous block before setting
31
+ * owners. This guarantees that downstream consumers (e.g. loadSegments) never
32
+ * see a partial set of SourceBuffers — preventing the Firefox bug where
33
+ * appending to a video SourceBuffer before the audio SourceBuffer exists
34
+ * causes mozHasAudio to be permanently false.
32
35
  *
33
36
  * Handles video-only, audio-only, and combined presentations correctly:
34
- * only the tracks that are actually selected are waited on and created.
37
+ * track types are derived from the presentation rather than hardcoded.
35
38
  *
36
39
  * @example
37
40
  * const cleanup = setupSourceBuffers({ state, owners });
38
41
  */
39
42
  function setupSourceBuffers({ state, owners }) {
40
- let setupDone = false;
41
- return combineLatest([state, owners]).subscribe(async ([currentState, currentOwners]) => {
42
- if (setupDone) return;
43
- if (!currentOwners.mediaSource) return;
44
- const videoSelected = !!currentState.selectedVideoTrackId;
45
- const audioSelected = !!currentState.selectedAudioTrackId;
46
- if (!videoSelected && !audioSelected) return;
47
- const videoTrack = videoSelected ? getSelectedTrack(currentState, "video") : null;
48
- const audioTrack = audioSelected ? getSelectedTrack(currentState, "audio") : null;
49
- if (videoSelected && (!videoTrack || !isResolvedTrack(videoTrack) || !videoTrack.codecs?.length)) return;
50
- if (audioSelected && (!audioTrack || !isResolvedTrack(audioTrack) || !audioTrack.codecs?.length)) return;
51
- setupDone = true;
43
+ const presentationTypesSignal = computed(() => {
44
+ const { presentation } = state.get();
45
+ if (!presentation || !("selectionSets" in presentation)) return [];
46
+ return presentation.selectionSets.map(({ type }) => type).filter((type) => type === "video" || type === "audio");
47
+ });
48
+ const canSetupSignal = computed(() => {
49
+ const types = presentationTypesSignal.get();
50
+ if (!owners.get().mediaSource || types.length === 0) return false;
51
+ const s = state.get();
52
+ return types.every((type) => {
53
+ const track = getSelectedTrack(s, type);
54
+ return track && isResolvedTrack(track) && !!track.codecs?.length;
55
+ });
56
+ });
57
+ const shouldSetupSignal = computed(() => {
58
+ const o = owners.get();
59
+ return presentationTypesSignal.get().every((type) => !o[BufferKeyByType[type]]);
60
+ });
61
+ return effect(() => {
62
+ if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;
63
+ const s = state.get();
64
+ const o = owners.get();
52
65
  const patch = {};
53
- if (videoSelected && videoTrack && isResolvedTrack(videoTrack)) {
54
- const buffer = createSourceBuffer(currentOwners.mediaSource, buildMimeCodec(videoTrack));
55
- patch.videoBuffer = buffer;
56
- patch.videoBufferActor = createSourceBufferActor(buffer);
66
+ for (const type of presentationTypesSignal.get()) {
67
+ const track = getSelectedTrack(s, type);
68
+ const buffer = createSourceBuffer(o.mediaSource, buildMimeCodec(track));
69
+ patch[BufferKeyByType[type]] = buffer;
70
+ patch[ActorKeyByType[type]] = createSourceBufferActor(buffer);
57
71
  }
58
- if (audioSelected && audioTrack && isResolvedTrack(audioTrack)) {
59
- const buffer = createSourceBuffer(currentOwners.mediaSource, buildMimeCodec(audioTrack));
60
- patch.audioBuffer = buffer;
61
- patch.audioBufferActor = createSourceBufferActor(buffer);
62
- }
63
- owners.patch(patch);
64
- await new Promise((resolve) => requestAnimationFrame(resolve));
72
+ update(owners, patch);
65
73
  });
66
74
  }
67
-
68
75
  //#endregion
69
76
  export { setupSourceBuffers };
77
+
70
78
  //# sourceMappingURL=setup-sourcebuffer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"setup-sourcebuffer.js","names":[],"sources":["../../../../src/dom/features/setup-sourcebuffer.ts"],"sourcesContent":["import { isUndefined } from '@videojs/utils/predicate';\nimport { combineLatest } from '../../core/reactive/combine-latest';\nimport type { WritableState } from '../../core/state/create-state';\nimport type { Presentation, ResolvedTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { BufferKeyByType, getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';\nimport { createSourceBuffer } from '../media/mediasource-setup';\nimport { createSourceBufferActor, type SourceBufferActor } from '../media/source-buffer-actor';\n\n/**\n * Media track type for SourceBuffer setup.\n * Text tracks are excluded as they don't use MSE SourceBuffers.\n */\nexport type MediaTrackType = 'video' | 'audio';\n\n/**\n * State shape for SourceBuffer setup.\n */\nexport interface SourceBufferState extends TrackSelectionState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * Owners shape for SourceBuffer setup.\n */\nexport interface SourceBufferOwners {\n mediaSource?: MediaSource;\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\n/**\n * Build MIME codec string from track metadata.\n *\n * @param track - Resolved track with mimeType and codecs\n * @returns MIME codec string (e.g., 'video/mp4; codecs=\"avc1.42E01E,mp4a.40.2\"')\n *\n * @example\n * buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] })\n * // => 'video/mp4; codecs=\"avc1.42E01E\"'\n */\nexport function buildMimeCodec(track: ResolvedTrack): string {\n const codecString = track.codecs?.join(',') ?? '';\n return `${track.mimeType}; codecs=\"${codecString}\"`;\n}\n\n/**\n * Check if we can setup SourceBuffer for track type.\n *\n * Requires:\n * - MediaSource exists in owners\n * - Track is selected\n *\n * Note: We don't check mediaSource.readyState because owners holds references\n * to mutable objects. Changes to properties on those objects won't trigger\n * observations. Instead, setupMediaSource only patches owners.mediaSource after\n * it's already open, so if it exists in owners, it's ready to use.\n *\n * Note: Track does not need to be resolved yet. The orchestration will wait\n * for the track to be resolved (via resolveTrack) before creating the SourceBuffer.\n */\nexport function canSetupBuffer(state: SourceBufferState, owners: SourceBufferOwners, type: MediaTrackType): boolean {\n // MediaSource exists (already open - see note above)\n if (!owners.mediaSource) {\n return false;\n }\n\n // Need selected track (doesn't need to be resolved yet)\n const track = getSelectedTrack(state, type);\n if (!track) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Check if we should create SourceBuffer (not already created).\n */\nexport function shouldSetupBuffer(owners: SourceBufferOwners, type: MediaTrackType): boolean {\n const bufferKey = BufferKeyByType[type];\n return isUndefined(owners[bufferKey]);\n}\n\n/**\n * Setup all needed SourceBuffers as a single coordinated operation.\n *\n * Waits until ALL selected tracks (video and/or audio) are resolved with\n * codecs, then creates every SourceBuffer in one synchronous block before\n * patching owners. This guarantees that downstream consumers (e.g.\n * loadSegments) never see a partial set of SourceBuffers — preventing the\n * Firefox bug where appending to a video SourceBuffer before the audio\n * SourceBuffer exists causes mozHasAudio to be permanently false.\n *\n * Handles video-only, audio-only, and combined presentations correctly:\n * only the tracks that are actually selected are waited on and created.\n *\n * @example\n * const cleanup = setupSourceBuffers({ state, owners });\n */\nexport function setupSourceBuffers({\n state,\n owners,\n}: {\n state: WritableState<SourceBufferState>;\n owners: WritableState<SourceBufferOwners>;\n}): () => void {\n let setupDone = false;\n\n const cleanup = combineLatest([state, owners]).subscribe(\n async ([currentState, currentOwners]: [SourceBufferState, SourceBufferOwners]) => {\n if (setupDone) return;\n if (!currentOwners.mediaSource) return;\n\n const videoSelected = !!currentState.selectedVideoTrackId;\n const audioSelected = !!currentState.selectedAudioTrackId;\n\n if (!videoSelected && !audioSelected) return;\n\n const videoTrack = videoSelected ? getSelectedTrack(currentState, 'video') : null;\n const audioTrack = audioSelected ? getSelectedTrack(currentState, 'audio') : null;\n\n // Wait until every selected track is resolved with codecs before\n // creating any SourceBuffer. This is the coordination guarantee.\n if (videoSelected && (!videoTrack || !isResolvedTrack(videoTrack) || !videoTrack.codecs?.length)) return;\n if (audioSelected && (!audioTrack || !isResolvedTrack(audioTrack) || !audioTrack.codecs?.length)) return;\n\n setupDone = true;\n\n // Create all SourceBuffers synchronously — no await between addSourceBuffer\n // calls — then patch owners once so subscribers see all buffers simultaneously.\n const patch: Partial<SourceBufferOwners> = {};\n\n if (videoSelected && videoTrack && isResolvedTrack(videoTrack)) {\n const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(videoTrack));\n patch.videoBuffer = buffer;\n patch.videoBufferActor = createSourceBufferActor(buffer);\n }\n\n if (audioSelected && audioTrack && isResolvedTrack(audioTrack)) {\n const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(audioTrack));\n patch.audioBuffer = buffer;\n patch.audioBufferActor = createSourceBufferActor(buffer);\n }\n\n owners.patch(patch);\n\n // Wait a frame to allow async state updates to flush before downstream\n // orchestrations (loadSegments) begin reacting to the new owners.\n await new Promise((resolve) => requestAnimationFrame(resolve));\n }\n );\n\n return cleanup;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6CA,SAAgB,eAAe,OAA8B;CAC3D,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,IAAI;AAC/C,QAAO,GAAG,MAAM,SAAS,YAAY,YAAY;;;;;;;;;;;;;;;;;;AAyDnD,SAAgB,mBAAmB,EACjC,OACA,UAIa;CACb,IAAI,YAAY;AA8ChB,QA5CgB,cAAc,CAAC,OAAO,OAAO,CAAC,CAAC,UAC7C,OAAO,CAAC,cAAc,mBAA4D;AAChF,MAAI,UAAW;AACf,MAAI,CAAC,cAAc,YAAa;EAEhC,MAAM,gBAAgB,CAAC,CAAC,aAAa;EACrC,MAAM,gBAAgB,CAAC,CAAC,aAAa;AAErC,MAAI,CAAC,iBAAiB,CAAC,cAAe;EAEtC,MAAM,aAAa,gBAAgB,iBAAiB,cAAc,QAAQ,GAAG;EAC7E,MAAM,aAAa,gBAAgB,iBAAiB,cAAc,QAAQ,GAAG;AAI7E,MAAI,kBAAkB,CAAC,cAAc,CAAC,gBAAgB,WAAW,IAAI,CAAC,WAAW,QAAQ,QAAS;AAClG,MAAI,kBAAkB,CAAC,cAAc,CAAC,gBAAgB,WAAW,IAAI,CAAC,WAAW,QAAQ,QAAS;AAElG,cAAY;EAIZ,MAAM,QAAqC,EAAE;AAE7C,MAAI,iBAAiB,cAAc,gBAAgB,WAAW,EAAE;GAC9D,MAAM,SAAS,mBAAmB,cAAc,aAAc,eAAe,WAAW,CAAC;AACzF,SAAM,cAAc;AACpB,SAAM,mBAAmB,wBAAwB,OAAO;;AAG1D,MAAI,iBAAiB,cAAc,gBAAgB,WAAW,EAAE;GAC9D,MAAM,SAAS,mBAAmB,cAAc,aAAc,eAAe,WAAW,CAAC;AACzF,SAAM,cAAc;AACpB,SAAM,mBAAmB,wBAAwB,OAAO;;AAG1D,SAAO,MAAM,MAAM;AAInB,QAAM,IAAI,SAAS,YAAY,sBAAsB,QAAQ,CAAC;GAEjE"}
1
+ {"version":3,"file":"setup-sourcebuffer.js","names":[],"sources":["../../../../src/dom/features/setup-sourcebuffer.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\nimport type { Presentation, ResolvedTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { BufferKeyByType, getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';\nimport { createSourceBuffer } from '../media/mediasource-setup';\nimport { createSourceBufferActor, type SourceBufferActor } from '../media/source-buffer-actor';\n\n/**\n * Media track type for SourceBuffer setup.\n * Text tracks are excluded as they don't use MSE SourceBuffers.\n */\nexport type MediaTrackType = 'video' | 'audio';\n\nconst ActorKeyByType = {\n video: 'videoBufferActor',\n audio: 'audioBufferActor',\n} as const satisfies Record<MediaTrackType, keyof SourceBufferOwners>;\n\n/**\n * State shape for SourceBuffer setup.\n */\nexport interface SourceBufferState extends TrackSelectionState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * Owners shape for SourceBuffer setup.\n */\nexport interface SourceBufferOwners {\n mediaSource?: MediaSource;\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\n/**\n * Build MIME codec string from track metadata.\n *\n * @param track - Resolved track with mimeType and codecs\n * @returns MIME codec string (e.g., 'video/mp4; codecs=\"avc1.42E01E,mp4a.40.2\"')\n *\n * @example\n * buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] })\n * // => 'video/mp4; codecs=\"avc1.42E01E\"'\n */\nexport function buildMimeCodec(track: ResolvedTrack): string {\n const codecString = track.codecs?.join(',') ?? '';\n return `${track.mimeType}; codecs=\"${codecString}\"`;\n}\n\n/**\n * Setup all needed SourceBuffers as a single coordinated operation.\n *\n * Waits until ALL media tracks in the presentation are resolved with codecs,\n * then creates every SourceBuffer in one synchronous block before setting\n * owners. This guarantees that downstream consumers (e.g. loadSegments) never\n * see a partial set of SourceBuffers — preventing the Firefox bug where\n * appending to a video SourceBuffer before the audio SourceBuffer exists\n * causes mozHasAudio to be permanently false.\n *\n * Handles video-only, audio-only, and combined presentations correctly:\n * track types are derived from the presentation rather than hardcoded.\n *\n * @example\n * const cleanup = setupSourceBuffers({ state, owners });\n */\nexport function setupSourceBuffers<S extends SourceBufferState, O extends SourceBufferOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n // Derive which media track types this presentation actually contains\n const presentationTypesSignal = computed((): MediaTrackType[] => {\n const { presentation } = state.get();\n if (!presentation || !('selectionSets' in presentation)) return [];\n return presentation.selectionSets\n .map(({ type }) => type)\n .filter((type): type is MediaTrackType => type === 'video' || type === 'audio');\n });\n\n // All presentation track types must have a selected, resolved track with codecs\n const canSetupSignal = computed(() => {\n const types = presentationTypesSignal.get();\n if (!owners.get().mediaSource || types.length === 0) return false;\n const s = state.get();\n return types.every((type) => {\n const track = getSelectedTrack(s, type);\n return track && isResolvedTrack(track) && !!track.codecs?.length;\n });\n });\n\n // DOM-observable \"already done\" guard once all presentation buffers exist in owners, setup has run\n const shouldSetupSignal = computed(() => {\n const o = owners.get();\n return presentationTypesSignal.get().every((type) => !o[BufferKeyByType[type]]);\n });\n\n const cleanupEffect = effect(() => {\n if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;\n\n const s = state.get();\n const o = owners.get();\n\n // Create all SourceBuffers synchronously — no await between addSourceBuffer\n // calls — then set owners once so subscribers see all buffers simultaneously.\n const patch: Partial<SourceBufferOwners> = {};\n\n for (const type of presentationTypesSignal.get()) {\n const track = getSelectedTrack(s, type) as ResolvedTrack;\n const buffer = createSourceBuffer(o.mediaSource!, buildMimeCodec(track));\n patch[BufferKeyByType[type]] = buffer;\n patch[ActorKeyByType[type]] = createSourceBufferActor(buffer);\n }\n\n update(owners, patch);\n });\n\n return cleanupEffect;\n}\n"],"mappings":";;;;;;;AAcA,MAAM,iBAAiB;CACrB,OAAO;CACP,OAAO;CACR;;;;;;;;;;;AAgCD,SAAgB,eAAe,OAA8B;CAC3D,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,IAAI;AAC/C,QAAO,GAAG,MAAM,SAAS,YAAY,YAAY;;;;;;;;;;;;;;;;;;AAmBnD,SAAgB,mBAA8E,EAC5F,OACA,UAIa;CAEb,MAAM,0BAA0B,eAAiC;EAC/D,MAAM,EAAE,iBAAiB,MAAM,KAAK;AACpC,MAAI,CAAC,gBAAgB,EAAE,mBAAmB,cAAe,QAAO,EAAE;AAClE,SAAO,aAAa,cACjB,KAAK,EAAE,WAAW,KAAK,CACvB,QAAQ,SAAiC,SAAS,WAAW,SAAS,QAAQ;GACjF;CAGF,MAAM,iBAAiB,eAAe;EACpC,MAAM,QAAQ,wBAAwB,KAAK;AAC3C,MAAI,CAAC,OAAO,KAAK,CAAC,eAAe,MAAM,WAAW,EAAG,QAAO;EAC5D,MAAM,IAAI,MAAM,KAAK;AACrB,SAAO,MAAM,OAAO,SAAS;GAC3B,MAAM,QAAQ,iBAAiB,GAAG,KAAK;AACvC,UAAO,SAAS,gBAAgB,MAAM,IAAI,CAAC,CAAC,MAAM,QAAQ;IAC1D;GACF;CAGF,MAAM,oBAAoB,eAAe;EACvC,MAAM,IAAI,OAAO,KAAK;AACtB,SAAO,wBAAwB,KAAK,CAAC,OAAO,SAAS,CAAC,EAAE,gBAAgB,OAAO;GAC/E;AAsBF,QApBsB,aAAa;AACjC,MAAI,CAAC,eAAe,KAAK,IAAI,CAAC,kBAAkB,KAAK,CAAE;EAEvD,MAAM,IAAI,MAAM,KAAK;EACrB,MAAM,IAAI,OAAO,KAAK;EAItB,MAAM,QAAqC,EAAE;AAE7C,OAAK,MAAM,QAAQ,wBAAwB,KAAK,EAAE;GAChD,MAAM,QAAQ,iBAAiB,GAAG,KAAK;GACvC,MAAM,SAAS,mBAAmB,EAAE,aAAc,eAAe,MAAM,CAAC;AACxE,SAAM,gBAAgB,SAAS;AAC/B,SAAM,eAAe,SAAS,wBAAwB,OAAO;;AAG/D,SAAO,QAAQ,MAAM;GACrB"}
@@ -1,32 +1,7 @@
1
- import { combineLatest } from "../../core/reactive/combine-latest.js";
2
-
1
+ import { effect } from "../../core/signals/effect.js";
2
+ import { computed, update } from "../../core/signals/primitives.js";
3
3
  //#region src/dom/features/setup-text-tracks.ts
4
4
  /**
5
- * Get all text tracks from presentation.
6
- */
7
- function getTextTracks(presentation) {
8
- if (!presentation?.selectionSets) return [];
9
- const textSet = presentation.selectionSets.find((set) => set.type === "text");
10
- if (!textSet?.switchingSets?.[0]?.tracks) return [];
11
- return textSet.switchingSets[0].tracks;
12
- }
13
- /**
14
- * Check if we can setup text tracks.
15
- *
16
- * Requires:
17
- * - mediaElement exists
18
- * - presentation has text tracks to setup
19
- */
20
- function canSetupTextTracks(state, owners) {
21
- return !!owners.mediaElement && !!getTextTracks(state.presentation).length;
22
- }
23
- /**
24
- * Check if we should setup text tracks (not already set up).
25
- */
26
- function shouldSetupTextTracks(owners) {
27
- return !owners.textTracks;
28
- }
29
- /**
30
5
  * Create a track element for a text track.
31
6
  *
32
7
  * Note: We use DOM <track> elements instead of the TextTrack JS API
@@ -59,30 +34,34 @@ function createTrackElement(track) {
59
34
  * const cleanup = setupTextTracks({ state, owners });
60
35
  */
61
36
  function setupTextTracks({ state, owners }) {
62
- let hasSetup = false;
63
- let createdTracks = [];
64
- const unsubscribe = combineLatest([state, owners]).subscribe(([s, o]) => {
65
- if (hasSetup) return;
66
- if (!canSetupTextTracks(s, o) || !shouldSetupTextTracks(o)) return;
67
- hasSetup = true;
68
- const textTracks = getTextTracks(s.presentation);
69
- if (textTracks.length === 0) return;
37
+ const modelTextTracksSignal = computed(() => state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === "text")?.switchingSets[0]?.tracks, { equals(prevTextTracks, nextTextTracks) {
38
+ if (prevTextTracks === nextTextTracks) return true;
39
+ if (typeof prevTextTracks !== typeof nextTextTracks) return false;
40
+ if (prevTextTracks?.length !== nextTextTracks?.length) return false;
41
+ return !!nextTextTracks && nextTextTracks.every((nextTextTrack) => prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id));
42
+ } });
43
+ const ownerTextTracksSignal = computed(() => owners.get().textTracks);
44
+ const mediaElementSignal = computed(() => owners.get().mediaElement);
45
+ const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length);
46
+ const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get());
47
+ const cleanupEffect = effect(() => {
48
+ if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return;
49
+ const mediaElement = mediaElementSignal.get();
50
+ const modelTextTracks = modelTextTracksSignal.get();
70
51
  const trackMap = /* @__PURE__ */ new Map();
71
- for (const track of textTracks) {
72
- const trackElement = createTrackElement(track);
73
- o.mediaElement.appendChild(trackElement);
74
- trackMap.set(track.id, trackElement);
75
- createdTracks.push(trackElement);
76
- }
77
- owners.patch({ textTracks: trackMap });
52
+ modelTextTracks.forEach((modelTextTrack) => {
53
+ const trackElement = createTrackElement(modelTextTrack);
54
+ mediaElement.appendChild(trackElement);
55
+ trackMap.set(modelTextTrack.id, trackElement);
56
+ });
57
+ if (trackMap.size) update(owners, { textTracks: trackMap });
78
58
  });
79
59
  return () => {
80
- for (const trackElement of createdTracks) trackElement.remove();
81
- createdTracks = [];
82
- unsubscribe();
60
+ owners.get().textTracks?.forEach((trackElement) => trackElement.remove());
61
+ cleanupEffect();
83
62
  };
84
63
  }
85
-
86
64
  //#endregion
87
65
  export { setupTextTracks };
66
+
88
67
  //# sourceMappingURL=setup-text-tracks.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"setup-text-tracks.js","names":[],"sources":["../../../../src/dom/features/setup-text-tracks.ts"],"sourcesContent":["import { combineLatest } from '../../core/reactive/combine-latest';\nimport type { WritableState } from '../../core/state/create-state';\nimport type { PartiallyResolvedTextTrack, Presentation } from '../../core/types';\n\n/**\n * State shape for text track setup.\n */\nexport interface TextTrackState {\n presentation?: Presentation | undefined;\n selectedTextTrackId?: string | undefined;\n}\n\n/**\n * Owners shape for text track setup.\n */\nexport interface TextTrackOwners {\n mediaElement?: HTMLMediaElement | undefined;\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Get all text tracks from presentation.\n */\nfunction getTextTracks(presentation?: Presentation): PartiallyResolvedTextTrack[] {\n if (!presentation?.selectionSets) return [];\n\n const textSet = presentation.selectionSets.find((set) => set.type === 'text');\n if (!textSet?.switchingSets?.[0]?.tracks) return [];\n\n return textSet.switchingSets[0].tracks as PartiallyResolvedTextTrack[];\n}\n\n/**\n * Check if we can setup text tracks.\n *\n * Requires:\n * - mediaElement exists\n * - presentation has text tracks to setup\n */\nexport function canSetupTextTracks(state: TextTrackState, owners: TextTrackOwners): boolean {\n return !!owners.mediaElement && !!getTextTracks(state.presentation).length;\n}\n\n/**\n * Check if we should setup text tracks (not already set up).\n */\nexport function shouldSetupTextTracks(owners: TextTrackOwners): boolean {\n return !owners.textTracks;\n}\n\n/**\n * Create a track element for a text track.\n *\n * Note: We use DOM <track> elements instead of the TextTrack JS API\n * because there's no way to remove TextTracks added via addTextTrack().\n */\nfunction createTrackElement(track: PartiallyResolvedTextTrack): HTMLTrackElement {\n const trackElement = document.createElement('track');\n\n trackElement.id = track.id;\n trackElement.kind = track.kind;\n trackElement.label = track.label;\n\n if (track.language) {\n trackElement.srclang = track.language;\n }\n\n if (track.default) {\n trackElement.default = true;\n }\n\n // Set src to track URL\n trackElement.src = track.url;\n\n return trackElement;\n}\n\n/**\n * Setup text tracks orchestration.\n *\n * Triggers when:\n * - mediaElement exists\n * - presentation is resolved (has text tracks)\n *\n * Creates <track> elements for all text tracks and adds them as children\n * to the media element. This allows the browser's native text track rendering.\n *\n * Note: Uses DOM track elements instead of TextTrack API because tracks\n * added via addTextTrack() cannot be removed.\n *\n * @example\n * const cleanup = setupTextTracks({ state, owners });\n */\nexport function setupTextTracks({\n state,\n owners,\n}: {\n state: WritableState<TextTrackState>;\n owners: WritableState<TextTrackOwners>;\n}): () => void {\n let hasSetup = false;\n let createdTracks: HTMLTrackElement[] = [];\n\n const unsubscribe = combineLatest([state, owners]).subscribe(([s, o]: [TextTrackState, TextTrackOwners]) => {\n // Check orchestration conditions\n if (hasSetup) return; // Only run once\n if (!canSetupTextTracks(s, o) || !shouldSetupTextTracks(o)) return;\n\n // Mark as setup before doing any work to prevent re-entry\n hasSetup = true;\n\n const textTracks = getTextTracks(s.presentation);\n if (textTracks.length === 0) return;\n\n const trackMap = new Map<string, HTMLTrackElement>();\n\n // Create track elements for all text tracks\n for (const track of textTracks) {\n const trackElement = createTrackElement(track);\n o.mediaElement!.appendChild(trackElement);\n trackMap.set(track.id, trackElement);\n createdTracks.push(trackElement);\n }\n\n // Update owners with track element map\n owners.patch({ textTracks: trackMap });\n });\n\n // Return cleanup function\n return () => {\n // Remove all created track elements\n for (const trackElement of createdTracks) {\n trackElement.remove();\n }\n createdTracks = [];\n unsubscribe();\n };\n}\n"],"mappings":";;;;;;AAuBA,SAAS,cAAc,cAA2D;AAChF,KAAI,CAAC,cAAc,cAAe,QAAO,EAAE;CAE3C,MAAM,UAAU,aAAa,cAAc,MAAM,QAAQ,IAAI,SAAS,OAAO;AAC7E,KAAI,CAAC,SAAS,gBAAgB,IAAI,OAAQ,QAAO,EAAE;AAEnD,QAAO,QAAQ,cAAc,GAAG;;;;;;;;;AAUlC,SAAgB,mBAAmB,OAAuB,QAAkC;AAC1F,QAAO,CAAC,CAAC,OAAO,gBAAgB,CAAC,CAAC,cAAc,MAAM,aAAa,CAAC;;;;;AAMtE,SAAgB,sBAAsB,QAAkC;AACtE,QAAO,CAAC,OAAO;;;;;;;;AASjB,SAAS,mBAAmB,OAAqD;CAC/E,MAAM,eAAe,SAAS,cAAc,QAAQ;AAEpD,cAAa,KAAK,MAAM;AACxB,cAAa,OAAO,MAAM;AAC1B,cAAa,QAAQ,MAAM;AAE3B,KAAI,MAAM,SACR,cAAa,UAAU,MAAM;AAG/B,KAAI,MAAM,QACR,cAAa,UAAU;AAIzB,cAAa,MAAM,MAAM;AAEzB,QAAO;;;;;;;;;;;;;;;;;;AAmBT,SAAgB,gBAAgB,EAC9B,OACA,UAIa;CACb,IAAI,WAAW;CACf,IAAI,gBAAoC,EAAE;CAE1C,MAAM,cAAc,cAAc,CAAC,OAAO,OAAO,CAAC,CAAC,WAAW,CAAC,GAAG,OAA0C;AAE1G,MAAI,SAAU;AACd,MAAI,CAAC,mBAAmB,GAAG,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAE;AAG5D,aAAW;EAEX,MAAM,aAAa,cAAc,EAAE,aAAa;AAChD,MAAI,WAAW,WAAW,EAAG;EAE7B,MAAM,2BAAW,IAAI,KAA+B;AAGpD,OAAK,MAAM,SAAS,YAAY;GAC9B,MAAM,eAAe,mBAAmB,MAAM;AAC9C,KAAE,aAAc,YAAY,aAAa;AACzC,YAAS,IAAI,MAAM,IAAI,aAAa;AACpC,iBAAc,KAAK,aAAa;;AAIlC,SAAO,MAAM,EAAE,YAAY,UAAU,CAAC;GACtC;AAGF,cAAa;AAEX,OAAK,MAAM,gBAAgB,cACzB,cAAa,QAAQ;AAEvB,kBAAgB,EAAE;AAClB,eAAa"}
1
+ {"version":3,"file":"setup-text-tracks.js","names":[],"sources":["../../../../src/dom/features/setup-text-tracks.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\nimport type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types';\n\n/**\n * State shape for text track setup.\n */\nexport interface TextTrackState {\n presentation?: Presentation | undefined;\n selectedTextTrackId?: string | undefined;\n}\n\n/**\n * Owners shape for text track setup.\n */\nexport interface TextTrackOwners {\n mediaElement?: HTMLMediaElement | undefined;\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Create a track element for a text track.\n *\n * Note: We use DOM <track> elements instead of the TextTrack JS API\n * because there's no way to remove TextTracks added via addTextTrack().\n */\nfunction createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement {\n const trackElement = document.createElement('track');\n\n trackElement.id = track.id;\n trackElement.kind = track.kind;\n trackElement.label = track.label;\n\n if (track.language) {\n trackElement.srclang = track.language;\n }\n\n if (track.default) {\n trackElement.default = true;\n }\n\n // Set src to track URL\n trackElement.src = track.url;\n\n return trackElement;\n}\n\n/**\n * Setup text tracks orchestration.\n *\n * Triggers when:\n * - mediaElement exists\n * - presentation is resolved (has text tracks)\n *\n * Creates <track> elements for all text tracks and adds them as children\n * to the media element. This allows the browser's native text track rendering.\n *\n * Note: Uses DOM track elements instead of TextTrack API because tracks\n * added via addTextTrack() cannot be removed.\n *\n * @example\n * const cleanup = setupTextTracks({ state, owners });\n */\nexport function setupTextTracks<S extends TextTrackState, O extends TextTrackOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const modelTextTracksSignal = computed(\n // NOTE: This assumes exactly one selection set and switching set for TextTracks (CJP)\n () =>\n state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === 'text')?.switchingSets[0]\n ?.tracks,\n {\n /** @TODO Make generic and abstract away for Array<T> | undefined (CJP) */\n equals(prevTextTracks, nextTextTracks) {\n if (prevTextTracks === nextTextTracks) return true;\n if (typeof prevTextTracks !== typeof nextTextTracks) return false;\n if (prevTextTracks?.length !== nextTextTracks?.length) return false;\n // NOTE: This could probably be optimized, but the set should generally be small (CJP)\n return (\n !!nextTextTracks &&\n nextTextTracks.every((nextTextTrack) =>\n prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id)\n )\n );\n },\n }\n );\n const ownerTextTracksSignal = computed(() => owners.get().textTracks);\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n\n const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length);\n const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get());\n\n const cleanupEffect = effect(() => {\n if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return;\n const mediaElement = mediaElementSignal.get();\n const modelTextTracks = modelTextTracksSignal.get() as TextTrack[];\n\n const trackMap = new Map<string, HTMLTrackElement>();\n modelTextTracks.forEach((modelTextTrack) => {\n const trackElement = createTrackElement(modelTextTrack);\n mediaElement!.appendChild(trackElement);\n trackMap.set(modelTextTrack.id, trackElement);\n });\n\n if (trackMap.size) {\n const patch: Partial<TextTrackOwners> = { textTracks: trackMap };\n update(owners, patch);\n }\n });\n\n return () => {\n owners.get().textTracks?.forEach((trackElement) => trackElement.remove());\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;;AA0BA,SAAS,mBAAmB,OAAiE;CAC3F,MAAM,eAAe,SAAS,cAAc,QAAQ;AAEpD,cAAa,KAAK,MAAM;AACxB,cAAa,OAAO,MAAM;AAC1B,cAAa,QAAQ,MAAM;AAE3B,KAAI,MAAM,SACR,cAAa,UAAU,MAAM;AAG/B,KAAI,MAAM,QACR,cAAa,UAAU;AAIzB,cAAa,MAAM,MAAM;AAEzB,QAAO;;;;;;;;;;;;;;;;;;AAmBT,SAAgB,gBAAqE,EACnF,OACA,UAIa;CACb,MAAM,wBAAwB,eAG1B,MAAM,KAAK,CAAC,cAAc,eAAe,MAAM,iBAAiB,aAAa,SAAS,OAAO,EAAE,cAAc,IACzG,QACN,EAEE,OAAO,gBAAgB,gBAAgB;AACrC,MAAI,mBAAmB,eAAgB,QAAO;AAC9C,MAAI,OAAO,mBAAmB,OAAO,eAAgB,QAAO;AAC5D,MAAI,gBAAgB,WAAW,gBAAgB,OAAQ,QAAO;AAE9D,SACE,CAAC,CAAC,kBACF,eAAe,OAAO,kBACpB,gBAAgB,MAAM,kBAAkB,cAAc,OAAO,cAAc,GAAG,CAC/E;IAGN,CACF;CACD,MAAM,wBAAwB,eAAe,OAAO,KAAK,CAAC,WAAW;CACrE,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CAEpE,MAAM,2BAA2B,eAAe,CAAC,CAAC,mBAAmB,KAAK,IAAI,sBAAsB,KAAK,EAAE,OAAO;CAClH,MAAM,8BAA8B,eAAe,CAAC,sBAAsB,KAAK,CAAC;CAEhF,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,yBAAyB,KAAK,IAAI,CAAC,4BAA4B,KAAK,CAAE;EAC3E,MAAM,eAAe,mBAAmB,KAAK;EAC7C,MAAM,kBAAkB,sBAAsB,KAAK;EAEnD,MAAM,2BAAW,IAAI,KAA+B;AACpD,kBAAgB,SAAS,mBAAmB;GAC1C,MAAM,eAAe,mBAAmB,eAAe;AACvD,gBAAc,YAAY,aAAa;AACvC,YAAS,IAAI,eAAe,IAAI,aAAa;IAC7C;AAEF,MAAI,SAAS,KAEX,QAAO,QADiC,EAAE,YAAY,UAAU,CAC3C;GAEvB;AAEF,cAAa;AACX,SAAO,KAAK,CAAC,YAAY,SAAS,iBAAiB,aAAa,QAAQ,CAAC;AACzE,iBAAe"}