@shenora/react 0.10.0 → 0.12.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 +29 -46
- package/dist/bridge.js +47 -71
- package/dist/clipboard.d.ts +89 -0
- package/dist/clipboard.js +119 -0
- package/dist/devInterceptor.d.ts +11 -8
- package/dist/devInterceptor.js +16 -10
- package/dist/errors.d.ts +5 -6
- package/dist/errors.js +6 -7
- package/dist/eventBus.d.ts +21 -26
- package/dist/eventBus.js +38 -40
- package/dist/fileDialogs.d.ts +9 -12
- package/dist/fileDialogs.js +12 -16
- package/dist/hooks.d.ts +19 -20
- package/dist/hooks.js +26 -29
- package/dist/index.d.ts +8 -2
- package/dist/index.js +14 -10
- package/dist/internal.d.ts +2 -8
- package/dist/internal.js +2 -8
- package/dist/media.d.ts +14 -22
- package/dist/media.js +14 -22
- package/dist/mediaPlayer.d.ts +103 -0
- package/dist/mediaPlayer.js +202 -0
- package/dist/moduleService.d.ts +17 -21
- package/dist/moduleService.js +17 -21
- package/dist/requests.d.ts +145 -0
- package/dist/requests.js +113 -0
- package/dist/segmentBinder.d.ts +74 -0
- package/dist/segmentBinder.js +239 -0
- package/dist/segmentStream.d.ts +125 -0
- package/dist/segmentStream.js +239 -0
- package/dist/store.d.ts +18 -26
- package/dist/store.js +69 -36
- package/dist/transport.d.ts +9 -18
- package/dist/transport.js +9 -18
- package/dist/types.d.ts +33 -42
- package/dist/types.js +29 -25
- package/dist/useDropZone.d.ts +23 -22
- package/dist/useDropZone.js +41 -37
- package/dist/windowCommands.d.ts +15 -19
- package/dist/windowCommands.js +18 -25
- package/package.json +10 -3
- package/dist/operations.d.ts +0 -256
- package/dist/operations.js +0 -191
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { ShenoraBridge } from './bridge.js';
|
|
2
|
+
import type { ShenoraEventBus } from './eventBus.js';
|
|
3
|
+
import { type ShenoraStore } from './store.js';
|
|
4
|
+
import type { IpcError } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2), pinned against the host by
|
|
7
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const IpcRequestStates: {
|
|
10
|
+
readonly Running: "running";
|
|
11
|
+
readonly Completed: "completed";
|
|
12
|
+
readonly Failed: "failed";
|
|
13
|
+
readonly Cancelled: "cancelled";
|
|
14
|
+
};
|
|
15
|
+
/** One of {@link IpcRequestStates}. */
|
|
16
|
+
export type IpcRequestState = (typeof IpcRequestStates)[keyof typeof IpcRequestStates];
|
|
17
|
+
/**
|
|
18
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents`, pinned against the host by
|
|
19
|
+
* `WireMirrorTests.Request_event_names_match_the_host`.
|
|
20
|
+
*/
|
|
21
|
+
export declare const IpcRequestEventTypes: {
|
|
22
|
+
readonly Updated: "REQUEST_UPDATED";
|
|
23
|
+
/**
|
|
24
|
+
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
25
|
+
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
26
|
+
* by deleting those ids.
|
|
27
|
+
*/
|
|
28
|
+
readonly Removed: "REQUEST_REMOVED";
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on, pinned by
|
|
32
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`.
|
|
33
|
+
*/
|
|
34
|
+
export declare const IpcRequestRoutes: {
|
|
35
|
+
readonly List: "LIST";
|
|
36
|
+
readonly Cancel: "CANCEL";
|
|
37
|
+
readonly ClearFinished: "CLEAR_FINISHED";
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
|
|
41
|
+
* `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const IpcRequestsModuleName = "SHENORA.REQUESTS";
|
|
44
|
+
/**
|
|
45
|
+
* Mirrors `Shenora.Core.Ipc.IpcLabel` — human-facing text the HOST never renders itself: an
|
|
46
|
+
* untranslated fallback plus an app i18n key and interpolation parameters.
|
|
47
|
+
*/
|
|
48
|
+
export interface IpcLabel {
|
|
49
|
+
text?: string;
|
|
50
|
+
key?: string;
|
|
51
|
+
parameters?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Mirrors `Shenora.Core.Ipc.IpcProgress` — how far a tracked operation has gotten, in the APP's own
|
|
55
|
+
* unit rather than a kit-assumed percent. `unit` is app-defined (`'bytes'`, `'files'`, `'percent'`)
|
|
56
|
+
* and the kit never interprets it.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ `total` undefined means there is NO known total — an absolute count with nothing to divide by,
|
|
59
|
+
* never zero. Render a ratio only when it is set; the README has the example.
|
|
60
|
+
*/
|
|
61
|
+
export interface IpcProgress {
|
|
62
|
+
value: number;
|
|
63
|
+
total?: number;
|
|
64
|
+
unit?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestStatus` — a full snapshot of one tracked operation. Every lifecycle
|
|
68
|
+
* transition (start, progress, terminal) publishes one of these under `REQUEST_UPDATED`, so the
|
|
69
|
+
* client folds by `id`: last write wins, with no cross-type ordering hazard.
|
|
70
|
+
*/
|
|
71
|
+
export interface IpcRequestStatus {
|
|
72
|
+
id: string;
|
|
73
|
+
module: string;
|
|
74
|
+
type: string;
|
|
75
|
+
scope?: string;
|
|
76
|
+
state: IpcRequestState;
|
|
77
|
+
progress?: IpcProgress;
|
|
78
|
+
detail?: IpcLabel;
|
|
79
|
+
error?: IpcError;
|
|
80
|
+
startedAt: string;
|
|
81
|
+
finishedAt?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* State behind {@link useShenoraRequests}. `byId` is the only thing a reducer writes; the two bands
|
|
85
|
+
* are derived from it on every read.
|
|
86
|
+
*
|
|
87
|
+
* ⚠ Most requests never appear at all: one that finishes inside the host's grace period is never
|
|
88
|
+
* announced, so this lists work that is actually TAKING A WHILE, not every call the page made.
|
|
89
|
+
*/
|
|
90
|
+
export interface RequestsState {
|
|
91
|
+
byId: Record<string, IpcRequestStatus>;
|
|
92
|
+
/** Every currently-running operation, in `byId` order. */
|
|
93
|
+
readonly running: IpcRequestStatus[];
|
|
94
|
+
/** Every operation that reached a terminal status (completed/failed/cancelled). */
|
|
95
|
+
readonly finished: IpcRequestStatus[];
|
|
96
|
+
}
|
|
97
|
+
/** Fire-and-forget actions exposed on {@link useShenoraRequests}, routed to `IpcRequestsModule`. */
|
|
98
|
+
export interface RequestsActions {
|
|
99
|
+
/** `CANCEL { requestId }` — the app-level cancel route `ipc-contracts` prescribes. */
|
|
100
|
+
cancel: (requestId: string) => string;
|
|
101
|
+
/**
|
|
102
|
+
* `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
|
|
103
|
+
* configured scope so a scoped store's "clear completed" cannot wipe another scope's history
|
|
104
|
+
* host-side. Nothing is mutated locally: the host's {@link IpcRequestEventTypes.Removed} is the only
|
|
105
|
+
* thing that removes a row.
|
|
106
|
+
*/
|
|
107
|
+
clearFinished: () => string;
|
|
108
|
+
}
|
|
109
|
+
/** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createRequestsStore}. */
|
|
110
|
+
export interface RequestsStoreOptions {
|
|
111
|
+
/**
|
|
112
|
+
* The request/event module this store talks to. Must match the host's
|
|
113
|
+
* `IpcRequestTrackerOptions.ModuleName` — default `'SHENORA.REQUESTS'` on both sides — for an app
|
|
114
|
+
* that renamed it to avoid colliding with one of its own modules.
|
|
115
|
+
*/
|
|
116
|
+
module?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Optional app-defined scope, applied to THREE places: the bus subscription, the actions' request
|
|
119
|
+
* envelope, and the initial `LIST` snapshot's PAYLOAD (`IpcRequestsModule` reads its scope filter
|
|
120
|
+
* from the payload, not the envelope).
|
|
121
|
+
*
|
|
122
|
+
* ⚠ All three or none. Threading it into only the first two loads every scope on first subscribe and
|
|
123
|
+
* then never sheds the out-of-scope rows, since no delta for them ever arrives.
|
|
124
|
+
*/
|
|
125
|
+
scope?: string;
|
|
126
|
+
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
127
|
+
bridge?: ShenoraBridge;
|
|
128
|
+
bus?: ShenoraEventBus;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests} is built
|
|
132
|
+
* from. Use it directly for a renamed host module, a scope-filtered instance, or a fake bridge/bus.
|
|
133
|
+
*/
|
|
134
|
+
export declare function createRequestsStore(options?: RequestsStoreOptions): ShenoraStore<RequestsState, RequestsActions>;
|
|
135
|
+
/**
|
|
136
|
+
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
137
|
+
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
138
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state.
|
|
139
|
+
* `running`/`finished` are selectors an activity panel reads directly:
|
|
140
|
+
* `useShenoraRequests((s) => s.running)`.
|
|
141
|
+
*
|
|
142
|
+
* Bound to the default module and no scope — use {@link createRequestsStore} for a renamed module or
|
|
143
|
+
* a scope-filtered instance.
|
|
144
|
+
*/
|
|
145
|
+
export declare const useShenoraRequests: ShenoraStore<RequestsState, RequestsActions>;
|
package/dist/requests.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createShenoraStore } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestState` (design §4.2), pinned against the host by
|
|
4
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides`.
|
|
5
|
+
*/
|
|
6
|
+
export const IpcRequestStates = {
|
|
7
|
+
Running: 'running',
|
|
8
|
+
Completed: 'completed',
|
|
9
|
+
Failed: 'failed',
|
|
10
|
+
Cancelled: 'cancelled',
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents`, pinned against the host by
|
|
14
|
+
* `WireMirrorTests.Request_event_names_match_the_host`.
|
|
15
|
+
*/
|
|
16
|
+
export const IpcRequestEventTypes = {
|
|
17
|
+
Updated: 'REQUEST_UPDATED',
|
|
18
|
+
/**
|
|
19
|
+
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
20
|
+
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
21
|
+
* by deleting those ids.
|
|
22
|
+
*/
|
|
23
|
+
Removed: 'REQUEST_REMOVED',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on, pinned by
|
|
27
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`.
|
|
28
|
+
*/
|
|
29
|
+
export const IpcRequestRoutes = {
|
|
30
|
+
List: 'LIST',
|
|
31
|
+
Cancel: 'CANCEL',
|
|
32
|
+
ClearFinished: 'CLEAR_FINISHED',
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
|
|
36
|
+
* `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
|
|
37
|
+
*/
|
|
38
|
+
export const IpcRequestsModuleName = 'SHENORA.REQUESTS';
|
|
39
|
+
/** The terminal states — everything that is not in flight. */
|
|
40
|
+
const TERMINAL_STATES = new Set([
|
|
41
|
+
IpcRequestStates.Completed,
|
|
42
|
+
IpcRequestStates.Failed,
|
|
43
|
+
IpcRequestStates.Cancelled,
|
|
44
|
+
]);
|
|
45
|
+
function index(list) {
|
|
46
|
+
const byId = {};
|
|
47
|
+
for (const operation of list)
|
|
48
|
+
byId[operation.id] = operation;
|
|
49
|
+
return byId;
|
|
50
|
+
}
|
|
51
|
+
/** The one place `running`/`finished` are computed — wrap `byId` here, nowhere else. */
|
|
52
|
+
function makeState(byId) {
|
|
53
|
+
return {
|
|
54
|
+
byId,
|
|
55
|
+
get running() {
|
|
56
|
+
return Object.values(byId).filter((request) => request.state === IpcRequestStates.Running);
|
|
57
|
+
},
|
|
58
|
+
get finished() {
|
|
59
|
+
return Object.values(byId).filter((request) => TERMINAL_STATES.has(request.state));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests} is built
|
|
65
|
+
* from. Use it directly for a renamed host module, a scope-filtered instance, or a fake bridge/bus.
|
|
66
|
+
*/
|
|
67
|
+
export function createRequestsStore(options = {}) {
|
|
68
|
+
const module = options.module ?? IpcRequestsModuleName;
|
|
69
|
+
return createShenoraStore(module, {
|
|
70
|
+
initial: makeState({}),
|
|
71
|
+
// LIST is the snapshot source (design §4.6): a store cannot replay a stream, so a component that
|
|
72
|
+
// mounts while work is already running gets it from here before folding any deltas. The payload
|
|
73
|
+
// carries `scope` so the initial load is filtered the same way the deltas are.
|
|
74
|
+
snapshot: {
|
|
75
|
+
type: IpcRequestRoutes.List,
|
|
76
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
77
|
+
apply: (_state, data) => makeState(index(data)),
|
|
78
|
+
},
|
|
79
|
+
on: {
|
|
80
|
+
// ONE event type for every transition (design §4.3) — last-write-wins by id, so folding needs
|
|
81
|
+
// no ordering logic and no cross-type races.
|
|
82
|
+
[IpcRequestEventTypes.Updated]: (state, payload) => makeState({ ...state.byId, [payload.id]: payload }),
|
|
83
|
+
// The ONE removal delta: deletes exactly the ids the host named, regardless of status. An id
|
|
84
|
+
// this store never had is a no-op.
|
|
85
|
+
[IpcRequestEventTypes.Removed]: (state, payload) => {
|
|
86
|
+
const byId = { ...state.byId };
|
|
87
|
+
for (const id of payload.requestIds)
|
|
88
|
+
delete byId[id];
|
|
89
|
+
return makeState(byId);
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
actions: ({ post }) => ({
|
|
93
|
+
cancel: (requestId) => post(IpcRequestRoutes.Cancel, { payload: { requestId } }),
|
|
94
|
+
clearFinished: () => post(IpcRequestRoutes.ClearFinished, {
|
|
95
|
+
payload: options.scope !== undefined ? { scope: options.scope } : undefined,
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
scope: options.scope,
|
|
99
|
+
bridge: options.bridge,
|
|
100
|
+
bus: options.bus,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
105
|
+
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
106
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state.
|
|
107
|
+
* `running`/`finished` are selectors an activity panel reads directly:
|
|
108
|
+
* `useShenoraRequests((s) => s.running)`.
|
|
109
|
+
*
|
|
110
|
+
* Bound to the default module and no scope — use {@link createRequestsStore} for a renamed module or
|
|
111
|
+
* a scope-filtered instance.
|
|
112
|
+
*/
|
|
113
|
+
export const useShenoraRequests = createRequestsStore();
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
* 🔴 **Three implementations disagree in ways the spec permits, so none of this is guessable** — the
|
|
7
|
+
* measurements are in `docs/design/media.md`:
|
|
8
|
+
*
|
|
9
|
+
* - **Attachment is not portable.** iOS takes `srcObject` (a `ManagedMediaSource` is a valid
|
|
10
|
+
* `MediaSourceHandle`); Chromium refuses it and wants an object URL. Feature-detected, not branched on
|
|
11
|
+
* the shell: which one works is a property of the MediaSource, not of the OS.
|
|
12
|
+
* - **The codecs are read from the init segment, never assumed.** The track set is a fact about the
|
|
13
|
+
* DEVICE, not the source: the same file yields a two-track init on iOS and a video-only one on Android,
|
|
14
|
+
* which cannot decode its AC-3 soundtrack. A mismatch kills the FIRST append and plays nothing.
|
|
15
|
+
* - **The streaming gate is real on iOS and absent elsewhere.** Fetching past `endstreaming` is the
|
|
16
|
+
* misuse `ManagedMediaSource` exists to detect. A plain `MediaSource` has neither the event nor a
|
|
17
|
+
* `streaming` property, and its absence means "always streaming" — never "never asked".
|
|
18
|
+
*
|
|
19
|
+
* The dependencies below are injectable, so a fake source and a fake fetch drive every branch here.
|
|
20
|
+
*/
|
|
21
|
+
export interface SegmentBinderOptions {
|
|
22
|
+
/** The playlist URL. Segment URIs are resolved relative to it. */
|
|
23
|
+
manifest: string;
|
|
24
|
+
/** The element to play into. Only the members this binder touches are required. */
|
|
25
|
+
element: HTMLMediaElement;
|
|
26
|
+
/** Where to look for a MediaSource. Defaults to `globalThis`. */
|
|
27
|
+
globals?: MediaSourceGlobals;
|
|
28
|
+
/** Defaults to `globalThis.fetch`. */
|
|
29
|
+
fetch?: (url: string) => Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
status: number;
|
|
32
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
33
|
+
text(): Promise<string>;
|
|
34
|
+
}>;
|
|
35
|
+
/** Defaults to `URL.createObjectURL`. Only used when `srcObject` is refused. */
|
|
36
|
+
createObjectURL?: (source: object) => string;
|
|
37
|
+
/** Defaults to `URL.revokeObjectURL`. The pair to {@link createObjectURL}. */
|
|
38
|
+
revokeObjectURL?: (url: string) => void;
|
|
39
|
+
/** Stop fetching once this many seconds are buffered ahead. Defaults to 30. */
|
|
40
|
+
targetAheadSeconds?: number;
|
|
41
|
+
/** Diagnostics. Every decision that could stall playback reports through here. */
|
|
42
|
+
onDiagnostic?: (line: string) => void;
|
|
43
|
+
}
|
|
44
|
+
/** A live binding. Dispose it when the element goes away — it detaches every listener it added. */
|
|
45
|
+
export interface SegmentBinding {
|
|
46
|
+
/** Indices appended so far. */
|
|
47
|
+
readonly appended: ReadonlySet<number>;
|
|
48
|
+
/** False while a managed source has said stop. Always true where the platform has no such signal. */
|
|
49
|
+
readonly streaming: boolean;
|
|
50
|
+
/** Which attachment this implementation accepted — the difference between the two shells. */
|
|
51
|
+
readonly attachedBy: 'srcObject' | 'objectURL';
|
|
52
|
+
/** What the SourceBuffer was opened with, read from the init segment. */
|
|
53
|
+
readonly codecs: string;
|
|
54
|
+
/** Detach listeners and release the object URL, if one was minted. */
|
|
55
|
+
dispose(): void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Thrown for every reason a stream cannot start, so a caller has one thing to catch.
|
|
59
|
+
*
|
|
60
|
+
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch and a `RangeError` from a
|
|
61
|
+
* truncated init segment propagate as themselves. Catch broadly if you need to be exhaustive.
|
|
62
|
+
*/
|
|
63
|
+
export declare class SegmentBinderError extends Error {
|
|
64
|
+
constructor(message: string);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Open a MediaSource for `options.manifest` and keep it fed.
|
|
68
|
+
*
|
|
69
|
+
* Resolves once the init segment has been appended — the point after which the element can play — and
|
|
70
|
+
* goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
|
|
71
|
+
*
|
|
72
|
+
* There is no `useSegmentStream` hook: this needs no React, so call it from an effect.
|
|
73
|
+
*/
|
|
74
|
+
export declare function bindSegmentStream(options: SegmentBinderOptions): Promise<SegmentBinding>;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { codecsFromInitSegment, nextSegment, parseManifest, pickMediaSource, segmentMimeType, } from './segmentStream.js';
|
|
2
|
+
/**
|
|
3
|
+
* How long to wait for the attached MediaSource to reach `open`. Attachment is local and immediate, so
|
|
4
|
+
* this is a deadline for "something is wrong": the element can refuse or tear down an attachment
|
|
5
|
+
* without raising any event this side can name, and the await would never return.
|
|
6
|
+
*/
|
|
7
|
+
const ATTACH_TIMEOUT_MS = 10000;
|
|
8
|
+
/**
|
|
9
|
+
* Thrown for every reason a stream cannot start, so a caller has one thing to catch.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ **Not literally every reason** — a `TypeError` from the manifest fetch and a `RangeError` from a
|
|
12
|
+
* truncated init segment propagate as themselves. Catch broadly if you need to be exhaustive.
|
|
13
|
+
*/
|
|
14
|
+
export class SegmentBinderError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'SegmentBinderError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Join a segment URI to the manifest's own location. */
|
|
21
|
+
function resolve(manifestUrl, uri) {
|
|
22
|
+
const slash = manifestUrl.lastIndexOf('/');
|
|
23
|
+
return slash < 0 ? uri : manifestUrl.slice(0, slash + 1) + uri;
|
|
24
|
+
}
|
|
25
|
+
/** Seconds already buffered ahead of `currentTime`, across whichever range holds it. */
|
|
26
|
+
function bufferedAhead(element) {
|
|
27
|
+
const ranges = element.buffered;
|
|
28
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
29
|
+
if (element.currentTime >= ranges.start(i) - 0.1 && element.currentTime <= ranges.end(i)) {
|
|
30
|
+
return ranges.end(i) - element.currentTime;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Open a MediaSource for `options.manifest` and keep it fed.
|
|
37
|
+
*
|
|
38
|
+
* Resolves once the init segment has been appended — the point after which the element can play — and
|
|
39
|
+
* goes on fetching in the background until every segment is in or {@link SegmentBinding.dispose} is called.
|
|
40
|
+
*
|
|
41
|
+
* There is no `useSegmentStream` hook: this needs no React, so call it from an effect.
|
|
42
|
+
*/
|
|
43
|
+
export async function bindSegmentStream(options) {
|
|
44
|
+
const { manifest: manifestUrl, element, globals = globalThis, targetAheadSeconds = 30, onDiagnostic, } = options;
|
|
45
|
+
const doFetch = options.fetch ?? ((url) => fetch(url));
|
|
46
|
+
const say = (line) => onDiagnostic?.(line);
|
|
47
|
+
/**
|
|
48
|
+
* A FAILURE, as opposed to a trace line: reported to `console.error` when the app supplied no
|
|
49
|
+
* `onDiagnostic`, so a segment answering 500 or an `appendBuffer` `QuotaExceededError` cannot stall
|
|
50
|
+
* playback with nothing anywhere to explain it. A caller that took `onDiagnostic` owns its reporting
|
|
51
|
+
* and is not double-logged.
|
|
52
|
+
*/
|
|
53
|
+
const fail = (line) => (onDiagnostic ? onDiagnostic(line) : console.error(`[shenora] ${line}`));
|
|
54
|
+
const kind = pickMediaSource(globals);
|
|
55
|
+
if (kind === 'none')
|
|
56
|
+
throw new SegmentBinderError('this browser has no MediaSource of either kind');
|
|
57
|
+
const Source = (kind === 'managed' ? globals.ManagedMediaSource : globals.MediaSource);
|
|
58
|
+
// ── the manifest, and the init segment it names ──────────────────────────────────────────────────
|
|
59
|
+
const playlist = await doFetch(manifestUrl);
|
|
60
|
+
if (!playlist.ok)
|
|
61
|
+
throw new SegmentBinderError(`the manifest answered ${playlist.status}`);
|
|
62
|
+
const parsed = parseManifest(await playlist.text());
|
|
63
|
+
if (!parsed.initUri) {
|
|
64
|
+
// A fragment repeats no decoder configuration, so appending one without this decodes nothing.
|
|
65
|
+
throw new SegmentBinderError('the playlist declares no #EXT-X-MAP, which is not playable');
|
|
66
|
+
}
|
|
67
|
+
if (parsed.segments.length === 0)
|
|
68
|
+
throw new SegmentBinderError('the playlist declares no segments');
|
|
69
|
+
const initResponse = await doFetch(resolve(manifestUrl, parsed.initUri));
|
|
70
|
+
if (!initResponse.ok)
|
|
71
|
+
throw new SegmentBinderError(`the init segment answered ${initResponse.status}`);
|
|
72
|
+
const init = new Uint8Array(await initResponse.arrayBuffer());
|
|
73
|
+
// 🔴 The TRACK SET, from the bytes rather than from a constant — see the module remarks.
|
|
74
|
+
const codecs = codecsFromInitSegment(init);
|
|
75
|
+
if (!codecs)
|
|
76
|
+
throw new SegmentBinderError('no track could be read from the init segment');
|
|
77
|
+
const mime = segmentMimeType(codecs);
|
|
78
|
+
say(`segments: opening ${mime} (${kind})`);
|
|
79
|
+
// ── attach ──────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
const source = new Source();
|
|
81
|
+
const revoke = options.revokeObjectURL ?? ((u) => URL.revokeObjectURL(u));
|
|
82
|
+
let attachedBy = 'srcObject';
|
|
83
|
+
let objectUrl;
|
|
84
|
+
try {
|
|
85
|
+
element.srcObject = source;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
attachedBy = 'objectURL';
|
|
89
|
+
const mint = options.createObjectURL ?? ((s) => URL.createObjectURL(s));
|
|
90
|
+
objectUrl = mint(source);
|
|
91
|
+
element.src = objectUrl;
|
|
92
|
+
}
|
|
93
|
+
// ⚠ The object URL is minted BEFORE anything below can fail, so EVERY failure between the mint and
|
|
94
|
+
// the returned binding must revoke it itself — the caller holds no binding to dispose yet, and the
|
|
95
|
+
// document keeps the MediaSource alive for its lifetime. Three places: the open wait,
|
|
96
|
+
// addSourceBuffer, and the init append.
|
|
97
|
+
const revokeAndRethrow = (e) => {
|
|
98
|
+
if (objectUrl)
|
|
99
|
+
revoke(objectUrl);
|
|
100
|
+
throw e;
|
|
101
|
+
};
|
|
102
|
+
// 🔴 `error` IS NOT A MediaSource EVENT — the spec fires `sourceopen`, `sourceended` and
|
|
103
|
+
// `sourceclose`. An attachment that CLOSES rather than opening (the element detached before load, an
|
|
104
|
+
// attachment refused) leaves this await pending FOREVER on any other listener: no error, no
|
|
105
|
+
// diagnostic, no way to reach dispose(). The deadline covers whatever is neither.
|
|
106
|
+
await new Promise((res, rej) => {
|
|
107
|
+
if (source.readyState === 'open')
|
|
108
|
+
return res();
|
|
109
|
+
const settle = (finish) => () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
source.removeEventListener('sourceopen', onOpen);
|
|
112
|
+
source.removeEventListener('sourceclose', onClose);
|
|
113
|
+
finish();
|
|
114
|
+
};
|
|
115
|
+
const onOpen = settle(() => res());
|
|
116
|
+
const onClose = settle(() => rej(new SegmentBinderError('the MediaSource closed before it opened')));
|
|
117
|
+
const timer = setTimeout(settle(() => rej(new SegmentBinderError(`the MediaSource did not open within ${ATTACH_TIMEOUT_MS} ms (attached by ${attachedBy})`))), ATTACH_TIMEOUT_MS);
|
|
118
|
+
source.addEventListener('sourceopen', onOpen);
|
|
119
|
+
source.addEventListener('sourceclose', onClose);
|
|
120
|
+
}).catch(revokeAndRethrow);
|
|
121
|
+
let buffer;
|
|
122
|
+
try {
|
|
123
|
+
// Throws synchronously for a codecs string this implementation refuses.
|
|
124
|
+
buffer = source.addSourceBuffer(mime);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
revokeAndRethrow(e);
|
|
128
|
+
}
|
|
129
|
+
// ── state ───────────────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
const appended = new Set();
|
|
131
|
+
// Absent signals mean ALWAYS streaming. A managed source flips this on its own events.
|
|
132
|
+
let streaming = true;
|
|
133
|
+
let disposed = false;
|
|
134
|
+
let pumping = false;
|
|
135
|
+
/**
|
|
136
|
+
* Append one buffer and settle when the source buffer says so.
|
|
137
|
+
*
|
|
138
|
+
* 🔴 **Both listeners come off on EITHER outcome.** `{ once: true }` removes only the listener that
|
|
139
|
+
* FIRES, and the success path fires `updateend` — so with it the `error` listener stays attached once
|
|
140
|
+
* per appended segment, each retaining a settled `rej` closure that `dispose()` cannot shed, and one
|
|
141
|
+
* later real `error` invokes every one of them.
|
|
142
|
+
*/
|
|
143
|
+
const append = (bytes) => new Promise((res, rej) => {
|
|
144
|
+
const done = (settle) => () => {
|
|
145
|
+
buffer.removeEventListener('updateend', onDone);
|
|
146
|
+
buffer.removeEventListener('error', onFail);
|
|
147
|
+
settle();
|
|
148
|
+
};
|
|
149
|
+
const onDone = done(() => res());
|
|
150
|
+
const onFail = done(() => rej(new SegmentBinderError('appendBuffer failed')));
|
|
151
|
+
buffer.addEventListener('updateend', onDone);
|
|
152
|
+
buffer.addEventListener('error', onFail);
|
|
153
|
+
try {
|
|
154
|
+
buffer.appendBuffer(bytes);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
// A synchronous throw settles nothing through the events, so shed them here too.
|
|
158
|
+
buffer.removeEventListener('updateend', onDone);
|
|
159
|
+
buffer.removeEventListener('error', onFail);
|
|
160
|
+
rej(new SegmentBinderError(`appendBuffer threw: ${e.message}`));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
await append(init).catch(revokeAndRethrow);
|
|
164
|
+
say('segments: init appended');
|
|
165
|
+
/**
|
|
166
|
+
* Fetch and append whatever {@link nextSegment} asks for, one at a time.
|
|
167
|
+
*
|
|
168
|
+
* ⚠ Re-entrancy is guarded rather than queued: element events fire far faster than an append
|
|
169
|
+
* completes, and two concurrent `appendBuffer` calls on one SourceBuffer throw `InvalidStateError`,
|
|
170
|
+
* which surfaces as a stall with no obvious cause.
|
|
171
|
+
*/
|
|
172
|
+
const pump = async () => {
|
|
173
|
+
if (pumping || disposed)
|
|
174
|
+
return;
|
|
175
|
+
pumping = true;
|
|
176
|
+
try {
|
|
177
|
+
for (;;) {
|
|
178
|
+
if (disposed)
|
|
179
|
+
return;
|
|
180
|
+
const index = nextSegment({ currentTime: element.currentTime, bufferedAhead: bufferedAhead(element), appended, streaming }, { segments: parsed.segments, targetAheadSeconds });
|
|
181
|
+
if (index === null)
|
|
182
|
+
break;
|
|
183
|
+
const entry = parsed.segments[index];
|
|
184
|
+
const response = await doFetch(resolve(manifestUrl, entry.uri));
|
|
185
|
+
if (disposed)
|
|
186
|
+
return;
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
// 503 is the host saying "still producing" — a WAIT, not a failure. The route answers it
|
|
189
|
+
// rather than 404ing a source that is merely not ready yet.
|
|
190
|
+
fail(`segments: ${entry.uri} answered ${response.status}`);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
await append(new Uint8Array(await response.arrayBuffer()));
|
|
194
|
+
appended.add(index);
|
|
195
|
+
if (disposed)
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Every segment in: say so, or the element never learns it has reached the end.
|
|
199
|
+
if (appended.size === parsed.segments.length && source.readyState === 'open') {
|
|
200
|
+
source.endOfStream();
|
|
201
|
+
say('segments: endOfStream');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
fail(`segments: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
pumping = false;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
// ── the events that should make us reconsider ───────────────────────────────────────────────────
|
|
212
|
+
const onStart = () => { streaming = true; say('segments: startstreaming'); void pump(); };
|
|
213
|
+
const onEnd = () => { streaming = false; say('segments: endstreaming'); };
|
|
214
|
+
const wake = () => { void pump(); };
|
|
215
|
+
source.addEventListener('startstreaming', onStart);
|
|
216
|
+
source.addEventListener('endstreaming', onEnd);
|
|
217
|
+
element.addEventListener('timeupdate', wake);
|
|
218
|
+
element.addEventListener('seeking', wake);
|
|
219
|
+
element.addEventListener('waiting', wake);
|
|
220
|
+
void pump();
|
|
221
|
+
return {
|
|
222
|
+
get appended() { return appended; },
|
|
223
|
+
get streaming() { return streaming; },
|
|
224
|
+
attachedBy,
|
|
225
|
+
codecs,
|
|
226
|
+
dispose() {
|
|
227
|
+
if (disposed)
|
|
228
|
+
return;
|
|
229
|
+
disposed = true;
|
|
230
|
+
source.removeEventListener('startstreaming', onStart);
|
|
231
|
+
source.removeEventListener('endstreaming', onEnd);
|
|
232
|
+
element.removeEventListener('timeupdate', wake);
|
|
233
|
+
element.removeEventListener('seeking', wake);
|
|
234
|
+
element.removeEventListener('waiting', wake);
|
|
235
|
+
if (objectUrl)
|
|
236
|
+
revoke(objectUrl);
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|