@videojs/spf 10.0.0-beta.15 → 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.
- package/dist/default/core/buffer/forward-buffer.js.map +1 -1
- package/dist/default/core/create-machine-actor.js +91 -0
- package/dist/default/core/create-machine-actor.js.map +1 -0
- package/dist/default/core/create-machine-reactor.js +83 -0
- package/dist/default/core/create-machine-reactor.js.map +1 -0
- package/dist/default/core/create-transition-actor.js +46 -0
- package/dist/default/core/create-transition-actor.js.map +1 -0
- package/dist/default/core/features/resolve-presentation.js +42 -46
- package/dist/default/core/features/resolve-presentation.js.map +1 -1
- package/dist/default/core/features/resolve-track.js +1 -1
- package/dist/default/core/machine.js +26 -0
- package/dist/default/core/machine.js.map +1 -0
- package/dist/default/core/signals/primitives.js +1 -1
- package/dist/default/core/task.js +76 -7
- package/dist/default/core/task.js.map +1 -1
- package/dist/default/core/types/index.js.map +1 -1
- package/dist/default/dom/features/end-of-stream.js +5 -5
- package/dist/default/dom/features/end-of-stream.js.map +1 -1
- package/dist/default/dom/features/load-text-track-cues.js +80 -137
- package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
- package/dist/default/dom/features/segment-loader-actor.js +150 -103
- package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
- package/dist/default/dom/features/sync-text-tracks.js +95 -0
- package/dist/default/dom/features/sync-text-tracks.js.map +1 -0
- package/dist/default/dom/features/text-track-segment-loader-actor.js +55 -0
- package/dist/default/dom/features/text-track-segment-loader-actor.js.map +1 -0
- package/dist/default/dom/features/text-tracks-actor.js +42 -0
- package/dist/default/dom/features/text-tracks-actor.js.map +1 -0
- package/dist/default/dom/features/track-playback-initiated.js +51 -38
- package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
- package/dist/default/dom/media/source-buffer-actor.js +52 -91
- package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
- package/dist/default/dom/network/fetch.js +0 -1
- package/dist/default/dom/network/fetch.js.map +1 -1
- package/dist/default/dom/playback-engine/engine.js +4 -13
- package/dist/default/dom/playback-engine/engine.js.map +1 -1
- package/dist/dev/core/actor.d.ts +19 -10
- package/dist/dev/core/actor.d.ts.map +1 -1
- package/dist/dev/core/buffer/forward-buffer.js.map +1 -1
- package/dist/dev/core/create-machine-actor.d.ts +12 -0
- package/dist/dev/core/create-machine-actor.d.ts.map +1 -0
- package/dist/dev/core/create-machine-actor.js +91 -0
- package/dist/dev/core/create-machine-actor.js.map +1 -0
- package/dist/dev/core/create-machine-reactor.d.ts +8 -0
- package/dist/dev/core/create-machine-reactor.d.ts.map +1 -0
- package/dist/dev/core/create-machine-reactor.js +83 -0
- package/dist/dev/core/create-machine-reactor.js.map +1 -0
- package/dist/dev/core/create-transition-actor.d.ts +23 -0
- package/dist/dev/core/create-transition-actor.d.ts.map +1 -0
- package/dist/dev/core/create-transition-actor.js +46 -0
- package/dist/dev/core/create-transition-actor.js.map +1 -0
- package/dist/dev/core/features/resolve-presentation.js +42 -46
- package/dist/dev/core/features/resolve-presentation.js.map +1 -1
- package/dist/dev/core/features/resolve-track.js +1 -1
- package/dist/dev/core/machine.d.ts +21 -0
- package/dist/dev/core/machine.d.ts.map +1 -0
- package/dist/dev/core/machine.js +26 -0
- package/dist/dev/core/machine.js.map +1 -0
- package/dist/dev/core/signals/primitives.js +1 -1
- package/dist/dev/core/task.js +76 -7
- package/dist/dev/core/task.js.map +1 -1
- package/dist/dev/core/types/index.d.ts +1 -8
- package/dist/dev/core/types/index.d.ts.map +1 -1
- package/dist/dev/core/types/index.js.map +1 -1
- package/dist/dev/dom/features/end-of-stream.js +5 -5
- package/dist/dev/dom/features/end-of-stream.js.map +1 -1
- package/dist/dev/dom/features/load-text-track-cues.d.ts +50 -26
- package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
- package/dist/dev/dom/features/load-text-track-cues.js +80 -137
- package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
- package/dist/dev/dom/features/segment-loader-actor.js +150 -103
- package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
- package/dist/dev/dom/features/sync-text-tracks.js +95 -0
- package/dist/dev/dom/features/sync-text-tracks.js.map +1 -0
- package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts +12 -0
- package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts.map +1 -0
- package/dist/dev/dom/features/text-track-segment-loader-actor.js +55 -0
- package/dist/dev/dom/features/text-track-segment-loader-actor.js.map +1 -0
- package/dist/dev/dom/features/text-tracks-actor.d.ts +31 -0
- package/dist/dev/dom/features/text-tracks-actor.d.ts.map +1 -0
- package/dist/dev/dom/features/text-tracks-actor.js +42 -0
- package/dist/dev/dom/features/text-tracks-actor.js.map +1 -0
- package/dist/dev/dom/features/track-playback-initiated.d.ts +11 -9
- package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
- package/dist/dev/dom/features/track-playback-initiated.js +51 -38
- package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
- package/dist/dev/dom/media/source-buffer-actor.d.ts +14 -8
- package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
- package/dist/dev/dom/media/source-buffer-actor.js +52 -91
- package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
- package/dist/dev/dom/network/fetch.js +0 -1
- package/dist/dev/dom/network/fetch.js.map +1 -1
- package/dist/dev/dom/playback-engine/engine.d.ts +4 -3
- package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
- package/dist/dev/dom/playback-engine/engine.js +4 -13
- package/dist/dev/dom/playback-engine/engine.js.map +1 -1
- package/package.json +5 -4
- package/dist/default/dom/features/setup-text-tracks.js +0 -67
- package/dist/default/dom/features/setup-text-tracks.js.map +0 -1
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js +0 -54
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
- package/dist/default/dom/features/sync-text-track-modes.js +0 -33
- package/dist/default/dom/features/sync-text-track-modes.js.map +0 -1
- package/dist/dev/dom/features/setup-text-tracks.js +0 -67
- package/dist/dev/dom/features/setup-text-tracks.js.map +0 -1
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +0 -54
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
- package/dist/dev/dom/features/sync-text-track-modes.js +0 -33
- package/dist/dev/dom/features/sync-text-track-modes.js.map +0 -1
|
@@ -1,159 +1,102 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
8
|
-
"
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
82
|
-
|
|
83
|
-
if (!
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
*
|
|
42
|
+
* Text track cue loading orchestration.
|
|
99
43
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* -
|
|
118
|
-
*
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 `
|
|
12
|
-
* an ordered LoadTask list. The runner drains that list
|
|
13
|
-
* message arrives mid-run,
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 };
|