@videojs/spf 10.0.0-beta.14 → 10.0.0-beta.16

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 (109) hide show
  1. package/dist/default/core/buffer/forward-buffer.js.map +1 -1
  2. package/dist/default/core/create-machine-actor.js +91 -0
  3. package/dist/default/core/create-machine-actor.js.map +1 -0
  4. package/dist/default/core/create-machine-reactor.js +83 -0
  5. package/dist/default/core/create-machine-reactor.js.map +1 -0
  6. package/dist/default/core/create-transition-actor.js +46 -0
  7. package/dist/default/core/create-transition-actor.js.map +1 -0
  8. package/dist/default/core/features/resolve-presentation.js +42 -46
  9. package/dist/default/core/features/resolve-presentation.js.map +1 -1
  10. package/dist/default/core/features/resolve-track.js +1 -1
  11. package/dist/default/core/machine.js +26 -0
  12. package/dist/default/core/machine.js.map +1 -0
  13. package/dist/default/core/signals/primitives.js +1 -1
  14. package/dist/default/core/task.js +76 -7
  15. package/dist/default/core/task.js.map +1 -1
  16. package/dist/default/core/types/index.js.map +1 -1
  17. package/dist/default/dom/features/end-of-stream.js +5 -5
  18. package/dist/default/dom/features/end-of-stream.js.map +1 -1
  19. package/dist/default/dom/features/load-text-track-cues.js +80 -137
  20. package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
  21. package/dist/default/dom/features/segment-loader-actor.js +150 -103
  22. package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
  23. package/dist/default/dom/features/sync-text-tracks.js +95 -0
  24. package/dist/default/dom/features/sync-text-tracks.js.map +1 -0
  25. package/dist/default/dom/features/text-track-segment-loader-actor.js +55 -0
  26. package/dist/default/dom/features/text-track-segment-loader-actor.js.map +1 -0
  27. package/dist/default/dom/features/text-tracks-actor.js +42 -0
  28. package/dist/default/dom/features/text-tracks-actor.js.map +1 -0
  29. package/dist/default/dom/features/track-playback-initiated.js +51 -38
  30. package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
  31. package/dist/default/dom/media/source-buffer-actor.js +52 -91
  32. package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
  33. package/dist/default/dom/network/fetch.js +0 -1
  34. package/dist/default/dom/network/fetch.js.map +1 -1
  35. package/dist/default/dom/playback-engine/engine.js +4 -13
  36. package/dist/default/dom/playback-engine/engine.js.map +1 -1
  37. package/dist/dev/core/actor.d.ts +19 -10
  38. package/dist/dev/core/actor.d.ts.map +1 -1
  39. package/dist/dev/core/buffer/forward-buffer.js.map +1 -1
  40. package/dist/dev/core/create-machine-actor.d.ts +12 -0
  41. package/dist/dev/core/create-machine-actor.d.ts.map +1 -0
  42. package/dist/dev/core/create-machine-actor.js +91 -0
  43. package/dist/dev/core/create-machine-actor.js.map +1 -0
  44. package/dist/dev/core/create-machine-reactor.d.ts +8 -0
  45. package/dist/dev/core/create-machine-reactor.d.ts.map +1 -0
  46. package/dist/dev/core/create-machine-reactor.js +83 -0
  47. package/dist/dev/core/create-machine-reactor.js.map +1 -0
  48. package/dist/dev/core/create-transition-actor.d.ts +23 -0
  49. package/dist/dev/core/create-transition-actor.d.ts.map +1 -0
  50. package/dist/dev/core/create-transition-actor.js +46 -0
  51. package/dist/dev/core/create-transition-actor.js.map +1 -0
  52. package/dist/dev/core/features/resolve-presentation.js +42 -46
  53. package/dist/dev/core/features/resolve-presentation.js.map +1 -1
  54. package/dist/dev/core/features/resolve-track.js +1 -1
  55. package/dist/dev/core/machine.d.ts +21 -0
  56. package/dist/dev/core/machine.d.ts.map +1 -0
  57. package/dist/dev/core/machine.js +26 -0
  58. package/dist/dev/core/machine.js.map +1 -0
  59. package/dist/dev/core/signals/primitives.js +1 -1
  60. package/dist/dev/core/task.js +76 -7
  61. package/dist/dev/core/task.js.map +1 -1
  62. package/dist/dev/core/types/index.d.ts +1 -8
  63. package/dist/dev/core/types/index.d.ts.map +1 -1
  64. package/dist/dev/core/types/index.js.map +1 -1
  65. package/dist/dev/dom/features/end-of-stream.js +5 -5
  66. package/dist/dev/dom/features/end-of-stream.js.map +1 -1
  67. package/dist/dev/dom/features/load-text-track-cues.d.ts +50 -26
  68. package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
  69. package/dist/dev/dom/features/load-text-track-cues.js +80 -137
  70. package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
  71. package/dist/dev/dom/features/segment-loader-actor.js +150 -103
  72. package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
  73. package/dist/dev/dom/features/sync-text-tracks.js +95 -0
  74. package/dist/dev/dom/features/sync-text-tracks.js.map +1 -0
  75. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts +12 -0
  76. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts.map +1 -0
  77. package/dist/dev/dom/features/text-track-segment-loader-actor.js +55 -0
  78. package/dist/dev/dom/features/text-track-segment-loader-actor.js.map +1 -0
  79. package/dist/dev/dom/features/text-tracks-actor.d.ts +31 -0
  80. package/dist/dev/dom/features/text-tracks-actor.d.ts.map +1 -0
  81. package/dist/dev/dom/features/text-tracks-actor.js +42 -0
  82. package/dist/dev/dom/features/text-tracks-actor.js.map +1 -0
  83. package/dist/dev/dom/features/track-playback-initiated.d.ts +11 -9
  84. package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
  85. package/dist/dev/dom/features/track-playback-initiated.js +51 -38
  86. package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
  87. package/dist/dev/dom/media/source-buffer-actor.d.ts +14 -8
  88. package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
  89. package/dist/dev/dom/media/source-buffer-actor.js +52 -91
  90. package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
  91. package/dist/dev/dom/network/fetch.js +0 -1
  92. package/dist/dev/dom/network/fetch.js.map +1 -1
  93. package/dist/dev/dom/playback-engine/engine.d.ts +4 -3
  94. package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
  95. package/dist/dev/dom/playback-engine/engine.js +4 -13
  96. package/dist/dev/dom/playback-engine/engine.js.map +1 -1
  97. package/package.json +5 -4
  98. package/dist/default/dom/features/setup-text-tracks.js +0 -67
  99. package/dist/default/dom/features/setup-text-tracks.js.map +0 -1
  100. package/dist/default/dom/features/sync-selected-text-track-from-dom.js +0 -54
  101. package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  102. package/dist/default/dom/features/sync-text-track-modes.js +0 -33
  103. package/dist/default/dom/features/sync-text-track-modes.js.map +0 -1
  104. package/dist/dev/dom/features/setup-text-tracks.js +0 -67
  105. package/dist/dev/dom/features/setup-text-tracks.js.map +0 -1
  106. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +0 -54
  107. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  108. package/dist/dev/dom/features/sync-text-track-modes.js +0 -33
  109. package/dist/dev/dom/features/sync-text-track-modes.js.map +0 -1
@@ -1,159 +1,102 @@
1
- import { getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
2
- import { effect } from "../../core/signals/effect.js";
3
- import { computed } from "../../core/signals/primitives.js";
1
+ import { computed, untrack, update } from "../../core/signals/primitives.js";
4
2
  import { isResolvedTrack } from "../../core/types/index.js";
5
- import { parseVttSegment } from "../text/parse-vtt-segment.js";
3
+ import { createMachineReactor } from "../../core/create-machine-reactor.js";
4
+ import { createTextTrackSegmentLoaderActor } from "./text-track-segment-loader-actor.js";
5
+ import { createTextTracksActor } from "./text-tracks-actor.js";
6
6
  //#region src/dom/features/load-text-track-cues.ts
7
- const CueKeys = [
8
- "startTime",
9
- "endTime",
10
- "text"
11
- ];
12
- function isDuplicateCue(cue, existingCues) {
13
- return Array.prototype.some.call(existingCues ?? [], (existingCue) => {
14
- return CueKeys.every((k) => existingCue[k] === cue[k]);
15
- });
7
+ function getTextTracks(presentation) {
8
+ return presentation?.selectionSets?.find((s) => s.type === "text")?.switchingSets[0]?.tracks;
16
9
  }
17
- const loadVttSegmentTask = async ({ segment }, { textTrack }) => {
18
- (await parseVttSegment(segment.url)).forEach((cue) => {
19
- if (isDuplicateCue(cue, textTrack.cues)) return;
20
- textTrack.addCue(cue);
21
- });
22
- };
23
- /**
24
- * Load text track cues task (composite - orchestrates VTT segment subtasks).
25
- */
26
- const loadTextTrackCuesTask = async ({ currentState }, context) => {
27
- const track = findSelectedTextTrack(currentState);
28
- if (!track || !isResolvedTrack(track)) return;
29
- const { segments } = track;
30
- if (segments.length === 0) return;
31
- const trackId = track.id;
32
- const loadedIds = new Set((currentState.textBufferState?.[trackId]?.segments ?? []).map((s) => s.id));
33
- const segmentsToLoad = getSegmentsToLoad(segments, segments.filter((s) => loadedIds.has(s.id)), currentState.currentTime ?? 0).filter((s) => !loadedIds.has(s.id));
34
- if (segmentsToLoad.length === 0) return;
35
- for (const segment of segmentsToLoad) {
36
- if (context.signal.aborted) break;
37
- try {
38
- await loadVttSegmentTask({ segment }, { textTrack: context.textTrack });
39
- const latestState = context.state.get();
40
- const latest = latestState.textBufferState ?? {};
41
- const trackState = latest[trackId] ?? { segments: [] };
42
- context.state.set({
43
- ...latestState,
44
- textBufferState: {
45
- ...latest,
46
- [trackId]: { segments: [...trackState.segments, { id: segment.id }] }
47
- }
48
- });
49
- } catch (error) {
50
- if (error instanceof Error && error.name === "AbortError") break;
51
- console.error("Failed to load VTT segment:", error);
52
- }
53
- }
54
- if (context.textTrack.mode === "showing" && context.textTrack.cues) Array.from(context.textTrack.cues).forEach((cue) => {
55
- context.textTrack.addCue(cue);
56
- });
57
- await new Promise((resolve) => requestAnimationFrame(resolve));
58
- };
59
- /**
60
- * Find the selected text track in the presentation.
61
- */
62
- function findSelectedTextTrack(state) {
63
- if (!state.presentation || !state.selectedTextTrackId) return;
64
- const textSet = state.presentation.selectionSets.find((set) => set.type === "text");
65
- if (!textSet?.switchingSets?.[0]?.tracks) return;
66
- return textSet.switchingSets[0].tracks.find((t) => t.id === state.selectedTextTrackId);
10
+ function findSelectedTrack(state) {
11
+ const track = getTextTracks(state.presentation)?.find((t) => t.id === state.selectedTextTrackId);
12
+ return track && isResolvedTrack(track) ? track : void 0;
67
13
  }
68
14
  /**
69
- * Get the browser's TextTrack object for the selected text track.
70
- *
71
- * Retrieves the live TextTrack interface from the track element in owners,
72
- * which is used for adding cues, checking mode, and managing track state.
15
+ * Derives the correct state from current state and owners.
73
16
  *
74
- * Note: Returns the DOM TextTrack interface (HTMLTrackElement.track),
75
- * not the presentation Track metadata type.
76
- *
77
- * @param state - Current playback state (track selection)
78
- * @param owners - DOM owners containing track elements map
79
- * @returns DOM TextTrack interface or undefined if not found
17
+ * States are mutually exclusive and exhaustive:
18
+ * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks
19
+ * - `'setting-up'`: preconditions met; actors not yet in owners
20
+ * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM
21
+ * - `'monitoring-for-loads'`: selected track resolved, in DOM ready to dispatch load messages
80
22
  */
81
- function getSelectedTextTrackFromOwners(state, owners) {
82
- const trackId = state.selectedTextTrackId;
83
- if (!trackId || !owners.textTracks) return;
84
- return owners.textTracks.get(trackId)?.track;
23
+ function deriveState(state, owners) {
24
+ if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) return "preconditions-unmet";
25
+ if (!owners.textTracksActor || !owners.segmentLoaderActor) return "setting-up";
26
+ const track = findSelectedTrack(state);
27
+ if (!track || track.segments.length === 0) return "pending";
28
+ if (!Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId)) return "pending";
29
+ return "monitoring-for-loads";
85
30
  }
86
- /**
87
- * Check if we can load text track cues.
88
- *
89
- * Requires:
90
- * - Selected text track ID exists
91
- * - Track elements map exists
92
- * - Track element exists for selected track
93
- */
94
- function canLoadTextTrackCues(state, owners) {
95
- return !!state.selectedTextTrackId && !!owners.textTracks && owners.textTracks.has(state.selectedTextTrackId);
31
+ function teardownActors(owners) {
32
+ const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get());
33
+ if (!textTracksActor && !segmentLoaderActor) return;
34
+ textTracksActor?.destroy();
35
+ segmentLoaderActor?.destroy();
36
+ update(owners, {
37
+ textTracksActor: void 0,
38
+ segmentLoaderActor: void 0
39
+ });
96
40
  }
97
41
  /**
98
- * Check if we should load text track cues.
42
+ * Text track cue loading orchestration.
99
43
  *
100
- * Only load if:
101
- * - Track is resolved (has segments)
102
- * - Track has at least one segment
103
- * - Track element exists
104
- */
105
- function shouldLoadTextTrackCues(state, owners) {
106
- if (!canLoadTextTrackCues(state, owners)) return false;
107
- const track = findSelectedTextTrack(state);
108
- if (!track || !isResolvedTrack(track) || track.segments.length === 0) return false;
109
- if (!getSelectedTextTrackFromOwners(state, owners)) return false;
110
- return true;
111
- }
112
- /**
113
- * Load text track cues orchestration.
44
+ * A single `always` monitor keeps the reactor in sync with conditions.
45
+ * Actor lifecycle is managed across two states:
114
46
  *
115
- * Triggers when:
116
- * - Text track is selected
117
- * - Track is resolved (has segments)
118
- * - Track element exists
47
+ * - **`'setting-up'`** — creates `TextTracksActor` and `TextTrackSegmentLoaderActor`
48
+ * and writes them to owners. Entry resets any stale actors first.
49
+ * - **`'preconditions-unmet'`** destroys any actors in owners and resets them to
50
+ * `undefined`. Handles all paths back from active states.
51
+ * - **`'monitoring-for-loads'`** — reactive dispatch: re-runs whenever `state`
52
+ * changes (selection, currentTime, presentation) and sends a `load` message to
53
+ * the segment loader.
119
54
  *
120
- * Fetches and parses VTT segments within the forward buffer window, then adds
121
- * cues to the track incrementally. Continues on segment errors to provide
122
- * partial subtitles.
55
+ * **Destroy note:** actors written to owners are NOT auto-destroyed when
56
+ * `reactor.destroy()` is called. Callers must read them from owners and destroy
57
+ * them explicitly alongside the reactor.
123
58
  *
124
59
  * @example
125
- * const cleanup = loadTextTrackCues({ state, owners });
60
+ * const reactor = loadTextTrackCues({ state, owners });
61
+ * // later:
62
+ * const { textTracksActor, segmentLoaderActor } = owners.get();
63
+ * textTracksActor?.destroy();
64
+ * segmentLoaderActor?.destroy();
65
+ * reactor.destroy();
126
66
  */
127
67
  function loadTextTrackCues({ state, owners }) {
128
- let currentTask = null;
129
- let abortController = null;
130
- let lastTrackId;
131
- const selectedTrackId = computed(() => state.get().selectedTextTrackId);
132
- const cleanupEffect = effect(() => {
133
- const s = state.get();
134
- const o = owners.get();
135
- if (selectedTrackId.get() !== lastTrackId) {
136
- lastTrackId = selectedTrackId.get();
137
- abortController?.abort();
138
- currentTask = null;
68
+ const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));
69
+ const currentTimeSignal = computed(() => state.get().currentTime ?? 0);
70
+ const selectedTrackSignal = computed(() => findSelectedTrack(state.get()));
71
+ return createMachineReactor({
72
+ initial: "preconditions-unmet",
73
+ monitor: () => derivedStateSignal.get(),
74
+ states: {
75
+ "preconditions-unmet": { entry: () => {
76
+ teardownActors(owners);
77
+ } },
78
+ "setting-up": { entry: () => {
79
+ teardownActors(owners);
80
+ const mediaElement = owners.get().mediaElement;
81
+ const textTracksActor = createTextTracksActor(mediaElement);
82
+ update(owners, {
83
+ textTracksActor,
84
+ segmentLoaderActor: createTextTrackSegmentLoaderActor(textTracksActor)
85
+ });
86
+ } },
87
+ pending: {},
88
+ "monitoring-for-loads": { effects: () => {
89
+ const currentTime = currentTimeSignal.get();
90
+ const track = selectedTrackSignal.get();
91
+ const { segmentLoaderActor } = untrack(() => owners.get());
92
+ segmentLoaderActor.send({
93
+ type: "load",
94
+ track,
95
+ currentTime
96
+ });
97
+ } }
139
98
  }
140
- if (currentTask) return;
141
- if (!shouldLoadTextTrackCues(s, o)) return;
142
- const textTrack = getSelectedTextTrackFromOwners(s, o);
143
- if (!textTrack) return;
144
- abortController = new AbortController();
145
- currentTask = loadTextTrackCuesTask({ currentState: s }, {
146
- signal: abortController.signal,
147
- textTrack,
148
- state
149
- }).finally(() => {
150
- currentTask = null;
151
- });
152
99
  });
153
- return () => {
154
- abortController?.abort();
155
- cleanupEffect();
156
- };
157
100
  }
158
101
  //#endregion
159
102
  export { loadTextTrackCues };
@@ -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 { 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
+ {"version":3,"file":"load-text-track-cues.js","names":[],"sources":["../../../../src/dom/features/load-text-track-cues.ts"],"sourcesContent":["import type { Reactor } from '../../core/create-machine-reactor';\nimport { createMachineReactor } from '../../core/create-machine-reactor';\nimport { computed, type Signal, untrack, update } from '../../core/signals/primitives';\nimport type { Presentation, TextTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport type { TextTrackSegmentLoaderActor } from './text-track-segment-loader-actor';\nimport { createTextTrackSegmentLoaderActor } from './text-track-segment-loader-actor';\nimport type { TextTracksActor } from './text-tracks-actor';\nimport { createTextTracksActor } from './text-tracks-actor';\n\n/**\n * FSM states for text track cue loading.\n *\n * ```\n * 'preconditions-unmet' ── mediaElement + presentation + text tracks ──→ 'setting-up'\n * ↑ |\n * │ actors created\n * │ in owners\n * │ ↓\n * ├───────────────── preconditions lost ─────────────────────── 'pending'\n * │ |\n * │ selectedTrack resolved + in DOM\n * │ ↓\n * └───────────────── preconditions lost ──────────── 'monitoring-for-loads'\n *\n * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'\n * ```\n */\nexport type LoadTextTrackCuesState = 'preconditions-unmet' | 'setting-up' | 'pending' | 'monitoring-for-loads';\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}\n\n/**\n * Owners shape for text track cue loading.\n *\n * `textTracksActor` and `segmentLoaderActor` are managed by this reactor:\n * written to owners in `'setting-up'` and reset to `undefined` on entry to\n * `'preconditions-unmet'` or `'setting-up'`. Callers are responsible for\n * destroying them when the reactor itself is destroyed (actors are not\n * auto-cleaned on `destroy()` — read them from owners and destroy explicitly).\n */\nexport interface TextTrackCueLoadingOwners {\n mediaElement?: HTMLMediaElement | undefined;\n textTracksActor?: TextTracksActor | undefined;\n segmentLoaderActor?: TextTrackSegmentLoaderActor | undefined;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction getTextTracks(presentation: Presentation | undefined) {\n return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks;\n}\n\nfunction findSelectedTrack(state: TextTrackCueLoadingState): TextTrack | undefined {\n const track = getTextTracks(state.presentation)?.find((t) => t.id === state.selectedTextTrackId);\n return track && isResolvedTrack(track) ? track : undefined;\n}\n\n/**\n * Derives the correct state from current state and owners.\n *\n * States are mutually exclusive and exhaustive:\n * - `'preconditions-unmet'`: no mediaElement, or no resolved presentation with text tracks\n * - `'setting-up'`: preconditions met; actors not yet in owners\n * - `'pending'`: actors alive; no selection, or selected track not yet resolved/in DOM\n * - `'monitoring-for-loads'`: selected track resolved, in DOM — ready to dispatch load messages\n */\nfunction deriveState(state: TextTrackCueLoadingState, owners: TextTrackCueLoadingOwners): LoadTextTrackCuesState {\n if (!owners.mediaElement || !getTextTracks(state.presentation)?.length) {\n return 'preconditions-unmet';\n }\n if (!owners.textTracksActor || !owners.segmentLoaderActor) {\n return 'setting-up';\n }\n const track = findSelectedTrack(state);\n if (!track || track.segments.length === 0) return 'pending';\n if (!Array.from(owners.mediaElement.textTracks).some((t) => t.id === state.selectedTextTrackId)) {\n return 'pending';\n }\n return 'monitoring-for-loads';\n}\n\nfunction teardownActors(owners: Signal<TextTrackCueLoadingOwners>) {\n const { textTracksActor, segmentLoaderActor } = untrack(() => owners.get());\n // Only update owners if there are actors to clean up — avoids spurious\n // signal writes on initial startup that would re-trigger other features.\n if (!textTracksActor && !segmentLoaderActor) return;\n textTracksActor?.destroy();\n segmentLoaderActor?.destroy();\n update(owners, { textTracksActor: undefined, segmentLoaderActor: undefined });\n}\n\n// ============================================================================\n// Main export\n// ============================================================================\n\n/**\n * Text track cue loading orchestration.\n *\n * A single `always` monitor keeps the reactor in sync with conditions.\n * Actor lifecycle is managed across two states:\n *\n * - **`'setting-up'`** — creates `TextTracksActor` and `TextTrackSegmentLoaderActor`\n * and writes them to owners. Entry resets any stale actors first.\n * - **`'preconditions-unmet'`** — destroys any actors in owners and resets them to\n * `undefined`. Handles all paths back from active states.\n * - **`'monitoring-for-loads'`** — reactive dispatch: re-runs whenever `state`\n * changes (selection, currentTime, presentation) and sends a `load` message to\n * the segment loader.\n *\n * **Destroy note:** actors written to owners are NOT auto-destroyed when\n * `reactor.destroy()` is called. Callers must read them from owners and destroy\n * them explicitly alongside the reactor.\n *\n * @example\n * const reactor = loadTextTrackCues({ state, owners });\n * // later:\n * const { textTracksActor, segmentLoaderActor } = owners.get();\n * textTracksActor?.destroy();\n * segmentLoaderActor?.destroy();\n * reactor.destroy();\n */\nexport function loadTextTrackCues<S extends TextTrackCueLoadingState, O extends TextTrackCueLoadingOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): Reactor<LoadTextTrackCuesState | 'destroying' | 'destroyed'> {\n const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));\n const currentTimeSignal = computed(() => state.get().currentTime ?? 0);\n const selectedTrackSignal = computed(() => findSelectedTrack(state.get()));\n\n return createMachineReactor<LoadTextTrackCuesState>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {\n // Entry: defensive actor reset on state entry (no-op if already undefined).\n entry: () => {\n teardownActors(owners);\n },\n },\n\n 'setting-up': {\n // Entry: reset any stale actors, then create fresh ones and write to owners.\n // The fn body is automatically untracked — no untrack() needed for mediaElement.\n entry: () => {\n teardownActors(owners);\n const mediaElement = owners.get().mediaElement as HTMLMediaElement;\n const textTracksActor = createTextTracksActor(mediaElement);\n const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor);\n update(owners, { textTracksActor, segmentLoaderActor } as Partial<O>);\n },\n },\n\n pending: {},\n\n 'monitoring-for-loads': {\n // Reaction: re-runs whenever currentTime or selectedTrack changes, dispatching\n // a load message to the segment loader. owners is read with untrack() since\n // actor presence is guaranteed by deriveState when in this state.\n effects: () => {\n const currentTime = currentTimeSignal.get();\n const track = selectedTrackSignal.get()!;\n // deriveState guarantees segmentLoaderActor is in owners and findSelectedTrack\n // returns a valid resolved track when in this state. The always monitor\n // (registered before this effect) transitions us out before this re-runs\n // if either invariant ever stops holding.\n const { segmentLoaderActor } = untrack(() => owners.get());\n segmentLoaderActor!.send({ type: 'load', track, currentTime });\n },\n },\n },\n });\n}\n"],"mappings":";;;;;;AA2DA,SAAS,cAAc,cAAwC;AAC7D,QAAO,cAAc,eAAe,MAAM,MAAM,EAAE,SAAS,OAAO,EAAE,cAAc,IAAI;;AAGxF,SAAS,kBAAkB,OAAwD;CACjF,MAAM,QAAQ,cAAc,MAAM,aAAa,EAAE,MAAM,MAAM,EAAE,OAAO,MAAM,oBAAoB;AAChG,QAAO,SAAS,gBAAgB,MAAM,GAAG,QAAQ,KAAA;;;;;;;;;;;AAYnD,SAAS,YAAY,OAAiC,QAA2D;AAC/G,KAAI,CAAC,OAAO,gBAAgB,CAAC,cAAc,MAAM,aAAa,EAAE,OAC9D,QAAO;AAET,KAAI,CAAC,OAAO,mBAAmB,CAAC,OAAO,mBACrC,QAAO;CAET,MAAM,QAAQ,kBAAkB,MAAM;AACtC,KAAI,CAAC,SAAS,MAAM,SAAS,WAAW,EAAG,QAAO;AAClD,KAAI,CAAC,MAAM,KAAK,OAAO,aAAa,WAAW,CAAC,MAAM,MAAM,EAAE,OAAO,MAAM,oBAAoB,CAC7F,QAAO;AAET,QAAO;;AAGT,SAAS,eAAe,QAA2C;CACjE,MAAM,EAAE,iBAAiB,uBAAuB,cAAc,OAAO,KAAK,CAAC;AAG3E,KAAI,CAAC,mBAAmB,CAAC,mBAAoB;AAC7C,kBAAiB,SAAS;AAC1B,qBAAoB,SAAS;AAC7B,QAAO,QAAQ;EAAE,iBAAiB,KAAA;EAAW,oBAAoB,KAAA;EAAW,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiC/E,SAAgB,kBAA2F,EACzG,OACA,UAI+D;CAC/D,MAAM,qBAAqB,eAAe,YAAY,MAAM,KAAK,EAAE,OAAO,KAAK,CAAC,CAAC;CACjF,MAAM,oBAAoB,eAAe,MAAM,KAAK,CAAC,eAAe,EAAE;CACtE,MAAM,sBAAsB,eAAe,kBAAkB,MAAM,KAAK,CAAC,CAAC;AAE1E,QAAO,qBAA6C;EAClD,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAErB,aAAa;AACX,mBAAe,OAAO;MAEzB;GAED,cAAc,EAGZ,aAAa;AACX,mBAAe,OAAO;IACtB,MAAM,eAAe,OAAO,KAAK,CAAC;IAClC,MAAM,kBAAkB,sBAAsB,aAAa;AAE3D,WAAO,QAAQ;KAAE;KAAiB,oBADP,kCAAkC,gBAAgB;KACvB,CAAe;MAExE;GAED,SAAS,EAAE;GAEX,wBAAwB,EAItB,eAAe;IACb,MAAM,cAAc,kBAAkB,KAAK;IAC3C,MAAM,QAAQ,oBAAoB,KAAK;IAKvC,MAAM,EAAE,uBAAuB,cAAc,OAAO,KAAK,CAAC;AAC1D,uBAAoB,KAAK;KAAE,MAAM;KAAQ;KAAO;KAAa,CAAC;MAEjE;GACF;EACF,CAAC"}
@@ -1,17 +1,121 @@
1
1
  import { calculateForwardFlushPoint, getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
2
+ import { effect } from "../../core/signals/effect.js";
2
3
  import { calculateBackBufferFlushPoint } from "../../core/buffer/back-buffer.js";
4
+ import { createMachineActor } from "../../core/create-machine-actor.js";
5
+ import { SerialRunner, Task } from "../../core/task.js";
3
6
  //#region src/dom/features/segment-loader-actor.ts
4
7
  /**
8
+ * Resolves when the SourceBufferActor snapshot reaches 'idle'.
9
+ * Rejects if the signal is aborted or the actor is destroyed.
10
+ *
11
+ * Used to sequence SourceBufferActor operations without awaiting send()
12
+ * directly — send() is fire-and-forget; callers observe completion via
13
+ * state transition.
14
+ */
15
+ function waitForIdle(snapshot, signal) {
16
+ return new Promise((resolve, reject) => {
17
+ if (snapshot.get().value === "idle") {
18
+ resolve();
19
+ return;
20
+ }
21
+ if (snapshot.get().value === "destroyed") {
22
+ reject(new DOMException("Aborted", "AbortError"));
23
+ return;
24
+ }
25
+ if (signal.aborted) {
26
+ reject(signal.reason);
27
+ return;
28
+ }
29
+ let stop;
30
+ const cleanup = (fn) => {
31
+ stop?.();
32
+ signal.removeEventListener("abort", onAbort);
33
+ fn();
34
+ };
35
+ const onAbort = () => cleanup(() => reject(signal.reason));
36
+ stop = effect(() => {
37
+ const value = snapshot.get().value;
38
+ if (value === "idle") cleanup(resolve);
39
+ else if (value === "destroyed") cleanup(() => reject(new DOMException("Aborted", "AbortError")));
40
+ });
41
+ signal.addEventListener("abort", onAbort, { once: true });
42
+ });
43
+ }
44
+ /**
45
+ * Wraps a LoadTask descriptor into a Task that fetches (if needed) and
46
+ * forwards to SourceBufferActor. Updates in-flight context around async
47
+ * operations so the loading handler can make accurate continue/preempt
48
+ * decisions at any point.
49
+ */
50
+ function makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor }) {
51
+ return new Task(async (taskSignal) => {
52
+ if (taskSignal.aborted) return;
53
+ if (op.type === "remove") {
54
+ sourceBufferActor.send(op);
55
+ await waitForIdle(sourceBufferActor.snapshot, taskSignal);
56
+ return;
57
+ }
58
+ if (op.type === "append-init") {
59
+ setContext({
60
+ ...getContext(),
61
+ inFlightInitTrackId: op.meta.trackId
62
+ });
63
+ try {
64
+ const data = await fetchBytes(op, {
65
+ signal: taskSignal,
66
+ minChunkSize: Infinity
67
+ });
68
+ if (!taskSignal.aborted) {
69
+ sourceBufferActor.send({
70
+ type: "append-init",
71
+ data,
72
+ meta: op.meta
73
+ });
74
+ await waitForIdle(sourceBufferActor.snapshot, taskSignal);
75
+ }
76
+ } finally {
77
+ setContext({
78
+ ...getContext(),
79
+ inFlightInitTrackId: null
80
+ });
81
+ }
82
+ return;
83
+ }
84
+ setContext({
85
+ ...getContext(),
86
+ inFlightSegmentId: op.meta.id
87
+ });
88
+ try {
89
+ const stream = await fetchBytes(op, { signal: taskSignal });
90
+ if (!taskSignal.aborted) {
91
+ sourceBufferActor.send({
92
+ type: "append-segment",
93
+ data: stream,
94
+ meta: op.meta
95
+ });
96
+ await waitForIdle(sourceBufferActor.snapshot, taskSignal);
97
+ }
98
+ } finally {
99
+ setContext({
100
+ ...getContext(),
101
+ inFlightSegmentId: null
102
+ });
103
+ }
104
+ });
105
+ }
106
+ /**
5
107
  * Creates a SegmentLoaderActor for one track type (video or audio).
6
108
  *
7
109
  * Receives load assignments via `send()` and owns all execution: planning,
8
110
  * removes, fetches, and appends. Coordinates with the SourceBufferActor for
9
111
  * all physical SourceBuffer operations.
10
112
  *
11
- * Planning (Cases 1–3) happens in `send()` on every incoming message, producing
12
- * an ordered LoadTask list. The runner drains that list sequentially. When a new
13
- * message arrives mid-run, send() replans and either continues the in-flight
14
- * operation (if still needed) or preempts it.
113
+ * Planning (Cases 1–3) happens in the `load` handler on every incoming
114
+ * message, producing an ordered LoadTask list. The runner drains that list
115
+ * sequentially via SerialRunner. When a new message arrives mid-run, the
116
+ * handler replans and either continues the in-flight operation (abortPending
117
+ * + schedule new remainder) or preempts it (abortAll + cancel SourceBuffer
118
+ * if needed + schedule new plan).
15
119
  *
16
120
  * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)
17
121
  * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).
@@ -19,19 +123,13 @@ import { calculateBackBufferFlushPoint } from "../../core/buffer/back-buffer.js"
19
123
  * so the entire body accumulates as one chunk before appending.
20
124
  */
21
125
  function createSegmentLoaderActor(sourceBufferActor, fetchBytes) {
22
- let pendingTasks = null;
23
- let inFlightInitTrackId = null;
24
- let inFlightSegmentId = null;
25
- let abortController = null;
26
- let running = false;
27
- let destroyed = false;
28
126
  const getBufferedSegments = (allSegments) => {
29
127
  const bufferedIds = new Set(sourceBufferActor.snapshot.get().context.segments.filter((s) => !s.partial).map((s) => s.id));
30
128
  return allSegments.filter((s) => bufferedIds.has(s.id));
31
129
  };
32
130
  /**
33
131
  * Translate a load message into an ordered LoadTask list based on committed
34
- * actor state. In-flight awareness is handled separately in send().
132
+ * actor state. In-flight awareness is handled separately in the load handler.
35
133
  *
36
134
  * @todo Rename alongside LoadTask (e.g. planOps).
37
135
  *
@@ -93,102 +191,51 @@ function createSegmentLoaderActor(sourceBufferActor, fetchBytes) {
93
191
  }
94
192
  return tasks;
95
193
  };
96
- /**
97
- * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor.
98
- * Sets/clears in-flight tracking around async operations so send() can make
99
- * accurate continue/preempt decisions at any point during execution.
100
- *
101
- * @todo Rename alongside LoadTask (e.g. executeOp).
102
- */
103
- const executeLoadTask = async (task) => {
104
- const signal = abortController.signal;
105
- try {
106
- if (task.type === "remove") {
107
- await sourceBufferActor.send(task, signal);
108
- return;
109
- }
110
- if (task.type === "append-init") {
111
- inFlightInitTrackId = task.meta.trackId;
112
- if (!signal.aborted) {
113
- const data = await fetchBytes(task, {
114
- signal,
115
- minChunkSize: Infinity
116
- });
117
- const isTrackSwitch = pendingTasks?.some((t) => t.type === "append-init" && t.meta.trackId !== task.meta.trackId);
118
- if (!signal.aborted || !isTrackSwitch) {
119
- const appendSignal = signal.aborted ? new AbortController().signal : signal;
120
- await sourceBufferActor.send({
121
- type: "append-init",
122
- data,
123
- meta: task.meta
124
- }, appendSignal);
125
- }
126
- }
127
- return;
128
- }
129
- inFlightSegmentId = task.meta.id;
130
- if (!signal.aborted) {
131
- const stream = await fetchBytes(task, { signal });
132
- if (!signal.aborted) await sourceBufferActor.send({
133
- type: "append-segment",
134
- data: stream,
135
- meta: task.meta
136
- }, signal);
137
- }
138
- } finally {
139
- inFlightInitTrackId = null;
140
- inFlightSegmentId = null;
141
- }
142
- };
143
- /**
144
- * Drain the scheduled task list sequentially.
145
- * After each task completes, checks for a pending replacement plan from send().
146
- * If the signal was aborted and no new plan arrived, stops immediately.
147
- */
148
- const runScheduled = async (initialTasks) => {
149
- running = true;
150
- abortController = new AbortController();
151
- let scheduled = initialTasks;
152
- while (scheduled.length > 0 && !destroyed) {
153
- const task = scheduled[0];
154
- scheduled = scheduled.slice(1);
155
- try {
156
- await executeLoadTask(task);
157
- } catch (error) {
158
- if (error instanceof Error && error.name === "AbortError") {} else {
159
- console.error("Unexpected error in segment loader:", error);
160
- scheduled = [];
161
- }
162
- }
163
- if (pendingTasks !== null) {
164
- scheduled = pendingTasks;
165
- pendingTasks = null;
166
- abortController = new AbortController();
167
- } else if (abortController.signal.aborted) break;
168
- }
169
- abortController = null;
170
- running = false;
194
+ const scheduleAll = (tasks, { getContext, setContext, runner }) => {
195
+ tasks.forEach((op) => {
196
+ runner.schedule(makeLoadTask(op, {
197
+ getContext,
198
+ setContext,
199
+ fetchBytes,
200
+ sourceBufferActor
201
+ })).then(void 0, (e) => {
202
+ if (e instanceof Error && e.name === "AbortError") return;
203
+ console.error("Unexpected error in segment loader:", e);
204
+ runner.abortPending();
205
+ });
206
+ });
171
207
  };
172
- return {
173
- send(message) {
174
- if (destroyed) return;
175
- const allTasks = planTasks(message);
176
- if (!running) {
208
+ return createMachineActor({
209
+ runner: () => new SerialRunner(),
210
+ initial: "idle",
211
+ context: {
212
+ inFlightInitTrackId: null,
213
+ inFlightSegmentId: null
214
+ },
215
+ states: {
216
+ idle: { on: { load: (msg, ctx) => {
217
+ const allTasks = planTasks(msg);
177
218
  if (allTasks.length === 0) return;
178
- runScheduled(allTasks);
179
- return;
180
- }
181
- if (inFlightSegmentId !== null && allTasks.some((t) => t.type === "append-segment" && t.meta.id === inFlightSegmentId) || inFlightInitTrackId !== null && allTasks.some((t) => t.type === "append-init" && t.meta.trackId === inFlightInitTrackId)) pendingTasks = allTasks.filter((t) => !(t.type === "append-segment" && t.meta.id === inFlightSegmentId) && !(t.type === "append-init" && t.meta.trackId === inFlightInitTrackId));
182
- else {
183
- pendingTasks = allTasks;
184
- abortController?.abort();
219
+ ctx.transition("loading");
220
+ scheduleAll(allTasks, ctx);
221
+ } } },
222
+ loading: {
223
+ onSettled: "idle",
224
+ on: { load: (msg, ctx) => {
225
+ const { context, runner } = ctx;
226
+ const allTasks = planTasks(msg);
227
+ if (context.inFlightSegmentId !== null && allTasks.some((t) => t.type === "append-segment" && t.meta.id === context.inFlightSegmentId) || context.inFlightInitTrackId !== null && allTasks.some((t) => t.type === "append-init" && t.meta.trackId === context.inFlightInitTrackId)) {
228
+ runner.abortPending();
229
+ scheduleAll(allTasks.filter((t) => !(t.type === "append-segment" && t.meta.id === context.inFlightSegmentId) && !(t.type === "append-init" && t.meta.trackId === context.inFlightInitTrackId)), ctx);
230
+ } else {
231
+ runner.abortAll();
232
+ if (context.inFlightSegmentId !== null || context.inFlightInitTrackId !== null && allTasks.some((t) => t.type === "append-init" && t.meta.trackId !== context.inFlightInitTrackId)) sourceBufferActor.send({ type: "cancel" });
233
+ scheduleAll(allTasks, ctx);
234
+ }
235
+ } }
185
236
  }
186
- },
187
- destroy() {
188
- destroyed = true;
189
- abortController?.abort();
190
237
  }
191
- };
238
+ });
192
239
  }
193
240
  //#endregion
194
241
  export { createSegmentLoaderActor };