@colixsystems/widget-sdk 0.190.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 +41 -1
- package/dist/audio.js +448 -0
- package/dist/contract.cjs +115 -6
- package/dist/contract.js +115 -6
- package/dist/hooks.js +230 -0
- package/dist/index.d.ts +172 -2
- package/dist/index.js +10 -0
- package/dist/index.native.js +10 -0
- package/package.json +1 -1
- package/src/contract.js +115 -6
package/README.md
CHANGED
|
@@ -43,6 +43,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
43
43
|
| **CORE** | `useCamera(options)` (optional: options) | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
|
|
44
44
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
45
45
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
46
|
+
| **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
|
|
46
47
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
47
48
|
| **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
|
|
48
49
|
| **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
|
|
@@ -76,7 +77,46 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
76
77
|
|
|
77
78
|
## Status
|
|
78
79
|
|
|
79
|
-
`v0.
|
|
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.
|
|
101
|
+
|
|
102
|
+
### What's new in 0.191.0 (contract 1.152.0)
|
|
103
|
+
|
|
104
|
+
**New `useLinkPreview(url)` hook: show a preview card for a link.** It returns `{ preview, loading, error, available }`, where `preview` is `{ url, title, description, site_name, image_url }` (the wire's snake_case names) once it loads. The platform fetches the page, so the device showing the link never contacts the linked site. `image_url` is a signed URL that loads directly in an `<Image>` with no auth headers.
|
|
105
|
+
|
|
106
|
+
A page with nothing to show (unreachable, not HTML, too slow) is not an error: it resolves with every field but `url` set to `null`, and you render the plain link. A `url` that is not an absolute `http:`/`https:` URL returns `preview: null` without a request, so you can pass the first link you found in a message straight in. Results are kept for up to an hour and shared by every widget, and concurrent calls for one URL make one request, so a message list may call the hook per row. `error` is a `LinkPreviewError` whose `.code` is `VALIDATION`, `AUTH_REQUIRED`, `RATE_LIMITED`, `NETWORK` or `INTERNAL`; render the plain link on any error. A `RATE_LIMITED` failure is retried once, automatically, after the `retryAfter` the server sends (clamped to 1–60 s), so a long list of links still fills in. `available` is `false` on a host that brokers no link-preview client, and the hook then never fetches.
|
|
107
|
+
|
|
108
|
+
```jsx
|
|
109
|
+
const { preview } = useLinkPreview(firstLink);
|
|
110
|
+
// {preview && preview.title && (
|
|
111
|
+
// <View>
|
|
112
|
+
// {preview.image_url && <Image source={{ uri: preview.image_url }} style={{ width: 64, height: 64 }} />}
|
|
113
|
+
// <Text>{preview.title}</Text>
|
|
114
|
+
// {preview.site_name && <Text>{preview.site_name}</Text>}
|
|
115
|
+
// </View>
|
|
116
|
+
// )}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
It needs no scope. The host injects `@colixsystems/link-preview-client` as the new optional `ctx.linkPreviews` slice. Additive; works identically in the web Player and the native export.
|
|
80
120
|
|
|
81
121
|
### What's new in 0.190.0 (contract 1.151.0)
|
|
82
122
|
|
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
|
+
}
|