@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
package/dist/internal.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
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
|
-
* These lived as per-file copies until a second consumer appeared for each (P5.5 H2): the debounce
|
|
6
|
-
* helper was private to `useDropZone` and `useWindowMaximized` needed the same thing, and the
|
|
7
|
-
* `randomUUID`-with-fallback pair had drifted into two spellings. H4.5 deliberately left both alone
|
|
8
|
-
* at the time, on the grounds that the package had no shared-internals home and inventing one for a
|
|
9
|
-
* single consumer is speculation — this file exists now because the need is real.
|
|
10
4
|
*/
|
|
11
5
|
/**
|
|
12
6
|
* Trailing-edge debounce: the callback runs `ms` after the LAST call. `cancel` must be called from a
|
|
@@ -22,8 +16,8 @@ export function debounce(fn, ms) {
|
|
|
22
16
|
return wrapped;
|
|
23
17
|
}
|
|
24
18
|
/**
|
|
25
|
-
* A unique id, optionally prefixed. Correlation ids and zone ids
|
|
26
|
-
*
|
|
19
|
+
* A unique id, optionally prefixed. Correlation ids and zone ids need uniqueness, not entropy, so the
|
|
20
|
+
* non-`crypto` fallback for a non-secure context is fine.
|
|
27
21
|
*/
|
|
28
22
|
export function randomId(prefix) {
|
|
29
23
|
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
package/dist/media.d.ts
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Building the URL that lets a page render LOCAL content — media, images, documents, exports — that it
|
|
3
|
-
* cannot reach directly.
|
|
4
|
-
*
|
|
5
|
-
* The host answers these through its resource interceptor; this module only builds the address, which is
|
|
6
|
-
* why it is a pure function and not a hook. A hook (`useMediaSource`) can follow if an adopter wants
|
|
7
|
-
* load/error state, but nothing needs one to start.
|
|
3
|
+
* cannot reach directly. The host answers these through its resource interceptor; this module only
|
|
4
|
+
* builds the address, which is why it is a pure function and not a hook.
|
|
8
5
|
*/
|
|
9
6
|
/**
|
|
10
7
|
* Build a URL the host's resource interceptor will answer: `<route>?<base64url of JSON>`.
|
|
11
8
|
*
|
|
12
|
-
* ⚠ **The result is RELATIVE, and that is
|
|
9
|
+
* ⚠ **The result is RELATIVE, and that is load-bearing.** Written as a path rather than with a scheme, it
|
|
13
10
|
* resolves against whatever origin the page is already served from — which means the browser hands the host
|
|
14
11
|
* a URL each platform can actually decode:
|
|
15
12
|
*
|
|
@@ -19,19 +16,16 @@
|
|
|
19
16
|
* | Android | `https://0.0.0.1/media?…` — Android's media pipeline REFUSES a non-standard scheme |
|
|
20
17
|
* | desktop | the app's virtual host |
|
|
21
18
|
*
|
|
22
|
-
*
|
|
23
|
-
* neither: iOS cannot register a handler for `https`, and Android cannot register a scheme
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* The PAYLOAD is opaque to this package: whatever you pass is JSON-encoded and handed to your own host-side
|
|
27
|
-
* route, which decodes it. That keeps the kit out of your addressing scheme — a filename, an id, a container
|
|
28
|
-
* preference, a cache key, several of them. The kit encodes; you decide what it means.
|
|
19
|
+
* 🔴 **So do not hardcode `app://`.** Either fixed form fails on exactly one platform, and registering the
|
|
20
|
+
* scheme rescues neither: iOS cannot register a handler for `https`, and Android cannot register a scheme
|
|
21
|
+
* at all.
|
|
29
22
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
23
|
+
* The payload is opaque to this package — JSON-encoded and handed to your own host-side route, which
|
|
24
|
+
* decodes it. base64**url** specifically, so `+`, `/` and `=` cannot be re-interpreted by anything that
|
|
25
|
+
* parses URLs along the way.
|
|
32
26
|
*
|
|
33
|
-
* ⚠ An encoded payload costs debuggability
|
|
34
|
-
*
|
|
27
|
+
* ⚠ An encoded payload costs debuggability: have the HOST log what it decoded to, because an error
|
|
28
|
+
* response body cannot say without leaking paths.
|
|
35
29
|
*
|
|
36
30
|
* @param payload Anything JSON-serialisable. Your host route decides what the shape means.
|
|
37
31
|
* @param route The reserved path the host answers on. **Must not collide with a real asset in your bundle** —
|
|
@@ -52,14 +46,12 @@ export declare function mediaUrl(payload: unknown, route?: string): string;
|
|
|
52
46
|
* The payload encoding on its own, for a caller that builds its own URL but wants the same wire format —
|
|
53
47
|
* and so a host-side decoder has one documented thing to mirror.
|
|
54
48
|
*
|
|
55
|
-
* `TextEncoder` before `btoa
|
|
56
|
-
* non-ASCII title or path would fail at the call site
|
|
49
|
+
* ⚠ `TextEncoder` before `btoa`, which throws on any character above U+00FF — a payload carrying a
|
|
50
|
+
* non-ASCII title or path would fail at the call site.
|
|
57
51
|
*/
|
|
58
52
|
export declare function encodeMediaPayload(payload: unknown): string;
|
|
59
53
|
/**
|
|
60
54
|
* Decode what {@link encodeMediaPayload} produced. Here for tests and for a page that round-trips its own
|
|
61
|
-
* URLs; the real decoder is host-side
|
|
62
|
-
*
|
|
63
|
-
* Padding is restored before decoding — base64url drops `=`, and `atob` requires it.
|
|
55
|
+
* URLs; the real decoder is host-side. Padding is restored first — base64url drops `=`, `atob` needs it.
|
|
64
56
|
*/
|
|
65
57
|
export declare function decodeMediaPayload<T = unknown>(encoded: string): T;
|
package/dist/media.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Building the URL that lets a page render LOCAL content — media, images, documents, exports — that it
|
|
3
|
-
* cannot reach directly.
|
|
4
|
-
*
|
|
5
|
-
* The host answers these through its resource interceptor; this module only builds the address, which is
|
|
6
|
-
* why it is a pure function and not a hook. A hook (`useMediaSource`) can follow if an adopter wants
|
|
7
|
-
* load/error state, but nothing needs one to start.
|
|
3
|
+
* cannot reach directly. The host answers these through its resource interceptor; this module only
|
|
4
|
+
* builds the address, which is why it is a pure function and not a hook.
|
|
8
5
|
*/
|
|
9
6
|
/**
|
|
10
7
|
* Build a URL the host's resource interceptor will answer: `<route>?<base64url of JSON>`.
|
|
11
8
|
*
|
|
12
|
-
* ⚠ **The result is RELATIVE, and that is
|
|
9
|
+
* ⚠ **The result is RELATIVE, and that is load-bearing.** Written as a path rather than with a scheme, it
|
|
13
10
|
* resolves against whatever origin the page is already served from — which means the browser hands the host
|
|
14
11
|
* a URL each platform can actually decode:
|
|
15
12
|
*
|
|
@@ -19,19 +16,16 @@
|
|
|
19
16
|
* | Android | `https://0.0.0.1/media?…` — Android's media pipeline REFUSES a non-standard scheme |
|
|
20
17
|
* | desktop | the app's virtual host |
|
|
21
18
|
*
|
|
22
|
-
*
|
|
23
|
-
* neither: iOS cannot register a handler for `https`, and Android cannot register a scheme
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* The PAYLOAD is opaque to this package: whatever you pass is JSON-encoded and handed to your own host-side
|
|
27
|
-
* route, which decodes it. That keeps the kit out of your addressing scheme — a filename, an id, a container
|
|
28
|
-
* preference, a cache key, several of them. The kit encodes; you decide what it means.
|
|
19
|
+
* 🔴 **So do not hardcode `app://`.** Either fixed form fails on exactly one platform, and registering the
|
|
20
|
+
* scheme rescues neither: iOS cannot register a handler for `https`, and Android cannot register a scheme
|
|
21
|
+
* at all.
|
|
29
22
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
23
|
+
* The payload is opaque to this package — JSON-encoded and handed to your own host-side route, which
|
|
24
|
+
* decodes it. base64**url** specifically, so `+`, `/` and `=` cannot be re-interpreted by anything that
|
|
25
|
+
* parses URLs along the way.
|
|
32
26
|
*
|
|
33
|
-
* ⚠ An encoded payload costs debuggability
|
|
34
|
-
*
|
|
27
|
+
* ⚠ An encoded payload costs debuggability: have the HOST log what it decoded to, because an error
|
|
28
|
+
* response body cannot say without leaking paths.
|
|
35
29
|
*
|
|
36
30
|
* @param payload Anything JSON-serialisable. Your host route decides what the shape means.
|
|
37
31
|
* @param route The reserved path the host answers on. **Must not collide with a real asset in your bundle** —
|
|
@@ -58,8 +52,8 @@ export function mediaUrl(payload, route = 'media') {
|
|
|
58
52
|
* The payload encoding on its own, for a caller that builds its own URL but wants the same wire format —
|
|
59
53
|
* and so a host-side decoder has one documented thing to mirror.
|
|
60
54
|
*
|
|
61
|
-
* `TextEncoder` before `btoa
|
|
62
|
-
* non-ASCII title or path would fail at the call site
|
|
55
|
+
* ⚠ `TextEncoder` before `btoa`, which throws on any character above U+00FF — a payload carrying a
|
|
56
|
+
* non-ASCII title or path would fail at the call site.
|
|
63
57
|
*/
|
|
64
58
|
export function encodeMediaPayload(payload) {
|
|
65
59
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
@@ -70,9 +64,7 @@ export function encodeMediaPayload(payload) {
|
|
|
70
64
|
}
|
|
71
65
|
/**
|
|
72
66
|
* Decode what {@link encodeMediaPayload} produced. Here for tests and for a page that round-trips its own
|
|
73
|
-
* URLs; the real decoder is host-side
|
|
74
|
-
*
|
|
75
|
-
* Padding is restored before decoding — base64url drops `=`, and `atob` requires it.
|
|
67
|
+
* URLs; the real decoder is host-side. Padding is restored first — base64url drops `=`, `atob` needs it.
|
|
76
68
|
*/
|
|
77
69
|
export function decodeMediaPayload(encoded) {
|
|
78
70
|
let padded = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -0,0 +1,103 @@
|
|
|
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, which
|
|
6
|
+
* defaults to the same string — change one and you must change the other.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), so your app stays free to own
|
|
9
|
+
* a module called plainly `MEDIA`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MEDIA_PLAYER_MODULE = "SHENORA.MEDIA";
|
|
12
|
+
/**
|
|
13
|
+
* Commands the host sends. **A wire contract**: these strings are duplicated in C# as
|
|
14
|
+
* `MediaPlayerEvents`, and the two halves agree by string or not at all.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MediaPlayerCommands: {
|
|
17
|
+
readonly load: "PLAYER_LOAD";
|
|
18
|
+
readonly play: "PLAYER_PLAY";
|
|
19
|
+
readonly pause: "PLAYER_PAUSE";
|
|
20
|
+
readonly seek: "PLAYER_SEEK";
|
|
21
|
+
readonly rate: "PLAYER_RATE";
|
|
22
|
+
readonly unload: "PLAYER_UNLOAD";
|
|
23
|
+
};
|
|
24
|
+
/** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
|
|
25
|
+
export declare const MEDIA_PLAYER_REPORT = "PLAYER_REPORT";
|
|
26
|
+
/** What the page asks to read the host player's current status (`MediaPlayerModule.StatusType`). */
|
|
27
|
+
export declare const MEDIA_PLAYER_STATUS = "PLAYER_STATUS";
|
|
28
|
+
/**
|
|
29
|
+
* A CONVERSION's events, on the same module. **A wire contract** mirrored from C#
|
|
30
|
+
* `MediaConversionEvents` — a conversion outlives the request that started it, so the page learns from
|
|
31
|
+
* these rather than from a response.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MediaConversionEvents: {
|
|
34
|
+
/** Fraction complete: `{ source, progress }`. Throttle in the app if the engine is chatty. */
|
|
35
|
+
readonly sourceProgress: "SOURCE_PROGRESS";
|
|
36
|
+
/** The converted file is servable: `{ source }`. Set the element's `src` now. */
|
|
37
|
+
readonly ready: "READY";
|
|
38
|
+
/** Failed: `{ source, reason }`, plus `dropped` when `reason` is {@link MediaConversionErrorCodes}. */
|
|
39
|
+
readonly failed: "FAILED";
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Stable `reason` tokens on {@link MediaConversionEvents.failed}. Anything else is a TYPE name from an
|
|
43
|
+
* unexpected fault — never exception text.
|
|
44
|
+
*/
|
|
45
|
+
export declare const MediaConversionErrorCodes: {
|
|
46
|
+
/**
|
|
47
|
+
* The output would have lost a stream, so nothing was cached; the event carries `dropped`, the codecs.
|
|
48
|
+
* ⚠ It means "not playable HERE", not always "not supported" — a run configured with no conversion
|
|
49
|
+
* seam never asked the platform.
|
|
50
|
+
*/
|
|
51
|
+
readonly unsupportedCodec: "UNSUPPORTED_CODEC";
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* What the element is doing, in the host's vocabulary (`MediaPlayerState`).
|
|
55
|
+
*
|
|
56
|
+
* ⚠ `Opening` and `Buffering` are distinct: opening is "no position yet", buffering is "had one and it
|
|
57
|
+
* stopped moving". Collapsing them makes a UI extrapolate a position that is not advancing.
|
|
58
|
+
*/
|
|
59
|
+
export type MediaPlayerReportState = 'Empty' | 'Opening' | 'Paused' | 'Playing' | 'Buffering' | 'Ended' | 'Failed';
|
|
60
|
+
/** One state report, sent on TRANSITIONS only. */
|
|
61
|
+
export interface MediaPlayerReport {
|
|
62
|
+
state: MediaPlayerReportState;
|
|
63
|
+
/** Seconds. */
|
|
64
|
+
position: number;
|
|
65
|
+
/** Seconds, or null for a live stream / not yet known. */
|
|
66
|
+
duration: number | null;
|
|
67
|
+
/** A short reason when `state` is `Failed`; never the platform's raw text. */
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
70
|
+
/** Inputs for {@link useMediaPlayer}. */
|
|
71
|
+
export interface UseMediaPlayerOptions {
|
|
72
|
+
/** Override the module. Must match the host's `MediaPlayerOptions.Access.Module`. */
|
|
73
|
+
module?: string;
|
|
74
|
+
/** Test seams. */
|
|
75
|
+
bridge?: ShenoraBridge;
|
|
76
|
+
eventBus?: ShenoraEventBus;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
|
|
80
|
+
* and .NET owns the lifecycle (D58).
|
|
81
|
+
*
|
|
82
|
+
* ```tsx
|
|
83
|
+
* const ref = useRef<HTMLVideoElement>(null);
|
|
84
|
+
* useMediaPlayer(ref);
|
|
85
|
+
* return <video ref={ref} playsInline />;
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
|
|
89
|
+
* calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The host half
|
|
90
|
+
* needs nothing from you: the kit's own `MediaPlayerModule` answers the reports posted here.
|
|
91
|
+
*
|
|
92
|
+
* ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
|
|
93
|
+
* object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
|
|
94
|
+
* the effect and never binds — silently. Render it unconditionally and hide it with CSS, or key the
|
|
95
|
+
* component so the hook remounts with it.
|
|
96
|
+
*
|
|
97
|
+
* ⚠ **It reports on TRANSITIONS, never on `timeupdate`**, which fires ~4×/second. For a moving scrubber,
|
|
98
|
+
* read `element.currentTime` in your own render loop.
|
|
99
|
+
*
|
|
100
|
+
* ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
|
|
101
|
+
* the browser, and the element then reports `Failed` rather than the host believing playback started.
|
|
102
|
+
*/
|
|
103
|
+
export declare function useMediaPlayer(ref: RefObject<HTMLMediaElement | null>, options?: UseMediaPlayerOptions): void;
|
|
@@ -0,0 +1,202 @@
|
|
|
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, which
|
|
6
|
+
* defaults to the same string — change one and you must change the other.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), so your app stays free to own
|
|
9
|
+
* a module called plainly `MEDIA`.
|
|
10
|
+
*/
|
|
11
|
+
export const MEDIA_PLAYER_MODULE = 'SHENORA.MEDIA';
|
|
12
|
+
/**
|
|
13
|
+
* Commands the host sends. **A wire contract**: these strings are duplicated in C# as
|
|
14
|
+
* `MediaPlayerEvents`, and the two halves agree by string or not at all.
|
|
15
|
+
*/
|
|
16
|
+
export const MediaPlayerCommands = {
|
|
17
|
+
load: 'PLAYER_LOAD',
|
|
18
|
+
play: 'PLAYER_PLAY',
|
|
19
|
+
pause: 'PLAYER_PAUSE',
|
|
20
|
+
seek: 'PLAYER_SEEK',
|
|
21
|
+
rate: 'PLAYER_RATE',
|
|
22
|
+
unload: 'PLAYER_UNLOAD',
|
|
23
|
+
};
|
|
24
|
+
/** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
|
|
25
|
+
export const MEDIA_PLAYER_REPORT = 'PLAYER_REPORT';
|
|
26
|
+
/** What the page asks to read the host player's current status (`MediaPlayerModule.StatusType`). */
|
|
27
|
+
export const MEDIA_PLAYER_STATUS = 'PLAYER_STATUS';
|
|
28
|
+
/**
|
|
29
|
+
* A CONVERSION's events, on the same module. **A wire contract** mirrored from C#
|
|
30
|
+
* `MediaConversionEvents` — a conversion outlives the request that started it, so the page learns from
|
|
31
|
+
* these rather than from a response.
|
|
32
|
+
*/
|
|
33
|
+
export const MediaConversionEvents = {
|
|
34
|
+
/** Fraction complete: `{ source, progress }`. Throttle in the app if the engine is chatty. */
|
|
35
|
+
sourceProgress: 'SOURCE_PROGRESS',
|
|
36
|
+
/** The converted file is servable: `{ source }`. Set the element's `src` now. */
|
|
37
|
+
ready: 'READY',
|
|
38
|
+
/** Failed: `{ source, reason }`, plus `dropped` when `reason` is {@link MediaConversionErrorCodes}. */
|
|
39
|
+
failed: 'FAILED',
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Stable `reason` tokens on {@link MediaConversionEvents.failed}. Anything else is a TYPE name from an
|
|
43
|
+
* unexpected fault — never exception text.
|
|
44
|
+
*/
|
|
45
|
+
export const MediaConversionErrorCodes = {
|
|
46
|
+
/**
|
|
47
|
+
* The output would have lost a stream, so nothing was cached; the event carries `dropped`, the codecs.
|
|
48
|
+
* ⚠ It means "not playable HERE", not always "not supported" — a run configured with no conversion
|
|
49
|
+
* seam never asked the platform.
|
|
50
|
+
*/
|
|
51
|
+
unsupportedCodec: 'UNSUPPORTED_CODEC',
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
|
|
55
|
+
* and .NET owns the lifecycle (D58).
|
|
56
|
+
*
|
|
57
|
+
* ```tsx
|
|
58
|
+
* const ref = useRef<HTMLVideoElement>(null);
|
|
59
|
+
* useMediaPlayer(ref);
|
|
60
|
+
* return <video ref={ref} playsInline />;
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
|
|
64
|
+
* calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The host half
|
|
65
|
+
* needs nothing from you: the kit's own `MediaPlayerModule` answers the reports posted here.
|
|
66
|
+
*
|
|
67
|
+
* ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
|
|
68
|
+
* object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
|
|
69
|
+
* the effect and never binds — silently. Render it unconditionally and hide it with CSS, or key the
|
|
70
|
+
* component so the hook remounts with it.
|
|
71
|
+
*
|
|
72
|
+
* ⚠ **It reports on TRANSITIONS, never on `timeupdate`**, which fires ~4×/second. For a moving scrubber,
|
|
73
|
+
* read `element.currentTime` in your own render loop.
|
|
74
|
+
*
|
|
75
|
+
* ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
|
|
76
|
+
* the browser, and the element then reports `Failed` rather than the host believing playback started.
|
|
77
|
+
*/
|
|
78
|
+
export function useMediaPlayer(ref, options = {}) {
|
|
79
|
+
const { module = MEDIA_PLAYER_MODULE, bridge, eventBus = defaultEventBus } = options;
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const element = ref.current;
|
|
82
|
+
if (!element)
|
|
83
|
+
return;
|
|
84
|
+
const link = bridge ?? getBridge();
|
|
85
|
+
// The element is the only clock: the host tracks no position of its own.
|
|
86
|
+
const report = (state, error) => {
|
|
87
|
+
const duration = Number.isFinite(element.duration) ? element.duration : null;
|
|
88
|
+
const payload = {
|
|
89
|
+
state,
|
|
90
|
+
position: Number.isFinite(element.currentTime) ? element.currentTime : 0,
|
|
91
|
+
duration,
|
|
92
|
+
...(error ? { error } : {}),
|
|
93
|
+
};
|
|
94
|
+
link.post(module, MEDIA_PLAYER_REPORT, { payload });
|
|
95
|
+
};
|
|
96
|
+
// The PENDING start-at seek, if any. 🔴 It has to be cancellable: `{ once: true }` removes a
|
|
97
|
+
// listener only when it FIRES, and a second PLAYER_LOAD aborts the first load so its
|
|
98
|
+
// `loadedmetadata` never comes. The stale listener then runs on the NEXT track's metadata — load A
|
|
99
|
+
// at 10:00 then B at 0:00 and B starts ten minutes in.
|
|
100
|
+
let pendingSeek = null;
|
|
101
|
+
const cancelPendingSeek = () => {
|
|
102
|
+
if (pendingSeek)
|
|
103
|
+
element.removeEventListener('loadedmetadata', pendingSeek);
|
|
104
|
+
pendingSeek = null;
|
|
105
|
+
};
|
|
106
|
+
// ── host → element ────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
const subscriptions = [
|
|
108
|
+
eventBus.subscribe(module, MediaPlayerCommands.load, (message) => {
|
|
109
|
+
const { uri, startAt } = message.payload ?? { uri: '', startAt: 0 };
|
|
110
|
+
element.src = uri;
|
|
111
|
+
// load() rather than trusting the src assignment: a second load on the same element keeps the
|
|
112
|
+
// previous buffer otherwise, and a seek then lands in the OLD media.
|
|
113
|
+
element.load();
|
|
114
|
+
cancelPendingSeek(); // this load supersedes any earlier one — see pendingSeek
|
|
115
|
+
if (startAt > 0) {
|
|
116
|
+
const seek = () => { pendingSeek = null; element.currentTime = startAt; };
|
|
117
|
+
// `loadedmetadata` is the earliest point currentTime is settable — before it, the assignment is
|
|
118
|
+
// silently dropped and the item starts at zero.
|
|
119
|
+
pendingSeek = seek;
|
|
120
|
+
element.addEventListener('loadedmetadata', seek, { once: true });
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
123
|
+
eventBus.subscribe(module, MediaPlayerCommands.play, () => {
|
|
124
|
+
// A rejected play() is an autoplay refusal, and the host must hear it rather than assume success.
|
|
125
|
+
// ⚠ Read `.name` STRUCTURALLY, never via `instanceof Error`: browsers reject with a
|
|
126
|
+
// DOMException, which is not an Error subclass everywhere (jsdom's is not). `NotAllowedError`
|
|
127
|
+
// is what an autoplay block says, and the name an adopter branches on.
|
|
128
|
+
void element.play().catch((cause) => {
|
|
129
|
+
const name = cause?.name;
|
|
130
|
+
report('Failed', typeof name === 'string' && name ? name : 'PlayRejected');
|
|
131
|
+
});
|
|
132
|
+
}),
|
|
133
|
+
eventBus.subscribe(module, MediaPlayerCommands.pause, () => element.pause()),
|
|
134
|
+
eventBus.subscribe(module, MediaPlayerCommands.seek, (message) => {
|
|
135
|
+
element.currentTime = message.payload?.position ?? 0;
|
|
136
|
+
}),
|
|
137
|
+
eventBus.subscribe(module, MediaPlayerCommands.rate, (message) => {
|
|
138
|
+
element.playbackRate = message.payload?.rate ?? 1;
|
|
139
|
+
}),
|
|
140
|
+
eventBus.subscribe(module, MediaPlayerCommands.unload, () => {
|
|
141
|
+
element.pause();
|
|
142
|
+
element.removeAttribute('src');
|
|
143
|
+
// ⚠ load() after clearing src is what actually FREES the buffer. Without it the element keeps the
|
|
144
|
+
// decoded data alive, which on a phone is the difference between releasing memory and not.
|
|
145
|
+
element.load();
|
|
146
|
+
report('Empty');
|
|
147
|
+
}),
|
|
148
|
+
];
|
|
149
|
+
// ── element → host ────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
// Transitions only — no `timeupdate`, see the remarks.
|
|
151
|
+
const listeners = [
|
|
152
|
+
['loadedmetadata', () => report('Paused')],
|
|
153
|
+
['canplay', () => report(element.paused ? 'Paused' : 'Playing')],
|
|
154
|
+
['play', () => report('Playing')],
|
|
155
|
+
['playing', () => report('Playing')],
|
|
156
|
+
['pause', () => report(element.ended ? 'Ended' : 'Paused')],
|
|
157
|
+
['waiting', () => report('Buffering')],
|
|
158
|
+
['seeked', () => report(element.paused ? 'Paused' : 'Playing')],
|
|
159
|
+
['ended', () => report('Ended')],
|
|
160
|
+
['error', () => report('Failed', mediaErrorReason(element))],
|
|
161
|
+
];
|
|
162
|
+
for (const [event, handler] of listeners)
|
|
163
|
+
element.addEventListener(event, handler);
|
|
164
|
+
// 🔴 REPORT WHEN THE PAGE IS ABOUT TO BE HIDDEN, or the host's position is whatever the last
|
|
165
|
+
// TRANSITION left — for steady playback, the moment it started. The user then backgrounds mid-film
|
|
166
|
+
// and `BackgroundPlaybackTransfer` resumes from the beginning. The platform's `pause` does fire,
|
|
167
|
+
// but not in time to cross IPC before the process is frozen.
|
|
168
|
+
//
|
|
169
|
+
// ⚠ `visibilitychange` rather than `pagehide`: it fires while the document is still alive and the
|
|
170
|
+
// bridge can still post, and both mobile shells raise it on the way to the background. ONE report
|
|
171
|
+
// per background, not `timeupdate`'s ~4/second.
|
|
172
|
+
const onHidden = () => {
|
|
173
|
+
if (document.visibilityState !== 'hidden')
|
|
174
|
+
return;
|
|
175
|
+
report(element.paused ? (element.ended ? 'Ended' : 'Paused') : 'Playing');
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('visibilitychange', onHidden);
|
|
178
|
+
return () => {
|
|
179
|
+
for (const subscription of subscriptions)
|
|
180
|
+
subscription();
|
|
181
|
+
for (const [event, handler] of listeners)
|
|
182
|
+
element.removeEventListener(event, handler);
|
|
183
|
+
cancelPendingSeek();
|
|
184
|
+
document.removeEventListener('visibilitychange', onHidden);
|
|
185
|
+
};
|
|
186
|
+
}, [ref, module, bridge, eventBus]);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A short, stable reason from `MediaError`.
|
|
190
|
+
*
|
|
191
|
+
* ⚠ Never `error.message`: browsers put decoder internals and sometimes the full URL in it, and this
|
|
192
|
+
* string crosses to the host and can reach a log.
|
|
193
|
+
*/
|
|
194
|
+
function mediaErrorReason(element) {
|
|
195
|
+
switch (element.error?.code) {
|
|
196
|
+
case 1: return 'Aborted';
|
|
197
|
+
case 2: return 'Network';
|
|
198
|
+
case 3: return 'Decode';
|
|
199
|
+
case 4: return 'SourceNotSupported';
|
|
200
|
+
default: return 'Unknown';
|
|
201
|
+
}
|
|
202
|
+
}
|
package/dist/moduleService.d.ts
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
import { type ShenoraBridge } from './bridge.js';
|
|
2
2
|
/**
|
|
3
|
-
* Base class for typed module services
|
|
4
|
-
* module
|
|
5
|
-
*
|
|
6
|
-
* (`{ [type]: payloadType }`) for compile-time payload checking:
|
|
3
|
+
* Base class for typed module services: each backend module gets one service subclass that binds the
|
|
4
|
+
* module name once and exposes app-typed methods over {@link send}. Bind `TRequests` to the module's
|
|
5
|
+
* request map (`{ [type]: payloadType }`) for compile-time payload checking:
|
|
7
6
|
*
|
|
8
7
|
* ```ts
|
|
9
8
|
* interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
10
9
|
* class NoteService extends BaseModuleService<NoteRequests> {
|
|
11
10
|
* constructor() { super('NOTES'); }
|
|
12
|
-
* getAll() { return this.send
|
|
13
|
-
* add(title: string) { return this.send
|
|
11
|
+
* getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
|
|
12
|
+
* add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
|
|
14
13
|
* }
|
|
15
14
|
* ```
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
|
|
17
|
+
* inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
|
|
18
|
+
* key — and `payload` collapses to the union of every route's payload. The check silently stops
|
|
19
|
+
* checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
|
|
20
|
+
* without the type argument is a TS2353. The response is inferred from the declared return type instead.
|
|
19
21
|
*
|
|
20
|
-
* `TRequests extends object`,
|
|
21
|
-
* unsatisfiable by a plain `interface`
|
|
22
|
-
*
|
|
23
|
-
* satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
|
|
24
|
-
* unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
|
|
25
|
-
* and every payload collapsed to `unknown` — the flagship typed-service feature checking nothing at all.
|
|
22
|
+
* ⚠ `TRequests extends object`, never `extends Record<string, unknown>`: the stricter bound is
|
|
23
|
+
* unsatisfiable by a plain `interface` (TS2344 on the example above), and satisfying it by widening the
|
|
24
|
+
* request map widens `keyof TRequests & string` back to `string` — turning the payload check off again.
|
|
26
25
|
*/
|
|
27
26
|
export declare abstract class BaseModuleService<TRequests extends object = Record<string, unknown>> {
|
|
28
27
|
/** The backend module this service fronts (e.g. `"NOTES"`). */
|
|
@@ -43,13 +42,10 @@ export declare abstract class BaseModuleService<TRequests extends object = Recor
|
|
|
43
42
|
/**
|
|
44
43
|
* The bridge this service speaks over — resolved on every access, never captured.
|
|
45
44
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* H2). Module services are commonly module-level singletons, so constructing one before the app's
|
|
51
|
-
* startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
|
|
52
|
-
* lazily for exactly this reason; this matches it.
|
|
45
|
+
* 🔴 Never default it at CONSTRUCTION. Module services are commonly module-level singletons, so one
|
|
46
|
+
* is routinely built before `configureBridge()` runs — and `configureBridge` DISPOSES the bridge it
|
|
47
|
+
* replaces, so a captured default makes every later call reject with "Bridge disposed" for the rest
|
|
48
|
+
* of the session, with nothing to suggest why.
|
|
53
49
|
*/
|
|
54
50
|
protected get bridge(): ShenoraBridge;
|
|
55
51
|
/** Send one typed request to this module and await the typed response data. */
|
package/dist/moduleService.js
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
import { getBridge } from './bridge.js';
|
|
2
2
|
/**
|
|
3
|
-
* Base class for typed module services
|
|
4
|
-
* module
|
|
5
|
-
*
|
|
6
|
-
* (`{ [type]: payloadType }`) for compile-time payload checking:
|
|
3
|
+
* Base class for typed module services: each backend module gets one service subclass that binds the
|
|
4
|
+
* module name once and exposes app-typed methods over {@link send}. Bind `TRequests` to the module's
|
|
5
|
+
* request map (`{ [type]: payloadType }`) for compile-time payload checking:
|
|
7
6
|
*
|
|
8
7
|
* ```ts
|
|
9
8
|
* interface NoteRequests { GET_ALL: void; ADD: { title: string } }
|
|
10
9
|
* class NoteService extends BaseModuleService<NoteRequests> {
|
|
11
10
|
* constructor() { super('NOTES'); }
|
|
12
|
-
* getAll() { return this.send
|
|
13
|
-
* add(title: string) { return this.send
|
|
11
|
+
* getAll(): Promise<Note[]> { return this.send('GET_ALL'); }
|
|
12
|
+
* add(title: string): Promise<Note> { return this.send('ADD', { payload: { title } }); }
|
|
14
13
|
* }
|
|
15
14
|
* ```
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 🔴 **DECLARE THE RETURN TYPE; NEVER WRITE `send<Note>(…)`.** TypeScript has no partial type-argument
|
|
17
|
+
* inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
|
|
18
|
+
* key — and `payload` collapses to the union of every route's payload. The check silently stops
|
|
19
|
+
* checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
|
|
20
|
+
* without the type argument is a TS2353. The response is inferred from the declared return type instead.
|
|
19
21
|
*
|
|
20
|
-
* `TRequests extends object`,
|
|
21
|
-
* unsatisfiable by a plain `interface`
|
|
22
|
-
*
|
|
23
|
-
* satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
|
|
24
|
-
* unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
|
|
25
|
-
* and every payload collapsed to `unknown` — the flagship typed-service feature checking nothing at all.
|
|
22
|
+
* ⚠ `TRequests extends object`, never `extends Record<string, unknown>`: the stricter bound is
|
|
23
|
+
* unsatisfiable by a plain `interface` (TS2344 on the example above), and satisfying it by widening the
|
|
24
|
+
* request map widens `keyof TRequests & string` back to `string` — turning the payload check off again.
|
|
26
25
|
*/
|
|
27
26
|
export class BaseModuleService {
|
|
28
27
|
constructor(
|
|
@@ -39,13 +38,10 @@ export class BaseModuleService {
|
|
|
39
38
|
/**
|
|
40
39
|
* The bridge this service speaks over — resolved on every access, never captured.
|
|
41
40
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* H2). Module services are commonly module-level singletons, so constructing one before the app's
|
|
47
|
-
* startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
|
|
48
|
-
* lazily for exactly this reason; this matches it.
|
|
41
|
+
* 🔴 Never default it at CONSTRUCTION. Module services are commonly module-level singletons, so one
|
|
42
|
+
* is routinely built before `configureBridge()` runs — and `configureBridge` DISPOSES the bridge it
|
|
43
|
+
* replaces, so a captured default makes every later call reject with "Bridge disposed" for the rest
|
|
44
|
+
* of the session, with nothing to suggest why.
|
|
49
45
|
*/
|
|
50
46
|
get bridge() {
|
|
51
47
|
return this.explicitBridge ?? getBridge();
|