@videojs/spf 10.0.0-beta.10 → 10.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -40
- package/dist/default/core/abr/bandwidth-estimator.js +95 -0
- package/dist/default/core/abr/bandwidth-estimator.js.map +1 -0
- package/dist/default/core/abr/ewma.js +71 -0
- package/dist/default/core/abr/ewma.js.map +1 -0
- package/dist/default/core/abr/quality-selection.js +53 -0
- package/dist/default/core/abr/quality-selection.js.map +1 -0
- package/dist/default/core/buffer/back-buffer.js +46 -0
- package/dist/default/core/buffer/back-buffer.js.map +1 -0
- package/dist/default/core/buffer/forward-buffer.js +73 -0
- package/dist/default/core/buffer/forward-buffer.js.map +1 -0
- package/dist/default/core/features/calculate-presentation-duration.js +53 -0
- package/dist/default/core/features/calculate-presentation-duration.js.map +1 -0
- package/dist/default/core/features/quality-switching.js +69 -0
- package/dist/default/core/features/quality-switching.js.map +1 -0
- package/dist/default/core/features/resolve-presentation.js +77 -0
- package/dist/default/core/features/resolve-presentation.js.map +1 -0
- package/dist/default/core/features/resolve-track.js +66 -0
- package/dist/default/core/features/resolve-track.js.map +1 -0
- package/dist/default/core/features/select-tracks.js +130 -0
- package/dist/default/core/features/select-tracks.js.map +1 -0
- package/dist/default/core/features/sync-preload-attribute.js +26 -0
- package/dist/default/core/features/sync-preload-attribute.js.map +1 -0
- package/dist/default/core/hls/parse-attributes.js +134 -0
- package/dist/default/core/hls/parse-attributes.js.map +1 -0
- package/dist/default/core/hls/parse-media-playlist.js +82 -0
- package/dist/default/core/hls/parse-media-playlist.js.map +1 -0
- package/dist/default/core/hls/parse-multivariant.js +214 -0
- package/dist/default/core/hls/parse-multivariant.js.map +1 -0
- package/dist/default/core/hls/resolve-url.js +11 -0
- package/dist/default/core/hls/resolve-url.js.map +1 -0
- package/dist/default/core/signals/effect.js +41 -0
- package/dist/default/core/signals/effect.js.map +1 -0
- package/dist/default/core/signals/primitives.js +32 -0
- package/dist/default/core/signals/primitives.js.map +1 -0
- package/dist/default/core/task.js +122 -0
- package/dist/default/core/task.js.map +1 -0
- package/dist/default/core/types/index.js +15 -0
- package/dist/default/core/types/index.js.map +1 -0
- package/dist/default/core/utils/generate-id.js +21 -0
- package/dist/default/core/utils/generate-id.js.map +1 -0
- package/dist/default/core/utils/track-selection.js +39 -0
- package/dist/default/core/utils/track-selection.js.map +1 -0
- package/dist/default/dom/features/end-of-stream.js +145 -0
- package/dist/default/dom/features/end-of-stream.js.map +1 -0
- package/dist/default/dom/features/load-segments.js +192 -0
- package/dist/default/dom/features/load-segments.js.map +1 -0
- package/dist/default/dom/features/load-text-track-cues.js +161 -0
- package/dist/default/dom/features/load-text-track-cues.js.map +1 -0
- package/dist/default/dom/features/segment-loader-actor.js +196 -0
- package/dist/default/dom/features/segment-loader-actor.js.map +1 -0
- package/dist/default/dom/features/setup-mediasource.js +48 -0
- package/dist/default/dom/features/setup-mediasource.js.map +1 -0
- package/dist/default/dom/features/setup-sourcebuffer.js +78 -0
- package/dist/default/dom/features/setup-sourcebuffer.js.map +1 -0
- package/dist/default/dom/features/setup-text-tracks.js +67 -0
- package/dist/default/dom/features/setup-text-tracks.js.map +1 -0
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js +54 -0
- package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +1 -0
- package/dist/default/dom/features/sync-text-track-modes.js +33 -0
- package/dist/default/dom/features/sync-text-track-modes.js.map +1 -0
- package/dist/default/dom/features/track-current-time.js +49 -0
- package/dist/default/dom/features/track-current-time.js.map +1 -0
- package/dist/default/dom/features/track-playback-initiated.js +53 -0
- package/dist/default/dom/features/track-playback-initiated.js.map +1 -0
- package/dist/default/dom/features/track-playback-rate.js +32 -0
- package/dist/default/dom/features/track-playback-rate.js.map +1 -0
- package/dist/default/dom/features/update-duration.js +78 -0
- package/dist/default/dom/features/update-duration.js.map +1 -0
- package/dist/default/dom/media/append-segment.js +60 -0
- package/dist/default/dom/media/append-segment.js.map +1 -0
- package/dist/default/dom/media/buffer-flusher.js +55 -0
- package/dist/default/dom/media/buffer-flusher.js.map +1 -0
- package/dist/default/dom/media/mediasource-setup.js +146 -0
- package/dist/default/dom/media/mediasource-setup.js.map +1 -0
- package/dist/default/dom/media/source-buffer-actor.js +176 -0
- package/dist/default/dom/media/source-buffer-actor.js.map +1 -0
- package/dist/default/dom/network/chunked-stream-iterable.js +49 -0
- package/dist/default/dom/network/chunked-stream-iterable.js.map +1 -0
- package/dist/default/dom/network/fetch.js +55 -0
- package/dist/default/dom/network/fetch.js.map +1 -0
- package/dist/default/dom/playback-engine/adapter.js +95 -0
- package/dist/default/dom/playback-engine/adapter.js.map +1 -0
- package/dist/default/dom/playback-engine/engine.js +151 -0
- package/dist/default/dom/playback-engine/engine.js.map +1 -0
- package/dist/default/dom/playback-engine.js +4 -0
- package/dist/default/dom/text/parse-vtt-segment.js +56 -0
- package/dist/default/dom/text/parse-vtt-segment.js.map +1 -0
- package/dist/default/dom.js +10 -0
- package/dist/{src-DRsS_H80.js → default/index.js} +3 -3
- package/dist/default/index.js.map +1 -0
- package/dist/dev/core/abr/bandwidth-estimator.d.ts +35 -0
- package/dist/dev/core/abr/bandwidth-estimator.d.ts.map +1 -0
- package/dist/dev/core/abr/bandwidth-estimator.js +95 -0
- package/dist/dev/core/abr/bandwidth-estimator.js.map +1 -0
- package/dist/dev/core/abr/ewma.js +71 -0
- package/dist/dev/core/abr/ewma.js.map +1 -0
- package/dist/dev/core/abr/quality-selection.js +53 -0
- package/dist/dev/core/abr/quality-selection.js.map +1 -0
- package/dist/dev/core/actor.d.ts +18 -0
- package/dist/dev/core/actor.d.ts.map +1 -0
- package/dist/dev/core/buffer/back-buffer.js +46 -0
- package/dist/dev/core/buffer/back-buffer.js.map +1 -0
- package/dist/dev/core/buffer/forward-buffer.js +73 -0
- package/dist/dev/core/buffer/forward-buffer.js.map +1 -0
- package/dist/dev/core/features/calculate-presentation-duration.js +53 -0
- package/dist/dev/core/features/calculate-presentation-duration.js.map +1 -0
- package/dist/dev/core/features/quality-switching.js +69 -0
- package/dist/dev/core/features/quality-switching.js.map +1 -0
- package/dist/dev/core/features/resolve-presentation.js +77 -0
- package/dist/dev/core/features/resolve-presentation.js.map +1 -0
- package/dist/dev/core/features/resolve-track.js +66 -0
- package/dist/dev/core/features/resolve-track.js.map +1 -0
- package/dist/dev/core/features/select-tracks.js +130 -0
- package/dist/dev/core/features/select-tracks.js.map +1 -0
- package/dist/dev/core/features/sync-preload-attribute.js +26 -0
- package/dist/dev/core/features/sync-preload-attribute.js.map +1 -0
- package/dist/dev/core/hls/parse-attributes.js +134 -0
- package/dist/dev/core/hls/parse-attributes.js.map +1 -0
- package/dist/dev/core/hls/parse-media-playlist.js +82 -0
- package/dist/dev/core/hls/parse-media-playlist.js.map +1 -0
- package/dist/dev/core/hls/parse-multivariant.js +214 -0
- package/dist/dev/core/hls/parse-multivariant.js.map +1 -0
- package/dist/dev/core/hls/resolve-url.js +11 -0
- package/dist/dev/core/hls/resolve-url.js.map +1 -0
- package/dist/dev/core/signals/effect.d.ts +15 -0
- package/dist/dev/core/signals/effect.d.ts.map +1 -0
- package/dist/dev/core/signals/effect.js +41 -0
- package/dist/dev/core/signals/effect.js.map +1 -0
- package/dist/dev/core/signals/primitives.d.ts +10 -0
- package/dist/dev/core/signals/primitives.d.ts.map +1 -0
- package/dist/dev/core/signals/primitives.js +32 -0
- package/dist/dev/core/signals/primitives.js.map +1 -0
- package/dist/dev/core/task.js +122 -0
- package/dist/dev/core/task.js.map +1 -0
- package/dist/dev/core/types/index.d.ts +173 -0
- package/dist/dev/core/types/index.d.ts.map +1 -0
- package/dist/dev/core/types/index.js +15 -0
- package/dist/dev/core/types/index.js.map +1 -0
- package/dist/dev/core/utils/generate-id.js +21 -0
- package/dist/dev/core/utils/generate-id.js.map +1 -0
- package/dist/dev/core/utils/track-selection.d.ts +16 -0
- package/dist/dev/core/utils/track-selection.d.ts.map +1 -0
- package/dist/dev/core/utils/track-selection.js +39 -0
- package/dist/dev/core/utils/track-selection.js.map +1 -0
- package/dist/dev/dom/features/end-of-stream.js +145 -0
- package/dist/dev/dom/features/end-of-stream.js.map +1 -0
- package/dist/dev/dom/features/load-segments.d.ts +72 -0
- package/dist/dev/dom/features/load-segments.d.ts.map +1 -0
- package/dist/dev/dom/features/load-segments.js +192 -0
- package/dist/dev/dom/features/load-segments.js.map +1 -0
- package/dist/dev/dom/features/load-text-track-cues.d.ts +61 -0
- package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -0
- package/dist/dev/dom/features/load-text-track-cues.js +161 -0
- package/dist/dev/dom/features/load-text-track-cues.js.map +1 -0
- package/dist/dev/dom/features/segment-loader-actor.d.ts +1 -0
- package/dist/dev/dom/features/segment-loader-actor.js +196 -0
- package/dist/dev/dom/features/segment-loader-actor.js.map +1 -0
- package/dist/dev/dom/features/setup-mediasource.js +48 -0
- package/dist/dev/dom/features/setup-mediasource.js.map +1 -0
- package/dist/dev/dom/features/setup-sourcebuffer.d.ts +9 -0
- package/dist/dev/dom/features/setup-sourcebuffer.d.ts.map +1 -0
- package/dist/dev/dom/features/setup-sourcebuffer.js +78 -0
- package/dist/dev/dom/features/setup-sourcebuffer.js.map +1 -0
- package/dist/dev/dom/features/setup-text-tracks.js +67 -0
- package/dist/dev/dom/features/setup-text-tracks.js.map +1 -0
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +54 -0
- package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +1 -0
- package/dist/dev/dom/features/sync-text-track-modes.js +33 -0
- package/dist/dev/dom/features/sync-text-track-modes.js.map +1 -0
- package/dist/dev/dom/features/track-current-time.d.ts +40 -0
- package/dist/dev/dom/features/track-current-time.d.ts.map +1 -0
- package/dist/dev/dom/features/track-current-time.js +49 -0
- package/dist/dev/dom/features/track-current-time.js.map +1 -0
- package/dist/dev/dom/features/track-playback-initiated.d.ts +44 -0
- package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -0
- package/dist/dev/dom/features/track-playback-initiated.js +53 -0
- package/dist/dev/dom/features/track-playback-initiated.js.map +1 -0
- package/dist/dev/dom/features/track-playback-rate.d.ts +34 -0
- package/dist/dev/dom/features/track-playback-rate.d.ts.map +1 -0
- package/dist/dev/dom/features/track-playback-rate.js +32 -0
- package/dist/dev/dom/features/track-playback-rate.js.map +1 -0
- package/dist/dev/dom/features/update-duration.js +78 -0
- package/dist/dev/dom/features/update-duration.js.map +1 -0
- package/dist/dev/dom/media/append-segment.d.ts +24 -0
- package/dist/dev/dom/media/append-segment.d.ts.map +1 -0
- package/dist/dev/dom/media/append-segment.js +60 -0
- package/dist/dev/dom/media/append-segment.js.map +1 -0
- package/dist/dev/dom/media/buffer-flusher.d.ts +24 -0
- package/dist/dev/dom/media/buffer-flusher.d.ts.map +1 -0
- package/dist/dev/dom/media/buffer-flusher.js +55 -0
- package/dist/dev/dom/media/buffer-flusher.js.map +1 -0
- package/dist/dev/dom/media/mediasource-setup.js +146 -0
- package/dist/dev/dom/media/mediasource-setup.js.map +1 -0
- package/dist/dev/dom/media/source-buffer-actor.d.ts +56 -0
- package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -0
- package/dist/dev/dom/media/source-buffer-actor.js +176 -0
- package/dist/dev/dom/media/source-buffer-actor.js.map +1 -0
- package/dist/dev/dom/network/chunked-stream-iterable.js +49 -0
- package/dist/dev/dom/network/chunked-stream-iterable.js.map +1 -0
- package/dist/dev/dom/network/fetch.js +55 -0
- package/dist/dev/dom/network/fetch.js.map +1 -0
- package/dist/dev/dom/playback-engine/adapter.d.ts +41 -0
- package/dist/dev/dom/playback-engine/adapter.d.ts.map +1 -0
- package/dist/dev/dom/playback-engine/adapter.js +95 -0
- package/dist/dev/dom/playback-engine/adapter.js.map +1 -0
- package/dist/dev/dom/playback-engine/engine.d.ts +123 -0
- package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -0
- package/dist/dev/dom/playback-engine/engine.js +151 -0
- package/dist/dev/dom/playback-engine/engine.js.map +1 -0
- package/dist/dev/dom/playback-engine.d.ts +4 -0
- package/dist/dev/dom/playback-engine.js +4 -0
- package/dist/dev/dom/text/parse-vtt-segment.d.ts +12 -0
- package/dist/dev/dom/text/parse-vtt-segment.d.ts.map +1 -0
- package/dist/dev/dom/text/parse-vtt-segment.js +56 -0
- package/dist/dev/dom/text/parse-vtt-segment.js.map +1 -0
- package/dist/dev/dom.d.ts +10 -0
- package/dist/dev/dom.js +10 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +14 -0
- package/dist/dev/index.js.map +1 -0
- package/package.json +24 -23
- package/dist/adapter-25AtMN8s.d.ts +0 -719
- package/dist/adapter-25AtMN8s.d.ts.map +0 -1
- package/dist/adapter-CflgYzjF.js +0 -3556
- package/dist/adapter-CflgYzjF.js.map +0 -1
- package/dist/dom/index.d.ts +0 -221
- package/dist/dom/index.d.ts.map +0 -1
- package/dist/dom/index.js +0 -38
- package/dist/dom/index.js.map +0 -1
- package/dist/dom/playback-engine/index.d.ts +0 -2
- package/dist/dom/playback-engine/index.js +0 -4
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -3
- package/dist/src-DRsS_H80.js.map +0 -1
- /package/dist/{index.d.ts → dev/index.d.ts} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { calculateForwardFlushPoint, getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
|
|
2
|
+
import { calculateBackBufferFlushPoint } from "../../core/buffer/back-buffer.js";
|
|
3
|
+
//#region src/dom/features/segment-loader-actor.ts
|
|
4
|
+
/**
|
|
5
|
+
* Creates a SegmentLoaderActor for one track type (video or audio).
|
|
6
|
+
*
|
|
7
|
+
* Receives load assignments via `send()` and owns all execution: planning,
|
|
8
|
+
* removes, fetches, and appends. Coordinates with the SourceBufferActor for
|
|
9
|
+
* all physical SourceBuffer operations.
|
|
10
|
+
*
|
|
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.
|
|
15
|
+
*
|
|
16
|
+
* @param sourceBufferActor - Shared SourceBufferActor reference (not owned)
|
|
17
|
+
* @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).
|
|
18
|
+
* Accepts an optional `minChunkSize` in options; init segments pass `Infinity`
|
|
19
|
+
* so the entire body accumulates as one chunk before appending.
|
|
20
|
+
*/
|
|
21
|
+
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
|
+
const getBufferedSegments = (allSegments) => {
|
|
29
|
+
const bufferedIds = new Set(sourceBufferActor.snapshot.get().context.segments.filter((s) => !s.partial).map((s) => s.id));
|
|
30
|
+
return allSegments.filter((s) => bufferedIds.has(s.id));
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Translate a load message into an ordered LoadTask list based on committed
|
|
34
|
+
* actor state. In-flight awareness is handled separately in send().
|
|
35
|
+
*
|
|
36
|
+
* @todo Rename alongside LoadTask (e.g. planOps).
|
|
37
|
+
*
|
|
38
|
+
* Case 1 — Removes: forward and back buffer flush points, segment-aligned.
|
|
39
|
+
* No flush on track switch: appending new content overwrites existing buffer
|
|
40
|
+
* ranges, and the actor's time-aligned deduplication keeps the segment model
|
|
41
|
+
* accurate as new segments arrive.
|
|
42
|
+
*
|
|
43
|
+
* Case 2 — Init: schedule if not yet committed for this track.
|
|
44
|
+
*
|
|
45
|
+
* Case 3 — Segments: all segments in the load window not yet committed.
|
|
46
|
+
*/
|
|
47
|
+
const planTasks = (message) => {
|
|
48
|
+
const { track, range } = message;
|
|
49
|
+
const actorCtx = sourceBufferActor.snapshot.get().context;
|
|
50
|
+
const bufferedSegments = getBufferedSegments(track.segments);
|
|
51
|
+
const currentTime = range?.start ?? 0;
|
|
52
|
+
const tasks = [];
|
|
53
|
+
if (range) {
|
|
54
|
+
const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);
|
|
55
|
+
if (forwardFlushStart < Infinity) tasks.push({
|
|
56
|
+
type: "remove",
|
|
57
|
+
start: forwardFlushStart,
|
|
58
|
+
end: Infinity
|
|
59
|
+
});
|
|
60
|
+
const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);
|
|
61
|
+
if (backFlushEnd > 0) tasks.push({
|
|
62
|
+
type: "remove",
|
|
63
|
+
start: 0,
|
|
64
|
+
end: backFlushEnd
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (actorCtx.initTrackId !== track.id) tasks.push({
|
|
68
|
+
type: "append-init",
|
|
69
|
+
meta: { trackId: track.id },
|
|
70
|
+
url: track.initialization.url,
|
|
71
|
+
...track.initialization.byteRange !== void 0 && { byteRange: track.initialization.byteRange }
|
|
72
|
+
});
|
|
73
|
+
if (range) {
|
|
74
|
+
const EPSILON = 1e-4;
|
|
75
|
+
const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {
|
|
76
|
+
const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);
|
|
77
|
+
if (existing?.partial) return true;
|
|
78
|
+
if (!existing?.trackBandwidth || !track.bandwidth) return true;
|
|
79
|
+
return track.bandwidth > existing.trackBandwidth;
|
|
80
|
+
});
|
|
81
|
+
for (const segment of segmentsToLoad) tasks.push({
|
|
82
|
+
type: "append-segment",
|
|
83
|
+
meta: {
|
|
84
|
+
id: segment.id,
|
|
85
|
+
startTime: segment.startTime,
|
|
86
|
+
duration: segment.duration,
|
|
87
|
+
trackId: track.id,
|
|
88
|
+
trackBandwidth: track.bandwidth
|
|
89
|
+
},
|
|
90
|
+
url: segment.url,
|
|
91
|
+
...segment.byteRange !== void 0 && { byteRange: segment.byteRange }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return tasks;
|
|
95
|
+
};
|
|
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;
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
send(message) {
|
|
174
|
+
if (destroyed) return;
|
|
175
|
+
const allTasks = planTasks(message);
|
|
176
|
+
if (!running) {
|
|
177
|
+
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();
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
destroy() {
|
|
188
|
+
destroyed = true;
|
|
189
|
+
abortController?.abort();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
export { createSegmentLoaderActor };
|
|
195
|
+
|
|
196
|
+
//# sourceMappingURL=segment-loader-actor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/segment-loader-actor.ts"],"sourcesContent":["import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer';\nimport { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types';\nimport type {\n AppendInitMessage,\n AppendSegmentMessage,\n RemoveMessage,\n SourceBufferActor,\n} from '../media/source-buffer-actor';\n\n// ============================================================================\n// BUFFER STATE TYPES\n// ============================================================================\n\n/**\n * Buffer state for a single SourceBuffer.\n * Tracks which init segment and media segments are loaded.\n */\nexport interface SourceBufferState {\n /** Track ID of the loaded init segment */\n initTrackId?: string;\n /** Loaded media segments (unordered - selectors derive ordering) */\n segments: Array<{ id: string; trackId: string }>;\n}\n\n/**\n * Buffer state for all SourceBuffers.\n */\nexport interface BufferState {\n video?: SourceBufferState;\n audio?: SourceBufferState;\n}\n\n// ============================================================================\n// MESSAGE PROTOCOL\n// ============================================================================\n\n/** Track types that have SourceBuffers (video and audio only). */\nexport type SegmentLoaderTrack = VideoTrack | AudioTrack;\n\n/**\n * Message sent to a SegmentLoaderActor.\n *\n * `range` is optional to distinguish loading modes:\n * - No range: load init segment only (metadata preload mode)\n * - With range: load init + all segments overlapping [start, end]\n *\n * `start` and `end` are raw time values — no segment snapping.\n * The actor maps them onto segment boundaries internally.\n */\nexport type SegmentLoaderMessage = {\n type: 'load';\n track: SegmentLoaderTrack;\n range?: { start: number; end: number };\n};\n\n// ============================================================================\n// LOAD TASK\n// ============================================================================\n\n/**\n * A LoadTask is the intent to perform one unit of SegmentLoader work.\n * Unlike SourceBufferMessage, fetch-based tasks carry a URL rather than\n * pre-fetched data — the runner fetches and appends them in sequence.\n *\n * Derived from SourceBufferMessage types by removing `data` and adding\n * a fetch URL via AddressableObject.\n *\n * @todo Rename — \"LoadTask\" risks confusion with the `Task` class used for\n * SourceBufferActor scheduling. These are closer to operation descriptors or\n * messages than tasks in that sense.\n */\nexport type LoadTask =\n | (Omit<AppendInitMessage, 'data'> & AddressableObject)\n | (Omit<AppendSegmentMessage, 'data'> & AddressableObject)\n | RemoveMessage;\n\n// ============================================================================\n// ACTOR INTERFACE\n// ============================================================================\n\nexport interface SegmentLoaderActor {\n send(message: SegmentLoaderMessage): void;\n destroy(): void;\n}\n\n// ============================================================================\n// IMPLEMENTATION\n// ============================================================================\n\n/**\n * Creates a SegmentLoaderActor for one track type (video or audio).\n *\n * Receives load assignments via `send()` and owns all execution: planning,\n * removes, fetches, and appends. Coordinates with the SourceBufferActor for\n * all physical SourceBuffer operations.\n *\n * Planning (Cases 1–3) happens in `send()` on every incoming message, producing\n * an ordered LoadTask list. The runner drains that list sequentially. When a new\n * message arrives mid-run, send() replans and either continues the in-flight\n * operation (if still needed) or preempts it.\n *\n * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)\n * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).\n * Accepts an optional `minChunkSize` in options; init segments pass `Infinity`\n * so the entire body accumulates as one chunk before appending.\n */\nexport function createSegmentLoaderActor(\n sourceBufferActor: SourceBufferActor,\n fetchBytes: (\n addressable: AddressableObject,\n options?: RequestInit & { minChunkSize?: number }\n ) => Promise<AsyncIterable<Uint8Array>>\n): SegmentLoaderActor {\n let pendingTasks: LoadTask[] | null = null;\n let inFlightInitTrackId: string | null = null;\n let inFlightSegmentId: string | null = null;\n let abortController: AbortController | null = null;\n let running = false;\n let destroyed = false;\n\n const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {\n // Exclude partial segments — they are still being streamed and must not be\n // treated as fully buffered for load planning or buffer window calculations.\n const bufferedIds = new Set(\n sourceBufferActor.snapshot\n .get()\n .context.segments.filter((s) => !s.partial)\n .map((s) => s.id)\n );\n return allSegments.filter((s) => bufferedIds.has(s.id));\n };\n\n /**\n * Translate a load message into an ordered LoadTask list based on committed\n * actor state. In-flight awareness is handled separately in send().\n *\n * @todo Rename alongside LoadTask (e.g. planOps).\n *\n * Case 1 — Removes: forward and back buffer flush points, segment-aligned.\n * No flush on track switch: appending new content overwrites existing buffer\n * ranges, and the actor's time-aligned deduplication keeps the segment model\n * accurate as new segments arrive.\n *\n * Case 2 — Init: schedule if not yet committed for this track.\n *\n * Case 3 — Segments: all segments in the load window not yet committed.\n */\n const planTasks = (message: SegmentLoaderMessage): LoadTask[] => {\n const { track, range } = message;\n const actorCtx = sourceBufferActor.snapshot.get().context;\n const bufferedSegments = getBufferedSegments(track.segments);\n const currentTime = range?.start ?? 0;\n const tasks: LoadTask[] = [];\n\n // Case 1: Removes\n if (range) {\n const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);\n if (forwardFlushStart < Infinity) {\n tasks.push({ type: 'remove', start: forwardFlushStart, end: Infinity });\n }\n const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);\n if (backFlushEnd > 0) {\n tasks.push({ type: 'remove', start: 0, end: backFlushEnd });\n }\n }\n\n // Case 2: Init\n if (actorCtx.initTrackId !== track.id) {\n tasks.push({\n type: 'append-init',\n meta: { trackId: track.id },\n url: track.initialization.url,\n ...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),\n });\n }\n\n // Case 3: Segments\n if (range) {\n const EPSILON = 0.0001;\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {\n // Quality-aware filter: skip segments already covered by equal-or-higher-quality\n // content in the actor context. Preserves buffered high-quality content during\n // ABR downgrades; loads during upgrades and for uncovered positions.\n const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);\n // Partial segments are still streaming — treat as not buffered so they\n // are always re-planned (avoids relying on incomplete data).\n if (existing?.partial) return true;\n if (!existing?.trackBandwidth || !track.bandwidth) return true;\n return track.bandwidth > existing.trackBandwidth;\n });\n for (const segment of segmentsToLoad) {\n tasks.push({\n type: 'append-segment',\n meta: {\n id: segment.id,\n startTime: segment.startTime,\n duration: segment.duration,\n trackId: track.id,\n trackBandwidth: track.bandwidth,\n },\n url: segment.url,\n ...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),\n });\n }\n }\n\n return tasks;\n };\n\n /**\n * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor.\n * Sets/clears in-flight tracking around async operations so send() can make\n * accurate continue/preempt decisions at any point during execution.\n *\n * @todo Rename alongside LoadTask (e.g. executeOp).\n */\n const executeLoadTask = async (task: LoadTask): Promise<void> => {\n const signal = abortController!.signal;\n try {\n if (task.type === 'remove') {\n await sourceBufferActor.send(task, signal);\n return;\n }\n\n if (task.type === 'append-init') {\n inFlightInitTrackId = task.meta.trackId;\n if (!signal.aborted) {\n // Init segments are small and need the full body before the\n // same-track-seek vs track-switch commit decision can be made.\n // minChunkSize: Infinity causes ChunkedStreamIterable to accumulate\n // all chunks and yield exactly one — equivalent to arrayBuffer() but\n // through the same streaming path as media segments.\n const data = await fetchBytes(task, { signal, minChunkSize: Infinity });\n // For seeks on the same track: commit even if aborted — avoids re-fetching the\n // same init next time. For track switches: don't commit the old track's init;\n // the new track's init follows in pendingTasks.\n const isTrackSwitch = pendingTasks?.some(\n (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId\n );\n if (!signal.aborted || !isTrackSwitch) {\n const appendSignal = signal.aborted ? new AbortController().signal : signal;\n await sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal);\n }\n }\n return;\n }\n\n // append-segment: await headers eagerly (starts the HTTP connection and\n // records the fetch in observers like tests), then pass the body stream\n // directly to the actor so chunks are appended as they arrive.\n inFlightSegmentId = task.meta.id;\n if (!signal.aborted) {\n const stream = await fetchBytes(task, { signal });\n if (!signal.aborted) {\n await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal);\n }\n }\n } finally {\n inFlightInitTrackId = null;\n inFlightSegmentId = null;\n }\n };\n\n /**\n * Drain the scheduled task list sequentially.\n * After each task completes, checks for a pending replacement plan from send().\n * If the signal was aborted and no new plan arrived, stops immediately.\n */\n const runScheduled = async (initialTasks: LoadTask[]): Promise<void> => {\n running = true;\n abortController = new AbortController();\n let scheduled = initialTasks;\n\n while (scheduled.length > 0 && !destroyed) {\n const task = scheduled[0]!;\n scheduled = scheduled.slice(1);\n\n try {\n await executeLoadTask(task);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') {\n // Abort is handled in the post-task check below.\n } else {\n console.error('Unexpected error in segment loader:', error);\n // Non-abort error (e.g. network failure on init): don't continue with\n // remaining tasks in this sequence — they depend on the failed step.\n // A pending replacement plan (if any) will still be picked up below.\n scheduled = [];\n }\n }\n\n if (pendingTasks !== null) {\n // A new plan arrived while this task was running — switch to it.\n scheduled = pendingTasks;\n pendingTasks = null;\n abortController = new AbortController();\n } else if (abortController.signal.aborted) {\n // Aborted with no replacement plan — stop.\n break;\n }\n }\n\n abortController = null;\n running = false;\n };\n\n return {\n send(message: SegmentLoaderMessage) {\n if (destroyed) return;\n\n const allTasks = planTasks(message);\n\n if (!running) {\n if (allTasks.length === 0) return;\n runScheduled(allTasks);\n return;\n }\n\n // Determine whether the in-flight operation is still needed for the new plan.\n const inFlightStillNeeded =\n (inFlightSegmentId !== null &&\n allTasks.some((t) => t.type === 'append-segment' && t.meta.id === inFlightSegmentId)) ||\n (inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId));\n\n if (inFlightStillNeeded) {\n // Continue: the in-flight operation covers something the new plan needs.\n // Queue everything except the in-flight item — it will complete on its own.\n pendingTasks = allTasks.filter(\n (t) =>\n !(t.type === 'append-segment' && t.meta.id === inFlightSegmentId) &&\n !(t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId)\n );\n } else {\n // Preempt: in-flight work is not needed for the new plan. Abort and replan.\n pendingTasks = allTasks;\n abortController?.abort();\n }\n },\n\n destroy() {\n destroyed = true;\n abortController?.abort();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA2GA,SAAgB,yBACd,mBACA,YAIoB;CACpB,IAAI,eAAkC;CACtC,IAAI,sBAAqC;CACzC,IAAI,oBAAmC;CACvC,IAAI,kBAA0C;CAC9C,IAAI,UAAU;CACd,IAAI,YAAY;CAEhB,MAAM,uBAAuB,gBAA+C;EAG1E,MAAM,cAAc,IAAI,IACtB,kBAAkB,SACf,KAAK,CACL,QAAQ,SAAS,QAAQ,MAAM,CAAC,EAAE,QAAQ,CAC1C,KAAK,MAAM,EAAE,GAAG,CACpB;AACD,SAAO,YAAY,QAAQ,MAAM,YAAY,IAAI,EAAE,GAAG,CAAC;;;;;;;;;;;;;;;;;CAkBzD,MAAM,aAAa,YAA8C;EAC/D,MAAM,EAAE,OAAO,UAAU;EACzB,MAAM,WAAW,kBAAkB,SAAS,KAAK,CAAC;EAClD,MAAM,mBAAmB,oBAAoB,MAAM,SAAS;EAC5D,MAAM,cAAc,OAAO,SAAS;EACpC,MAAM,QAAoB,EAAE;AAG5B,MAAI,OAAO;GACT,MAAM,oBAAoB,2BAA2B,kBAAkB,YAAY;AACnF,OAAI,oBAAoB,SACtB,OAAM,KAAK;IAAE,MAAM;IAAU,OAAO;IAAmB,KAAK;IAAU,CAAC;GAEzE,MAAM,eAAe,8BAA8B,kBAAkB,YAAY;AACjF,OAAI,eAAe,EACjB,OAAM,KAAK;IAAE,MAAM;IAAU,OAAO;IAAG,KAAK;IAAc,CAAC;;AAK/D,MAAI,SAAS,gBAAgB,MAAM,GACjC,OAAM,KAAK;GACT,MAAM;GACN,MAAM,EAAE,SAAS,MAAM,IAAI;GAC3B,KAAK,MAAM,eAAe;GAC1B,GAAI,MAAM,eAAe,cAAc,KAAA,KAAa,EAAE,WAAW,MAAM,eAAe,WAAW;GAClG,CAAC;AAIJ,MAAI,OAAO;GACT,MAAM,UAAU;GAChB,MAAM,iBAAiB,kBAAkB,MAAM,UAAU,kBAAkB,YAAY,CAAC,QAAQ,QAAQ;IAItG,MAAM,WAAW,SAAS,SAAS,MAAM,MAAM,KAAK,IAAI,EAAE,YAAY,IAAI,UAAU,GAAG,QAAQ;AAG/F,QAAI,UAAU,QAAS,QAAO;AAC9B,QAAI,CAAC,UAAU,kBAAkB,CAAC,MAAM,UAAW,QAAO;AAC1D,WAAO,MAAM,YAAY,SAAS;KAClC;AACF,QAAK,MAAM,WAAW,eACpB,OAAM,KAAK;IACT,MAAM;IACN,MAAM;KACJ,IAAI,QAAQ;KACZ,WAAW,QAAQ;KACnB,UAAU,QAAQ;KAClB,SAAS,MAAM;KACf,gBAAgB,MAAM;KACvB;IACD,KAAK,QAAQ;IACb,GAAI,QAAQ,cAAc,KAAA,KAAa,EAAE,WAAW,QAAQ,WAAW;IACxE,CAAC;;AAIN,SAAO;;;;;;;;;CAUT,MAAM,kBAAkB,OAAO,SAAkC;EAC/D,MAAM,SAAS,gBAAiB;AAChC,MAAI;AACF,OAAI,KAAK,SAAS,UAAU;AAC1B,UAAM,kBAAkB,KAAK,MAAM,OAAO;AAC1C;;AAGF,OAAI,KAAK,SAAS,eAAe;AAC/B,0BAAsB,KAAK,KAAK;AAChC,QAAI,CAAC,OAAO,SAAS;KAMnB,MAAM,OAAO,MAAM,WAAW,MAAM;MAAE;MAAQ,cAAc;MAAU,CAAC;KAIvE,MAAM,gBAAgB,cAAc,MACjC,MAAM,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,KAAK,KAAK,QACjE;AACD,SAAI,CAAC,OAAO,WAAW,CAAC,eAAe;MACrC,MAAM,eAAe,OAAO,UAAU,IAAI,iBAAiB,CAAC,SAAS;AACrE,YAAM,kBAAkB,KAAK;OAAE,MAAM;OAAe;OAAM,MAAM,KAAK;OAAM,EAAE,aAAa;;;AAG9F;;AAMF,uBAAoB,KAAK,KAAK;AAC9B,OAAI,CAAC,OAAO,SAAS;IACnB,MAAM,SAAS,MAAM,WAAW,MAAM,EAAE,QAAQ,CAAC;AACjD,QAAI,CAAC,OAAO,QACV,OAAM,kBAAkB,KAAK;KAAE,MAAM;KAAkB,MAAM;KAAQ,MAAM,KAAK;KAAM,EAAE,OAAO;;YAG3F;AACR,yBAAsB;AACtB,uBAAoB;;;;;;;;CASxB,MAAM,eAAe,OAAO,iBAA4C;AACtE,YAAU;AACV,oBAAkB,IAAI,iBAAiB;EACvC,IAAI,YAAY;AAEhB,SAAO,UAAU,SAAS,KAAK,CAAC,WAAW;GACzC,MAAM,OAAO,UAAU;AACvB,eAAY,UAAU,MAAM,EAAE;AAE9B,OAAI;AACF,UAAM,gBAAgB,KAAK;YACpB,OAAO;AACd,QAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc,QAEpD;AACL,aAAQ,MAAM,uCAAuC,MAAM;AAI3D,iBAAY,EAAE;;;AAIlB,OAAI,iBAAiB,MAAM;AAEzB,gBAAY;AACZ,mBAAe;AACf,sBAAkB,IAAI,iBAAiB;cAC9B,gBAAgB,OAAO,QAEhC;;AAIJ,oBAAkB;AAClB,YAAU;;AAGZ,QAAO;EACL,KAAK,SAA+B;AAClC,OAAI,UAAW;GAEf,MAAM,WAAW,UAAU,QAAQ;AAEnC,OAAI,CAAC,SAAS;AACZ,QAAI,SAAS,WAAW,EAAG;AAC3B,iBAAa,SAAS;AACtB;;AAUF,OALG,sBAAsB,QACrB,SAAS,MAAM,MAAM,EAAE,SAAS,oBAAoB,EAAE,KAAK,OAAO,kBAAkB,IACrF,wBAAwB,QACvB,SAAS,MAAM,MAAM,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,oBAAoB,CAK1F,gBAAe,SAAS,QACrB,MACC,EAAE,EAAE,SAAS,oBAAoB,EAAE,KAAK,OAAO,sBAC/C,EAAE,EAAE,SAAS,iBAAiB,EAAE,KAAK,YAAY,qBACpD;QACI;AAEL,mBAAe;AACf,qBAAiB,OAAO;;;EAI5B,UAAU;AACR,eAAY;AACZ,oBAAiB,OAAO;;EAE3B"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { computed } from "../../core/signals/primitives.js";
|
|
3
|
+
import { attachMediaSource, createMediaSource, observeMediaSourceReadyState } from "../media/mediasource-setup.js";
|
|
4
|
+
//#region src/dom/features/setup-mediasource.ts
|
|
5
|
+
/**
|
|
6
|
+
* Setup MediaSource orchestration.
|
|
7
|
+
*
|
|
8
|
+
* Creates and attaches MediaSource when:
|
|
9
|
+
* - mediaElement exists in owners
|
|
10
|
+
* - presentation.url exists in state
|
|
11
|
+
*
|
|
12
|
+
* Updates owners.mediaSource after successful setup.
|
|
13
|
+
*/
|
|
14
|
+
function setupMediaSource({ state, owners }) {
|
|
15
|
+
const abortController = new AbortController();
|
|
16
|
+
const mediaElementSignal = computed(() => owners.get().mediaElement);
|
|
17
|
+
const presentationUrlSignal = computed(() => state.get().presentation?.url);
|
|
18
|
+
const canSetupSignal = computed(() => !!mediaElementSignal.get() && !!presentationUrlSignal.get());
|
|
19
|
+
const mediaElementSrcSignal = computed(() => mediaElementSignal.get()?.src);
|
|
20
|
+
const mediaSourceSignal = computed(() => owners.get().mediaSource);
|
|
21
|
+
const shouldSetupSignal = computed(() => !mediaElementSrcSignal.get());
|
|
22
|
+
const cleanupEffect = effect(() => {
|
|
23
|
+
if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;
|
|
24
|
+
const mediaElement = mediaElementSignal.get();
|
|
25
|
+
const { signal } = abortController;
|
|
26
|
+
const mediaSource = createMediaSource({ preferManaged: true });
|
|
27
|
+
const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, signal);
|
|
28
|
+
attachMediaSource(mediaSource, mediaElement);
|
|
29
|
+
const cleanupOwnersUpdateEffect = effect(() => {
|
|
30
|
+
if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== "open") return;
|
|
31
|
+
owners.set(Object.assign({}, owners.get(), {
|
|
32
|
+
mediaSource,
|
|
33
|
+
mediaSourceReadyState
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
return () => {
|
|
37
|
+
cleanupOwnersUpdateEffect();
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
return () => {
|
|
41
|
+
abortController?.abort();
|
|
42
|
+
cleanupEffect();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { setupMediaSource };
|
|
47
|
+
|
|
48
|
+
//# sourceMappingURL=setup-mediasource.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-mediasource.js","names":[],"sources":["../../../../src/dom/features/setup-mediasource.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';\nimport type { Presentation } from '../../core/types';\nimport { attachMediaSource, createMediaSource, observeMediaSourceReadyState } from '../media/mediasource-setup';\n\n/**\n * State shape required for MediaSource setup.\n */\nexport interface MediaSourceState {\n presentation?: Presentation;\n}\n\n/**\n * Owners shape for MediaSource setup.\n */\nexport interface MediaSourceOwners {\n mediaElement?: HTMLMediaElement | undefined;\n mediaSource?: MediaSource;\n /** Reactive mirror of `mediaSource.readyState` — updated via DOM events. */\n mediaSourceReadyState?: ReadonlySignal<MediaSource['readyState']>;\n}\n\n/**\n * Setup MediaSource orchestration.\n *\n * Creates and attaches MediaSource when:\n * - mediaElement exists in owners\n * - presentation.url exists in state\n *\n * Updates owners.mediaSource after successful setup.\n */\nexport function setupMediaSource<S extends MediaSourceState, O extends MediaSourceOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const abortController = new AbortController();\n\n // Get the latest mediaElement (even if nullish)\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n // Get the latest presentationUrl (even if nullish)\n const presentationUrlSignal = computed(() => state.get().presentation?.url);\n\n const canSetupSignal = computed(() => !!mediaElementSignal.get() && !!presentationUrlSignal.get());\n\n const mediaElementSrcSignal = computed(() => mediaElementSignal.get()?.src);\n const mediaSourceSignal = computed(() => owners.get().mediaSource);\n const shouldSetupSignal = computed(() => !mediaElementSrcSignal.get());\n\n // NOTE: This should be cleaner and less brittle if/when Reactors have their own internal finite state. This is planned as followup work.\n // (here, e.g. something like: \"preconditions_unmet\"|\"pending\"|\"setting_up\"|\"tearing_down\"|\"set_up\")\n // This should also avoid needing nested effect().\n const cleanupEffect = effect(() => {\n if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;\n const mediaElement = mediaElementSignal.get() as HTMLMediaElement;\n const { signal } = abortController;\n\n const mediaSource = createMediaSource({ preferManaged: true });\n // NOTE: Consider making MediaSource an Actor and using this in it.\n const mediaSourceReadyState = observeMediaSourceReadyState(mediaSource, signal);\n attachMediaSource(mediaSource, mediaElement);\n\n const cleanupOwnersUpdateEffect = effect(() => {\n // If we already have a MediaSource or the *internal* mediaSource is not yet fully attached, wait to add it to owners;\n if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== 'open') return;\n owners.set(Object.assign({}, owners.get(), { mediaSource, mediaSourceReadyState }) as O);\n });\n\n return () => {\n cleanupOwnersUpdateEffect();\n };\n });\n\n return () => {\n abortController?.abort();\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;;;;;;AA+BA,SAAgB,iBAA0E,EACxF,OACA,UAIa;CACb,MAAM,kBAAkB,IAAI,iBAAiB;CAG7C,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CAEpE,MAAM,wBAAwB,eAAe,MAAM,KAAK,CAAC,cAAc,IAAI;CAE3E,MAAM,iBAAiB,eAAe,CAAC,CAAC,mBAAmB,KAAK,IAAI,CAAC,CAAC,sBAAsB,KAAK,CAAC;CAElG,MAAM,wBAAwB,eAAe,mBAAmB,KAAK,EAAE,IAAI;CAC3E,MAAM,oBAAoB,eAAe,OAAO,KAAK,CAAC,YAAY;CAClE,MAAM,oBAAoB,eAAe,CAAC,sBAAsB,KAAK,CAAC;CAKtE,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,eAAe,KAAK,IAAI,CAAC,kBAAkB,KAAK,CAAE;EACvD,MAAM,eAAe,mBAAmB,KAAK;EAC7C,MAAM,EAAE,WAAW;EAEnB,MAAM,cAAc,kBAAkB,EAAE,eAAe,MAAM,CAAC;EAE9D,MAAM,wBAAwB,6BAA6B,aAAa,OAAO;AAC/E,oBAAkB,aAAa,aAAa;EAE5C,MAAM,4BAA4B,aAAa;AAE7C,OAAI,CAAC,CAAC,kBAAkB,KAAK,IAAI,sBAAsB,KAAK,KAAK,OAAQ;AACzE,UAAO,IAAI,OAAO,OAAO,EAAE,EAAE,OAAO,KAAK,EAAE;IAAE;IAAa;IAAuB,CAAC,CAAM;IACxF;AAEF,eAAa;AACX,8BAA2B;;GAE7B;AAEF,cAAa;AACX,mBAAiB,OAAO;AACxB,iBAAe"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { computed, update } from "../../core/signals/primitives.js";
|
|
3
|
+
import { isResolvedTrack } from "../../core/types/index.js";
|
|
4
|
+
import { BufferKeyByType, getSelectedTrack } from "../../core/utils/track-selection.js";
|
|
5
|
+
import { createSourceBuffer } from "../media/mediasource-setup.js";
|
|
6
|
+
import { createSourceBufferActor } from "../media/source-buffer-actor.js";
|
|
7
|
+
//#region src/dom/features/setup-sourcebuffer.ts
|
|
8
|
+
const ActorKeyByType = {
|
|
9
|
+
video: "videoBufferActor",
|
|
10
|
+
audio: "audioBufferActor"
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Build MIME codec string from track metadata.
|
|
14
|
+
*
|
|
15
|
+
* @param track - Resolved track with mimeType and codecs
|
|
16
|
+
* @returns MIME codec string (e.g., 'video/mp4; codecs="avc1.42E01E,mp4a.40.2"')
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] })
|
|
20
|
+
* // => 'video/mp4; codecs="avc1.42E01E"'
|
|
21
|
+
*/
|
|
22
|
+
function buildMimeCodec(track) {
|
|
23
|
+
const codecString = track.codecs?.join(",") ?? "";
|
|
24
|
+
return `${track.mimeType}; codecs="${codecString}"`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Setup all needed SourceBuffers as a single coordinated operation.
|
|
28
|
+
*
|
|
29
|
+
* Waits until ALL media tracks in the presentation are resolved with codecs,
|
|
30
|
+
* then creates every SourceBuffer in one synchronous block before setting
|
|
31
|
+
* owners. This guarantees that downstream consumers (e.g. loadSegments) never
|
|
32
|
+
* see a partial set of SourceBuffers — preventing the Firefox bug where
|
|
33
|
+
* appending to a video SourceBuffer before the audio SourceBuffer exists
|
|
34
|
+
* causes mozHasAudio to be permanently false.
|
|
35
|
+
*
|
|
36
|
+
* Handles video-only, audio-only, and combined presentations correctly:
|
|
37
|
+
* track types are derived from the presentation rather than hardcoded.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const cleanup = setupSourceBuffers({ state, owners });
|
|
41
|
+
*/
|
|
42
|
+
function setupSourceBuffers({ state, owners }) {
|
|
43
|
+
const presentationTypesSignal = computed(() => {
|
|
44
|
+
const { presentation } = state.get();
|
|
45
|
+
if (!presentation || !("selectionSets" in presentation)) return [];
|
|
46
|
+
return presentation.selectionSets.map(({ type }) => type).filter((type) => type === "video" || type === "audio");
|
|
47
|
+
});
|
|
48
|
+
const canSetupSignal = computed(() => {
|
|
49
|
+
const types = presentationTypesSignal.get();
|
|
50
|
+
if (!owners.get().mediaSource || types.length === 0) return false;
|
|
51
|
+
const s = state.get();
|
|
52
|
+
return types.every((type) => {
|
|
53
|
+
const track = getSelectedTrack(s, type);
|
|
54
|
+
return track && isResolvedTrack(track) && !!track.codecs?.length;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
const shouldSetupSignal = computed(() => {
|
|
58
|
+
const o = owners.get();
|
|
59
|
+
return presentationTypesSignal.get().every((type) => !o[BufferKeyByType[type]]);
|
|
60
|
+
});
|
|
61
|
+
return effect(() => {
|
|
62
|
+
if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;
|
|
63
|
+
const s = state.get();
|
|
64
|
+
const o = owners.get();
|
|
65
|
+
const patch = {};
|
|
66
|
+
for (const type of presentationTypesSignal.get()) {
|
|
67
|
+
const track = getSelectedTrack(s, type);
|
|
68
|
+
const buffer = createSourceBuffer(o.mediaSource, buildMimeCodec(track));
|
|
69
|
+
patch[BufferKeyByType[type]] = buffer;
|
|
70
|
+
patch[ActorKeyByType[type]] = createSourceBufferActor(buffer);
|
|
71
|
+
}
|
|
72
|
+
update(owners, patch);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { setupSourceBuffers };
|
|
77
|
+
|
|
78
|
+
//# sourceMappingURL=setup-sourcebuffer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-sourcebuffer.js","names":[],"sources":["../../../../src/dom/features/setup-sourcebuffer.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\nimport type { Presentation, ResolvedTrack } from '../../core/types';\nimport { isResolvedTrack } from '../../core/types';\nimport { BufferKeyByType, getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';\nimport { createSourceBuffer } from '../media/mediasource-setup';\nimport { createSourceBufferActor, type SourceBufferActor } from '../media/source-buffer-actor';\n\n/**\n * Media track type for SourceBuffer setup.\n * Text tracks are excluded as they don't use MSE SourceBuffers.\n */\nexport type MediaTrackType = 'video' | 'audio';\n\nconst ActorKeyByType = {\n video: 'videoBufferActor',\n audio: 'audioBufferActor',\n} as const satisfies Record<MediaTrackType, keyof SourceBufferOwners>;\n\n/**\n * State shape for SourceBuffer setup.\n */\nexport interface SourceBufferState extends TrackSelectionState {\n presentation?: Presentation;\n selectedVideoTrackId?: string;\n selectedAudioTrackId?: string;\n}\n\n/**\n * Owners shape for SourceBuffer setup.\n */\nexport interface SourceBufferOwners {\n mediaSource?: MediaSource;\n videoBuffer?: SourceBuffer;\n audioBuffer?: SourceBuffer;\n videoBufferActor?: SourceBufferActor;\n audioBufferActor?: SourceBufferActor;\n}\n\n/**\n * Build MIME codec string from track metadata.\n *\n * @param track - Resolved track with mimeType and codecs\n * @returns MIME codec string (e.g., 'video/mp4; codecs=\"avc1.42E01E,mp4a.40.2\"')\n *\n * @example\n * buildMimeCodec({ mimeType: 'video/mp4', codecs: ['avc1.42E01E'] })\n * // => 'video/mp4; codecs=\"avc1.42E01E\"'\n */\nexport function buildMimeCodec(track: ResolvedTrack): string {\n const codecString = track.codecs?.join(',') ?? '';\n return `${track.mimeType}; codecs=\"${codecString}\"`;\n}\n\n/**\n * Setup all needed SourceBuffers as a single coordinated operation.\n *\n * Waits until ALL media tracks in the presentation are resolved with codecs,\n * then creates every SourceBuffer in one synchronous block before setting\n * owners. This guarantees that downstream consumers (e.g. loadSegments) never\n * see a partial set of SourceBuffers — preventing the Firefox bug where\n * appending to a video SourceBuffer before the audio SourceBuffer exists\n * causes mozHasAudio to be permanently false.\n *\n * Handles video-only, audio-only, and combined presentations correctly:\n * track types are derived from the presentation rather than hardcoded.\n *\n * @example\n * const cleanup = setupSourceBuffers({ state, owners });\n */\nexport function setupSourceBuffers<S extends SourceBufferState, O extends SourceBufferOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n // Derive which media track types this presentation actually contains\n const presentationTypesSignal = computed((): MediaTrackType[] => {\n const { presentation } = state.get();\n if (!presentation || !('selectionSets' in presentation)) return [];\n return presentation.selectionSets\n .map(({ type }) => type)\n .filter((type): type is MediaTrackType => type === 'video' || type === 'audio');\n });\n\n // All presentation track types must have a selected, resolved track with codecs\n const canSetupSignal = computed(() => {\n const types = presentationTypesSignal.get();\n if (!owners.get().mediaSource || types.length === 0) return false;\n const s = state.get();\n return types.every((type) => {\n const track = getSelectedTrack(s, type);\n return track && isResolvedTrack(track) && !!track.codecs?.length;\n });\n });\n\n // DOM-observable \"already done\" guard — once all presentation buffers exist in owners, setup has run\n const shouldSetupSignal = computed(() => {\n const o = owners.get();\n return presentationTypesSignal.get().every((type) => !o[BufferKeyByType[type]]);\n });\n\n const cleanupEffect = effect(() => {\n if (!canSetupSignal.get() || !shouldSetupSignal.get()) return;\n\n const s = state.get();\n const o = owners.get();\n\n // Create all SourceBuffers synchronously — no await between addSourceBuffer\n // calls — then set owners once so subscribers see all buffers simultaneously.\n const patch: Partial<SourceBufferOwners> = {};\n\n for (const type of presentationTypesSignal.get()) {\n const track = getSelectedTrack(s, type) as ResolvedTrack;\n const buffer = createSourceBuffer(o.mediaSource!, buildMimeCodec(track));\n patch[BufferKeyByType[type]] = buffer;\n patch[ActorKeyByType[type]] = createSourceBufferActor(buffer);\n }\n\n update(owners, patch);\n });\n\n return cleanupEffect;\n}\n"],"mappings":";;;;;;;AAcA,MAAM,iBAAiB;CACrB,OAAO;CACP,OAAO;CACR;;;;;;;;;;;AAgCD,SAAgB,eAAe,OAA8B;CAC3D,MAAM,cAAc,MAAM,QAAQ,KAAK,IAAI,IAAI;AAC/C,QAAO,GAAG,MAAM,SAAS,YAAY,YAAY;;;;;;;;;;;;;;;;;;AAmBnD,SAAgB,mBAA8E,EAC5F,OACA,UAIa;CAEb,MAAM,0BAA0B,eAAiC;EAC/D,MAAM,EAAE,iBAAiB,MAAM,KAAK;AACpC,MAAI,CAAC,gBAAgB,EAAE,mBAAmB,cAAe,QAAO,EAAE;AAClE,SAAO,aAAa,cACjB,KAAK,EAAE,WAAW,KAAK,CACvB,QAAQ,SAAiC,SAAS,WAAW,SAAS,QAAQ;GACjF;CAGF,MAAM,iBAAiB,eAAe;EACpC,MAAM,QAAQ,wBAAwB,KAAK;AAC3C,MAAI,CAAC,OAAO,KAAK,CAAC,eAAe,MAAM,WAAW,EAAG,QAAO;EAC5D,MAAM,IAAI,MAAM,KAAK;AACrB,SAAO,MAAM,OAAO,SAAS;GAC3B,MAAM,QAAQ,iBAAiB,GAAG,KAAK;AACvC,UAAO,SAAS,gBAAgB,MAAM,IAAI,CAAC,CAAC,MAAM,QAAQ;IAC1D;GACF;CAGF,MAAM,oBAAoB,eAAe;EACvC,MAAM,IAAI,OAAO,KAAK;AACtB,SAAO,wBAAwB,KAAK,CAAC,OAAO,SAAS,CAAC,EAAE,gBAAgB,OAAO;GAC/E;AAsBF,QApBsB,aAAa;AACjC,MAAI,CAAC,eAAe,KAAK,IAAI,CAAC,kBAAkB,KAAK,CAAE;EAEvD,MAAM,IAAI,MAAM,KAAK;EACrB,MAAM,IAAI,OAAO,KAAK;EAItB,MAAM,QAAqC,EAAE;AAE7C,OAAK,MAAM,QAAQ,wBAAwB,KAAK,EAAE;GAChD,MAAM,QAAQ,iBAAiB,GAAG,KAAK;GACvC,MAAM,SAAS,mBAAmB,EAAE,aAAc,eAAe,MAAM,CAAC;AACxE,SAAM,gBAAgB,SAAS;AAC/B,SAAM,eAAe,SAAS,wBAAwB,OAAO;;AAG/D,SAAO,QAAQ,MAAM;GACrB"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { computed, update } from "../../core/signals/primitives.js";
|
|
3
|
+
//#region src/dom/features/setup-text-tracks.ts
|
|
4
|
+
/**
|
|
5
|
+
* Create a track element for a text track.
|
|
6
|
+
*
|
|
7
|
+
* Note: We use DOM <track> elements instead of the TextTrack JS API
|
|
8
|
+
* because there's no way to remove TextTracks added via addTextTrack().
|
|
9
|
+
*/
|
|
10
|
+
function createTrackElement(track) {
|
|
11
|
+
const trackElement = document.createElement("track");
|
|
12
|
+
trackElement.id = track.id;
|
|
13
|
+
trackElement.kind = track.kind;
|
|
14
|
+
trackElement.label = track.label;
|
|
15
|
+
if (track.language) trackElement.srclang = track.language;
|
|
16
|
+
if (track.default) trackElement.default = true;
|
|
17
|
+
trackElement.src = track.url;
|
|
18
|
+
return trackElement;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Setup text tracks orchestration.
|
|
22
|
+
*
|
|
23
|
+
* Triggers when:
|
|
24
|
+
* - mediaElement exists
|
|
25
|
+
* - presentation is resolved (has text tracks)
|
|
26
|
+
*
|
|
27
|
+
* Creates <track> elements for all text tracks and adds them as children
|
|
28
|
+
* to the media element. This allows the browser's native text track rendering.
|
|
29
|
+
*
|
|
30
|
+
* Note: Uses DOM track elements instead of TextTrack API because tracks
|
|
31
|
+
* added via addTextTrack() cannot be removed.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const cleanup = setupTextTracks({ state, owners });
|
|
35
|
+
*/
|
|
36
|
+
function setupTextTracks({ state, owners }) {
|
|
37
|
+
const modelTextTracksSignal = computed(() => state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === "text")?.switchingSets[0]?.tracks, { equals(prevTextTracks, nextTextTracks) {
|
|
38
|
+
if (prevTextTracks === nextTextTracks) return true;
|
|
39
|
+
if (typeof prevTextTracks !== typeof nextTextTracks) return false;
|
|
40
|
+
if (prevTextTracks?.length !== nextTextTracks?.length) return false;
|
|
41
|
+
return !!nextTextTracks && nextTextTracks.every((nextTextTrack) => prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id));
|
|
42
|
+
} });
|
|
43
|
+
const ownerTextTracksSignal = computed(() => owners.get().textTracks);
|
|
44
|
+
const mediaElementSignal = computed(() => owners.get().mediaElement);
|
|
45
|
+
const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length);
|
|
46
|
+
const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get());
|
|
47
|
+
const cleanupEffect = effect(() => {
|
|
48
|
+
if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return;
|
|
49
|
+
const mediaElement = mediaElementSignal.get();
|
|
50
|
+
const modelTextTracks = modelTextTracksSignal.get();
|
|
51
|
+
const trackMap = /* @__PURE__ */ new Map();
|
|
52
|
+
modelTextTracks.forEach((modelTextTrack) => {
|
|
53
|
+
const trackElement = createTrackElement(modelTextTrack);
|
|
54
|
+
mediaElement.appendChild(trackElement);
|
|
55
|
+
trackMap.set(modelTextTrack.id, trackElement);
|
|
56
|
+
});
|
|
57
|
+
if (trackMap.size) update(owners, { textTracks: trackMap });
|
|
58
|
+
});
|
|
59
|
+
return () => {
|
|
60
|
+
owners.get().textTracks?.forEach((trackElement) => trackElement.remove());
|
|
61
|
+
cleanupEffect();
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { setupTextTracks };
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=setup-text-tracks.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"setup-text-tracks.js","names":[],"sources":["../../../../src/dom/features/setup-text-tracks.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\nimport type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types';\n\n/**\n * State shape for text track setup.\n */\nexport interface TextTrackState {\n presentation?: Presentation | undefined;\n selectedTextTrackId?: string | undefined;\n}\n\n/**\n * Owners shape for text track setup.\n */\nexport interface TextTrackOwners {\n mediaElement?: HTMLMediaElement | undefined;\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Create a track element for a text track.\n *\n * Note: We use DOM <track> elements instead of the TextTrack JS API\n * because there's no way to remove TextTracks added via addTextTrack().\n */\nfunction createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement {\n const trackElement = document.createElement('track');\n\n trackElement.id = track.id;\n trackElement.kind = track.kind;\n trackElement.label = track.label;\n\n if (track.language) {\n trackElement.srclang = track.language;\n }\n\n if (track.default) {\n trackElement.default = true;\n }\n\n // Set src to track URL\n trackElement.src = track.url;\n\n return trackElement;\n}\n\n/**\n * Setup text tracks orchestration.\n *\n * Triggers when:\n * - mediaElement exists\n * - presentation is resolved (has text tracks)\n *\n * Creates <track> elements for all text tracks and adds them as children\n * to the media element. This allows the browser's native text track rendering.\n *\n * Note: Uses DOM track elements instead of TextTrack API because tracks\n * added via addTextTrack() cannot be removed.\n *\n * @example\n * const cleanup = setupTextTracks({ state, owners });\n */\nexport function setupTextTracks<S extends TextTrackState, O extends TextTrackOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const modelTextTracksSignal = computed(\n // NOTE: This assumes exactly one selection set and switching set for TextTracks (CJP)\n () =>\n state.get().presentation?.selectionSets?.find((selectionSet) => selectionSet.type === 'text')?.switchingSets[0]\n ?.tracks,\n {\n /** @TODO Make generic and abstract away for Array<T> | undefined (CJP) */\n equals(prevTextTracks, nextTextTracks) {\n if (prevTextTracks === nextTextTracks) return true;\n if (typeof prevTextTracks !== typeof nextTextTracks) return false;\n if (prevTextTracks?.length !== nextTextTracks?.length) return false;\n // NOTE: This could probably be optimized, but the set should generally be small (CJP)\n return (\n !!nextTextTracks &&\n nextTextTracks.every((nextTextTrack) =>\n prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id)\n )\n );\n },\n }\n );\n const ownerTextTracksSignal = computed(() => owners.get().textTracks);\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n\n const canSetupTextTracksSignal = computed(() => !!mediaElementSignal.get() && modelTextTracksSignal.get()?.length);\n const shouldSetupTextTracksSignal = computed(() => !ownerTextTracksSignal.get());\n\n const cleanupEffect = effect(() => {\n if (!canSetupTextTracksSignal.get() || !shouldSetupTextTracksSignal.get()) return;\n const mediaElement = mediaElementSignal.get();\n const modelTextTracks = modelTextTracksSignal.get() as TextTrack[];\n\n const trackMap = new Map<string, HTMLTrackElement>();\n modelTextTracks.forEach((modelTextTrack) => {\n const trackElement = createTrackElement(modelTextTrack);\n mediaElement!.appendChild(trackElement);\n trackMap.set(modelTextTrack.id, trackElement);\n });\n\n if (trackMap.size) {\n const patch: Partial<TextTrackOwners> = { textTracks: trackMap };\n update(owners, patch);\n }\n });\n\n return () => {\n owners.get().textTracks?.forEach((trackElement) => trackElement.remove());\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;;AA0BA,SAAS,mBAAmB,OAAiE;CAC3F,MAAM,eAAe,SAAS,cAAc,QAAQ;AAEpD,cAAa,KAAK,MAAM;AACxB,cAAa,OAAO,MAAM;AAC1B,cAAa,QAAQ,MAAM;AAE3B,KAAI,MAAM,SACR,cAAa,UAAU,MAAM;AAG/B,KAAI,MAAM,QACR,cAAa,UAAU;AAIzB,cAAa,MAAM,MAAM;AAEzB,QAAO;;;;;;;;;;;;;;;;;;AAmBT,SAAgB,gBAAqE,EACnF,OACA,UAIa;CACb,MAAM,wBAAwB,eAG1B,MAAM,KAAK,CAAC,cAAc,eAAe,MAAM,iBAAiB,aAAa,SAAS,OAAO,EAAE,cAAc,IACzG,QACN,EAEE,OAAO,gBAAgB,gBAAgB;AACrC,MAAI,mBAAmB,eAAgB,QAAO;AAC9C,MAAI,OAAO,mBAAmB,OAAO,eAAgB,QAAO;AAC5D,MAAI,gBAAgB,WAAW,gBAAgB,OAAQ,QAAO;AAE9D,SACE,CAAC,CAAC,kBACF,eAAe,OAAO,kBACpB,gBAAgB,MAAM,kBAAkB,cAAc,OAAO,cAAc,GAAG,CAC/E;IAGN,CACF;CACD,MAAM,wBAAwB,eAAe,OAAO,KAAK,CAAC,WAAW;CACrE,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CAEpE,MAAM,2BAA2B,eAAe,CAAC,CAAC,mBAAmB,KAAK,IAAI,sBAAsB,KAAK,EAAE,OAAO;CAClH,MAAM,8BAA8B,eAAe,CAAC,sBAAsB,KAAK,CAAC;CAEhF,MAAM,gBAAgB,aAAa;AACjC,MAAI,CAAC,yBAAyB,KAAK,IAAI,CAAC,4BAA4B,KAAK,CAAE;EAC3E,MAAM,eAAe,mBAAmB,KAAK;EAC7C,MAAM,kBAAkB,sBAAsB,KAAK;EAEnD,MAAM,2BAAW,IAAI,KAA+B;AACpD,kBAAgB,SAAS,mBAAmB;GAC1C,MAAM,eAAe,mBAAmB,eAAe;AACvD,gBAAc,YAAY,aAAa;AACvC,YAAS,IAAI,eAAe,IAAI,aAAa;IAC7C;AAEF,MAAI,SAAS,KAEX,QAAO,QADiC,EAAE,YAAY,UAAU,CAC3C;GAEvB;AAEF,cAAa;AACX,SAAO,KAAK,CAAC,YAAY,SAAS,iBAAiB,aAAa,QAAQ,CAAC;AACzE,iBAAe"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { computed, update } from "../../core/signals/primitives.js";
|
|
3
|
+
import { listen } from "@videojs/utils/dom";
|
|
4
|
+
//#region src/dom/features/sync-selected-text-track-from-dom.ts
|
|
5
|
+
/**
|
|
6
|
+
* Sync selectedTextTrackId from DOM text track mode changes.
|
|
7
|
+
*
|
|
8
|
+
* Listens to the `change` event on `media.textTracks` and updates
|
|
9
|
+
* `selectedTextTrackId` when external code (e.g. the captions button via
|
|
10
|
+
* `toggleSubtitles()`) changes a subtitle/caption track mode to 'showing'.
|
|
11
|
+
*
|
|
12
|
+
* This bridges the core store's `toggleSubtitles()` with SPF's reactive text
|
|
13
|
+
* track pipeline (`syncTextTrackModes`, `loadTextTrackCues`). Without this
|
|
14
|
+
* bridge, direct DOM mode changes would be immediately overridden by
|
|
15
|
+
* `syncTextTrackModes` on the next SPF state update.
|
|
16
|
+
*
|
|
17
|
+
* When a subtitle/caption track's mode is 'showing', its DOM `id` — which
|
|
18
|
+
* matches the SPF track ID set by `setupTextTracks` — is written to
|
|
19
|
+
* `selectedTextTrackId`. When no subtitle/caption track is 'showing',
|
|
20
|
+
* `selectedTextTrackId` is cleared along with the deselected track's
|
|
21
|
+
* `textBufferState` entry — setting mode to 'disabled' clears native cues from
|
|
22
|
+
* the track element, so the buffer must be reset to re-fetch cues on re-enable.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const cleanup = syncSelectedTextTrackFromDom({ state, owners });
|
|
26
|
+
*/
|
|
27
|
+
function syncSelectedTextTrackFromDom({ state, owners }) {
|
|
28
|
+
const mediaElement = computed(() => owners.get().mediaElement);
|
|
29
|
+
return effect(() => {
|
|
30
|
+
const el = mediaElement.get();
|
|
31
|
+
if (!el) return;
|
|
32
|
+
return listen(el.textTracks, "change", () => {
|
|
33
|
+
const newId = Array.from(el.textTracks).find((t) => t.mode === "showing" && (t.kind === "subtitles" || t.kind === "captions"))?.id || void 0;
|
|
34
|
+
const current = state.get();
|
|
35
|
+
if (current.selectedTextTrackId === newId) return;
|
|
36
|
+
if (newId) update(state, { selectedTextTrackId: newId });
|
|
37
|
+
else {
|
|
38
|
+
const prevId = current.selectedTextTrackId;
|
|
39
|
+
if (prevId && current.textBufferState?.[prevId]) {
|
|
40
|
+
const next = { ...current.textBufferState };
|
|
41
|
+
delete next[prevId];
|
|
42
|
+
update(state, {
|
|
43
|
+
selectedTextTrackId: void 0,
|
|
44
|
+
textBufferState: next
|
|
45
|
+
});
|
|
46
|
+
} else update(state, { selectedTextTrackId: void 0 });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { syncSelectedTextTrackFromDom };
|
|
53
|
+
|
|
54
|
+
//# sourceMappingURL=sync-selected-text-track-from-dom.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-selected-text-track-from-dom.js","names":[],"sources":["../../../../src/dom/features/sync-selected-text-track-from-dom.ts"],"sourcesContent":["import { listen } from '@videojs/utils/dom';\nimport { effect } from '../../core/signals/effect';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\nimport type { TextTrackBufferState } from './load-text-track-cues';\n\n/**\n * State shape for DOM-driven text track selection.\n */\nexport interface SelectedTextTrackFromDomState {\n selectedTextTrackId?: string | undefined;\n textBufferState?: TextTrackBufferState | undefined;\n}\n\n/**\n * Owners shape for DOM-driven text track selection.\n */\nexport interface SelectedTextTrackFromDomOwners {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\n/**\n * Sync selectedTextTrackId from DOM text track mode changes.\n *\n * Listens to the `change` event on `media.textTracks` and updates\n * `selectedTextTrackId` when external code (e.g. the captions button via\n * `toggleSubtitles()`) changes a subtitle/caption track mode to 'showing'.\n *\n * This bridges the core store's `toggleSubtitles()` with SPF's reactive text\n * track pipeline (`syncTextTrackModes`, `loadTextTrackCues`). Without this\n * bridge, direct DOM mode changes would be immediately overridden by\n * `syncTextTrackModes` on the next SPF state update.\n *\n * When a subtitle/caption track's mode is 'showing', its DOM `id` — which\n * matches the SPF track ID set by `setupTextTracks` — is written to\n * `selectedTextTrackId`. When no subtitle/caption track is 'showing',\n * `selectedTextTrackId` is cleared along with the deselected track's\n * `textBufferState` entry — setting mode to 'disabled' clears native cues from\n * the track element, so the buffer must be reset to re-fetch cues on re-enable.\n *\n * @example\n * const cleanup = syncSelectedTextTrackFromDom({ state, owners });\n */\nexport function syncSelectedTextTrackFromDom<\n S extends SelectedTextTrackFromDomState,\n O extends SelectedTextTrackFromDomOwners,\n>({ state, owners }: { state: Signal<S>; owners: Signal<O> }): () => void {\n const mediaElement = computed(() => owners.get().mediaElement);\n\n return effect(() => {\n const el = mediaElement.get();\n if (!el) return;\n\n return listen(el.textTracks, 'change', () => {\n const showingTrack = Array.from(el.textTracks).find(\n (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions')\n );\n\n // showingTrack.id is set from the SPF presentation track ID by setupTextTracks.\n // Fall back to undefined for empty-string IDs (non-SPF-managed tracks).\n const newId = showingTrack?.id || undefined;\n const current = state.get();\n\n // Guard against redundant writes — e.g. syncTextTrackModes confirming the\n // current selection, which would otherwise create a feedback loop.\n if (current.selectedTextTrackId === newId) return;\n\n if (newId) {\n const patch: Partial<SelectedTextTrackFromDomState> = { selectedTextTrackId: newId };\n update(state, patch);\n } else {\n // When deselecting, clear the textBufferState entry for the previous track.\n // Setting mode to 'disabled' (as toggleSubtitles() does) clears native cues\n // from the track element, so the buffer must be reset to allow re-fetching\n // on re-enable.\n const prevId = current.selectedTextTrackId;\n if (prevId && current.textBufferState?.[prevId]) {\n const next = { ...current.textBufferState };\n delete next[prevId];\n const patch: Partial<SelectedTextTrackFromDomState> = {\n selectedTextTrackId: undefined,\n textBufferState: next,\n };\n update(state, patch);\n } else {\n const patch: Partial<SelectedTextTrackFromDomState> = { selectedTextTrackId: undefined };\n update(state, patch);\n }\n }\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,6BAGd,EAAE,OAAO,UAA+D;CACxE,MAAM,eAAe,eAAe,OAAO,KAAK,CAAC,aAAa;AAE9D,QAAO,aAAa;EAClB,MAAM,KAAK,aAAa,KAAK;AAC7B,MAAI,CAAC,GAAI;AAET,SAAO,OAAO,GAAG,YAAY,gBAAgB;GAO3C,MAAM,QANe,MAAM,KAAK,GAAG,WAAW,CAAC,MAC5C,MAAM,EAAE,SAAS,cAAc,EAAE,SAAS,eAAe,EAAE,SAAS,YACtE,EAI2B,MAAM,KAAA;GAClC,MAAM,UAAU,MAAM,KAAK;AAI3B,OAAI,QAAQ,wBAAwB,MAAO;AAE3C,OAAI,MAEF,QAAO,OAD+C,EAAE,qBAAqB,OAAO,CAChE;QACf;IAKL,MAAM,SAAS,QAAQ;AACvB,QAAI,UAAU,QAAQ,kBAAkB,SAAS;KAC/C,MAAM,OAAO,EAAE,GAAG,QAAQ,iBAAiB;AAC3C,YAAO,KAAK;AAKZ,YAAO,OAJ+C;MACpD,qBAAqB,KAAA;MACrB,iBAAiB;MAClB,CACmB;UAGpB,QAAO,OAD+C,EAAE,qBAAqB,KAAA,GAAW,CACpE;;IAGxB;GACF"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { computed } from "../../core/signals/primitives.js";
|
|
3
|
+
//#region src/dom/features/sync-text-track-modes.ts
|
|
4
|
+
/**
|
|
5
|
+
* Sync text track modes orchestration.
|
|
6
|
+
*
|
|
7
|
+
* Manages track element modes based on selectedTextTrackId:
|
|
8
|
+
* - Selected track: mode = "showing"
|
|
9
|
+
* - Other tracks: mode = "hidden"
|
|
10
|
+
* - No selection: all tracks mode = "hidden"
|
|
11
|
+
*
|
|
12
|
+
* Note: Uses "hidden" instead of "disabled" for non-selected tracks
|
|
13
|
+
* so they remain available in the browser's track menu.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const cleanup = syncTextTrackModes({ state, owners });
|
|
17
|
+
*/
|
|
18
|
+
function syncTextTrackModes({ state, owners }) {
|
|
19
|
+
const textTracksSignal = computed(() => owners.get().textTracks);
|
|
20
|
+
const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId);
|
|
21
|
+
const canSyncTextTrackModes = computed(() => !!textTracksSignal.get()?.size);
|
|
22
|
+
return effect(() => {
|
|
23
|
+
if (!canSyncTextTrackModes.get()) return;
|
|
24
|
+
/** @TODO refactor TextTracks owners model. Should simply use id. Also should use corresponding TextTrack (JS) element if possible (CJP) */
|
|
25
|
+
const textTracks = textTracksSignal.get();
|
|
26
|
+
const selectedTextTrackId = selectedTextTrackIdSignal.get();
|
|
27
|
+
for (const [trackId, trackElement] of textTracks) trackElement.track.mode = trackId === selectedTextTrackId ? "showing" : "hidden";
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { syncTextTrackModes };
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=sync-text-track-modes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-text-track-modes.js","names":[],"sources":["../../../../src/dom/features/sync-text-track-modes.ts"],"sourcesContent":["import { effect } from '../../core/signals/effect';\nimport { computed, type Signal } from '../../core/signals/primitives';\n\n/**\n * State shape for text track mode synchronization.\n */\nexport interface TextTrackModeState {\n selectedTextTrackId?: string | undefined;\n}\n\n/**\n * Owners shape for text track mode synchronization.\n */\nexport interface TextTrackModeOwners {\n textTracks?: Map<string, HTMLTrackElement>;\n}\n\n/**\n * Sync text track modes orchestration.\n *\n * Manages track element modes based on selectedTextTrackId:\n * - Selected track: mode = \"showing\"\n * - Other tracks: mode = \"hidden\"\n * - No selection: all tracks mode = \"hidden\"\n *\n * Note: Uses \"hidden\" instead of \"disabled\" for non-selected tracks\n * so they remain available in the browser's track menu.\n *\n * @example\n * const cleanup = syncTextTrackModes({ state, owners });\n */\nexport function syncTextTrackModes<S extends TextTrackModeState, O extends TextTrackModeOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const textTracksSignal = computed(() => owners.get().textTracks);\n const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId);\n\n const canSyncTextTrackModes = computed(() => !!textTracksSignal.get()?.size);\n\n return effect(() => {\n if (!canSyncTextTrackModes.get()) return;\n /** @TODO refactor TextTracks owners model. Should simply use id. Also should use corresponding TextTrack (JS) element if possible (CJP) */\n const textTracks = textTracksSignal.get() as Map<string, HTMLTrackElement>;\n const selectedTextTrackId = selectedTextTrackIdSignal.get() as string;\n\n for (const [trackId, trackElement] of textTracks) {\n trackElement.track.mode = trackId === selectedTextTrackId ? 'showing' : 'hidden';\n }\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA+BA,SAAgB,mBAAgF,EAC9F,OACA,UAIa;CACb,MAAM,mBAAmB,eAAe,OAAO,KAAK,CAAC,WAAW;CAChE,MAAM,4BAA4B,eAAe,MAAM,KAAK,CAAC,oBAAoB;CAEjF,MAAM,wBAAwB,eAAe,CAAC,CAAC,iBAAiB,KAAK,EAAE,KAAK;AAE5E,QAAO,aAAa;AAClB,MAAI,CAAC,sBAAsB,KAAK,CAAE;;EAElC,MAAM,aAAa,iBAAiB,KAAK;EACzC,MAAM,sBAAsB,0BAA0B,KAAK;AAE3D,OAAK,MAAM,CAAC,SAAS,iBAAiB,WACpC,cAAa,MAAM,OAAO,YAAY,sBAAsB,YAAY;GAE1E"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { effect } from "../../core/signals/effect.js";
|
|
2
|
+
import { update } from "../../core/signals/primitives.js";
|
|
3
|
+
import { listen } from "@videojs/utils/dom";
|
|
4
|
+
//#region src/dom/features/track-current-time.ts
|
|
5
|
+
/**
|
|
6
|
+
* Track current playback position from the media element.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `mediaElement.currentTime` into reactive state on:
|
|
9
|
+
* - `timeupdate` — fires during playback (~4 Hz)
|
|
10
|
+
* - `seeking` — fires when a seek begins; per spec, `currentTime` is
|
|
11
|
+
* already at the new position when this event dispatches, so buffer
|
|
12
|
+
* management can react immediately rather than waiting for `timeupdate`,
|
|
13
|
+
* which does not fire while paused.
|
|
14
|
+
*
|
|
15
|
+
* Also syncs immediately when a media element becomes available.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const cleanup = trackCurrentTime({ state, owners });
|
|
19
|
+
*/
|
|
20
|
+
function trackCurrentTime({ state, owners }) {
|
|
21
|
+
let lastMediaElement;
|
|
22
|
+
let removeListeners = null;
|
|
23
|
+
const cleanupEffect = effect(() => {
|
|
24
|
+
const { mediaElement } = owners.get();
|
|
25
|
+
if (mediaElement === lastMediaElement) return;
|
|
26
|
+
removeListeners?.();
|
|
27
|
+
removeListeners = null;
|
|
28
|
+
lastMediaElement = mediaElement;
|
|
29
|
+
if (!mediaElement) return;
|
|
30
|
+
const sync = () => {
|
|
31
|
+
update(state, { currentTime: mediaElement.currentTime });
|
|
32
|
+
};
|
|
33
|
+
sync();
|
|
34
|
+
const removeTimeupdate = listen(mediaElement, "timeupdate", sync);
|
|
35
|
+
const removeSeeking = listen(mediaElement, "seeking", sync);
|
|
36
|
+
removeListeners = () => {
|
|
37
|
+
removeTimeupdate();
|
|
38
|
+
removeSeeking();
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return () => {
|
|
42
|
+
removeListeners?.();
|
|
43
|
+
cleanupEffect();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { trackCurrentTime };
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=track-current-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"track-current-time.js","names":[],"sources":["../../../../src/dom/features/track-current-time.ts"],"sourcesContent":["import { listen } from '@videojs/utils/dom';\nimport { effect } from '../../core/signals/effect';\nimport { type Signal, update } from '../../core/signals/primitives';\n\n/**\n * State shape for current time tracking.\n */\nexport interface CurrentTimeState {\n currentTime?: number;\n}\n\n/**\n * Owners shape for current time tracking.\n */\nexport interface CurrentTimeOwners {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\n/**\n * Check if we can track current time.\n *\n * Requires:\n * - mediaElement exists in owners\n */\nexport function canTrackCurrentTime(owners: CurrentTimeOwners): boolean {\n return !!owners.mediaElement;\n}\n\n/**\n * Track current playback position from the media element.\n *\n * Mirrors `mediaElement.currentTime` into reactive state on:\n * - `timeupdate` — fires during playback (~4 Hz)\n * - `seeking` — fires when a seek begins; per spec, `currentTime` is\n * already at the new position when this event dispatches, so buffer\n * management can react immediately rather than waiting for `timeupdate`,\n * which does not fire while paused.\n *\n * Also syncs immediately when a media element becomes available.\n *\n * @example\n * const cleanup = trackCurrentTime({ state, owners });\n */\nexport function trackCurrentTime<S extends CurrentTimeState, O extends CurrentTimeOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n let lastMediaElement: HTMLMediaElement | undefined;\n let removeListeners: (() => void) | null = null;\n\n const cleanupEffect = effect(() => {\n const { mediaElement } = owners.get();\n\n if (mediaElement === lastMediaElement) return;\n\n removeListeners?.();\n removeListeners = null;\n lastMediaElement = mediaElement;\n\n if (!mediaElement) return;\n\n const sync = () => {\n const patch: Partial<CurrentTimeState> = { currentTime: mediaElement.currentTime };\n update(state, patch);\n };\n\n // Sync immediately so consumers don't wait for the first event\n sync();\n const removeTimeupdate = listen(mediaElement, 'timeupdate', sync);\n const removeSeeking = listen(mediaElement, 'seeking', sync);\n removeListeners = () => {\n removeTimeupdate();\n removeSeeking();\n };\n });\n\n return () => {\n removeListeners?.();\n cleanupEffect();\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2CA,SAAgB,iBAA0E,EACxF,OACA,UAIa;CACb,IAAI;CACJ,IAAI,kBAAuC;CAE3C,MAAM,gBAAgB,aAAa;EACjC,MAAM,EAAE,iBAAiB,OAAO,KAAK;AAErC,MAAI,iBAAiB,iBAAkB;AAEvC,qBAAmB;AACnB,oBAAkB;AAClB,qBAAmB;AAEnB,MAAI,CAAC,aAAc;EAEnB,MAAM,aAAa;AAEjB,UAAO,OADkC,EAAE,aAAa,aAAa,aAAa,CAC9D;;AAItB,QAAM;EACN,MAAM,mBAAmB,OAAO,cAAc,cAAc,KAAK;EACjE,MAAM,gBAAgB,OAAO,cAAc,WAAW,KAAK;AAC3D,0BAAwB;AACtB,qBAAkB;AAClB,kBAAe;;GAEjB;AAEF,cAAa;AACX,qBAAmB;AACnB,iBAAe"}
|