@colixsystems/widget-sdk 0.191.0 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,27 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
77
77
 
78
78
  ## Status
79
79
 
80
- `v0.191.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
80
+ `v0.192.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
81
+
82
+ ### What's new in 0.192.0 (contract 1.153.0)
83
+
84
+ **New `useAudioRecorder()` and `useAudioPlayer(uri)` hooks: record and play audio clips on both hosts.** A voice note or voice message no longer needs a `widget.web.jsx` / `widget.native.jsx` split around `expo-audio`.
85
+
86
+ `useAudioRecorder()` returns `{ supported, permission, recording, durationMs, level, error, start, stop, cancel }`. Call `start()` from a tap; the microphone prompt never opens on mount. `stop()` resolves `{ file, uri, durationMs, mime }` (or `null` when nothing was recording): `file` is the upload part for the host, so pass it to `upload(file)` from `useFilestoreUpload()`, and `uri` previews the clip before you upload it. `cancel()` discards the clip. `permission` is `"granted"`, `"denied"` or `"undetermined"`, and `level` is the input level from 0 to 1 for a meter. Errors are an `AudioRecorderError` (`PERMISSION_DENIED`, `UNSUPPORTED`, `INTERNAL`).
87
+
88
+ `useAudioPlayer(uri)` returns `{ supported, loading, playing, positionMs, durationMs, error, play, pause, seek }`. `durationMs` is `null` until it is known. When the clip ends, `playing` turns false and `positionMs` returns to 0, so `play()` replays it. Call `play()` from a tap: browsers refuse playback otherwise, and the hook rejects with an `AudioPlayerError` whose code is `BLOCKED` (the others are `UNSUPPORTED`, `LOAD_FAILED` and `INTERNAL`).
89
+
90
+ ```jsx
91
+ const recorder = useAudioRecorder();
92
+ const { upload } = useFilestoreUpload({ spaceType: "project" });
93
+ // start: <Pressable onPress={() => recorder.start().catch(() => {})}>
94
+ // stop: try { const clip = await recorder.stop(); if (clip) await upload(clip.file); } catch { /* show it failed */ }
95
+
96
+ const player = useAudioPlayer(url);
97
+ // <Pressable onPress={() => (player.playing ? player.pause() : player.play().catch(() => {}))}>
98
+ ```
99
+
100
+ The web Player records AAC (`audio/mp4`) where the browser can, and Opus in WebM or Ogg otherwise. iOS cannot play Opus, so an Opus or Vorbis clip a signed-in user uploads with compression on (the default) is converted to AAC (`.m4a`) on upload. An anonymous upload, a clip over 25 MB or 15 minutes, or one uploaded with `compress: false` is stored as recorded. The native export always records AAC. Both hooks need no scope. The host brokers them as the new optional `ctx.device.audioRecorder` and `ctx.device.audioPlayer` slices (MediaRecorder and an `<audio>` element on web, `expo-audio` on native). Additive.
81
101
 
82
102
  ### What's new in 0.191.0 (contract 1.152.0)
83
103
 
package/dist/audio.js ADDED
@@ -0,0 +1,448 @@
1
+ // Host-brokered audio: `useAudioRecorder()` and `useAudioPlayer(uri)`.
2
+ //
3
+ // One source for both hosts. The web Player brokers ctx.device.audioRecorder
4
+ // with MediaRecorder and ctx.device.audioPlayer with an <audio> element; the
5
+ // Expo export brokers both with expo-audio. A widget never splits itself by
6
+ // platform to record or play a clip.
7
+ //
8
+ // This file avoids JSX so it can ship as a plain .js without a transform.
9
+
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useWidgetContextOrThrow } from "./hooks.js";
12
+
13
+ // How often the recorder's duration and input level are read while recording.
14
+ const RECORDER_POLL_MS = 100;
15
+
16
+ // Input level is reported in dBFS; this floor reads as silence.
17
+ const LEVEL_FLOOR_DB = -60;
18
+
19
+ /**
20
+ * Error surfaced by `useAudioRecorder()` (thrown by `start()` / `stop()` and
21
+ * stored in the hook's `error` slot).
22
+ *
23
+ * `code` is one of:
24
+ * - "PERMISSION_DENIED" — the user or the OS refused microphone access.
25
+ * - "UNSUPPORTED" — this host cannot record (no broker, no microphone).
26
+ * - "INTERNAL" — anything else.
27
+ */
28
+ export class AudioRecorderError extends Error {
29
+ constructor(code, message, opts) {
30
+ super(message);
31
+ this.name = "AudioRecorderError";
32
+ this.code = code;
33
+ if (opts && opts.cause) this.cause = opts.cause;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Error surfaced by `useAudioPlayer()` (thrown by `play()` / `seek()` and
39
+ * stored in the hook's `error` slot).
40
+ *
41
+ * `code` is one of:
42
+ * - "UNSUPPORTED" — this host cannot play audio.
43
+ * - "LOAD_FAILED" — the clip could not be fetched or decoded.
44
+ * - "BLOCKED" — the host refused to start playback outside a user gesture.
45
+ * - "INTERNAL" — anything else.
46
+ */
47
+ export class AudioPlayerError extends Error {
48
+ constructor(code, message, opts) {
49
+ super(message);
50
+ this.name = "AudioPlayerError";
51
+ this.code = code;
52
+ if (opts && opts.cause) this.cause = opts.cause;
53
+ }
54
+ }
55
+
56
+ const RECORDER_CODES = new Set(["PERMISSION_DENIED", "UNSUPPORTED"]);
57
+ const PLAYER_CODES = new Set(["UNSUPPORTED", "LOAD_FAILED", "BLOCKED"]);
58
+
59
+ function coerceError(ErrorClass, knownCodes, fallbackMessage, err) {
60
+ if (err instanceof ErrorClass) return err;
61
+ const raw = err && err.code;
62
+ const code = knownCodes.has(raw) ? raw : "INTERNAL";
63
+ const message =
64
+ (err && typeof err.message === "string" && err.message) || fallbackMessage;
65
+ return new ErrorClass(code, message, { cause: err });
66
+ }
67
+
68
+ const toRecorderError = (err) =>
69
+ coerceError(AudioRecorderError, RECORDER_CODES, "Recording failed", err);
70
+
71
+ const toPlayerError = (err) =>
72
+ coerceError(AudioPlayerError, PLAYER_CODES, "Playback failed", err);
73
+
74
+ /** dBFS → 0..1, so a level meter needs no knowledge of either host's scale. */
75
+ function normaliseInputLevel(levelDb) {
76
+ if (typeof levelDb !== "number" || !Number.isFinite(levelDb)) return 0;
77
+ const ratio = (levelDb - LEVEL_FLOOR_DB) / -LEVEL_FLOOR_DB;
78
+ return Math.min(1, Math.max(0, ratio));
79
+ }
80
+
81
+ const PERMISSION_STATES = new Set(["granted", "denied", "undetermined"]);
82
+
83
+ function isUsable(client, method) {
84
+ return Boolean(
85
+ client &&
86
+ typeof client[method] === "function" &&
87
+ (typeof client.isSupported !== "function" || client.isSupported()),
88
+ );
89
+ }
90
+
91
+ function releaseQuietly(release) {
92
+ if (typeof release !== "function") return;
93
+ try {
94
+ release();
95
+ } catch {
96
+ /* the host already released it */
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Record a clip from the microphone. Returns `{ supported, permission,
102
+ * recording, durationMs, level, error, start, stop, cancel }`.
103
+ *
104
+ * Capture is IMPERATIVE — call `start()` from a user gesture. Both the
105
+ * browser and the mobile OS gate the microphone prompt on one, so the hook
106
+ * never records on mount.
107
+ *
108
+ * `stop()` resolves `{ file, uri, durationMs, mime }`, or `null` when nothing
109
+ * was recording. `file` is already the upload part for the host (a `File` on
110
+ * web, expo-file-system's `File` on native) — pass it to `upload(file)` from
111
+ * `useFilestoreUpload()`. `uri` plays the clip locally through
112
+ * `useAudioPlayer(uri)` before it is uploaded; the hook releases it on the
113
+ * next `start()`, on `cancel()` and on unmount. `cancel()` discards the clip.
114
+ *
115
+ * `permission` is "granted" | "denied" | "undetermined"; `level` is the input
116
+ * level from 0 (silence) to 1, for a live meter.
117
+ *
118
+ * Safe-by-default: on a host that brokers no recorder `supported` is false
119
+ * and `start()` rejects with `code: "UNSUPPORTED"`, so a widget can call the
120
+ * hook unconditionally and disable its record control.
121
+ */
122
+ export function useAudioRecorder() {
123
+ const ctx = useWidgetContextOrThrow("useAudioRecorder");
124
+ const [permission, setPermission] = useState("undetermined");
125
+ const [recording, setRecording] = useState(false);
126
+ const [durationMs, setDurationMs] = useState(0);
127
+ const [level, setLevel] = useState(0);
128
+ const [error, setError] = useState(null);
129
+
130
+ // `ctx` is a fresh identity every host render — hold the live client in a
131
+ // ref so the returned callbacks stay stable.
132
+ const clientRef = useRef(ctx.device && ctx.device.audioRecorder);
133
+ clientRef.current = ctx.device && ctx.device.audioRecorder;
134
+ const sessionRef = useRef(null);
135
+ const startingRef = useRef(null);
136
+ const pollRef = useRef(null);
137
+ const clipReleaseRef = useRef(null);
138
+ const runRef = useRef(0);
139
+
140
+ const supported = isUsable(clientRef.current, "start");
141
+
142
+ const stopPolling = useCallback(() => {
143
+ if (pollRef.current !== null) clearInterval(pollRef.current);
144
+ pollRef.current = null;
145
+ }, []);
146
+
147
+ const releaseClip = useCallback(() => {
148
+ const release = clipReleaseRef.current;
149
+ clipReleaseRef.current = null;
150
+ releaseQuietly(release);
151
+ }, []);
152
+
153
+ const discardSession = useCallback(() => {
154
+ runRef.current += 1;
155
+ startingRef.current = null;
156
+ stopPolling();
157
+ const session = sessionRef.current;
158
+ sessionRef.current = null;
159
+ if (session && typeof session.cancel === "function") {
160
+ try {
161
+ session.cancel();
162
+ } catch {
163
+ /* the host is already tearing the session down */
164
+ }
165
+ }
166
+ }, [stopPolling]);
167
+
168
+ useEffect(() => {
169
+ const client = clientRef.current;
170
+ if (!client || typeof client.getPermission !== "function") return undefined;
171
+ let live = true;
172
+ Promise.resolve()
173
+ .then(() => client.getPermission())
174
+ .then((state) => {
175
+ // A start() that already answered the prompt outranks this read.
176
+ if (live && PERMISSION_STATES.has(state)) {
177
+ setPermission((current) =>
178
+ current === "undetermined" ? state : current,
179
+ );
180
+ }
181
+ })
182
+ .catch(() => {
183
+ /* unknown stays "undetermined" */
184
+ });
185
+ return () => {
186
+ live = false;
187
+ };
188
+ }, []);
189
+
190
+ // An abandoned recorder leaves the microphone hot on both hosts.
191
+ useEffect(
192
+ () => () => {
193
+ discardSession();
194
+ releaseClip();
195
+ },
196
+ [discardSession, releaseClip],
197
+ );
198
+
199
+ const start = useCallback(() => {
200
+ if (sessionRef.current) return Promise.resolve();
201
+ // A double tap while the permission prompt is open joins the first start.
202
+ if (startingRef.current) return startingRef.current;
203
+ const client = clientRef.current;
204
+ if (!isUsable(client, "start")) {
205
+ const e = new AudioRecorderError(
206
+ "UNSUPPORTED",
207
+ "This host does not provide audio recording.",
208
+ );
209
+ setError(e);
210
+ return Promise.reject(e);
211
+ }
212
+ const run = (runRef.current += 1);
213
+ setError(null);
214
+ releaseClip();
215
+ // A broker that throws synchronously settles the IIFE before it returns.
216
+ let settled = false;
217
+ const opening = (async () => {
218
+ try {
219
+ const session = await client.start();
220
+ if (run !== runRef.current) {
221
+ if (session && typeof session.cancel === "function") session.cancel();
222
+ return;
223
+ }
224
+ sessionRef.current = session;
225
+ setPermission("granted");
226
+ setDurationMs(0);
227
+ setLevel(0);
228
+ setRecording(true);
229
+ pollRef.current = setInterval(() => {
230
+ const live = sessionRef.current;
231
+ if (!live || typeof live.getStatus !== "function") return;
232
+ const status = live.getStatus() || {};
233
+ if (typeof status.durationMs === "number") {
234
+ setDurationMs(status.durationMs);
235
+ }
236
+ // The host ended it (a lost headset, a phone call); stop() keeps it.
237
+ if (status.ended) {
238
+ stopPolling();
239
+ setRecording(false);
240
+ setLevel(0);
241
+ return;
242
+ }
243
+ setLevel(normaliseInputLevel(status.levelDb));
244
+ }, RECORDER_POLL_MS);
245
+ } catch (err) {
246
+ const re = toRecorderError(err);
247
+ if (run === runRef.current) {
248
+ if (re.code === "PERMISSION_DENIED") setPermission("denied");
249
+ setError(re);
250
+ }
251
+ throw re;
252
+ } finally {
253
+ settled = true;
254
+ if (run === runRef.current) startingRef.current = null;
255
+ }
256
+ })();
257
+ if (!settled) startingRef.current = opening;
258
+ return opening;
259
+ }, [releaseClip, stopPolling]);
260
+
261
+ const stop = useCallback(async () => {
262
+ // A stop pressed while the permission prompt is open ends that recording.
263
+ if (!sessionRef.current && startingRef.current) {
264
+ await startingRef.current.catch(() => {});
265
+ }
266
+ const session = sessionRef.current;
267
+ if (!session) return null;
268
+ const run = (runRef.current += 1);
269
+ sessionRef.current = null;
270
+ stopPolling();
271
+ setRecording(false);
272
+ setLevel(0);
273
+ let clip;
274
+ try {
275
+ clip = await session.stop();
276
+ } catch (err) {
277
+ const re = toRecorderError(err);
278
+ if (run === runRef.current) setError(re);
279
+ throw re;
280
+ }
281
+ if (!clip) return null;
282
+ // A cancel(), a new start() or an unmount meanwhile discards this clip.
283
+ if (run !== runRef.current) {
284
+ releaseQuietly(clip.release);
285
+ return null;
286
+ }
287
+ releaseClip();
288
+ clipReleaseRef.current =
289
+ typeof clip.release === "function" ? clip.release : null;
290
+ setDurationMs(clip.durationMs);
291
+ return {
292
+ file: clip.file,
293
+ uri: clip.uri,
294
+ durationMs: clip.durationMs,
295
+ mime: clip.mime,
296
+ };
297
+ }, [releaseClip, stopPolling]);
298
+
299
+ const cancel = useCallback(() => {
300
+ discardSession();
301
+ releaseClip();
302
+ setRecording(false);
303
+ setDurationMs(0);
304
+ setLevel(0);
305
+ }, [discardSession, releaseClip]);
306
+
307
+ return {
308
+ supported,
309
+ permission,
310
+ recording,
311
+ durationMs,
312
+ level,
313
+ error,
314
+ start,
315
+ stop,
316
+ cancel,
317
+ };
318
+ }
319
+
320
+ const IDLE_PLAYBACK = {
321
+ loaded: false,
322
+ playing: false,
323
+ positionMs: 0,
324
+ durationMs: null,
325
+ };
326
+
327
+ /**
328
+ * Play one audio clip. Returns `{ supported, loading, playing, positionMs,
329
+ * durationMs, error, play, pause, seek }`.
330
+ *
331
+ * `uri` is any playable URL — a stored file's URL, or the `uri` a
332
+ * `useAudioRecorder()` clip resolves for local preview. Changing it loads the
333
+ * new clip; a falsy `uri` loads nothing. `durationMs` is `null` until the
334
+ * host knows the length. When the clip ends, `playing` turns false and
335
+ * `positionMs` returns to 0, so `play()` replays it. `seek(ms)` moves the
336
+ * playhead. Call `play()` from a user gesture — browsers refuse to start
337
+ * audio otherwise and the hook rejects with `code: "BLOCKED"`.
338
+ *
339
+ * Safe-by-default: on a host that brokers no player `supported` is false and
340
+ * `play()` rejects with `code: "UNSUPPORTED"`.
341
+ */
342
+ export function useAudioPlayer(uri) {
343
+ const ctx = useWidgetContextOrThrow("useAudioPlayer");
344
+ const [playback, setPlayback] = useState(IDLE_PLAYBACK);
345
+ const [error, setError] = useState(null);
346
+
347
+ const clientRef = useRef(ctx.device && ctx.device.audioPlayer);
348
+ clientRef.current = ctx.device && ctx.device.audioPlayer;
349
+ const handleRef = useRef(null);
350
+
351
+ const supported = isUsable(clientRef.current, "load");
352
+ const source = typeof uri === "string" && uri ? uri : null;
353
+
354
+ useEffect(() => {
355
+ setPlayback(IDLE_PLAYBACK);
356
+ setError(null);
357
+ const client = clientRef.current;
358
+ if (!source || !isUsable(client, "load")) return undefined;
359
+ let live = true;
360
+ let handle = null;
361
+ try {
362
+ handle = client.load(source, (status) => {
363
+ if (!live || !status) return;
364
+ if (status.error) {
365
+ setError(toPlayerError(status.error));
366
+ return;
367
+ }
368
+ setPlayback({
369
+ loaded: Boolean(status.loaded),
370
+ playing: Boolean(status.playing),
371
+ positionMs:
372
+ typeof status.positionMs === "number" ? status.positionMs : 0,
373
+ durationMs:
374
+ typeof status.durationMs === "number" &&
375
+ Number.isFinite(status.durationMs)
376
+ ? status.durationMs
377
+ : null,
378
+ });
379
+ });
380
+ } catch (err) {
381
+ setError(toPlayerError(err));
382
+ }
383
+ handleRef.current = handle;
384
+ return () => {
385
+ live = false;
386
+ if (handleRef.current === handle) handleRef.current = null;
387
+ releaseQuietly(handle && handle.release);
388
+ };
389
+ }, [source]);
390
+
391
+ const requireHandle = useCallback(() => {
392
+ const handle = handleRef.current;
393
+ if (handle) return handle;
394
+ const unsupported = !isUsable(clientRef.current, "load");
395
+ const e = new AudioPlayerError(
396
+ unsupported ? "UNSUPPORTED" : "LOAD_FAILED",
397
+ unsupported
398
+ ? "This host does not provide audio playback."
399
+ : "There is no clip to play.",
400
+ );
401
+ setError(e);
402
+ throw e;
403
+ }, []);
404
+
405
+ const play = useCallback(async () => {
406
+ const handle = requireHandle();
407
+ setError(null);
408
+ try {
409
+ await handle.play();
410
+ } catch (err) {
411
+ const pe = toPlayerError(err);
412
+ setError(pe);
413
+ throw pe;
414
+ }
415
+ }, [requireHandle]);
416
+
417
+ const pause = useCallback(() => {
418
+ const handle = handleRef.current;
419
+ if (handle) handle.pause();
420
+ }, []);
421
+
422
+ const seek = useCallback(
423
+ async (ms) => {
424
+ const handle = requireHandle();
425
+ const target = Math.max(0, Number(ms) || 0);
426
+ try {
427
+ await handle.seek(target);
428
+ } catch (err) {
429
+ const pe = toPlayerError(err);
430
+ setError(pe);
431
+ throw pe;
432
+ }
433
+ },
434
+ [requireHandle],
435
+ );
436
+
437
+ return {
438
+ supported,
439
+ loading: Boolean(source) && supported && !playback.loaded && !error,
440
+ playing: playback.playing,
441
+ positionMs: playback.positionMs,
442
+ durationMs: playback.durationMs,
443
+ error,
444
+ play,
445
+ pause,
446
+ seek,
447
+ };
448
+ }
package/dist/contract.cjs CHANGED
@@ -2212,6 +2212,64 @@ const HOOKS = [
2212
2212
  requiredContextSlice: [],
2213
2213
  scopes: null,
2214
2214
  },
2215
+ // sc-10590 — host-brokered audio recording + playback. Optional slices; the
2216
+ // hooks report supported:false rather than throwing at render.
2217
+ {
2218
+ name: "useAudioRecorder",
2219
+ signature: "useAudioRecorder()",
2220
+ description:
2221
+ "Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
2222
+ "cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
2223
+ "mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
2224
+ "durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
2225
+ "for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
2226
+ "useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
2227
+ "releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
2228
+ "`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
2229
+ "AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
2230
+ "where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
2231
+ "library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
2232
+ "upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
2233
+ "`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
2234
+ returnShape: {
2235
+ supported: "boolean // false when the host brokers no recorder",
2236
+ permission: "'granted' | 'denied' | 'undetermined'",
2237
+ recording: "boolean",
2238
+ durationMs: "number // length of the current (or last) recording",
2239
+ level: "number // input level 0..1 while recording",
2240
+ error: "AudioRecorderError | null",
2241
+ start: "() => Promise<void> // rejects with AudioRecorderError",
2242
+ stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
2243
+ cancel: "() => void // stop and discard the clip",
2244
+ },
2245
+ requiredContextSlice: [],
2246
+ scopes: null,
2247
+ },
2248
+ {
2249
+ name: "useAudioPlayer",
2250
+ signature: "useAudioPlayer(uri)",
2251
+ description:
2252
+ "Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
2253
+ "is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
2254
+ "resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
2255
+ "length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
2256
+ "the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
2257
+ "with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
2258
+ "WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
2259
+ returnShape: {
2260
+ supported: "boolean // false when the host brokers no player",
2261
+ loading: "boolean // true until the host has loaded the clip",
2262
+ playing: "boolean",
2263
+ positionMs: "number",
2264
+ durationMs: "number | null // null until known",
2265
+ error: "AudioPlayerError | null",
2266
+ play: "() => Promise<void> // rejects with AudioPlayerError",
2267
+ pause: "() => void",
2268
+ seek: "(positionMs) => Promise<void>",
2269
+ },
2270
+ requiredContextSlice: [],
2271
+ scopes: null,
2272
+ },
2215
2273
  // sc-6448 — host-brokered camera capture / photo picking. Optional slice;
2216
2274
  // the hook reports supported:false rather than throwing at render.
2217
2275
  {
@@ -3154,10 +3212,17 @@ const WIDGET_CONTEXT_SHAPE = {
3154
3212
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
3155
3213
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
3156
3214
  "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
3157
- "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
3158
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
3159
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
3160
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
3215
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
3216
+ "audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
3217
+ "start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
3218
+ "audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
3219
+ "{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
3220
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
3221
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector, a new browser tab, MediaRecorder and an <audio> element; the Expo export via " +
3222
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
3223
+ "audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
3224
+ "(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
3225
+ "still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
3161
3226
  "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
3162
3227
  "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
3163
3228
  "cache and hands them to the OS print sheet. " +
@@ -3189,6 +3254,8 @@ const WIDGET_CONTEXT_SHAPE = {
3189
3254
  imageEditor: "object",
3190
3255
  barcode: "object",
3191
3256
  pdfViewer: "object",
3257
+ audioRecorder: "object",
3258
+ audioPlayer: "object",
3192
3259
  },
3193
3260
  },
3194
3261
  };
@@ -3380,7 +3447,8 @@ const VETTED_IMPORTS = [
3380
3447
  platforms: ["native"],
3381
3448
  category: "media",
3382
3449
  description:
3383
- "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx.",
3450
+ "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
3451
+ "To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
3384
3452
  },
3385
3453
  {
3386
3454
  specifier: "expo-video",
@@ -4998,7 +5066,11 @@ const CONTRACT = deepFreeze({
4998
5066
  // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4999
5067
  // `{ preview, loading, error, available }` from the new optional
5000
5068
  // `linkPreviews` slice (@colixsystems/link-preview-client).
5001
- version: "1.152.0",
5069
+ // 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
5070
+ // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5071
+ // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5072
+ // on native), with `AudioRecorderError` / `AudioPlayerError`.
5073
+ version: "1.153.0",
5002
5074
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5003
5075
  hooks: HOOKS,
5004
5076
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2212,6 +2212,64 @@ const HOOKS = [
2212
2212
  requiredContextSlice: [],
2213
2213
  scopes: null,
2214
2214
  },
2215
+ // sc-10590 — host-brokered audio recording + playback. Optional slices; the
2216
+ // hooks report supported:false rather than throwing at render.
2217
+ {
2218
+ name: "useAudioRecorder",
2219
+ signature: "useAudioRecorder()",
2220
+ description:
2221
+ "Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
2222
+ "cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
2223
+ "mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
2224
+ "durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
2225
+ "for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
2226
+ "useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
2227
+ "releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
2228
+ "`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
2229
+ "AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
2230
+ "where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
2231
+ "library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
2232
+ "upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
2233
+ "`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
2234
+ returnShape: {
2235
+ supported: "boolean // false when the host brokers no recorder",
2236
+ permission: "'granted' | 'denied' | 'undetermined'",
2237
+ recording: "boolean",
2238
+ durationMs: "number // length of the current (or last) recording",
2239
+ level: "number // input level 0..1 while recording",
2240
+ error: "AudioRecorderError | null",
2241
+ start: "() => Promise<void> // rejects with AudioRecorderError",
2242
+ stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
2243
+ cancel: "() => void // stop and discard the clip",
2244
+ },
2245
+ requiredContextSlice: [],
2246
+ scopes: null,
2247
+ },
2248
+ {
2249
+ name: "useAudioPlayer",
2250
+ signature: "useAudioPlayer(uri)",
2251
+ description:
2252
+ "Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
2253
+ "is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
2254
+ "resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
2255
+ "length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
2256
+ "the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
2257
+ "with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
2258
+ "WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
2259
+ returnShape: {
2260
+ supported: "boolean // false when the host brokers no player",
2261
+ loading: "boolean // true until the host has loaded the clip",
2262
+ playing: "boolean",
2263
+ positionMs: "number",
2264
+ durationMs: "number | null // null until known",
2265
+ error: "AudioPlayerError | null",
2266
+ play: "() => Promise<void> // rejects with AudioPlayerError",
2267
+ pause: "() => void",
2268
+ seek: "(positionMs) => Promise<void>",
2269
+ },
2270
+ requiredContextSlice: [],
2271
+ scopes: null,
2272
+ },
2215
2273
  // sc-6448 — host-brokered camera capture / photo picking. Optional slice;
2216
2274
  // the hook reports supported:false rather than throwing at render.
2217
2275
  {
@@ -3150,10 +3208,17 @@ const WIDGET_CONTEXT_SHAPE = {
3150
3208
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
3151
3209
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
3152
3210
  "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
3153
- "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
3154
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
3155
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
3156
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
3211
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
3212
+ "audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
3213
+ "start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
3214
+ "audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
3215
+ "{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
3216
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
3217
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector, a new browser tab, MediaRecorder and an <audio> element; the Expo export via " +
3218
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
3219
+ "audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
3220
+ "(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
3221
+ "still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
3157
3222
  "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
3158
3223
  "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
3159
3224
  "cache and hands them to the OS print sheet. " +
@@ -3185,6 +3250,8 @@ const WIDGET_CONTEXT_SHAPE = {
3185
3250
  imageEditor: "object",
3186
3251
  barcode: "object",
3187
3252
  pdfViewer: "object",
3253
+ audioRecorder: "object",
3254
+ audioPlayer: "object",
3188
3255
  },
3189
3256
  },
3190
3257
  };
@@ -3376,7 +3443,8 @@ const VETTED_IMPORTS = [
3376
3443
  platforms: ["native"],
3377
3444
  category: "media",
3378
3445
  description:
3379
- "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx.",
3446
+ "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
3447
+ "To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
3380
3448
  },
3381
3449
  {
3382
3450
  specifier: "expo-video",
@@ -4994,7 +5062,11 @@ const CONTRACT = deepFreeze({
4994
5062
  // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4995
5063
  // `{ preview, loading, error, available }` from the new optional
4996
5064
  // `linkPreviews` slice (@colixsystems/link-preview-client).
4997
- version: "1.152.0",
5065
+ // 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
5066
+ // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5067
+ // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5068
+ // on native), with `AudioRecorderError` / `AudioPlayerError`.
5069
+ version: "1.153.0",
4998
5070
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4999
5071
  hooks: HOOKS,
5000
5072
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -717,10 +717,40 @@ export interface WidgetContext<TProps = unknown> {
717
717
  showToast(args: { kind?: string; message: string }): void;
718
718
  };
719
719
  /**
720
- * Optional host-brokered device capabilities; backs useGeolocation and
721
- * useSpeechToText.
720
+ * Optional host-brokered device capabilities; backs useGeolocation,
721
+ * useSpeechToText, useAudioRecorder and useAudioPlayer.
722
722
  */
723
723
  device?: {
724
+ /** sc-10590 — backs useAudioRecorder. */
725
+ audioRecorder?: {
726
+ isSupported?(): boolean;
727
+ getPermission?(): Promise<AudioPermissionState>;
728
+ start(): Promise<{
729
+ /** `ended`: the host stopped it itself; stop() still resolves the clip. */
730
+ getStatus(): { durationMs: number; levelDb: number | null; ended?: boolean };
731
+ stop(): Promise<AudioClip & { release?(): void }>;
732
+ cancel(): void;
733
+ }>;
734
+ };
735
+ /** sc-10590 — backs useAudioPlayer. */
736
+ audioPlayer?: {
737
+ isSupported?(): boolean;
738
+ load(
739
+ uri: string,
740
+ onStatus: (status: {
741
+ loaded: boolean;
742
+ playing: boolean;
743
+ positionMs: number;
744
+ durationMs: number | null;
745
+ error?: { code?: string; message?: string };
746
+ }) => void,
747
+ ): {
748
+ play(): Promise<void>;
749
+ pause(): void;
750
+ seek(positionMs: number): Promise<void>;
751
+ release(): void;
752
+ };
753
+ };
724
754
  speech?: {
725
755
  isSupported?(): boolean;
726
756
  start(
@@ -1930,6 +1960,94 @@ export class SpeechToTextError extends Error {
1930
1960
  );
1931
1961
  }
1932
1962
 
1963
+ /** Microphone permission as `useAudioRecorder()` reports it. */
1964
+ export type AudioPermissionState = "granted" | "denied" | "undetermined";
1965
+
1966
+ /** A finished recording, as `useAudioRecorder().stop()` resolves it. */
1967
+ export interface AudioClip {
1968
+ /**
1969
+ * The upload part for this host: a `File` on web, expo-file-system's `File`
1970
+ * on native (not `instanceof Blob` at runtime) — append it, don't branch on it.
1971
+ */
1972
+ file: unknown;
1973
+ /** Plays the clip locally via `useAudioPlayer(uri)` until the hook releases it. */
1974
+ uri: string;
1975
+ durationMs: number;
1976
+ /** e.g. "audio/mp4" or "audio/webm". */
1977
+ mime: string;
1978
+ }
1979
+
1980
+ export interface AudioRecorderResult {
1981
+ /** False when the host brokers no recorder; disable the record control. */
1982
+ supported: boolean;
1983
+ permission: AudioPermissionState;
1984
+ recording: boolean;
1985
+ /** Length of the current (or last) recording. */
1986
+ durationMs: number;
1987
+ /** Input level from 0 (silence) to 1, for a live meter. */
1988
+ level: number;
1989
+ error: AudioRecorderError | null;
1990
+ /** Start recording — call from a user gesture. Rejects with AudioRecorderError. */
1991
+ start(): Promise<void>;
1992
+ /** Finish and keep the clip; `null` when nothing was recording. */
1993
+ stop(): Promise<AudioClip | null>;
1994
+ /** Stop and discard the clip. */
1995
+ cancel(): void;
1996
+ }
1997
+
1998
+ /**
1999
+ * Record a clip from the microphone. The web Player brokers it via
2000
+ * MediaRecorder, the Expo export via `expo-audio`. Capture is imperative (call
2001
+ * `start()` from a user gesture). Safe to call on a host that brokers no
2002
+ * recorder: `supported` is then false and `start()` rejects with
2003
+ * `code: "UNSUPPORTED"`.
2004
+ */
2005
+ export function useAudioRecorder(): AudioRecorderResult;
2006
+
2007
+ /** Error surfaced by `useAudioRecorder()`. */
2008
+ export class AudioRecorderError extends Error {
2009
+ code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL";
2010
+ constructor(
2011
+ code: AudioRecorderError["code"],
2012
+ message: string,
2013
+ opts?: { cause?: unknown },
2014
+ );
2015
+ }
2016
+
2017
+ export interface AudioPlayerResult {
2018
+ /** False when the host brokers no player. */
2019
+ supported: boolean;
2020
+ /** True from a new `uri` until the host has loaded it. */
2021
+ loading: boolean;
2022
+ playing: boolean;
2023
+ positionMs: number;
2024
+ /** `null` until the host knows the clip's length. */
2025
+ durationMs: number | null;
2026
+ error: AudioPlayerError | null;
2027
+ /** Start or resume — call from a user gesture. Rejects with AudioPlayerError. */
2028
+ play(): Promise<void>;
2029
+ pause(): void;
2030
+ seek(positionMs: number): Promise<void>;
2031
+ }
2032
+
2033
+ /**
2034
+ * Play one clip from `uri` (a stored file's URL, or a recording's local
2035
+ * `uri`). The web Player brokers it via an `<audio>` element, the Expo export
2036
+ * via `expo-audio`. When the clip ends, `playing` turns false and
2037
+ * `positionMs` returns to 0.
2038
+ */
2039
+ export function useAudioPlayer(uri: string | null | undefined): AudioPlayerResult;
2040
+
2041
+ /** Error surfaced by `useAudioPlayer()`. */
2042
+ export class AudioPlayerError extends Error {
2043
+ code: "UNSUPPORTED" | "LOAD_FAILED" | "BLOCKED" | "INTERNAL";
2044
+ constructor(
2045
+ code: AudioPlayerError["code"],
2046
+ message: string,
2047
+ opts?: { cause?: unknown },
2048
+ );
2049
+ }
2050
+
1933
2051
  /**
1934
2052
  * Options for `useCamera(...)`. Both are HINTS the host honours where it can:
1935
2053
  * the Expo export applies them, the web file input applies neither — so never
package/dist/index.js CHANGED
@@ -108,6 +108,14 @@ export {
108
108
  // the web variant and index.native.js picks the native variant.
109
109
  export { useClipboard, ClipboardError } from "./clipboard.js";
110
110
  export { useToast } from "./toast.js";
111
+ // sc-10590 — one host-brokered source for both entries: each host injects
112
+ // ctx.device.audioRecorder / audioPlayer, so neither file splits by platform.
113
+ export {
114
+ useAudioRecorder,
115
+ AudioRecorderError,
116
+ useAudioPlayer,
117
+ AudioPlayerError,
118
+ } from "./audio.js";
111
119
  export { useDatastoreFigure } from "./datastore-figure.js";
112
120
  // sc-6607 — `<Overlay>` is one shared component (./overlay-view.js) bound to
113
121
  // each platform's primitives, so a widget's modal escapes its container box
@@ -106,6 +106,14 @@ export {
106
106
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
107
107
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
108
108
  export { useToast } from "./toast.native.js";
109
+ // sc-10590 — one host-brokered source for both entries: each host injects
110
+ // ctx.device.audioRecorder / audioPlayer, so neither file splits by platform.
111
+ export {
112
+ useAudioRecorder,
113
+ AudioRecorderError,
114
+ useAudioPlayer,
115
+ AudioPlayerError,
116
+ } from "./audio.js";
109
117
  export { useDatastoreFigure } from "./datastore-figure.js";
110
118
  // sc-6607 — see the note on the web mirror in ./index.js.
111
119
  export { Overlay } from "./overlay.native.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.191.0",
3
+ "version": "0.192.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -2212,6 +2212,64 @@ const HOOKS = [
2212
2212
  requiredContextSlice: [],
2213
2213
  scopes: null,
2214
2214
  },
2215
+ // sc-10590 — host-brokered audio recording + playback. Optional slices; the
2216
+ // hooks report supported:false rather than throwing at render.
2217
+ {
2218
+ name: "useAudioRecorder",
2219
+ signature: "useAudioRecorder()",
2220
+ description:
2221
+ "Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
2222
+ "cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
2223
+ "mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
2224
+ "durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
2225
+ "for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
2226
+ "useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
2227
+ "releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
2228
+ "`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
2229
+ "AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
2230
+ "where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
2231
+ "library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
2232
+ "upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
2233
+ "`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
2234
+ returnShape: {
2235
+ supported: "boolean // false when the host brokers no recorder",
2236
+ permission: "'granted' | 'denied' | 'undetermined'",
2237
+ recording: "boolean",
2238
+ durationMs: "number // length of the current (or last) recording",
2239
+ level: "number // input level 0..1 while recording",
2240
+ error: "AudioRecorderError | null",
2241
+ start: "() => Promise<void> // rejects with AudioRecorderError",
2242
+ stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
2243
+ cancel: "() => void // stop and discard the clip",
2244
+ },
2245
+ requiredContextSlice: [],
2246
+ scopes: null,
2247
+ },
2248
+ {
2249
+ name: "useAudioPlayer",
2250
+ signature: "useAudioPlayer(uri)",
2251
+ description:
2252
+ "Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
2253
+ "is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
2254
+ "resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
2255
+ "length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
2256
+ "the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
2257
+ "with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
2258
+ "WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
2259
+ returnShape: {
2260
+ supported: "boolean // false when the host brokers no player",
2261
+ loading: "boolean // true until the host has loaded the clip",
2262
+ playing: "boolean",
2263
+ positionMs: "number",
2264
+ durationMs: "number | null // null until known",
2265
+ error: "AudioPlayerError | null",
2266
+ play: "() => Promise<void> // rejects with AudioPlayerError",
2267
+ pause: "() => void",
2268
+ seek: "(positionMs) => Promise<void>",
2269
+ },
2270
+ requiredContextSlice: [],
2271
+ scopes: null,
2272
+ },
2215
2273
  // sc-6448 — host-brokered camera capture / photo picking. Optional slice;
2216
2274
  // the hook reports supported:false rather than throwing at render.
2217
2275
  {
@@ -3150,10 +3208,17 @@ const WIDGET_CONTEXT_SHAPE = {
3150
3208
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
3151
3209
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
3152
3210
  "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
3153
- "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
3154
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
3155
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
3156
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
3211
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
3212
+ "audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
3213
+ "start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
3214
+ "audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
3215
+ "{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
3216
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
3217
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector, a new browser tab, MediaRecorder and an <audio> element; the Expo export via " +
3218
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
3219
+ "audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
3220
+ "(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
3221
+ "still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
3157
3222
  "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
3158
3223
  "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
3159
3224
  "cache and hands them to the OS print sheet. " +
@@ -3185,6 +3250,8 @@ const WIDGET_CONTEXT_SHAPE = {
3185
3250
  imageEditor: "object",
3186
3251
  barcode: "object",
3187
3252
  pdfViewer: "object",
3253
+ audioRecorder: "object",
3254
+ audioPlayer: "object",
3188
3255
  },
3189
3256
  },
3190
3257
  };
@@ -3376,7 +3443,8 @@ const VETTED_IMPORTS = [
3376
3443
  platforms: ["native"],
3377
3444
  category: "media",
3378
3445
  description:
3379
- "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx.",
3446
+ "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
3447
+ "To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
3380
3448
  },
3381
3449
  {
3382
3450
  specifier: "expo-video",
@@ -4994,7 +5062,11 @@ const CONTRACT = deepFreeze({
4994
5062
  // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4995
5063
  // `{ preview, loading, error, available }` from the new optional
4996
5064
  // `linkPreviews` slice (@colixsystems/link-preview-client).
4997
- version: "1.152.0",
5065
+ // 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
5066
+ // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5067
+ // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5068
+ // on native), with `AudioRecorderError` / `AudioPlayerError`.
5069
+ version: "1.153.0",
4998
5070
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4999
5071
  hooks: HOOKS,
5000
5072
  primitives: PRIMITIVES,