@shenora/react 0.9.1 → 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.
- package/README.md +152 -165
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +19 -10
- package/dist/clipboard.d.ts +94 -0
- package/dist/clipboard.js +126 -0
- package/dist/devInterceptor.d.ts +9 -2
- package/dist/devInterceptor.js +15 -4
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -3
- package/dist/eventBus.d.ts +15 -5
- package/dist/eventBus.js +29 -10
- package/dist/fileDialogs.d.ts +153 -0
- package/dist/fileDialogs.js +90 -0
- package/dist/hooks.d.ts +32 -2
- package/dist/hooks.js +36 -3
- package/dist/index.d.ts +11 -4
- package/dist/index.js +20 -6
- package/dist/mediaPlayer.d.ts +86 -0
- package/dist/mediaPlayer.js +192 -0
- package/dist/moduleService.d.ts +9 -2
- package/dist/moduleService.js +9 -2
- package/dist/requests.d.ts +176 -0
- package/dist/requests.js +146 -0
- package/dist/segmentBinder.d.ts +87 -0
- package/dist/segmentBinder.js +256 -0
- package/dist/segmentStream.d.ts +136 -0
- package/dist/segmentStream.js +248 -0
- package/dist/store.d.ts +10 -7
- package/dist/store.js +74 -13
- package/dist/types.d.ts +39 -1
- package/dist/types.js +39 -1
- package/dist/useDropZone.d.ts +16 -3
- package/dist/useDropZone.js +28 -7
- package/dist/windowCommands.d.ts +1 -1
- package/dist/windowCommands.js +2 -2
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
package/dist/requests.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createShenoraStore } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2) — crosses the wire as its camelCase name for
|
|
4
|
+
* free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
|
|
5
|
+
* needed on either side. Pinned against the host by
|
|
6
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
|
|
7
|
+
* not the other fails that test by name, not by a green suite that never looked.
|
|
8
|
+
*/
|
|
9
|
+
export const IpcRequestStates = {
|
|
10
|
+
Running: 'running',
|
|
11
|
+
Completed: 'completed',
|
|
12
|
+
Failed: 'failed',
|
|
13
|
+
Cancelled: 'cancelled',
|
|
14
|
+
// NO 'waiting'. A request is IN FLIGHT or DONE — the XHR model this mirrors has no parked state,
|
|
15
|
+
// and neither does the host since D66. Work that parks awaiting a human is host-initiated work
|
|
16
|
+
// (a queued mission), which reports on its own event stream rather than as a request.
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents` — pinned against the host by
|
|
20
|
+
* `WireMirrorTests.Request_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
21
|
+
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
22
|
+
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
23
|
+
*/
|
|
24
|
+
export const IpcRequestEventTypes = {
|
|
25
|
+
Updated: 'REQUEST_UPDATED',
|
|
26
|
+
/**
|
|
27
|
+
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
28
|
+
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
29
|
+
* by deleting those ids. One authoritative event, so a client never has to guess what the host
|
|
30
|
+
* removed.
|
|
31
|
+
*/
|
|
32
|
+
Removed: 'REQUEST_REMOVED',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on (its own
|
|
36
|
+
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
37
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
|
|
38
|
+
* {@link IpcRequestEventTypes}.
|
|
39
|
+
*/
|
|
40
|
+
export const IpcRequestRoutes = {
|
|
41
|
+
List: 'LIST',
|
|
42
|
+
Cancel: 'CANCEL',
|
|
43
|
+
ClearFinished: 'CLEAR_FINISHED',
|
|
44
|
+
// THREE routes — the same three `XMLHttpRequest` offers. RESUME/WAIT/DISMISS went with the waiting
|
|
45
|
+
// band (D66), and the wire-mirror test pins this object's SIZE so a retired name cannot creep back.
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
|
|
49
|
+
* `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
|
|
50
|
+
*/
|
|
51
|
+
export const IpcRequestsModuleName = 'SHENORA.REQUESTS';
|
|
52
|
+
/** The terminal states — everything that is not in flight. */
|
|
53
|
+
const TERMINAL_STATES = new Set([
|
|
54
|
+
IpcRequestStates.Completed,
|
|
55
|
+
IpcRequestStates.Failed,
|
|
56
|
+
IpcRequestStates.Cancelled,
|
|
57
|
+
]);
|
|
58
|
+
function index(list) {
|
|
59
|
+
const byId = {};
|
|
60
|
+
for (const operation of list)
|
|
61
|
+
byId[operation.id] = operation;
|
|
62
|
+
return byId;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The one place `running`/`finished` are computed — wrap `byId` here, nowhere else.
|
|
66
|
+
*/
|
|
67
|
+
function makeState(byId) {
|
|
68
|
+
return {
|
|
69
|
+
byId,
|
|
70
|
+
get running() {
|
|
71
|
+
return Object.values(byId).filter((request) => request.state === IpcRequestStates.Running);
|
|
72
|
+
},
|
|
73
|
+
get finished() {
|
|
74
|
+
return Object.values(byId).filter((request) => TERMINAL_STATES.has(request.state));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests}
|
|
80
|
+
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
81
|
+
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
82
|
+
* (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
|
|
83
|
+
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
84
|
+
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
85
|
+
* stuck with the shared, unscoped default.
|
|
86
|
+
*/
|
|
87
|
+
export function createRequestsStore(options = {}) {
|
|
88
|
+
const module = options.module ?? IpcRequestsModuleName;
|
|
89
|
+
return createShenoraStore(module, {
|
|
90
|
+
initial: makeState({}),
|
|
91
|
+
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component
|
|
92
|
+
// that mounts while work is already running gets it from here before folding any deltas.
|
|
93
|
+
// The payload carries `scope` so the initial load is filtered the SAME way the deltas are
|
|
94
|
+
// (below, and via createShenoraStore's own `scope` option) — both halves must agree, or a
|
|
95
|
+
// scoped store loads every scope once and then never sheds the out-of-scope rows.
|
|
96
|
+
snapshot: {
|
|
97
|
+
type: IpcRequestRoutes.List,
|
|
98
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
99
|
+
apply: (_state, data) => makeState(index(data)),
|
|
100
|
+
},
|
|
101
|
+
on: {
|
|
102
|
+
// ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
|
|
103
|
+
// no ordering logic and no cross-type races.
|
|
104
|
+
[IpcRequestEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
|
|
105
|
+
// The ONE removal delta (Finding 4, generic-library audit), replacing the two hand-written
|
|
106
|
+
// optimistic prunes `clearFinished`/`resume` used to carry (see their own docs below) — deletes
|
|
107
|
+
// exactly the ids the host named, regardless of status; an id this store never had is a no-op.
|
|
108
|
+
[IpcRequestEventTypes.Removed]: (state, payload) => {
|
|
109
|
+
const byId = { ...state.byId };
|
|
110
|
+
for (const id of payload.requestIds)
|
|
111
|
+
delete byId[id];
|
|
112
|
+
return makeState(byId);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
actions: ({ post }) => ({
|
|
116
|
+
cancel: (requestId) => post(IpcRequestRoutes.Cancel, { payload: { requestId } }),
|
|
117
|
+
clearFinished: () =>
|
|
118
|
+
// Forward THIS store's own configured scope (Finding 1, generic-library audit) — the same
|
|
119
|
+
// key the LIST snapshot payload already carries above. No local mutation here any more
|
|
120
|
+
// (Finding 4): the host's REQUEST_REMOVED is the ONLY thing that removes a row now, which
|
|
121
|
+
// is also what makes the scope threading safe to add — nothing here can diverge from what
|
|
122
|
+
// the host actually cleared.
|
|
123
|
+
post(IpcRequestRoutes.ClearFinished, {
|
|
124
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
125
|
+
}),
|
|
126
|
+
}),
|
|
127
|
+
scope: options.scope,
|
|
128
|
+
bridge: options.bridge,
|
|
129
|
+
bus: options.bus,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
134
|
+
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
135
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
136
|
+
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
137
|
+
* host-backed end to end). `running`/`finished` are selectors an activity panel or status bar reads
|
|
138
|
+
* directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
|
|
139
|
+
* a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
|
|
140
|
+
* default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
|
|
141
|
+
* scope-filtered instance.
|
|
142
|
+
*
|
|
143
|
+
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
144
|
+
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
145
|
+
*/
|
|
146
|
+
export const useShenoraRequests = createRequestsStore();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type MediaSourceGlobals } from './segmentStream.js';
|
|
2
|
+
/**
|
|
3
|
+
* The imperative half of the segment route (D71 piece 4b): open a `SourceBuffer`, feed it, and stop when
|
|
4
|
+
* the platform says stop.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 **Everything here was written against measurements rather than against the specification**, because
|
|
7
|
+
* three implementations disagree in ways the spec permits and none of it is guessable:
|
|
8
|
+
*
|
|
9
|
+
* - **Attachment is not portable.** iOS takes `srcObject` — a `ManagedMediaSource` is a valid
|
|
10
|
+
* `MediaSourceHandle` — and Chromium refuses it outright ("not of type '(MediaSourceHandle or
|
|
11
|
+
* MediaStream)'"), wanting an object URL. Feature-detected, not branched on the shell: which one works
|
|
12
|
+
* is a property of the MediaSource, not of the OS.
|
|
13
|
+
* - **The codecs are read from the init segment, never assumed.** The track set is a fact about the
|
|
14
|
+
* DEVICE, not the source: the same file yields a two-track init on iOS and a video-only one on Android,
|
|
15
|
+
* which cannot decode its AC-3 soundtrack. A mismatch kills the FIRST append and plays nothing.
|
|
16
|
+
* - **The streaming gate is real on iOS and absent elsewhere.** `endstreaming` fires once enough is
|
|
17
|
+
* buffered (measured: at 60 s, not at 6 s), and fetching past it is the misuse `ManagedMediaSource`
|
|
18
|
+
* exists to detect. A plain `MediaSource` has neither event nor a `streaming` property, and its absence
|
|
19
|
+
* means "always streaming" — never "never asked".
|
|
20
|
+
*
|
|
21
|
+
* ⚠ **The dependencies are INJECTABLE so this is testable without a browser.** jsdom has no MediaSource,
|
|
22
|
+
* and "cannot be verified anywhere this repo runs" was true of the whole file until the seams below
|
|
23
|
+
* existed. A fake source and a fake fetch drive every branch here.
|
|
24
|
+
*/
|
|
25
|
+
export interface SegmentBinderOptions {
|
|
26
|
+
/** The playlist URL. Segment URIs are resolved relative to it. */
|
|
27
|
+
manifest: string;
|
|
28
|
+
/** The element to play into. Only the members this binder touches are required. */
|
|
29
|
+
element: HTMLMediaElement;
|
|
30
|
+
/** Where to look for a MediaSource. Defaults to `globalThis`. */
|
|
31
|
+
globals?: MediaSourceGlobals;
|
|
32
|
+
/** Defaults to `globalThis.fetch`. */
|
|
33
|
+
fetch?: (url: string) => Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
status: number;
|
|
36
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
37
|
+
text(): Promise<string>;
|
|
38
|
+
}>;
|
|
39
|
+
/** Defaults to `URL.createObjectURL`. Only used when `srcObject` is refused. */
|
|
40
|
+
createObjectURL?: (source: object) => string;
|
|
41
|
+
/**
|
|
42
|
+
* Defaults to `URL.revokeObjectURL`. The pair to {@link createObjectURL}, and injectable for the same
|
|
43
|
+
* reason: without it the failure paths that must revoke — a source that closes before opening, a
|
|
44
|
+
* codec the device refuses — cannot be asserted, only hoped for.
|
|
45
|
+
*/
|
|
46
|
+
revokeObjectURL?: (url: string) => void;
|
|
47
|
+
/** Stop fetching once this many seconds are buffered ahead. Defaults to 30. */
|
|
48
|
+
targetAheadSeconds?: number;
|
|
49
|
+
/** Diagnostics. Every decision that could stall playback reports through here. */
|
|
50
|
+
onDiagnostic?: (line: string) => void;
|
|
51
|
+
}
|
|
52
|
+
/** A live binding. Dispose it when the element goes away — it detaches every listener it added. */
|
|
53
|
+
export interface SegmentBinding {
|
|
54
|
+
/** Indices appended so far. */
|
|
55
|
+
readonly appended: ReadonlySet<number>;
|
|
56
|
+
/** False while a managed source has said stop. Always true where the platform has no such signal. */
|
|
57
|
+
readonly streaming: boolean;
|
|
58
|
+
/** Which attachment this implementation accepted — the difference between the two shells. */
|
|
59
|
+
readonly attachedBy: 'srcObject' | 'objectURL';
|
|
60
|
+
/** What the SourceBuffer was opened with, read from the init segment. */
|
|
61
|
+
readonly codecs: string;
|
|
62
|
+
/** Detach listeners and release the object URL, if one was minted. */
|
|
63
|
+
dispose(): void;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Thrown for every reason a stream cannot start, so a caller has one thing to catch.
|
|
67
|
+
*
|
|
68
|
+
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch failing at the network layer,
|
|
69
|
+
* and a `RangeError` from a truncated init segment, both propagate as themselves. Catch broadly if you
|
|
70
|
+
* need to be exhaustive.
|
|
71
|
+
*/
|
|
72
|
+
export declare class SegmentBinderError extends Error {
|
|
73
|
+
constructor(message: string);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Open a MediaSource for `options.manifest` and keep it fed.
|
|
77
|
+
*
|
|
78
|
+
* Resolves once the init segment has been appended — the point after which the element can play — and
|
|
79
|
+
* goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
|
|
80
|
+
*
|
|
81
|
+
* ⚠ **There is deliberately NO `useSegmentStream` hook.** This needs no React — it takes an element and
|
|
82
|
+
* returns a handle, exactly as `mediaUrl` takes a path and returns a string — so a hook would add a
|
|
83
|
+
* lifecycle without adding a capability. The case that would earn one is an app wanting load/error
|
|
84
|
+
* state as component state, and the shape of that hook depends on what such an app actually asks for;
|
|
85
|
+
* inventing it first is how a seam nothing consults gets built (D63). Call this from an effect.
|
|
86
|
+
*/
|
|
87
|
+
export declare function bindSegmentStream(options: SegmentBinderOptions): Promise<SegmentBinding>;
|
|
@@ -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;
|