@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/index.d.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, type IpcRequest, type IpcResponse, type IpcError, type IpcNotification, type IpcNotificationBatch, type EventMessage, ShellCapabilities, type ShellInfo, } from './types.js';
|
|
2
|
-
export {
|
|
2
|
+
export { ShenoraError } from './errors.js';
|
|
3
3
|
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
|
|
4
|
-
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
4
|
+
export { ShenoraEventBus, eventBus, type SubscribeOptions, } from './eventBus.js';
|
|
5
5
|
export { ShenoraBridge, getBridge, configureBridge, type ShenoraBridgeOptions, type InvokeOptions, type PostOptions, type PostFailure, } from './bridge.js';
|
|
6
6
|
export { createShenoraStore, type ShenoraStore, type ShenoraStoreOptions, type ShenoraStoreIo, type ShenoraStoreSnapshot, } from './store.js';
|
|
7
7
|
export { BaseModuleService } from './moduleService.js';
|
|
8
|
-
export {
|
|
8
|
+
export { IpcRequestStates, IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, type IpcRequestState, type IpcLabel, type IpcProgress, type IpcRequestStatus, type RequestsState, type RequestsActions, type RequestsStoreOptions, } from './requests.js';
|
|
9
9
|
export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
|
|
10
10
|
export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
|
|
11
|
-
export { useShenora, useShenoraEvent, useShenoraQuery, type ShenoraQueryResult } from './hooks.js';
|
|
11
|
+
export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
|
|
12
|
+
export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
|
|
13
|
+
export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, type ClipboardContent, type ClipboardHandle, } from './clipboard.js';
|
|
12
14
|
export { installDevInterceptor, type DevInterceptorOptions, type DevIpcEntry, type DevEventEntry, } from './devInterceptor.js';
|
|
13
15
|
export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
|
|
16
|
+
export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
|
|
17
|
+
export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
|
|
18
|
+
export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
|
|
19
|
+
export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
|
|
20
|
+
export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
|
package/dist/index.js
CHANGED
|
@@ -3,22 +3,36 @@
|
|
|
3
3
|
// React hooks, and the dev interceptor for CDP-driven testing. Headless by design (D13): no UI
|
|
4
4
|
// components, no design-system dependency — apps bring their own.
|
|
5
5
|
export { IpcCategories, IpcErrorCodes, HANDSHAKE_MODULE, HANDSHAKE_TYPE, ShellCapabilities, } from './types.js';
|
|
6
|
-
export {
|
|
6
|
+
export { ShenoraError } from './errors.js';
|
|
7
7
|
export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
|
|
8
|
-
export { ShenoraEventBus, eventBus } from './eventBus.js';
|
|
8
|
+
export { ShenoraEventBus, eventBus, } from './eventBus.js';
|
|
9
9
|
export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
|
|
10
10
|
export { createShenoraStore, } from './store.js';
|
|
11
11
|
export { BaseModuleService } from './moduleService.js';
|
|
12
|
-
export {
|
|
13
|
-
// The event vocabulary + the default module name. `
|
|
12
|
+
export { IpcRequestStates,
|
|
13
|
+
// The event vocabulary + the default module name. `createRequestsStore` deliberately does NOT
|
|
14
14
|
// subscribe to RESUME_REQUESTED / WAIT_REQUESTED — those target the OWNING module's own service,
|
|
15
15
|
// not the generic store — so the app writing that handler needs both symbols, and until now had
|
|
16
16
|
// neither: it had to hard-code the literals the wire-mirror tests exist to keep it from doing.
|
|
17
|
-
|
|
17
|
+
IpcRequestEventTypes,
|
|
18
|
+
// The ROUTE half of that same wire, and it was the one left behind: an app cancelling a request
|
|
19
|
+
// without `useShenoraRequests` had the module name and the event names but had to hard-code
|
|
20
|
+
// 'CANCEL'. Pinned by `WireMirrorTests.Request_route_names_match_the_hosts_module`.
|
|
21
|
+
IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
|
|
18
22
|
export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
|
|
19
23
|
export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
|
|
20
|
-
export { useShenora, useShenoraEvent, useShenoraQuery } from './hooks.js';
|
|
24
|
+
export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
|
|
25
|
+
// Native dialogs, capability-gated. The client half of the host's SHENORA.DIALOGS module.
|
|
26
|
+
export { FileDialogs, useFileDialogs, } from './fileDialogs.js';
|
|
27
|
+
// The native clipboard, for the two things navigator.clipboard cannot do: FILES, and access with no
|
|
28
|
+
// user gesture. The client half of the host's SHENORA.CLIPBOARD module.
|
|
29
|
+
export { ClipboardAccess, useClipboard, PNG_IMAGE, HTML, } from './clipboard.js';
|
|
21
30
|
export { installDevInterceptor, } from './devInterceptor.js';
|
|
22
31
|
// Addressing local content the page cannot reach itself. A pure function, not a hook — building the URL
|
|
23
32
|
// needs no React, and a `useMediaSource` can follow if an adopter wants load/error state.
|
|
24
33
|
export { mediaUrl, encodeMediaPayload, decodeMediaPayload } from './media.js';
|
|
34
|
+
export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFromInitSegment, } from './segmentStream.js';
|
|
35
|
+
export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
|
|
36
|
+
// The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
|
|
37
|
+
// One hook, and the page stops deciding anything about formats.
|
|
38
|
+
export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MediaPlayerCommands, } from './mediaPlayer.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type ShenoraBridge } from './bridge.js';
|
|
3
|
+
import { type ShenoraEventBus } from './eventBus.js';
|
|
4
|
+
/**
|
|
5
|
+
* The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
|
|
6
|
+
* containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
|
|
7
|
+
* defaults to the same string — change one and you must change the other.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
|
|
10
|
+
* `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MEDIA_PLAYER_MODULE = "SHENORA.MEDIA";
|
|
13
|
+
/**
|
|
14
|
+
* Commands the host sends. **A wire contract**: these strings are duplicated in C# as
|
|
15
|
+
* `MediaPlayerEvents`, and the two halves agree by string or not at all.
|
|
16
|
+
*/
|
|
17
|
+
export declare const MediaPlayerCommands: {
|
|
18
|
+
readonly load: "PLAYER_LOAD";
|
|
19
|
+
readonly play: "PLAYER_PLAY";
|
|
20
|
+
readonly pause: "PLAYER_PAUSE";
|
|
21
|
+
readonly seek: "PLAYER_SEEK";
|
|
22
|
+
readonly rate: "PLAYER_RATE";
|
|
23
|
+
readonly unload: "PLAYER_UNLOAD";
|
|
24
|
+
};
|
|
25
|
+
/** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
|
|
26
|
+
export declare const MEDIA_PLAYER_REPORT = "PLAYER_REPORT";
|
|
27
|
+
/**
|
|
28
|
+
* What the element is doing, in the host's vocabulary (`MediaPlayerState`).
|
|
29
|
+
*
|
|
30
|
+
* ⚠ `opening` and `buffering` are distinct, matching the host: opening is "no position yet", buffering is
|
|
31
|
+
* "had one and it stopped moving". Collapsing them makes a UI extrapolate a position that is not advancing.
|
|
32
|
+
*/
|
|
33
|
+
export type MediaPlayerReportState = 'Empty' | 'Opening' | 'Paused' | 'Playing' | 'Buffering' | 'Ended' | 'Failed';
|
|
34
|
+
/** One state report, sent on TRANSITIONS only. */
|
|
35
|
+
export interface MediaPlayerReport {
|
|
36
|
+
state: MediaPlayerReportState;
|
|
37
|
+
/** Seconds. */
|
|
38
|
+
position: number;
|
|
39
|
+
/** Seconds, or null for a live stream / not yet known. */
|
|
40
|
+
duration: number | null;
|
|
41
|
+
/** A short reason when `state` is `Failed`; never the platform's raw text. */
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Inputs for {@link useMediaPlayer}. */
|
|
45
|
+
export interface UseMediaPlayerOptions {
|
|
46
|
+
/** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
|
|
47
|
+
module?: string;
|
|
48
|
+
/** Test seams. */
|
|
49
|
+
bridge?: ShenoraBridge;
|
|
50
|
+
eventBus?: ShenoraEventBus;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
|
|
54
|
+
* and .NET owns the lifecycle (D58).
|
|
55
|
+
*
|
|
56
|
+
* ```tsx
|
|
57
|
+
* const ref = useRef<HTMLVideoElement>(null);
|
|
58
|
+
* useMediaPlayer(ref);
|
|
59
|
+
* return <video ref={ref} playsInline />;
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
|
|
63
|
+
* calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
|
|
64
|
+
* it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
|
|
65
|
+
* at all, which needs a probe and a device capability query).
|
|
66
|
+
*
|
|
67
|
+
* ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
|
|
68
|
+
* (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
|
|
69
|
+
* registered by the media feature itself. If you wrote that route by hand against a build from before
|
|
70
|
+
* 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
|
|
71
|
+
*
|
|
72
|
+
* ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
|
|
73
|
+
* object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
|
|
74
|
+
* the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
|
|
75
|
+
* the component so the hook remounts with it.
|
|
76
|
+
*
|
|
77
|
+
* ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
|
|
78
|
+
* would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
|
|
79
|
+
* you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
|
|
80
|
+
* actually redraw.
|
|
81
|
+
*
|
|
82
|
+
* ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
|
|
83
|
+
* the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
|
|
84
|
+
* hears about it rather than silently believing playback started.
|
|
85
|
+
*/
|
|
86
|
+
export declare function useMediaPlayer(ref: RefObject<HTMLMediaElement | null>, options?: UseMediaPlayerOptions): void;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { getBridge } from './bridge.js';
|
|
3
|
+
import { eventBus as defaultEventBus } from './eventBus.js';
|
|
4
|
+
/**
|
|
5
|
+
* The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
|
|
6
|
+
* containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
|
|
7
|
+
* defaults to the same string — change one and you must change the other.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
|
|
10
|
+
* `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
|
|
11
|
+
*/
|
|
12
|
+
export const MEDIA_PLAYER_MODULE = 'SHENORA.MEDIA';
|
|
13
|
+
/**
|
|
14
|
+
* Commands the host sends. **A wire contract**: these strings are duplicated in C# as
|
|
15
|
+
* `MediaPlayerEvents`, and the two halves agree by string or not at all.
|
|
16
|
+
*/
|
|
17
|
+
export const MediaPlayerCommands = {
|
|
18
|
+
load: 'PLAYER_LOAD',
|
|
19
|
+
play: 'PLAYER_PLAY',
|
|
20
|
+
pause: 'PLAYER_PAUSE',
|
|
21
|
+
seek: 'PLAYER_SEEK',
|
|
22
|
+
rate: 'PLAYER_RATE',
|
|
23
|
+
unload: 'PLAYER_UNLOAD',
|
|
24
|
+
};
|
|
25
|
+
/** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
|
|
26
|
+
export const MEDIA_PLAYER_REPORT = 'PLAYER_REPORT';
|
|
27
|
+
/**
|
|
28
|
+
* Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
|
|
29
|
+
* and .NET owns the lifecycle (D58).
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const ref = useRef<HTMLVideoElement>(null);
|
|
33
|
+
* useMediaPlayer(ref);
|
|
34
|
+
* return <video ref={ref} playsInline />;
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
|
|
38
|
+
* calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
|
|
39
|
+
* it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
|
|
40
|
+
* at all, which needs a probe and a device capability query).
|
|
41
|
+
*
|
|
42
|
+
* ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
|
|
43
|
+
* (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
|
|
44
|
+
* registered by the media feature itself. If you wrote that route by hand against a build from before
|
|
45
|
+
* 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
|
|
48
|
+
* object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
|
|
49
|
+
* the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
|
|
50
|
+
* the component so the hook remounts with it.
|
|
51
|
+
*
|
|
52
|
+
* ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
|
|
53
|
+
* would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
|
|
54
|
+
* you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
|
|
55
|
+
* actually redraw.
|
|
56
|
+
*
|
|
57
|
+
* ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
|
|
58
|
+
* the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
|
|
59
|
+
* hears about it rather than silently believing playback started.
|
|
60
|
+
*/
|
|
61
|
+
export function useMediaPlayer(ref, options = {}) {
|
|
62
|
+
const { module = MEDIA_PLAYER_MODULE, bridge, eventBus = defaultEventBus } = options;
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const element = ref.current;
|
|
65
|
+
if (!element)
|
|
66
|
+
return;
|
|
67
|
+
const link = bridge ?? getBridge();
|
|
68
|
+
// The element is the only clock: the host asks IT for position rather than tracking its own, so a
|
|
69
|
+
// report always carries what the element actually believes.
|
|
70
|
+
const report = (state, error) => {
|
|
71
|
+
const duration = Number.isFinite(element.duration) ? element.duration : null;
|
|
72
|
+
const payload = {
|
|
73
|
+
state,
|
|
74
|
+
position: Number.isFinite(element.currentTime) ? element.currentTime : 0,
|
|
75
|
+
duration,
|
|
76
|
+
...(error ? { error } : {}),
|
|
77
|
+
};
|
|
78
|
+
link.post(module, MEDIA_PLAYER_REPORT, { payload });
|
|
79
|
+
};
|
|
80
|
+
// The PENDING start-at seek, if any. 🔴 It has to be cancellable: `{ once: true }` removes a listener
|
|
81
|
+
// only when it FIRES, and a second PLAYER_LOAD calls `element.load()`, which aborts the first load so
|
|
82
|
+
// its `loadedmetadata` never comes. The stale listener then survives and runs on the NEXT track's
|
|
83
|
+
// metadata — so loading A at 10:00 and then B at 0:00 starts B ten minutes in, because B sets no
|
|
84
|
+
// listener of its own and A's is still attached.
|
|
85
|
+
let pendingSeek = null;
|
|
86
|
+
const cancelPendingSeek = () => {
|
|
87
|
+
if (pendingSeek)
|
|
88
|
+
element.removeEventListener('loadedmetadata', pendingSeek);
|
|
89
|
+
pendingSeek = null;
|
|
90
|
+
};
|
|
91
|
+
// ── host → element ────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
const subscriptions = [
|
|
93
|
+
eventBus.subscribe(module, MediaPlayerCommands.load, (message) => {
|
|
94
|
+
const { uri, startAt } = message.payload ?? { uri: '', startAt: 0 };
|
|
95
|
+
element.src = uri;
|
|
96
|
+
// load() rather than trusting the src assignment: a second load on the same element keeps the
|
|
97
|
+
// previous buffer otherwise, and a seek then lands in the OLD media.
|
|
98
|
+
element.load();
|
|
99
|
+
cancelPendingSeek(); // this load supersedes any earlier one — see pendingSeek
|
|
100
|
+
if (startAt > 0) {
|
|
101
|
+
const seek = () => { pendingSeek = null; element.currentTime = startAt; };
|
|
102
|
+
// `loadedmetadata` is the earliest point currentTime is settable — before it, the assignment is
|
|
103
|
+
// silently dropped and the item starts at zero.
|
|
104
|
+
pendingSeek = seek;
|
|
105
|
+
element.addEventListener('loadedmetadata', seek, { once: true });
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
eventBus.subscribe(module, MediaPlayerCommands.play, () => {
|
|
109
|
+
// A rejected play() is an autoplay refusal, and the host must hear it rather than assume success.
|
|
110
|
+
// ⚠ Read `.name` STRUCTURALLY rather than testing `instanceof Error`: the value browsers reject
|
|
111
|
+
// with is a DOMException, which is not an Error subclass everywhere (jsdom's is not), and a page
|
|
112
|
+
// can reject with anything at all. The name is the stable, app-safe part — `NotAllowedError` is
|
|
113
|
+
// what an autoplay block actually says, and it is the one an adopter will want to branch on.
|
|
114
|
+
void element.play().catch((cause) => {
|
|
115
|
+
const name = cause?.name;
|
|
116
|
+
report('Failed', typeof name === 'string' && name ? name : 'PlayRejected');
|
|
117
|
+
});
|
|
118
|
+
}),
|
|
119
|
+
eventBus.subscribe(module, MediaPlayerCommands.pause, () => element.pause()),
|
|
120
|
+
eventBus.subscribe(module, MediaPlayerCommands.seek, (message) => {
|
|
121
|
+
element.currentTime = message.payload?.position ?? 0;
|
|
122
|
+
}),
|
|
123
|
+
eventBus.subscribe(module, MediaPlayerCommands.rate, (message) => {
|
|
124
|
+
element.playbackRate = message.payload?.rate ?? 1;
|
|
125
|
+
}),
|
|
126
|
+
eventBus.subscribe(module, MediaPlayerCommands.unload, () => {
|
|
127
|
+
element.pause();
|
|
128
|
+
element.removeAttribute('src');
|
|
129
|
+
// ⚠ load() after clearing src is what actually FREES the buffer. Without it the element keeps the
|
|
130
|
+
// decoded data alive, which on a phone is the difference between releasing memory and not.
|
|
131
|
+
element.load();
|
|
132
|
+
report('Empty');
|
|
133
|
+
}),
|
|
134
|
+
];
|
|
135
|
+
// ── element → host ────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Transitions only. `timeupdate` is deliberately absent — see the remarks.
|
|
137
|
+
const listeners = [
|
|
138
|
+
['loadedmetadata', () => report('Paused')],
|
|
139
|
+
['canplay', () => report(element.paused ? 'Paused' : 'Playing')],
|
|
140
|
+
['play', () => report('Playing')],
|
|
141
|
+
['playing', () => report('Playing')],
|
|
142
|
+
['pause', () => report(element.ended ? 'Ended' : 'Paused')],
|
|
143
|
+
['waiting', () => report('Buffering')],
|
|
144
|
+
['seeked', () => report(element.paused ? 'Paused' : 'Playing')],
|
|
145
|
+
['ended', () => report('Ended')],
|
|
146
|
+
['error', () => report('Failed', mediaErrorReason(element))],
|
|
147
|
+
];
|
|
148
|
+
for (const [event, handler] of listeners)
|
|
149
|
+
element.addEventListener(event, handler);
|
|
150
|
+
// 🔴 REPORT WHEN THE PAGE IS ABOUT TO BE HIDDEN, or the host's position is whatever the last
|
|
151
|
+
// TRANSITION left — which for steady playback is the moment it started.
|
|
152
|
+
//
|
|
153
|
+
// Measured on an Android emulator 2026-08-15: with transition-only reporting the page sat at 19.79 s
|
|
154
|
+
// while the host believed 0.01 s, and `BackgroundPlaybackTransfer` handed the native player 0.01 s.
|
|
155
|
+
// The user backgrounds mid-film and resumes from the beginning. The platform's `pause` at background
|
|
156
|
+
// time does fire, but not in time to cross IPC before the process is frozen.
|
|
157
|
+
//
|
|
158
|
+
// ⚠ `visibilitychange` rather than `pagehide`: this fires while the document is still alive and the
|
|
159
|
+
// bridge can still post, and it is the signal both mobile shells raise on the way to the background.
|
|
160
|
+
// It costs ONE report per background — nothing like `timeupdate`'s ~4/second, which is why that one
|
|
161
|
+
// is still deliberately absent.
|
|
162
|
+
const onHidden = () => {
|
|
163
|
+
if (document.visibilityState !== 'hidden')
|
|
164
|
+
return;
|
|
165
|
+
report(element.paused ? (element.ended ? 'Ended' : 'Paused') : 'Playing');
|
|
166
|
+
};
|
|
167
|
+
document.addEventListener('visibilitychange', onHidden);
|
|
168
|
+
return () => {
|
|
169
|
+
for (const subscription of subscriptions)
|
|
170
|
+
subscription();
|
|
171
|
+
for (const [event, handler] of listeners)
|
|
172
|
+
element.removeEventListener(event, handler);
|
|
173
|
+
cancelPendingSeek();
|
|
174
|
+
document.removeEventListener('visibilitychange', onHidden);
|
|
175
|
+
};
|
|
176
|
+
}, [ref, module, bridge, eventBus]);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A short, stable reason from `MediaError`.
|
|
180
|
+
*
|
|
181
|
+
* ⚠ Deliberately NOT `error.message`: browsers put decoder internals and sometimes the full URL in it, and
|
|
182
|
+
* this string crosses to the host and can reach a log. The host applies the same rule to platform errors.
|
|
183
|
+
*/
|
|
184
|
+
function mediaErrorReason(element) {
|
|
185
|
+
switch (element.error?.code) {
|
|
186
|
+
case 1: return 'Aborted';
|
|
187
|
+
case 2: return 'Network';
|
|
188
|
+
case 3: return 'Decode';
|
|
189
|
+
case 4: return 'SourceNotSupported';
|
|
190
|
+
default: return 'Unknown';
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/moduleService.d.ts
CHANGED
|
@@ -9,11 +9,18 @@ import { type ShenoraBridge } from './bridge.js';
|
|
|
9
9
|
* interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
10
10
|
* class NoteService extends BaseModuleService<NoteRequests> {
|
|
11
11
|
* constructor() { super('NOTES'); }
|
|
12
|
-
* getAll() { return this.send
|
|
13
|
-
* add(title: string) { return this.send
|
|
12
|
+
* getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
|
|
13
|
+
* add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
|
|
14
14
|
* }
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
17
|
+
* 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
|
|
18
|
+
* inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
|
|
19
|
+
* key — and `payload` collapses to the union of every route's payload. The check silently stops
|
|
20
|
+
* checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
|
|
21
|
+
* without the type argument is a TS2353. The response is inferred from the method's declared return
|
|
22
|
+
* type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
|
|
23
|
+
*
|
|
17
24
|
* DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
|
|
18
25
|
* around the same call — the response generic already expresses them, so they're gone.
|
|
19
26
|
*
|
package/dist/moduleService.js
CHANGED
|
@@ -9,11 +9,18 @@ import { getBridge } from './bridge.js';
|
|
|
9
9
|
* interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
10
10
|
* class NoteService extends BaseModuleService<NoteRequests> {
|
|
11
11
|
* constructor() { super('NOTES'); }
|
|
12
|
-
* getAll() { return this.send
|
|
13
|
-
* add(title: string) { return this.send
|
|
12
|
+
* getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
|
|
13
|
+
* add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
|
|
14
14
|
* }
|
|
15
15
|
* ```
|
|
16
16
|
*
|
|
17
|
+
* 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
|
|
18
|
+
* inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
|
|
19
|
+
* key — and `payload` collapses to the union of every route's payload. The check silently stops
|
|
20
|
+
* checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
|
|
21
|
+
* without the type argument is a TS2353. The response is inferred from the method's declared return
|
|
22
|
+
* type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
|
|
23
|
+
*
|
|
17
24
|
* DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
|
|
18
25
|
* around the same call — the response generic already expresses them, so they're gone.
|
|
19
26
|
*
|
|
@@ -0,0 +1,176 @@
|
|
|
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) — crosses the wire as its camelCase name for
|
|
7
|
+
* free: `IpcJson` already installs a camelCase `JsonStringEnumConverter`, so no per-type wiring is
|
|
8
|
+
* needed on either side. Pinned against the host by
|
|
9
|
+
* `WireMirrorTests.Every_request_state_exists_on_both_sides` — a status added on one side and
|
|
10
|
+
* not the other fails that test by name, not by a green suite that never looked.
|
|
11
|
+
*/
|
|
12
|
+
export declare const IpcRequestStates: {
|
|
13
|
+
readonly Running: "running";
|
|
14
|
+
readonly Completed: "completed";
|
|
15
|
+
readonly Failed: "failed";
|
|
16
|
+
readonly Cancelled: "cancelled";
|
|
17
|
+
};
|
|
18
|
+
/** One of {@link IpcRequestStates}. */
|
|
19
|
+
export type IpcRequestState = (typeof IpcRequestStates)[keyof typeof IpcRequestStates];
|
|
20
|
+
/**
|
|
21
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestEvents` — pinned against the host by
|
|
22
|
+
* `WireMirrorTests.Request_event_names_match_the_host` (ALSO IN THIS BATCH, whole-branch review):
|
|
23
|
+
* these were bare string literals with nothing comparing them to the host's own constants, so a host
|
|
24
|
+
* rename left the suite green and the client permanently deaf to the renamed event.
|
|
25
|
+
*/
|
|
26
|
+
export declare const IpcRequestEventTypes: {
|
|
27
|
+
readonly Updated: "REQUEST_UPDATED";
|
|
28
|
+
/**
|
|
29
|
+
* One or more request ids left the host with no corresponding `Updated` snapshot — history eviction
|
|
30
|
+
* and `CLEAR_FINISHED`. Payload is `{ requestIds: string[] }`; {@link createRequestsStore} folds it
|
|
31
|
+
* by deleting those ids. One authoritative event, so a client never has to guess what the host
|
|
32
|
+
* removed.
|
|
33
|
+
*/
|
|
34
|
+
readonly Removed: "REQUEST_REMOVED";
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Mirrors the route names `Shenora.Modules.Requests.IpcRequestsModule` switches on (its own
|
|
38
|
+
* `ListType`/`CancelType`/`ClearFinishedType`/`ResumeType` constants) — pinned by
|
|
39
|
+
* `WireMirrorTests.Request_route_names_match_the_hosts_module`, same rationale as
|
|
40
|
+
* {@link IpcRequestEventTypes}.
|
|
41
|
+
*/
|
|
42
|
+
export declare const IpcRequestRoutes: {
|
|
43
|
+
readonly List: "LIST";
|
|
44
|
+
readonly Cancel: "CANCEL";
|
|
45
|
+
readonly ClearFinished: "CLEAR_FINISHED";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Default `Shenora.Core.Ipc.IpcRequestTrackerOptions.ModuleName` — pinned by
|
|
49
|
+
* `WireMirrorTests.The_default_requests_module_name_matches_the_host`.
|
|
50
|
+
*/
|
|
51
|
+
export declare const IpcRequestsModuleName = "SHENORA.REQUESTS";
|
|
52
|
+
/**
|
|
53
|
+
* Mirrors `Shenora.Core.Ipc.IpcLabel` — human-facing text the HOST never renders itself: an
|
|
54
|
+
* untranslated fallback plus an app i18n key and interpolation parameters (headless, D13).
|
|
55
|
+
*/
|
|
56
|
+
export interface IpcLabel {
|
|
57
|
+
text?: string;
|
|
58
|
+
key?: string;
|
|
59
|
+
parameters?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Mirrors `Shenora.Core.Ipc.IpcProgress` — how far a tracked operation has gotten, in the APP's own
|
|
63
|
+
* unit, never a kit-assumed percent (generic-library audit, before publish: percent is not the
|
|
64
|
+
* mechanism, it is one way an app happens to measure). `total` is the denominator when one is known;
|
|
65
|
+
* `undefined` means there is NO known total — an absolute count with nothing to divide by (bytes
|
|
66
|
+
* streamed so far off a chunked response, say), never zero. `unit` is app-defined, like `kind`
|
|
67
|
+
* (`'bytes'`, `'files'`, `'percent'`) — the kit never interprets it and ships no percent helper: render
|
|
68
|
+
* a ratio only when `total` is set, e.g. `total ? (value / total) * 100 : undefined` — that division
|
|
69
|
+
* is the consumer's own policy (see the README example).
|
|
70
|
+
*/
|
|
71
|
+
export interface IpcProgress {
|
|
72
|
+
value: number;
|
|
73
|
+
total?: number;
|
|
74
|
+
unit?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Mirrors `Shenora.Core.Ipc.IpcRequestStatus` — a full snapshot of one tracked operation. Every lifecycle
|
|
78
|
+
* transition (start, progress, terminal) publishes one of these under `REQUEST_UPDATED`, so the
|
|
79
|
+
* client folds by `id`: last write wins, with no cross-type ordering hazard.
|
|
80
|
+
*/
|
|
81
|
+
export interface IpcRequestStatus {
|
|
82
|
+
id: string;
|
|
83
|
+
module: string;
|
|
84
|
+
type: string;
|
|
85
|
+
scope?: string;
|
|
86
|
+
state: IpcRequestState;
|
|
87
|
+
progress?: IpcProgress;
|
|
88
|
+
detail?: IpcLabel;
|
|
89
|
+
error?: IpcError;
|
|
90
|
+
startedAt: string;
|
|
91
|
+
finishedAt?: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* State behind {@link useShenoraRequests}. TWO bands, because a request is in flight or done:
|
|
95
|
+
*
|
|
96
|
+
* | Band | Getter |
|
|
97
|
+
* |---|---|
|
|
98
|
+
* | In flight | {@link running} |
|
|
99
|
+
* | Finished | {@link finished} — prunable via `clearFinished` |
|
|
100
|
+
*
|
|
101
|
+
* Every getter is DERIVED from `byId` on each read — never a second copy a fold has to remember to
|
|
102
|
+
* keep in sync. `byId` is the only thing any reducer here writes.
|
|
103
|
+
*
|
|
104
|
+
* ⚠ Most requests never appear at all: one that finishes inside the host's grace period is never
|
|
105
|
+
* announced, so this store is a list of work that is actually TAKING A WHILE rather than a log of
|
|
106
|
+
* every call the page made.
|
|
107
|
+
*/
|
|
108
|
+
export interface RequestsState {
|
|
109
|
+
byId: Record<string, IpcRequestStatus>;
|
|
110
|
+
/** Every currently-running operation, in `byId` order. */
|
|
111
|
+
readonly running: IpcRequestStatus[];
|
|
112
|
+
/** Every operation that reached a terminal status (completed/failed/cancelled). */
|
|
113
|
+
readonly finished: IpcRequestStatus[];
|
|
114
|
+
}
|
|
115
|
+
/** Fire-and-forget actions exposed on {@link useShenoraRequests}, routed to `IpcRequestsModule`. */
|
|
116
|
+
export interface RequestsActions {
|
|
117
|
+
/** `CANCEL { requestId }` — the app-level cancel route `ipc-contracts` prescribes. */
|
|
118
|
+
cancel: (requestId: string) => string;
|
|
119
|
+
/**
|
|
120
|
+
* `CLEAR_FINISHED { scope? }` — drop retained finished history, forwarding this store's own
|
|
121
|
+
* configured scope (generic-library audit finding 1) so a scoped store's "clear completed" cannot
|
|
122
|
+
* wipe another scope's history host-side. No local mutation here: the host's
|
|
123
|
+
* `REQUEST_REMOVED` (finding 4) is the only thing that removes a row from this store now — see
|
|
124
|
+
* {@link IpcRequestEventTypes.Removed}. It used to carry an optimistic local prune of every TERMINAL
|
|
125
|
+
* entry, added because removals had no wire event at all; that guess is retired now that one exists.
|
|
126
|
+
*/
|
|
127
|
+
clearFinished: () => string;
|
|
128
|
+
}
|
|
129
|
+
/** Test/alternate-transport seams, a renamed host module, and an optional scope filter, for {@link createRequestsStore}. */
|
|
130
|
+
export interface RequestsStoreOptions {
|
|
131
|
+
/**
|
|
132
|
+
* The request/event module this store talks to. Must match the host's
|
|
133
|
+
* `IpcRequestTrackerOptions.ModuleName` — default `'SHENORA.REQUESTS'` on both sides — when an app
|
|
134
|
+
* renamed it to avoid a collision with one of its own module names (the duplicate-module guard
|
|
135
|
+
* `IpcRequestsModule`'s own docs describe). A store bound to the default name cannot reach a
|
|
136
|
+
* renamed host at all, which is exactly the gap this field closes.
|
|
137
|
+
*/
|
|
138
|
+
module?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Optional app-defined scope, applied to THREE places so the store stays internally consistent:
|
|
141
|
+
* the bus subscription (only deltas whose event scope matches are folded), the actions' request
|
|
142
|
+
* envelope, and the initial `LIST` snapshot's payload (`IpcRequestsModule` reads its scope filter
|
|
143
|
+
* from the payload, not the envelope — see `IpcRequestsModule.RouteMessageAsync`). Threading it
|
|
144
|
+
* into only the first two would load every scope on first subscribe and never remove the
|
|
145
|
+
* out-of-scope rows, since no delta for them ever arrives: a silent, permanent leak.
|
|
146
|
+
*/
|
|
147
|
+
scope?: string;
|
|
148
|
+
/** Test/multi-transport seams. Default: the shared bridge and event bus. */
|
|
149
|
+
bridge?: ShenoraBridge;
|
|
150
|
+
bus?: ShenoraEventBus;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build a store instance over the requests module — the factory {@link useShenoraRequests}
|
|
154
|
+
* itself is built from. Exposed (rather than only the ready-made hook) for the same reason
|
|
155
|
+
* `WindowCommands` takes an optional bridge: a test needs a fake bridge/bus
|
|
156
|
+
* (`requests.test.ts`), an app that renamed the host's `IpcRequestTrackerOptions.ModuleName`
|
|
157
|
+
* needs a store bound to that name instead of the unreachable default, and an app running a
|
|
158
|
+
* secondary window or auxiliary session needs its own scope-filtered instance instead of being
|
|
159
|
+
* stuck with the shared, unscoped default.
|
|
160
|
+
*/
|
|
161
|
+
export declare function createRequestsStore(options?: RequestsStoreOptions): ShenoraStore<RequestsState, RequestsActions>;
|
|
162
|
+
/**
|
|
163
|
+
* The client side of the operations primitive (design §4.6, `IpcRequestsModule` +
|
|
164
|
+
* `IIpcRequestTracker`): snapshots via `LIST` on first subscribe, then folds `REQUEST_UPDATED` by
|
|
165
|
+
* id — one subscription however many components read it, and a late mounter renders CURRENT state
|
|
166
|
+
* because the host is authoritative (the store primitive's own late-mounter case is now
|
|
167
|
+
* host-backed end to end). `running`/`finished` are selectors an activity panel or status bar reads
|
|
168
|
+
* directly: `useShenoraRequests((s) => s.running)`. There is no `waiting` band and no `waitReason` —
|
|
169
|
+
* a request is IN FLIGHT or DONE, the same two states `XMLHttpRequest` has, since D66. Bound to the
|
|
170
|
+
* default module/no scope — use {@link createRequestsStore} directly for a renamed module or a
|
|
171
|
+
* scope-filtered instance.
|
|
172
|
+
*
|
|
173
|
+
* Headless, per D13: no component, no UI opinion, no `ProcessType`-style enum — what an operation
|
|
174
|
+
* IS stays the app's `kind` string; this only carries the uniform lifecycle around it.
|
|
175
|
+
*/
|
|
176
|
+
export declare const useShenoraRequests: ShenoraStore<RequestsState, RequestsActions>;
|