@shenora/react 0.15.0 → 0.17.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @shenora/react
2
2
 
3
- React client for [Shenora](https://github.com/JiarongGu/Shenora) desktop hosts (.NET + WinForms +
4
- WebView2). The typed bridge between a React frontend and the Shenora host: correlated `invoke`
3
+ React client for [Shenora](https://github.com/JiarongGu/Shenora) hosts: the Windows shell (WebView2 or
4
+ Chromium), the Chromium shell, Android and iOS. The typed bridge between a React frontend and the Shenora host: correlated `invoke`
5
5
  with timeouts and structured errors, the event hub host notifications stream into, typed module
6
6
  services, React hooks, and a pluggable transport with a browser fallback so the UI can be
7
7
  developed in a plain browser. Headless by design — no UI components, bring your own design
@@ -144,8 +144,9 @@ runs ahead of the feature code it is observing. Prefer `subscribe` when you know
144
144
  catch-all wakes for every event on the bus.
145
145
 
146
146
  Pure-UI development in a plain browser: pass a `fallback` to `configureBridge` (gated behind
147
- `import.meta.env.DEV`) to answer requests with canned data. Other shells (WebSocket,
148
- mobile/Capacitor) implement the small `ShenoraTransport` seam and speak the same envelopes.
147
+ `import.meta.env.DEV`) to answer requests with canned data. The kit's own hosts are found by
148
+ `createHostTransport()`; another host (a WebSocket, say) implements the small `ShenoraTransport` seam and
149
+ speaks the same envelopes.
149
150
  For CDP-driven testing, `installDevInterceptor()` records IPC/event traffic into ring buffers
150
151
  and exposes `window.__shenora.call()/waitEvent()`.
151
152
 
package/dist/index.d.ts CHANGED
@@ -1,12 +1,13 @@
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
2
  export { ShenoraError } from './errors.js';
3
- export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, type ShenoraTransport, } from './transport.js';
3
+ export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, type ShenoraTransport, } from './transport.js';
4
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
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
- export { WindowCommands, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, } from './windowCommands.js';
9
+ export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, type WindowResizeEdge, type CaptionButtonKind, type CaptionButtonRect, type CaptionButtonState, type CaptionButtonColors, } from './windowCommands.js';
10
+ export { WindowOrientation, type WindowOrientationKind, } from './windowOrientation.js';
10
11
  export { useDropZone, DROP_ZONE_MODULE, type DropZoneFileDrop, type UseDropZoneOptions, } from './useDropZone.js';
11
12
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, type ShenoraQueryResult, } from './hooks.js';
12
13
  export { FileDialogs, useFileDialogs, type FileDialogFilter, type FileDialogOptions, type OpenFileOptions, type OpenFolderOptions, type SaveFileOptions, type FileDialogResult, type FileDialogsHandle, } from './fileDialogs.js';
@@ -20,3 +21,5 @@ export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
20
21
  export type { SegmentBinderOptions, SegmentBinding } from './segmentBinder.js';
21
22
  export type { SegmentEntry, SegmentManifest, MediaSourceKind, MediaSourceGlobals, FetchState, FetchPolicy, } from './segmentStream.js';
22
23
  export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, type MediaPlayerReport, type MediaPlayerReportState, type UseMediaPlayerOptions, } from './mediaPlayer.js';
24
+ export { useMediaSurface, MediaSurfaceCommands, type UseMediaSurfaceOptions, } from './mediaSurface.js';
25
+ export { useMediaTransport, type MediaTransport, type MediaTransportStatus, type UseMediaTransportOptions, } from './mediaTransport.js';
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
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
6
  export { ShenoraError } from './errors.js';
7
- export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, } from './transport.js';
7
+ export { isShenoraAvailable, createHostTransport, createWebView2Transport, createHybridWebViewTransport, createChromiumTransport, } from './transport.js';
8
8
  export { ShenoraEventBus, eventBus, } from './eventBus.js';
9
9
  export { ShenoraBridge, getBridge, configureBridge, } from './bridge.js';
10
10
  export { createShenoraStore, } from './store.js';
@@ -13,7 +13,8 @@ export { IpcRequestStates,
13
13
  // The event and route vocabulary + the default module name, so an app handling or cancelling a
14
14
  // request without `useShenoraRequests` never types the wire literals by hand.
15
15
  IpcRequestEventTypes, IpcRequestRoutes, IpcRequestsModuleName, createRequestsStore, useShenoraRequests, } from './requests.js';
16
- export { WindowCommands, useWindowMaximized, } from './windowCommands.js';
16
+ export { WindowCommands, WindowEventTypes, useCaptionButtonState, useWindowMaximized, } from './windowCommands.js';
17
+ export { WindowOrientation, } from './windowOrientation.js';
17
18
  export { useDropZone, DROP_ZONE_MODULE, } from './useDropZone.js';
18
19
  export { useShenora, useShenoraEvent, useShenoraQuery, useShellInfo, } from './hooks.js';
19
20
  // Native dialogs, capability-gated. The client half of the host's SHENORA.DIALOGS module.
@@ -34,3 +35,7 @@ export { parseManifest, pickMediaSource, nextSegment, segmentMimeType, codecsFro
34
35
  export { bindSegmentStream, SegmentBinderError } from './segmentBinder.js';
35
36
  // The HOST-owned player (D58): .NET holds the lifecycle, the page's element is the display and the sound.
36
37
  export { useMediaPlayer, MEDIA_PLAYER_MODULE, MEDIA_PLAYER_REPORT, MEDIA_PLAYER_STATUS, MediaPlayerCommands, MediaConversionEvents, MediaConversionErrorCodes, } from './mediaPlayer.js';
38
+ // The SHELL-owned picture — the same player's second surface, for what the element cannot decode.
39
+ export { useMediaSurface, MediaSurfaceCommands, } from './mediaSurface.js';
40
+ // Driving and reading that same player, for when the shell owns the picture and the page owns the controls.
41
+ export { useMediaTransport, } from './mediaTransport.js';
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
3
3
  * surface, and it must not become so by accident.
4
+ *
5
+ * ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
6
+ * `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
4
7
  */
5
8
  /** A debounced void callback with a `cancel` for effect teardown. */
6
9
  export interface Debounced {
package/dist/internal.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Shared internals for `@shenora/react`. NOT exported from the barrel — nothing here is public
3
3
  * surface, and it must not become so by accident.
4
+ *
5
+ * ⚠ No React here: the bridge imports this module, and the bridge must load without React (a page importing
6
+ * `bridge.js` directly, a worker, a test). React-only internals live in `internalHooks.ts`.
4
7
  */
5
8
  /**
6
9
  * Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
@@ -0,0 +1,12 @@
1
+ /**
2
+ * React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
3
+ * imports. NOT exported from the barrel.
4
+ */
5
+ import { type RefObject } from 'react';
6
+ /**
7
+ * A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
8
+ * is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
9
+ * hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
10
+ * NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
11
+ */
12
+ export declare function useRefElement<T extends Element>(ref: RefObject<T | null>): T | null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * React-only internals for `@shenora/react`'s hooks, apart from `internal.ts`, which the React-free bridge
3
+ * imports. NOT exported from the barrel.
4
+ */
5
+ import { useEffect, useState } from 'react';
6
+ /**
7
+ * A ref's CONTENT, as state. A ref is a stable object, so an effect keyed on it runs once, and a target that
8
+ * is not there on that run (rendered conditionally, or attached after the first commit) is never seen: the
9
+ * hook is silently dead for the component's whole life. A ref mutation triggers no render, so this effect has
10
+ * NO dependency array; setting an unchanged value is a React no-op, so it cannot loop.
11
+ */
12
+ export function useRefElement(ref) {
13
+ const [element, setElement] = useState(null);
14
+ useEffect(() => {
15
+ setElement(ref.current ?? null);
16
+ });
17
+ return element;
18
+ }
@@ -0,0 +1,62 @@
1
+ import { type RefObject } from 'react';
2
+ import { type ShenoraBridge } from './bridge.js';
3
+ /**
4
+ * The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
5
+ * `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
6
+ */
7
+ export declare const MediaSurfaceCommands: {
8
+ /** `{ x, y, width, height, onTop? }` in CSS pixels. */
9
+ readonly show: "SURFACE_SHOW";
10
+ /** No payload. */
11
+ readonly hide: "SURFACE_HIDE";
12
+ };
13
+ /** Inputs for {@link useMediaSurface}. */
14
+ export interface UseMediaSurfaceOptions {
15
+ /**
16
+ * Draw the picture ABOVE the page instead of behind it. Default `false`, which is what lets you paint
17
+ * captions and controls over it.
18
+ */
19
+ onTop?: boolean;
20
+ /**
21
+ * Stop measuring and hide the picture. Use it to turn the surface off without unmounting — flipping this
22
+ * to `false` sends one `hide`.
23
+ */
24
+ enabled?: boolean;
25
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
26
+ module?: string;
27
+ /** Test seam. */
28
+ bridge?: ShenoraBridge;
29
+ }
30
+ /**
31
+ * Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
32
+ * player fills it from underneath (D58's second surface).
33
+ *
34
+ * ```tsx
35
+ * const stage = useRef<HTMLDivElement>(null);
36
+ * useMediaSurface(stage);
37
+ * return <div ref={stage} style={{ background: 'transparent' }} />;
38
+ * ```
39
+ *
40
+ * **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
41
+ * player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
42
+ * both are the same `IMediaPlayer` underneath.
43
+ *
44
+ * 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
45
+ * BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
46
+ * failure looks exactly like a player that never started, so check the backgrounds before the player.
47
+ * ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
48
+ * still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
49
+ *
50
+ * ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
51
+ * a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
52
+ * source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
53
+ * is `false` for the whole session on a page that mounts before the host answers.
54
+ *
55
+ * ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
56
+ * `devicePixelRatio`.
57
+ *
58
+ * ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
59
+ * the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
60
+ * "hide" rather than drawing a dot at the origin.
61
+ */
62
+ export declare function useMediaSurface(ref: RefObject<HTMLElement | null>, options?: UseMediaSurfaceOptions): void;
@@ -0,0 +1,133 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { useRefElement } from './internalHooks.js';
4
+ import { MEDIA_PLAYER_MODULE } from './mediaPlayer.js';
5
+ /**
6
+ * The routes that move the SHELL's picture. **A wire contract**: these strings are duplicated in C# as
7
+ * `MediaPlayerModule.SurfaceShowType` / `SurfaceHideType`, and the two halves agree by string or not at all.
8
+ */
9
+ export const MediaSurfaceCommands = {
10
+ /** `{ x, y, width, height, onTop? }` in CSS pixels. */
11
+ show: 'SURFACE_SHOW',
12
+ /** No payload. */
13
+ hide: 'SURFACE_HIDE',
14
+ };
15
+ /**
16
+ * Tell the shell where to draw the picture: this element's rectangle becomes a hole, and the host's own
17
+ * player fills it from underneath (D58's second surface).
18
+ *
19
+ * ```tsx
20
+ * const stage = useRef<HTMLDivElement>(null);
21
+ * useMediaSurface(stage);
22
+ * return <div ref={stage} style={{ background: 'transparent' }} />;
23
+ * ```
24
+ *
25
+ * **Use this instead of {@link useMediaPlayer} when the webview cannot decode the file** — the shell's
26
+ * player opens what a `<video>` element refuses. Everywhere else the element is the better answer, and
27
+ * both are the same `IMediaPlayer` underneath.
28
+ *
29
+ * 🔴 **The element must be genuinely TRANSPARENT, and so must everything behind it.** The picture is drawn
30
+ * BELOW the webview, so any opaque ancestor — most often a `body` background — hides it completely. That
31
+ * failure looks exactly like a player that never started, so check the backgrounds before the player.
32
+ * ⚠ **A transparent `body` is necessary and not sufficient for a full-bleed stage**: your own content is
33
+ * still painting over the picture. Hide it for the duration — `docs/guides/media.md` has the two rules.
34
+ *
35
+ * ⚠ **Gate it on the `mediaSurface` capability**, by rendering this component only on a shell that has one:
36
+ * a host without a surface answers every post with `MEDIA_SURFACE_UNAVAILABLE`. Read the capability from a
37
+ * source that RE-RENDERS when the handshake lands — a synchronous cache read taken during the first render
38
+ * is `false` for the whole session on a page that mounts before the host answers.
39
+ *
40
+ * ⚠ **The rectangle is in CSS pixels and crosses to the shell unconverted** — do not scale it by
41
+ * `devicePixelRatio`.
42
+ *
43
+ * ⚠ **It follows scroll and resize, coalesced to one post per animation frame**, and posts nothing when
44
+ * the rectangle has not moved. A collapsed or unmounted element measures zero, which the host reads as
45
+ * "hide" rather than drawing a dot at the origin.
46
+ */
47
+ export function useMediaSurface(ref, options = {}) {
48
+ const { onTop = false, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
49
+ // The ref's CONTENT: a stage rendered after the first commit (conditionally, say) would otherwise never bind.
50
+ const element = useRefElement(ref);
51
+ // Whether the shell is drawing for this hook now, so a stage that is not there yet posts no hide.
52
+ const shown = useRef(false);
53
+ useEffect(() => {
54
+ /* 🔴 NOTHING HERE MAY THROW AT THE PAGE, and the call sites are why.
55
+ *
56
+ * These run from a scroll handler, a ResizeObserver and an effect CLEANUP — three places where an
57
+ * exception is not a caught error but a broken render or a leaked observer. And the throw is reachable:
58
+ * a page rendering the same component in a browser has no host at all, which is exactly the fallback
59
+ * case the capability check is supposed to make safe.
60
+ */
61
+ const post = (type, payload) => {
62
+ try {
63
+ (bridge ?? getBridge()).post(module, type, payload ? { payload } : {});
64
+ }
65
+ catch { /* a picture is never worth taking the page down for */ }
66
+ };
67
+ const hide = () => {
68
+ if (!shown.current)
69
+ return;
70
+ shown.current = false;
71
+ post(MediaSurfaceCommands.hide);
72
+ };
73
+ if (!element || !enabled) {
74
+ // Not a no-op once shown: turning the surface off has to reach the shell, or the picture stays where it was.
75
+ hide();
76
+ return;
77
+ }
78
+ let frame = 0;
79
+ let pending = false;
80
+ let last = '';
81
+ const measure = () => {
82
+ pending = false;
83
+ const box = element.getBoundingClientRect();
84
+ // Rounded before the comparison, not after: sub-pixel jitter during a scroll otherwise makes every
85
+ // frame look like a move and posts one message per frame forever.
86
+ const payload = {
87
+ x: Math.round(box.left),
88
+ y: Math.round(box.top),
89
+ width: Math.round(box.width),
90
+ height: Math.round(box.height),
91
+ onTop,
92
+ };
93
+ // ⚠ `onTop` is part of the dedupe key, or a surface that changes ONLY its z-order is never told.
94
+ const key = JSON.stringify(payload);
95
+ if (key === last)
96
+ return;
97
+ last = key;
98
+ shown.current = true;
99
+ post(MediaSurfaceCommands.show, payload);
100
+ };
101
+ // Coalesced to a frame: scroll fires far more often than the compositor draws, and every post is a
102
+ // trip across the bridge.
103
+ //
104
+ // ⚠ The guard is a FLAG, not the frame id. Clearing `frame` inside the callback only works while the
105
+ // callback is asynchronous — and the id is assigned AFTER it returns — so a synchronous frame leaves
106
+ // the id set for good and the surface never moves again.
107
+ const schedule = () => {
108
+ if (pending)
109
+ return;
110
+ pending = true;
111
+ frame = requestAnimationFrame(measure);
112
+ };
113
+ measure();
114
+ // `capture: true` is what makes a NESTED scroller count — a scroll inside a div does not bubble, and
115
+ // without it the picture stays behind whenever the stage is inside its own scroll area.
116
+ window.addEventListener('scroll', schedule, { capture: true, passive: true });
117
+ window.addEventListener('resize', schedule, { passive: true });
118
+ // Size and layout changes that no scroll or resize event reports: a sibling collapsing, a font
119
+ // loading, the element itself being animated.
120
+ const observer = new ResizeObserver(schedule);
121
+ observer.observe(element);
122
+ return () => {
123
+ if (frame !== 0)
124
+ cancelAnimationFrame(frame);
125
+ window.removeEventListener('scroll', schedule, { capture: true });
126
+ window.removeEventListener('resize', schedule);
127
+ observer.disconnect();
128
+ // 🔴 The picture is the SHELL's and outlives this component. Without this, unmounting the stage
129
+ // leaves a native rectangle painted over whatever the page navigates to next.
130
+ hide();
131
+ };
132
+ }, [element, onTop, enabled, module, bridge]);
133
+ }
@@ -0,0 +1,92 @@
1
+ import { type ShenoraBridge } from './bridge.js';
2
+ import { type MediaPlayerReportState } from './mediaPlayer.js';
3
+ /**
4
+ * What the HOST's player is doing. The same vocabulary `MediaPlayerStatus` uses on the other side, with
5
+ * seconds where C# has `TimeSpan`.
6
+ */
7
+ export interface MediaTransportStatus {
8
+ state: MediaPlayerReportState;
9
+ /** Seconds. */
10
+ position: number;
11
+ /** Seconds, or null for a live stream and — briefly — while opening. */
12
+ duration: number | null;
13
+ /** The rate ASKED FOR; a platform may clamp it. */
14
+ rate: number;
15
+ /** A short reason when `state` is `Failed`; never the platform's raw text. */
16
+ error?: string | null;
17
+ /**
18
+ * Which player produced this reading — `AndroidMediaPlayer`, `IosMediaPlayer`, or an app's own.
19
+ *
20
+ * 🔴 **Log it before forming a theory about playback.** With a pluggable player behind the shell's
21
+ * surface, "which decoder ran" is not answerable from anywhere else, and assuming it wrong is the
22
+ * cheapest way to debug the wrong code.
23
+ *
24
+ * ⚠ **A diagnostic, not a branch** — it names an implementation, so anything conditional on it is
25
+ * coupled to a class name. Branch on the shell's capabilities instead.
26
+ */
27
+ engine?: string | null;
28
+ }
29
+ /** Inputs for {@link useMediaTransport}. */
30
+ export interface UseMediaTransportOptions {
31
+ /** How often to sample the position, in ms. Default 250 — the rate a scrubber redraws at. */
32
+ intervalMs?: number;
33
+ /**
34
+ * Poll at all. Default `true`. Set it `false` while no player surface is on screen: a paused player's
35
+ * position does not move, and the host answers every ask.
36
+ */
37
+ enabled?: boolean;
38
+ /** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
39
+ module?: string;
40
+ /** Test seam. */
41
+ bridge?: ShenoraBridge;
42
+ }
43
+ /** What {@link useMediaTransport} gives you. */
44
+ export interface MediaTransport {
45
+ /** The most recent trustworthy reading, or `null` before the first one lands. */
46
+ status: MediaTransportStatus | null;
47
+ /**
48
+ * The host has stopped answering: eight asks in a row failed. About 2 s at the default interval when the
49
+ * host refuses them, about 10 s when it has gone quiet (each ask waits up to a second).
50
+ *
51
+ * 🔴 **This exists because a dead poll has NO symptom of its own.** The callback simply stops running:
52
+ * the scrubber keeps its last value, the play button keeps whatever the last press set, and nothing
53
+ * anywhere says the transport is gone. Show it, or at least log it — the alternative is diagnosing it
54
+ * from an ABSENCE, which is the hardest evidence there is to notice.
55
+ */
56
+ unanswered: boolean;
57
+ /** Point the host's player at a source and prepare it. Does not start playback. */
58
+ load(uri: string): Promise<void>;
59
+ play(): Promise<void>;
60
+ pause(): Promise<void>;
61
+ /** Move to an absolute position, in seconds. */
62
+ seek(position: number): Promise<void>;
63
+ /** Set the speed multiplier; 1 is normal. */
64
+ setRate(rate: number): Promise<void>;
65
+ /** Release the source and free the decoder. */
66
+ unload(): Promise<void>;
67
+ }
68
+ /**
69
+ * Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
70
+ * the shell owns the picture and the page owns the controls.
71
+ *
72
+ * ```tsx
73
+ * const { status, unanswered, play, pause, seek } = useMediaTransport();
74
+ * ```
75
+ *
76
+ * **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
77
+ * is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
78
+ * clocks that disagree.
79
+ *
80
+ * 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
81
+ * command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
82
+ * it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
83
+ * the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
84
+ *
85
+ * ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
86
+ * so a press updates the UI without waiting up to `intervalMs` for the next sample.
87
+ *
88
+ * ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
89
+ * over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
90
+ * Watch {@link MediaTransport.unanswered} for the case that matters.
91
+ */
92
+ export declare function useMediaTransport(options?: UseMediaTransportOptions): MediaTransport;
@@ -0,0 +1,135 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getBridge } from './bridge.js';
3
+ import { MEDIA_PLAYER_MODULE, MEDIA_PLAYER_STATUS, MediaPlayerCommands, } from './mediaPlayer.js';
4
+ /**
5
+ * Consecutive failed asks before {@link MediaTransport.unanswered} goes true.
6
+ *
7
+ * ⚠ Counted in TICKS rather than elapsed ms so the threshold means the same thing however fast a caller
8
+ * samples: ONE dropped reply is ordinary, eight in a row is a transport that has gone.
9
+ */
10
+ const UNANSWERED_AFTER_TICKS = 8;
11
+ /**
12
+ * How long one status ask waits: four intervals, and never under a second. Without its own bound an ask
13
+ * waits the bridge's default of 30 s, and eight of them made a host that went quiet take four minutes to
14
+ * notice.
15
+ */
16
+ const askTimeoutMs = (intervalMs) => Math.max(1000, intervalMs * 4);
17
+ /**
18
+ * Drive the HOST's player and read what it is doing — the companion to {@link useMediaSurface}, for when
19
+ * the shell owns the picture and the page owns the controls.
20
+ *
21
+ * ```tsx
22
+ * const { status, unanswered, play, pause, seek } = useMediaTransport();
23
+ * ```
24
+ *
25
+ * **Use it when the shell is the player.** With the picture on the shell's surface the page's own element
26
+ * is not playing, so its `timeupdate` says nothing and the host is the only clock. Running both is two
27
+ * clocks that disagree.
28
+ *
29
+ * 🔴 **THE COMMANDS ARE HERE, AND NOT BY CONVENIENCE.** A status answer that was asked for BEFORE a
30
+ * command is stale in every field, not just in `state` — so this hook drops it, which it can only do if
31
+ * it knows when a command happened. Calling the routes directly instead re-opens exactly that hole:
32
+ * the poll returns the pre-command reading, the UI flips back to it, and the next poll flips it again.
33
+ *
34
+ * ⚠ **A command's own answer is applied at once.** The host's drive routes return the resulting status,
35
+ * so a press updates the UI without waiting up to `intervalMs` for the next sample.
36
+ *
37
+ * ⚠ **Failures are swallowed, deliberately.** A poll that cannot answer is not worth breaking playback
38
+ * over, and a refused command resolves quietly: it is the host's answer, not a reason to throw at the page.
39
+ * Watch {@link MediaTransport.unanswered} for the case that matters.
40
+ */
41
+ export function useMediaTransport(options = {}) {
42
+ const { intervalMs = 250, enabled = true, module = MEDIA_PLAYER_MODULE, bridge } = options;
43
+ const [status, setStatus] = useState(null);
44
+ const [unanswered, setUnanswered] = useState(false);
45
+ /**
46
+ * How many commands have been issued. A reading asked for while this was lower describes a player that
47
+ * has since been told to do something else.
48
+ *
49
+ * ⚠ A ref, not state: bumping it must not re-render, and every reader needs the value as of NOW rather
50
+ * than as of the render it closed over.
51
+ */
52
+ const commands = useRef(0);
53
+ const live = useRef(true);
54
+ useEffect(() => {
55
+ live.current = true;
56
+ return () => { live.current = false; };
57
+ }, []);
58
+ const send = useCallback(async (type, payload) => {
59
+ commands.current += 1;
60
+ try {
61
+ const answer = await (bridge ?? getBridge())
62
+ .invoke(module, type, payload ? { payload } : {});
63
+ // The host answers a drive command with the status it produced — apply it rather than waiting for
64
+ // the next sample. ⚠ Only if this component is still mounted; a resolve after unmount is ordinary.
65
+ if (live.current && answer)
66
+ setStatus(normalise(answer));
67
+ }
68
+ catch {
69
+ /* A refused command is the host's answer, not a reason to throw at the page. */
70
+ }
71
+ }, [module, bridge]);
72
+ useEffect(() => {
73
+ if (!enabled)
74
+ return;
75
+ let stopped = false;
76
+ let timer;
77
+ let misses = 0;
78
+ const tick = async () => {
79
+ if (stopped)
80
+ return;
81
+ // Read BEFORE the ask goes out: everything after this point is a command the answer cannot have seen.
82
+ const asked = commands.current;
83
+ let answer = null;
84
+ try {
85
+ answer = await (bridge ?? getBridge())
86
+ .invoke(module, MEDIA_PLAYER_STATUS, { timeoutMs: askTimeoutMs(intervalMs) });
87
+ }
88
+ catch {
89
+ answer = null;
90
+ }
91
+ if (stopped)
92
+ return;
93
+ if (answer) {
94
+ misses = 0;
95
+ setUnanswered(false);
96
+ // 🔴 DROPPED, not reported-with-a-caveat: a pre-command reading is stale in position as well as
97
+ // state, so there is nothing in it a caller could safely keep.
98
+ if (commands.current === asked)
99
+ setStatus(normalise(answer));
100
+ }
101
+ else if (++misses >= UNANSWERED_AFTER_TICKS) {
102
+ setUnanswered(true);
103
+ }
104
+ // ⚠ Scheduled from the ANSWER, never on a bare interval: a slow host stretches the gap instead of
105
+ // queueing asks behind each other.
106
+ timer = setTimeout(() => { void tick(); }, intervalMs);
107
+ };
108
+ void tick();
109
+ return () => { stopped = true; if (timer)
110
+ clearTimeout(timer); };
111
+ }, [enabled, intervalMs, module, bridge]);
112
+ const load = useCallback((uri) => send(MediaPlayerCommands.load, { uri }), [send]);
113
+ const play = useCallback(() => send(MediaPlayerCommands.play), [send]);
114
+ const pause = useCallback(() => send(MediaPlayerCommands.pause), [send]);
115
+ const seek = useCallback((position) => send(MediaPlayerCommands.seek, { position }), [send]);
116
+ const setRate = useCallback((rate) => send(MediaPlayerCommands.rate, { rate }), [send]);
117
+ const unload = useCallback(() => send(MediaPlayerCommands.unload), [send]);
118
+ return { status, unanswered, load, play, pause, seek, setRate, unload };
119
+ }
120
+ /**
121
+ * A host answer, with every field given a safe shape.
122
+ *
123
+ * ⚠ `duration` is null for a live stream and while opening, and a UI that reads a missing one as 0 puts
124
+ * the playhead at the end of something that has just started.
125
+ */
126
+ function normalise(answer) {
127
+ return {
128
+ state: (answer.state ?? 'Empty'),
129
+ position: Number.isFinite(answer.position) ? Number(answer.position) : 0,
130
+ duration: Number.isFinite(answer.duration) ? Number(answer.duration) : null,
131
+ rate: Number.isFinite(answer.rate) ? Number(answer.rate) : 1,
132
+ error: answer.error ?? null,
133
+ engine: answer.engine ?? null,
134
+ };
135
+ }
@@ -1,3 +1,18 @@
1
+ /**
2
+ * The Chromium shell's marker. The shell writes it into every HTML document it serves, so its presence
3
+ * IS the host advertising itself (D36, D83), never a guess about the engine. The page posts to `ipc`, a
4
+ * same-origin route the shell answers; the shell pushes by calling `receive`, which the transport sets.
5
+ */
6
+ export interface ChromiumHost {
7
+ ipc: string;
8
+ receive?: ChromiumReceive;
9
+ }
10
+ /** A named type, not inline: WireMirrorTests reads the interface's fields and would count a parameter. */
11
+ type ChromiumReceive = (message: unknown) => void;
12
+ /** The global the Chromium shell marks a document with. Mirrored by `ChromiumTransport.HostGlobal`. */
13
+ export declare const CHROMIUM_HOST_GLOBAL = "__shenora_chromium";
14
+ /** What the Chromium shell calls to push a message. Mirrored by `ChromiumTransport.ReceiveMember`. */
15
+ export declare const CHROMIUM_RECEIVE = "receive";
1
16
  /**
2
17
  * A message channel the bridge speaks over — the transport-pluggable seam (design D16): WebView2
3
18
  * postMessage on desktop today; a WebSocket or a mobile shell's native channel speaks the same
@@ -10,8 +25,8 @@ export interface ShenoraTransport {
10
25
  subscribe(listener: (message: string) => void): () => void;
11
26
  }
12
27
  /**
13
- * True when running inside ANY Shenora host — a WebView2 desktop shell or a MAUI `HybridWebView` — i.e.
14
- * when a transport to the host exists. In a plain browser this is false and callers should fall back to
28
+ * True when running inside ANY Shenora host — a WebView2 desktop shell, the Chromium shell or a
29
+ * `ChromiumView`, or a MAUI `HybridWebView` — i.e. when a transport to the host exists. In a plain browser this is false and callers should fall back to
15
30
  * browser-only behavior. The question is "is there a host", never "is it WebView2".
16
31
  */
17
32
  export declare function isShenoraAvailable(): boolean;
@@ -26,10 +41,17 @@ export declare function createWebView2Transport(): ShenoraTransport | null;
26
41
  * `Shenora.Maui.MauiIpcBridge`.
27
42
  */
28
43
  export declare function createHybridWebViewTransport(): ShenoraTransport | null;
44
+ /**
45
+ * The Chromium shell's transport, or null outside it. There is no code in the renderer: the page posts
46
+ * each envelope with `fetch` to the marker's same-origin `ipc` route, which the shell answers from its
47
+ * resource handler, and the shell pushes by calling the marker's `receive`.
48
+ */
49
+ export declare function createChromiumTransport(): ShenoraTransport | null;
29
50
  /**
30
51
  * The transport for whichever Shenora host this page is running in, or null in a plain browser.
31
52
  * This is what the bridge uses by default, so an app that simply calls `invoke`/`post` works on the
32
- * desktop shell and the MAUI shell without knowing which one it is. A page is only ever in one of
33
- * them; WebView2 wins if both objects are somehow present.
53
+ * desktop shell, the MAUI shell and the Chromium shell without knowing which one it is. A page is only
54
+ * ever in one of them; the order decides only if several objects are somehow present.
34
55
  */
35
56
  export declare function createHostTransport(): ShenoraTransport | null;
57
+ export {};