@shenora/react 0.10.0 → 0.11.0

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.
@@ -0,0 +1,256 @@
1
+ import { codecsFromInitSegment, nextSegment, parseManifest, pickMediaSource, segmentMimeType, } from './segmentStream.js';
2
+ /**
3
+ * How long to wait for the attached MediaSource to reach `open`.
4
+ *
5
+ * Attachment is local and immediate — there is no network in it — so this is a deadline for "something
6
+ * is wrong", not a budget. It exists because the alternative to a deadline here is an await that never
7
+ * returns: the element can refuse or tear down an attachment without ever raising an event this side
8
+ * can name.
9
+ */
10
+ const ATTACH_TIMEOUT_MS = 10000;
11
+ /**
12
+ * Thrown for every reason a stream cannot start, so a caller has one thing to catch.
13
+ *
14
+ * ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch failing at the network layer,
15
+ * and a `RangeError` from a truncated init segment, both propagate as themselves. Catch broadly if you
16
+ * need to be exhaustive.
17
+ */
18
+ export class SegmentBinderError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'SegmentBinderError';
22
+ }
23
+ }
24
+ /** Join a segment URI to the manifest's own location. */
25
+ function resolve(manifestUrl, uri) {
26
+ const slash = manifestUrl.lastIndexOf('/');
27
+ return slash < 0 ? uri : manifestUrl.slice(0, slash + 1) + uri;
28
+ }
29
+ /** Seconds already buffered ahead of `currentTime`, across whichever range holds it. */
30
+ function bufferedAhead(element) {
31
+ const ranges = element.buffered;
32
+ for (let i = 0; i < ranges.length; i++) {
33
+ if (element.currentTime >= ranges.start(i) - 0.1 && element.currentTime <= ranges.end(i)) {
34
+ return ranges.end(i) - element.currentTime;
35
+ }
36
+ }
37
+ return 0;
38
+ }
39
+ /**
40
+ * Open a MediaSource for `options.manifest` and keep it fed.
41
+ *
42
+ * Resolves once the init segment has been appended — the point after which the element can play — and
43
+ * goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
44
+ *
45
+ * ⚠ **There is deliberately NO `useSegmentStream` hook.** This needs no React — it takes an element and
46
+ * returns a handle, exactly as `mediaUrl` takes a path and returns a string — so a hook would add a
47
+ * lifecycle without adding a capability. The case that would earn one is an app wanting load/error
48
+ * state as component state, and the shape of that hook depends on what such an app actually asks for;
49
+ * inventing it first is how a seam nothing consults gets built (D63). Call this from an effect.
50
+ */
51
+ export async function bindSegmentStream(options) {
52
+ const { manifest: manifestUrl, element, globals = globalThis, targetAheadSeconds = 30, onDiagnostic, } = options;
53
+ const doFetch = options.fetch ?? ((url) => fetch(url));
54
+ const say = (line) => onDiagnostic?.(line);
55
+ /**
56
+ * A FAILURE, as opposed to a trace line.
57
+ *
58
+ * 🔴 **`onDiagnostic` is optional, so failures used to go nowhere by default.** A segment answering 500
59
+ * or an `appendBuffer` `QuotaExceededError` produced no console output, no rejection and no state
60
+ * change — playback simply stalled, with nothing anywhere to explain it. Every other error path in this
61
+ * package already defaults to `console.error` (`bridge.ts`'s `onPostError`, `store.ts`'s `onError`,
62
+ * `eventBus.ts`); this one was the exception.
63
+ *
64
+ * ⚠ Only when the app supplied NO handler. A caller that took `onDiagnostic` owns its reporting and
65
+ * must not be double-logged — the same rule the two sinks above follow.
66
+ */
67
+ const fail = (line) => (onDiagnostic ? onDiagnostic(line) : console.error(`[shenora] ${line}`));
68
+ const kind = pickMediaSource(globals);
69
+ if (kind === 'none')
70
+ throw new SegmentBinderError('this browser has no MediaSource of either kind');
71
+ const Source = (kind === 'managed' ? globals.ManagedMediaSource : globals.MediaSource);
72
+ // ── the manifest, and the init segment it names ──────────────────────────────────────────────────
73
+ const playlist = await doFetch(manifestUrl);
74
+ if (!playlist.ok)
75
+ throw new SegmentBinderError(`the manifest answered ${playlist.status}`);
76
+ const parsed = parseManifest(await playlist.text());
77
+ if (!parsed.initUri) {
78
+ // A fragment repeats no decoder configuration, so appending one without this decodes nothing.
79
+ throw new SegmentBinderError('the playlist declares no #EXT-X-MAP, which is not playable');
80
+ }
81
+ if (parsed.segments.length === 0)
82
+ throw new SegmentBinderError('the playlist declares no segments');
83
+ const initResponse = await doFetch(resolve(manifestUrl, parsed.initUri));
84
+ if (!initResponse.ok)
85
+ throw new SegmentBinderError(`the init segment answered ${initResponse.status}`);
86
+ const init = new Uint8Array(await initResponse.arrayBuffer());
87
+ // 🔴 The TRACK SET, from the bytes rather than from a constant — see the module remarks.
88
+ const codecs = codecsFromInitSegment(init);
89
+ if (!codecs)
90
+ throw new SegmentBinderError('no track could be read from the init segment');
91
+ const mime = segmentMimeType(codecs);
92
+ say(`segments: opening ${mime} (${kind})`);
93
+ // ── attach ──────────────────────────────────────────────────────────────────────────────────────
94
+ const source = new Source();
95
+ const revoke = options.revokeObjectURL ?? ((u) => URL.revokeObjectURL(u));
96
+ let attachedBy = 'srcObject';
97
+ let objectUrl;
98
+ try {
99
+ element.srcObject = source;
100
+ }
101
+ catch {
102
+ attachedBy = 'objectURL';
103
+ const mint = options.createObjectURL ?? ((s) => URL.createObjectURL(s));
104
+ objectUrl = mint(source);
105
+ element.src = objectUrl;
106
+ }
107
+ // ⚠ The object URL is minted BEFORE anything below can fail, so EVERY failure between the mint and
108
+ // the returned binding has to revoke it itself — bindSegmentStream has not returned, so the caller
109
+ // holds no binding to dispose, and the document keeps the MediaSource alive for its lifetime. That
110
+ // is three places, not one: the open wait, addSourceBuffer, and the init append.
111
+ const revokeAndRethrow = (e) => {
112
+ if (objectUrl)
113
+ revoke(objectUrl);
114
+ throw e;
115
+ };
116
+ // 🔴 `error` IS NOT A MediaSource EVENT. The spec fires `sourceopen`, `sourceended` and
117
+ // `sourceclose`, so the only rejection path here could never fire — and an attachment that CLOSES
118
+ // rather than opening (the element detached from the document before load, an attachment refused)
119
+ // left this await pending FOREVER: no error, no diagnostic, no way to reach dispose(), and the object
120
+ // URL never revoked. `sourceclose` is the real signal, and the deadline covers whatever is neither.
121
+ await new Promise((res, rej) => {
122
+ if (source.readyState === 'open')
123
+ return res();
124
+ const settle = (finish) => () => {
125
+ clearTimeout(timer);
126
+ source.removeEventListener('sourceopen', onOpen);
127
+ source.removeEventListener('sourceclose', onClose);
128
+ finish();
129
+ };
130
+ const onOpen = settle(() => res());
131
+ const onClose = settle(() => rej(new SegmentBinderError('the MediaSource closed before it opened')));
132
+ const timer = setTimeout(settle(() => rej(new SegmentBinderError(`the MediaSource did not open within ${ATTACH_TIMEOUT_MS} ms (attached by ${attachedBy})`))), ATTACH_TIMEOUT_MS);
133
+ source.addEventListener('sourceopen', onOpen);
134
+ source.addEventListener('sourceclose', onClose);
135
+ }).catch(revokeAndRethrow);
136
+ let buffer;
137
+ try {
138
+ // Throws synchronously for a codecs string this implementation refuses — a real case, per the
139
+ // remarks on codec derivation above.
140
+ buffer = source.addSourceBuffer(mime);
141
+ }
142
+ catch (e) {
143
+ revokeAndRethrow(e);
144
+ }
145
+ // ── state ───────────────────────────────────────────────────────────────────────────────────────
146
+ const appended = new Set();
147
+ // Absent signals mean ALWAYS streaming. A managed source flips this on its own events.
148
+ let streaming = true;
149
+ let disposed = false;
150
+ let pumping = false;
151
+ /**
152
+ * Append one buffer and settle when the source buffer says so.
153
+ *
154
+ * 🔴 **Both listeners come off on EITHER outcome.** `{ once: true }` removes only the listener that
155
+ * FIRES, and the success path fires `updateend` — so the `error` listener stayed attached, once per
156
+ * appended segment, each retaining a settled `rej` closure. A two-hour stream at six-second segments
157
+ * accumulates ~1,200 dead listeners on one `SourceBuffer`, none of which `dispose()` could shed, and a
158
+ * later real `error` invokes every one of them.
159
+ */
160
+ const append = (bytes) => new Promise((res, rej) => {
161
+ const done = (settle) => () => {
162
+ buffer.removeEventListener('updateend', onDone);
163
+ buffer.removeEventListener('error', onFail);
164
+ settle();
165
+ };
166
+ const onDone = done(() => res());
167
+ const onFail = done(() => rej(new SegmentBinderError('appendBuffer failed')));
168
+ buffer.addEventListener('updateend', onDone);
169
+ buffer.addEventListener('error', onFail);
170
+ try {
171
+ buffer.appendBuffer(bytes);
172
+ }
173
+ catch (e) {
174
+ // A synchronous throw settles nothing through the events, so shed them here too.
175
+ buffer.removeEventListener('updateend', onDone);
176
+ buffer.removeEventListener('error', onFail);
177
+ rej(new SegmentBinderError(`appendBuffer threw: ${e.message}`));
178
+ }
179
+ });
180
+ await append(init).catch(revokeAndRethrow);
181
+ say('segments: init appended');
182
+ /**
183
+ * Fetch and append whatever {@link nextSegment} asks for, one at a time.
184
+ *
185
+ * ⚠ Re-entrancy is guarded rather than queued: this is driven by element events that fire far faster
186
+ * than an append completes, and two concurrent `appendBuffer` calls on one SourceBuffer throw
187
+ * `InvalidStateError` — which surfaces as a stall with no obvious cause.
188
+ */
189
+ const pump = async () => {
190
+ if (pumping || disposed)
191
+ return;
192
+ pumping = true;
193
+ try {
194
+ for (;;) {
195
+ if (disposed)
196
+ return;
197
+ const index = nextSegment({ currentTime: element.currentTime, bufferedAhead: bufferedAhead(element), appended, streaming }, { segments: parsed.segments, targetAheadSeconds });
198
+ if (index === null)
199
+ break;
200
+ const entry = parsed.segments[index];
201
+ const response = await doFetch(resolve(manifestUrl, entry.uri));
202
+ if (disposed)
203
+ return;
204
+ if (!response.ok) {
205
+ // 503 is the host saying "still producing", which is a WAIT and not a failure — the route
206
+ // answers it deliberately rather than 404ing a source that is merely not ready.
207
+ fail(`segments: ${entry.uri} answered ${response.status}`);
208
+ break;
209
+ }
210
+ await append(new Uint8Array(await response.arrayBuffer()));
211
+ appended.add(index);
212
+ if (disposed)
213
+ return;
214
+ }
215
+ // Every segment in: say so, or the element never learns it has reached the end.
216
+ if (appended.size === parsed.segments.length && source.readyState === 'open') {
217
+ source.endOfStream();
218
+ say('segments: endOfStream');
219
+ }
220
+ }
221
+ catch (e) {
222
+ fail(`segments: ${e.message}`);
223
+ }
224
+ finally {
225
+ pumping = false;
226
+ }
227
+ };
228
+ // ── the events that should make us reconsider ───────────────────────────────────────────────────
229
+ const onStart = () => { streaming = true; say('segments: startstreaming'); void pump(); };
230
+ const onEnd = () => { streaming = false; say('segments: endstreaming'); };
231
+ const wake = () => { void pump(); };
232
+ source.addEventListener('startstreaming', onStart);
233
+ source.addEventListener('endstreaming', onEnd);
234
+ element.addEventListener('timeupdate', wake);
235
+ element.addEventListener('seeking', wake);
236
+ element.addEventListener('waiting', wake);
237
+ void pump();
238
+ return {
239
+ get appended() { return appended; },
240
+ get streaming() { return streaming; },
241
+ attachedBy,
242
+ codecs,
243
+ dispose() {
244
+ if (disposed)
245
+ return;
246
+ disposed = true;
247
+ source.removeEventListener('startstreaming', onStart);
248
+ source.removeEventListener('endstreaming', onEnd);
249
+ element.removeEventListener('timeupdate', wake);
250
+ element.removeEventListener('seeking', wake);
251
+ element.removeEventListener('waiting', wake);
252
+ if (objectUrl)
253
+ revoke(objectUrl);
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
+ * MediaSource this browser actually has, and deciding what to fetch next.
4
+ *
5
+ * 🔴 **Everything in this module is PURE, and the split is deliberate.** The DECISIONS live here, where a
6
+ * test pins them exactly, and `segmentBinder.ts` holds the imperative half — creating a `SourceBuffer`,
7
+ * appending bytes, listening to element events. That is the same division `SegmentGrid` makes on the host
8
+ * side, for the same reason.
9
+ *
10
+ * ⚠ **"The imperative half cannot be verified anywhere this repo runs" was true and is no longer.** jsdom
11
+ * still has no MediaSource and both real implementations still live on devices — but the binder takes its
12
+ * source and its fetch as PARAMETERS, so a fake drives every branch of it. What a fake cannot say is
13
+ * whether a real implementation accepts the bytes; that is measured on hardware and recorded in
14
+ * `docs/design/media.md`.
15
+ */
16
+ /** One entry in the playlist the host serves. */
17
+ export interface SegmentEntry {
18
+ /** Relative to the manifest — `seg12.m4s`. */
19
+ uri: string;
20
+ /** What the playlist declares this segment lasts, in seconds. */
21
+ seconds: number;
22
+ }
23
+ /** What a playlist says, reduced to the three things a binder acts on. */
24
+ export interface SegmentManifest {
25
+ /**
26
+ * The initialisation segment (`#EXT-X-MAP`), which carries the tracks and their decoder configuration.
27
+ *
28
+ * ⚠ **Null means the playlist declared none, and that is not playable through MediaSource** — a fragment
29
+ * repeats no configuration, so appending one without this produces a silent decode error. The kit's host
30
+ * route always writes it; a foreign playlist may not.
31
+ */
32
+ initUri: string | null;
33
+ /** The longest a segment may be, from `#EXT-X-TARGETDURATION`. */
34
+ targetSeconds: number;
35
+ segments: SegmentEntry[];
36
+ }
37
+ /**
38
+ * Parse the subset of HLS the host emits. Deliberately NOT a general playlist parser: this reads what
39
+ * `SegmentStream` writes, and anything it does not understand is ignored rather than guessed at.
40
+ *
41
+ * ⚠ Unknown tags are skipped silently BY DESIGN — a playlist gains tags over time and a parser that threw
42
+ * on the first one it had not met would break on a host newer than the page.
43
+ */
44
+ export declare function parseManifest(text: string): SegmentManifest;
45
+ /** Which MediaSource implementation this browser has, if any. */
46
+ export type MediaSourceKind = 'managed' | 'standard' | 'none';
47
+ /** A window-shaped object, so the pick is testable without a browser. */
48
+ export interface MediaSourceGlobals {
49
+ MediaSource?: unknown;
50
+ ManagedMediaSource?: unknown;
51
+ }
52
+ /**
53
+ * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
54
+ *
55
+ * 🔴 **The order is the decision, and it is not "newest wins".** iOS on iPhone has only
56
+ * `ManagedMediaSource`; Android has only `MediaSource`. Where BOTH exist the managed one is still preferred,
57
+ * because it is the one that tells the page when the platform actually wants data — a page that streams
58
+ * regardless is what the managed variant was introduced to stop.
59
+ *
60
+ * ✅ **Measured rather than assumed** (iPhone 16 Pro simulator, iOS 26, 2026-08-14): `window.MediaSource` is
61
+ * `false` there and `ManagedMediaSource` is `true`. So on iOS this is not a preference at all — **a binder
62
+ * that only knows `window.MediaSource` does nothing**, and the naming here is what makes one bundle work on
63
+ * both shells.
64
+ *
65
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND rather than just a constructor.**
66
+ * A managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
67
+ * outside that window is the thing it exists to prevent. A caller that ignores the kind has not merely
68
+ * missed an optimisation.
69
+ */
70
+ export declare function pickMediaSource(globals: MediaSourceGlobals): MediaSourceKind;
71
+ /** What {@link nextSegment} needs to know about the element and the buffer. */
72
+ export interface FetchState {
73
+ /** Where playback is, in seconds. */
74
+ currentTime: number;
75
+ /** How many seconds are already buffered AHEAD of `currentTime`. */
76
+ bufferedAhead: number;
77
+ /** Indices already appended, so a seek back into them costs nothing. */
78
+ appended: ReadonlySet<number>;
79
+ /** False while a managed source has told us to stop — see {@link pickMediaSource}. */
80
+ streaming: boolean;
81
+ }
82
+ /** How far ahead to keep the buffer, and how much of the playlist there is. */
83
+ export interface FetchPolicy {
84
+ segments: readonly SegmentEntry[];
85
+ /** Stop fetching once this many seconds are buffered ahead. */
86
+ targetAheadSeconds: number;
87
+ }
88
+ /**
89
+ * The next segment index to fetch, or null for "nothing right now".
90
+ *
91
+ * 🔴 **Every branch here is a decision whose failure is SILENT in a browser**, which is why it is a pure
92
+ * function with a test rather than an `if` inside an event handler:
93
+ *
94
+ * - **Not streaming → null.** A managed source that is fetched while it said stop is the exact misuse it
95
+ * exists to detect, and on iOS the penalty is the platform tearing the source down.
96
+ * - **Enough buffered → null.** Fetching further ahead than the policy asks does not make playback smoother;
97
+ * it fills a quota, and a `QuotaExceededError` on append arrives as a stall with no obvious cause.
98
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it.** Starting from
99
+ * "the next index after the last one appended" instead is what breaks seeking: after a jump the last
100
+ * append is nowhere near where the user is now.
101
+ */
102
+ export declare function nextSegment(state: FetchState, policy: FetchPolicy): number | null;
103
+ /**
104
+ * The MIME type to open a `SourceBuffer` with.
105
+ *
106
+ * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
107
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed before the
108
+ * init segment arrives. The default is H.264 High 4.0 plus AAC-LC.
109
+ *
110
+ * 🔴 **And the default is a DEFAULT rather than a guarantee, because the host copies whatever the source
111
+ * already holds** (D76): a segment's picture keeps the profile and level the original encoder chose, and an
112
+ * HEVC source arrives as `hvc1` and not `avc1` at all. The family is what an implementation actually checks,
113
+ * so an H.264 source of any profile plays through the default — **an HEVC one needs its own string**, e.g.
114
+ * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
115
+ */
116
+ export declare function segmentMimeType(codecs?: string): string;
117
+ /**
118
+ * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
119
+ * tracks it will actually be fed.
120
+ *
121
+ * 🔴 **The TRACK SET is the part that must be right, and getting it wrong is fatal rather than
122
+ * degraded.** Measured against Chromium 151 on the kit's own segments: a video-only init segment
123
+ * appended to a buffer opened with the two-track default (`avc1.640028,mp4a.40.2`) fails the FIRST
124
+ * append and plays nothing at all — while the same bytes with `avc1.640015` play. A source with no
125
+ * soundtrack is ordinary, so a fixed default cannot serve both.
126
+ *
127
+ * ⚠ **The profile and level, by contrast, are barely checked.** The same measurement fed High 2.1
128
+ * content to a buffer opened as Baseline 3.0 (`avc1.42E01E`) and it played. That is why this returns a
129
+ * precise string when the configuration is there to read and a family default when it is not: precision
130
+ * where it is free, and never a guess about which tracks exist.
131
+ *
132
+ * @param init The bytes of the `#EXT-X-MAP` segment.
133
+ * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
134
+ * should treat that as "do not open a SourceBuffer", not as "use the default".
135
+ */
136
+ export declare function codecsFromInitSegment(init: Uint8Array): string | null;
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The page half of the host's segment route (D71 piece 4) — reading the manifest it serves, choosing the
3
+ * MediaSource this browser actually has, and deciding what to fetch next.
4
+ *
5
+ * 🔴 **Everything in this module is PURE, and the split is deliberate.** The DECISIONS live here, where a
6
+ * test pins them exactly, and `segmentBinder.ts` holds the imperative half — creating a `SourceBuffer`,
7
+ * appending bytes, listening to element events. That is the same division `SegmentGrid` makes on the host
8
+ * side, for the same reason.
9
+ *
10
+ * ⚠ **"The imperative half cannot be verified anywhere this repo runs" was true and is no longer.** jsdom
11
+ * still has no MediaSource and both real implementations still live on devices — but the binder takes its
12
+ * source and its fetch as PARAMETERS, so a fake drives every branch of it. What a fake cannot say is
13
+ * whether a real implementation accepts the bytes; that is measured on hardware and recorded in
14
+ * `docs/design/media.md`.
15
+ */
16
+ /**
17
+ * Parse the subset of HLS the host emits. Deliberately NOT a general playlist parser: this reads what
18
+ * `SegmentStream` writes, and anything it does not understand is ignored rather than guessed at.
19
+ *
20
+ * ⚠ Unknown tags are skipped silently BY DESIGN — a playlist gains tags over time and a parser that threw
21
+ * on the first one it had not met would break on a host newer than the page.
22
+ */
23
+ export function parseManifest(text) {
24
+ const segments = [];
25
+ let initUri = null;
26
+ let targetSeconds = 0;
27
+ let pending = null;
28
+ for (const raw of text.split('\n')) {
29
+ const line = raw.trim();
30
+ if (line.length === 0)
31
+ continue;
32
+ if (line.startsWith('#EXT-X-MAP:')) {
33
+ // URI="init.mp4" — quoted per the spec, and the quotes are not optional there.
34
+ initUri = /URI="([^"]*)"/.exec(line)?.[1] ?? null;
35
+ continue;
36
+ }
37
+ if (line.startsWith('#EXT-X-TARGETDURATION:')) {
38
+ targetSeconds = Number(line.slice('#EXT-X-TARGETDURATION:'.length)) || 0;
39
+ continue;
40
+ }
41
+ if (line.startsWith('#EXTINF:')) {
42
+ // `#EXTINF:6.000,` — the trailing comma introduces an optional title.
43
+ pending = Number(line.slice('#EXTINF:'.length).split(',')[0]) || 0;
44
+ continue;
45
+ }
46
+ if (line.startsWith('#'))
47
+ continue;
48
+ // A bare line is a URI, and it belongs to the EXTINF above it.
49
+ segments.push({ uri: line, seconds: pending ?? 0 });
50
+ pending = null;
51
+ }
52
+ return { initUri, targetSeconds, segments };
53
+ }
54
+ /**
55
+ * Which MediaSource to use — **`ManagedMediaSource` first where it exists**.
56
+ *
57
+ * 🔴 **The order is the decision, and it is not "newest wins".** iOS on iPhone has only
58
+ * `ManagedMediaSource`; Android has only `MediaSource`. Where BOTH exist the managed one is still preferred,
59
+ * because it is the one that tells the page when the platform actually wants data — a page that streams
60
+ * regardless is what the managed variant was introduced to stop.
61
+ *
62
+ * ✅ **Measured rather than assumed** (iPhone 16 Pro simulator, iOS 26, 2026-08-14): `window.MediaSource` is
63
+ * `false` there and `ManagedMediaSource` is `true`. So on iOS this is not a preference at all — **a binder
64
+ * that only knows `window.MediaSource` does nothing**, and the naming here is what makes one bundle work on
65
+ * both shells.
66
+ *
67
+ * ⚠ **`'managed'` carries an obligation, which is why this returns a KIND rather than just a constructor.**
68
+ * A managed source only wants data between its `startstreaming` and `endstreaming` events, and fetching
69
+ * outside that window is the thing it exists to prevent. A caller that ignores the kind has not merely
70
+ * missed an optimisation.
71
+ */
72
+ export function pickMediaSource(globals) {
73
+ if (typeof globals.ManagedMediaSource === 'function')
74
+ return 'managed';
75
+ if (typeof globals.MediaSource === 'function')
76
+ return 'standard';
77
+ return 'none';
78
+ }
79
+ /**
80
+ * The next segment index to fetch, or null for "nothing right now".
81
+ *
82
+ * 🔴 **Every branch here is a decision whose failure is SILENT in a browser**, which is why it is a pure
83
+ * function with a test rather than an `if` inside an event handler:
84
+ *
85
+ * - **Not streaming → null.** A managed source that is fetched while it said stop is the exact misuse it
86
+ * exists to detect, and on iOS the penalty is the platform tearing the source down.
87
+ * - **Enough buffered → null.** Fetching further ahead than the policy asks does not make playback smoother;
88
+ * it fills a quota, and a `QuotaExceededError` on append arrives as a stall with no obvious cause.
89
+ * - **Otherwise the segment CONTAINING `currentTime`, or the first unappended one after it.** Starting from
90
+ * "the next index after the last one appended" instead is what breaks seeking: after a jump the last
91
+ * append is nowhere near where the user is now.
92
+ */
93
+ export function nextSegment(state, policy) {
94
+ if (!state.streaming)
95
+ return null;
96
+ if (state.bufferedAhead >= policy.targetAheadSeconds)
97
+ return null;
98
+ if (policy.segments.length === 0)
99
+ return null;
100
+ // Walk the playlist's own durations rather than assuming a fixed grid: the LAST segment is short, so
101
+ // dividing by the target duration puts the tail index past the end.
102
+ let at = 0;
103
+ let index = 0;
104
+ for (; index < policy.segments.length; index++) {
105
+ const end = at + policy.segments[index].seconds;
106
+ if (state.currentTime < end)
107
+ break;
108
+ at = end;
109
+ }
110
+ for (let i = Math.min(index, policy.segments.length - 1); i < policy.segments.length; i++) {
111
+ if (!state.appended.has(i))
112
+ return i;
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * The MIME type to open a `SourceBuffer` with.
118
+ *
119
+ * ⚠ **The codecs parameter is REQUIRED, not decorative.** `addSourceBuffer('video/mp4')` throws
120
+ * `NotSupportedError` on every implementation — the buffer has to know what it is about to be fed before the
121
+ * init segment arrives. The default is H.264 High 4.0 plus AAC-LC.
122
+ *
123
+ * 🔴 **And the default is a DEFAULT rather than a guarantee, because the host copies whatever the source
124
+ * already holds** (D76): a segment's picture keeps the profile and level the original encoder chose, and an
125
+ * HEVC source arrives as `hvc1` and not `avc1` at all. The family is what an implementation actually checks,
126
+ * so an H.264 source of any profile plays through the default — **an HEVC one needs its own string**, e.g.
127
+ * `segmentMimeType('hvc1.1.6.L93.B0,mp4a.40.2')`.
128
+ */
129
+ export function segmentMimeType(codecs = 'avc1.640028,mp4a.40.2') {
130
+ return `video/mp4; codecs="${codecs}"`;
131
+ }
132
+ /**
133
+ * Read the codecs parameter out of an initialisation segment, so the `SourceBuffer` is opened for the
134
+ * tracks it will actually be fed.
135
+ *
136
+ * 🔴 **The TRACK SET is the part that must be right, and getting it wrong is fatal rather than
137
+ * degraded.** Measured against Chromium 151 on the kit's own segments: a video-only init segment
138
+ * appended to a buffer opened with the two-track default (`avc1.640028,mp4a.40.2`) fails the FIRST
139
+ * append and plays nothing at all — while the same bytes with `avc1.640015` play. A source with no
140
+ * soundtrack is ordinary, so a fixed default cannot serve both.
141
+ *
142
+ * ⚠ **The profile and level, by contrast, are barely checked.** The same measurement fed High 2.1
143
+ * content to a buffer opened as Baseline 3.0 (`avc1.42E01E`) and it played. That is why this returns a
144
+ * precise string when the configuration is there to read and a family default when it is not: precision
145
+ * where it is free, and never a guess about which tracks exist.
146
+ *
147
+ * @param init The bytes of the `#EXT-X-MAP` segment.
148
+ * @returns A codecs string for {@link segmentMimeType}, or null when no track could be read — the caller
149
+ * should treat that as "do not open a SourceBuffer", not as "use the default".
150
+ */
151
+ export function codecsFromInitSegment(init) {
152
+ const view = new DataView(init.buffer, init.byteOffset, init.byteLength);
153
+ const codecs = [];
154
+ /** Bytes are read through the view rather than by index: `noUncheckedIndexedAccess` types `init[i]`
155
+ * as possibly-undefined, and a `!` on every read would hide the one that IS out of range. */
156
+ const u8 = (at) => view.getUint8(at);
157
+ const fourcc = (at) => String.fromCharCode(u8(at), u8(at + 1), u8(at + 2), u8(at + 3));
158
+ const hex2 = (n) => n.toString(16).padStart(2, '0');
159
+ /** Walk the direct children of [start, end), calling `visit` with (type, contentStart, contentEnd). */
160
+ const children = (start, end, visit) => {
161
+ let at = start;
162
+ while (at + 8 <= end) {
163
+ let size = view.getUint32(at);
164
+ let header = 8;
165
+ if (size === 1) {
166
+ if (at + 16 > end)
167
+ return;
168
+ size = Number(view.getBigUint64(at + 8));
169
+ header = 16;
170
+ }
171
+ if (size === 0)
172
+ size = end - at;
173
+ if (size < header || at + size > end)
174
+ return;
175
+ visit(fourcc(at + 4), at + header, at + size);
176
+ at += size;
177
+ }
178
+ };
179
+ /** The `avcC`/`hvcC`/`esds` inside a sample entry, whose own fields come first. */
180
+ const configuration = (format, start, end) => {
181
+ // Measured against the host's own init segment: an `avc1` entry holds 132 bytes of content and a
182
+ // 54-byte `avcC`, so the child boxes start 78 in — the 8-byte SampleEntry base (6 reserved + a data
183
+ // reference index) plus VisualSampleEntry's 70. An `mp4a` entry is that base plus 20.
184
+ const visual = format === 'avc1' || format === 'avc3' || format === 'hvc1' || format === 'hev1';
185
+ const at = start + (visual ? 78 : 28);
186
+ let derived = '';
187
+ children(at, end, (type, s) => {
188
+ if ((type === 'avcC') && s + 4 <= end) {
189
+ // configurationVersion, AVCProfileIndication, profile_compatibility, AVCLevelIndication
190
+ derived = `${format}.${hex2(u8(s + 1))}${hex2(u8(s + 2))}${hex2(u8(s + 3))}`;
191
+ }
192
+ else if (type === 'hvcC' && s + 13 <= end) {
193
+ // ISO 14496-15 §8.3.3: profile_space/tier/idc, then 4 compatibility bytes, 6 constraint bytes,
194
+ // then the level. Rendered in the form Chromium and Safari both accept.
195
+ const space = (u8(s + 1) >> 6) & 0x3;
196
+ const tier = (u8(s + 1) >> 5) & 0x1;
197
+ const idc = u8(s + 1) & 0x1f;
198
+ const compat = view.getUint32(s + 2);
199
+ const level = u8(s + 12);
200
+ const spaces = ['', 'A', 'B', 'C'][space];
201
+ // The compatibility flags travel REVERSED, as a bit string, per the spec's C.1.
202
+ let reversed = 0;
203
+ for (let bit = 0; bit < 32; bit++)
204
+ reversed |= ((compat >>> bit) & 1) << (31 - bit);
205
+ derived = `${format}.${spaces}${idc}.${(reversed >>> 0).toString(16).toUpperCase()}.` +
206
+ `${tier ? 'H' : 'L'}${level}`;
207
+ }
208
+ else if (type === 'esds' && s + 21 <= end) {
209
+ // The object type indication, then the audio object type from the DecoderSpecificInfo's first
210
+ // 5 bits. Only AAC is ever copied here, so the walk stays a search rather than a full parser.
211
+ for (let p = s; p + 2 < end; p++) {
212
+ if (u8(p) === 0x04 && u8(p + 2) === 0x40) {
213
+ derived = `mp4a.40.${u8(p + 3) || 2}`;
214
+ break;
215
+ }
216
+ }
217
+ if (!derived)
218
+ derived = 'mp4a.40.2';
219
+ }
220
+ });
221
+ if (derived)
222
+ return derived;
223
+ // No configuration to read: name the FAMILY, which is what an implementation actually checks.
224
+ return format === 'mp4a' ? 'mp4a.40.2' : format;
225
+ };
226
+ const stsd = (start, end) => {
227
+ // FullBox header (4) + entry_count (4), then the entries.
228
+ let at = start + 8;
229
+ while (at + 8 <= end) {
230
+ const size = view.getUint32(at);
231
+ if (size < 8 || at + size > end)
232
+ return;
233
+ codecs.push(configuration(fourcc(at + 4), at + 8, at + size));
234
+ at += size;
235
+ }
236
+ };
237
+ const descend = (start, end) => {
238
+ children(start, end, (type, s, e) => {
239
+ if (type === 'stsd')
240
+ stsd(s, e);
241
+ else if (type === 'moov' || type === 'trak' || type === 'mdia' || type === 'minf' || type === 'stbl') {
242
+ descend(s, e);
243
+ }
244
+ });
245
+ };
246
+ descend(0, init.byteLength);
247
+ return codecs.length > 0 ? codecs.join(',') : null;
248
+ }