@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.
Files changed (44) hide show
  1. package/README.md +152 -165
  2. package/dist/bridge.d.ts +29 -46
  3. package/dist/bridge.js +47 -71
  4. package/dist/clipboard.d.ts +89 -0
  5. package/dist/clipboard.js +119 -0
  6. package/dist/devInterceptor.d.ts +11 -8
  7. package/dist/devInterceptor.js +16 -10
  8. package/dist/errors.d.ts +5 -6
  9. package/dist/errors.js +6 -7
  10. package/dist/eventBus.d.ts +21 -26
  11. package/dist/eventBus.js +38 -40
  12. package/dist/fileDialogs.d.ts +9 -12
  13. package/dist/fileDialogs.js +12 -16
  14. package/dist/hooks.d.ts +19 -20
  15. package/dist/hooks.js +26 -29
  16. package/dist/index.d.ts +8 -2
  17. package/dist/index.js +14 -10
  18. package/dist/internal.d.ts +2 -8
  19. package/dist/internal.js +2 -8
  20. package/dist/media.d.ts +14 -22
  21. package/dist/media.js +14 -22
  22. package/dist/mediaPlayer.d.ts +103 -0
  23. package/dist/mediaPlayer.js +202 -0
  24. package/dist/moduleService.d.ts +17 -21
  25. package/dist/moduleService.js +17 -21
  26. package/dist/requests.d.ts +145 -0
  27. package/dist/requests.js +113 -0
  28. package/dist/segmentBinder.d.ts +74 -0
  29. package/dist/segmentBinder.js +239 -0
  30. package/dist/segmentStream.d.ts +125 -0
  31. package/dist/segmentStream.js +239 -0
  32. package/dist/store.d.ts +18 -26
  33. package/dist/store.js +69 -36
  34. package/dist/transport.d.ts +9 -18
  35. package/dist/transport.js +9 -18
  36. package/dist/types.d.ts +33 -42
  37. package/dist/types.js +29 -25
  38. package/dist/useDropZone.d.ts +23 -22
  39. package/dist/useDropZone.js +41 -37
  40. package/dist/windowCommands.d.ts +15 -19
  41. package/dist/windowCommands.js +18 -25
  42. package/package.json +10 -3
  43. package/dist/operations.d.ts +0 -256
  44. 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 only need uniqueness, not entropy,
26
- * so the non-`crypto` fallback (ancient or non-secure-context environments) is fine.
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 the whole point.** Written as a path rather than with a scheme, it
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
- * Both fixed forms fail on exactly one platform, in opposite directions, and registering the scheme rescues
23
- * neither: iOS cannot register a handler for `https`, and Android cannot register a scheme at all. Measured
24
- * on devices — so if you are tempted to hardcode `app://`, that is why not.
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
- * base64**url** specifically, so `+`, `/` and `=` cannot survive into a query string and be re-interpreted
31
- * by anything that parses URLs along the way.
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 — you cannot read the URL in a log any more. Have the host log
34
- * what it DECODED to: the response body cannot say, because an error body would leak paths.
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` because `btoa` throws on any character above U+00FF: a payload carrying a
56
- * non-ASCII title or path would fail at the call site, which is a poor way to discover an encoding choice.
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, in whatever language the shell is written in.
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 the whole point.** Written as a path rather than with a scheme, it
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
- * Both fixed forms fail on exactly one platform, in opposite directions, and registering the scheme rescues
23
- * neither: iOS cannot register a handler for `https`, and Android cannot register a scheme at all. Measured
24
- * on devices — so if you are tempted to hardcode `app://`, that is why not.
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
- * base64**url** specifically, so `+`, `/` and `=` cannot survive into a query string and be re-interpreted
31
- * by anything that parses URLs along the way.
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 — you cannot read the URL in a log any more. Have the host log
34
- * what it DECODED to: the response body cannot say, because an error body would leak paths.
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` because `btoa` throws on any character above U+00FF: a payload carrying a
62
- * non-ASCII title or path would fail at the call site, which is a poor way to discover an encoding choice.
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, in whatever language the shell is written in.
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
+ }
@@ -1,28 +1,27 @@
1
1
  import { type ShenoraBridge } from './bridge.js';
2
2
  /**
3
- * Base class for typed module services, ported from the primary desktop sibling: each backend
4
- * module gets one service subclass that binds the module name once and exposes app-typed
5
- * methods over {@link send}. Bind `TRequests` to the module's request map
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<Note[]>('GET_ALL'); }
13
- * add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
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
- * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
18
- * around the same call — the response generic already expresses them, so they're gone.
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`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
21
- * unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
22
- * above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
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
- * This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
47
- * at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
48
- * `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
49
- * rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
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. */
@@ -1,28 +1,27 @@
1
1
  import { getBridge } from './bridge.js';
2
2
  /**
3
- * Base class for typed module services, ported from the primary desktop sibling: each backend
4
- * module gets one service subclass that binds the module name once and exposes app-typed
5
- * methods over {@link send}. Bind `TRequests` to the module's request map
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<Note[]>('GET_ALL'); }
13
- * add(title: string) { return this.send<Note>('ADD', { payload: { title } }); }
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
- * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
18
- * around the same call — the response generic already expresses them, so they're gone.
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`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
21
- * unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
22
- * above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
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
- * This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
43
- * at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
44
- * `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
45
- * rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
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();