@shenora/react 0.11.0 → 0.13.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/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, '/');
@@ -2,12 +2,11 @@ import { type RefObject } from 'react';
2
2
  import { type ShenoraBridge } from './bridge.js';
3
3
  import { type ShenoraEventBus } from './eventBus.js';
4
4
  /**
5
- * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
6
- * containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
5
+ * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host, which
7
6
  * defaults to the same string — change one and you must change the other.
8
7
  *
9
- * ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
10
- * `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
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`.
11
10
  */
12
11
  export declare const MEDIA_PLAYER_MODULE = "SHENORA.MEDIA";
13
12
  /**
@@ -24,11 +23,38 @@ export declare const MediaPlayerCommands: {
24
23
  };
25
24
  /** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
26
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
+ };
27
53
  /**
28
54
  * What the element is doing, in the host's vocabulary (`MediaPlayerState`).
29
55
  *
30
- * ⚠ `opening` and `buffering` are distinct, matching the host: opening is "no position yet", buffering is
31
- * "had one and it stopped moving". Collapsing them makes a UI extrapolate a position that is not advancing.
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.
32
58
  */
33
59
  export type MediaPlayerReportState = 'Empty' | 'Opening' | 'Paused' | 'Playing' | 'Buffering' | 'Ended' | 'Failed';
34
60
  /** One state report, sent on TRANSITIONS only. */
@@ -60,27 +86,18 @@ export interface UseMediaPlayerOptions {
60
86
  * ```
61
87
  *
62
88
  * **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
63
- * calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
64
- * it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
65
- * at all, which needs a probe and a device capability query).
66
- *
67
- * ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
68
- * (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
69
- * registered by the media feature itself. If you wrote that route by hand against a build from before
70
- * 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
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.
71
91
  *
72
92
  * ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
73
93
  * object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
74
- * the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
75
- * the component so the hook remounts with it.
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.
76
96
  *
77
- * ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
78
- * would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
79
- * you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
80
- * actually redraw.
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.
81
99
  *
82
100
  * ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
83
- * the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
84
- * hears about it rather than silently believing playback started.
101
+ * the browser, and the element then reports `Failed` rather than the host believing playback started.
85
102
  */
86
103
  export declare function useMediaPlayer(ref: RefObject<HTMLMediaElement | null>, options?: UseMediaPlayerOptions): void;
@@ -2,12 +2,11 @@ import { useEffect } from 'react';
2
2
  import { getBridge } from './bridge.js';
3
3
  import { eventBus as defaultEventBus } from './eventBus.js';
4
4
  /**
5
- * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host (the
6
- * containment/module boundary every media delivery path shares — `MediaAccessOptions`, D71), which
5
+ * The module the player speaks on. Matches `MediaPlayerOptions.Access.Module` on the host, which
7
6
  * defaults to the same string — change one and you must change the other.
8
7
  *
9
- * ⚠ The `SHENORA.` prefix is RESERVED for the kit's own modules (D64), beside the handshake's bare
10
- * `SHENORA`. It exists so your app stays free to own a module called plainly `MEDIA`.
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`.
11
10
  */
12
11
  export const MEDIA_PLAYER_MODULE = 'SHENORA.MEDIA';
13
12
  /**
@@ -24,6 +23,33 @@ export const MediaPlayerCommands = {
24
23
  };
25
24
  /** The one message the page sends back. The host turns it into `MediaPlayer.Report(...)`. */
26
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
+ };
27
53
  /**
28
54
  * Bind a `<video>`/`<audio>` element to the HOST's player: the element becomes the display and the sound,
29
55
  * and .NET owns the lifecycle (D58).
@@ -35,28 +61,19 @@ export const MEDIA_PLAYER_REPORT = 'PLAYER_REPORT';
35
61
  * ```
36
62
  *
37
63
  * **That is the whole page-side integration.** No `src`, no play button wiring, no state machine — the app
38
- * calls `IMediaPlayer.OpenAsync/PlayAsync` in C#, and this drives the element to match. The page keeps what
39
- * it is good at (rendering) and gives up what it was never good at (deciding whether a file can be played
40
- * at all, which needs a probe and a device capability query).
41
- *
42
- * ⚠ **The host half needs nothing from you.** The reports posted here are an ordinary IPC message
43
- * (`PLAYER_REPORT` on {@link MEDIA_PLAYER_MODULE}) and the kit's own `MediaPlayerModule` answers it,
44
- * registered by the media feature itself. If you wrote that route by hand against a build from before
45
- * 2026-08-07, **delete it** — two facades on one module is a duplicate the host rejects.
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.
46
66
  *
47
67
  * ⚠ **The element must exist when this effect first runs.** `ref.current` is read once, and a `useRef`
48
68
  * object is stable, so an element rendered CONDITIONALLY (`{ready && <video ref={ref} />}`) mounts after
49
- * the effect and never binds — silently. Render the element unconditionally and hide it with CSS, or key
50
- * the component so the hook remounts with it.
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.
51
71
  *
52
- * ⚠ **It reports on TRANSITIONS, never on `timeupdate`.** That event fires ~4×/second and forwarding it
53
- * would cost battery and IPC to tell the host something it can extrapolate from a position and a rate. If
54
- * you need a moving scrubber, read `element.currentTime` in your own render loop — locally, at the rate you
55
- * actually redraw.
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.
56
74
  *
57
75
  * ⚠ **Autoplay policies still apply.** A `PLAYER_PLAY` arriving before any user gesture can be refused by
58
- * the browser, and the element reports `Failed`. That is the platform's rule, not the kit's, and the host
59
- * hears about it rather than silently believing playback started.
76
+ * the browser, and the element then reports `Failed` rather than the host believing playback started.
60
77
  */
61
78
  export function useMediaPlayer(ref, options = {}) {
62
79
  const { module = MEDIA_PLAYER_MODULE, bridge, eventBus = defaultEventBus } = options;
@@ -65,8 +82,7 @@ export function useMediaPlayer(ref, options = {}) {
65
82
  if (!element)
66
83
  return;
67
84
  const link = bridge ?? getBridge();
68
- // The element is the only clock: the host asks IT for position rather than tracking its own, so a
69
- // report always carries what the element actually believes.
85
+ // The element is the only clock: the host tracks no position of its own.
70
86
  const report = (state, error) => {
71
87
  const duration = Number.isFinite(element.duration) ? element.duration : null;
72
88
  const payload = {
@@ -77,11 +93,10 @@ export function useMediaPlayer(ref, options = {}) {
77
93
  };
78
94
  link.post(module, MEDIA_PLAYER_REPORT, { payload });
79
95
  };
80
- // The PENDING start-at seek, if any. 🔴 It has to be cancellable: `{ once: true }` removes a listener
81
- // only when it FIRES, and a second PLAYER_LOAD calls `element.load()`, which aborts the first load so
82
- // its `loadedmetadata` never comes. The stale listener then survives and runs on the NEXT track's
83
- // metadata — so loading A at 10:00 and then B at 0:00 starts B ten minutes in, because B sets no
84
- // listener of its own and A's is still attached.
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.
85
100
  let pendingSeek = null;
86
101
  const cancelPendingSeek = () => {
87
102
  if (pendingSeek)
@@ -107,10 +122,9 @@ export function useMediaPlayer(ref, options = {}) {
107
122
  }),
108
123
  eventBus.subscribe(module, MediaPlayerCommands.play, () => {
109
124
  // A rejected play() is an autoplay refusal, and the host must hear it rather than assume success.
110
- // ⚠ Read `.name` STRUCTURALLY rather than testing `instanceof Error`: the value browsers reject
111
- // with is a DOMException, which is not an Error subclass everywhere (jsdom's is not), and a page
112
- // can reject with anything at all. The name is the stable, app-safe part — `NotAllowedError` is
113
- // what an autoplay block actually says, and it is the one an adopter will want to branch on.
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.
114
128
  void element.play().catch((cause) => {
115
129
  const name = cause?.name;
116
130
  report('Failed', typeof name === 'string' && name ? name : 'PlayRejected');
@@ -133,7 +147,7 @@ export function useMediaPlayer(ref, options = {}) {
133
147
  }),
134
148
  ];
135
149
  // ── element → host ────────────────────────────────────────────────────────────────────────────
136
- // Transitions only. `timeupdate` is deliberately absent — see the remarks.
150
+ // Transitions only — no `timeupdate`, see the remarks.
137
151
  const listeners = [
138
152
  ['loadedmetadata', () => report('Paused')],
139
153
  ['canplay', () => report(element.paused ? 'Paused' : 'Playing')],
@@ -148,17 +162,13 @@ export function useMediaPlayer(ref, options = {}) {
148
162
  for (const [event, handler] of listeners)
149
163
  element.addEventListener(event, handler);
150
164
  // 🔴 REPORT WHEN THE PAGE IS ABOUT TO BE HIDDEN, or the host's position is whatever the last
151
- // TRANSITION left — which for steady playback is the moment it started.
152
- //
153
- // Measured on an Android emulator 2026-08-15: with transition-only reporting the page sat at 19.79 s
154
- // while the host believed 0.01 s, and `BackgroundPlaybackTransfer` handed the native player 0.01 s.
155
- // The user backgrounds mid-film and resumes from the beginning. The platform's `pause` at background
156
- // time does fire, but not in time to cross IPC before the process is frozen.
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.
157
168
  //
158
- // ⚠ `visibilitychange` rather than `pagehide`: this fires while the document is still alive and the
159
- // bridge can still post, and it is the signal both mobile shells raise on the way to the background.
160
- // It costs ONE report per background — nothing like `timeupdate`'s ~4/second, which is why that one
161
- // is still deliberately absent.
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.
162
172
  const onHidden = () => {
163
173
  if (document.visibilityState !== 'hidden')
164
174
  return;
@@ -178,8 +188,8 @@ export function useMediaPlayer(ref, options = {}) {
178
188
  /**
179
189
  * A short, stable reason from `MediaError`.
180
190
  *
181
- * ⚠ Deliberately NOT `error.message`: browsers put decoder internals and sometimes the full URL in it, and
182
- * this string crosses to the host and can reach a log. The host applies the same rule to platform errors.
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.
183
193
  */
184
194
  function mediaErrorReason(element) {
185
195
  switch (element.error?.code) {
@@ -1,9 +1,8 @@
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 } }
@@ -18,18 +17,11 @@ import { type ShenoraBridge } from './bridge.js';
18
17
  * inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
19
18
  * key — and `payload` collapses to the union of every route's payload. The check silently stops
20
19
  * checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
21
- * without the type argument is a TS2353. The response is inferred from the method's declared return
22
- * type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
20
+ * without the type argument is a TS2353. The response is inferred from the declared return type instead.
23
21
  *
24
- * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
25
- * around the same call — the response generic already expresses them, so they're gone.
26
- *
27
- * `TRequests extends object`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
28
- * unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
29
- * above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
30
- * satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
31
- * unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
32
- * 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.
33
25
  */
34
26
  export declare abstract class BaseModuleService<TRequests extends object = Record<string, unknown>> {
35
27
  /** The backend module this service fronts (e.g. `"NOTES"`). */
@@ -50,13 +42,10 @@ export declare abstract class BaseModuleService<TRequests extends object = Recor
50
42
  /**
51
43
  * The bridge this service speaks over — resolved on every access, never captured.
52
44
  *
53
- * This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
54
- * at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
55
- * `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
56
- * rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
57
- * H2). Module services are commonly module-level singletons, so constructing one before the app's
58
- * startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
59
- * 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.
60
49
  */
61
50
  protected get bridge(): ShenoraBridge;
62
51
  /** Send one typed request to this module and await the typed response data. */
@@ -1,9 +1,8 @@
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 } }
@@ -18,18 +17,11 @@ import { getBridge } from './bridge.js';
18
17
  * inference, so naming the response argument makes `TType` fall back to its DEFAULT — the union of every
19
18
  * key — and `payload` collapses to the union of every route's payload. The check silently stops
20
19
  * checking: `send<Note>('ADD', { payload: { notAField: 1 } })` compiles clean, while the same call
21
- * without the type argument is a TS2353. The response is inferred from the method's declared return
22
- * type instead, which every method here has anyway. `moduleService.test.ts` pins both halves.
20
+ * without the type argument is a TS2353. The response is inferred from the declared return type instead.
23
21
  *
24
- * DEVIATION from the source: its boolean/array/optional convenience wrappers were pure casts
25
- * around the same call — the response generic already expresses them, so they're gone.
26
- *
27
- * `TRequests extends object`, NOT `extends Record<string, unknown>` (P5.5 H6). The stricter bound was
28
- * unsatisfiable by a plain `interface` — interfaces get no implicit index signature — so the example
29
- * above and the README's snippet both failed with TS2344, on the first line an adopter copies. And
30
- * satisfying it the way the kit's own `windowCommands.ts` did (`interface X extends Record<string,
31
- * unknown>`) widened `keyof TRequests & string` back to `string`, so a mistyped request type compiled
32
- * 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.
33
25
  */
34
26
  export class BaseModuleService {
35
27
  constructor(
@@ -46,13 +38,10 @@ export class BaseModuleService {
46
38
  /**
47
39
  * The bridge this service speaks over — resolved on every access, never captured.
48
40
  *
49
- * This used to be a constructor default (`bridge: ShenoraBridge = getBridge()`), which is evaluated
50
- * at CONSTRUCTION: a service built before `configureBridge()` captured the old default, and
51
- * `configureBridge` DISPOSES the bridge it replaces — so every later call from that service
52
- * rejected with "Bridge disposed" for the rest of the session, with nothing to suggest why (P5.5
53
- * H2). Module services are commonly module-level singletons, so constructing one before the app's
54
- * startup configuration ran is the normal case, not an edge case. `useDropZone` already resolved
55
- * 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.
56
45
  */
57
46
  get bridge() {
58
47
  return this.explicitBridge ?? getBridge();