@colixsystems/widget-sdk 0.191.0 → 0.193.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 +44 -2
- package/dist/audio.js +448 -0
- package/dist/contract.cjs +89 -6
- package/dist/contract.js +89 -6
- package/dist/hooks.js +7 -2
- package/dist/index.d.ts +130 -6
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/package.json +1 -1
- package/src/contract.js +89 -6
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
53
53
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
54
54
|
| **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
|
|
55
55
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
56
|
-
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
56
|
+
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). For a live list of the signed-in user's own rows pass `{ kind: "granted" }`: every row they read through a grant to them or one of their groups, plus `onAccessGained(record)` / `onAccessLost({ id })` as rows enter and leave that set. Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
57
57
|
| **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
|
|
58
58
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
59
59
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
@@ -77,7 +77,49 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
77
77
|
|
|
78
78
|
## Status
|
|
79
79
|
|
|
80
|
-
`v0.
|
|
80
|
+
`v0.193.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.193.0 (contract 1.154.0)
|
|
83
|
+
|
|
84
|
+
**`useDatastoreSubscription` can keep a list of the user's own rows live.** Pass `{ scope: { kind: "granted" } }` and the hook streams every row of the table the signed-in user can read through a grant to them or to one of their groups — their conversations, their cases. Without it such a list had to poll: a whole-table subscribe needs read on every row, and one record scope per row runs out of subscriptions.
|
|
85
|
+
|
|
86
|
+
Two new handlers report changes to the set itself. `onAccessGained(record)` fires with the full row when a grant starts letting the user read it, for example when they are added to a conversation. `onAccessLost({ id })` fires when they can no longer read it, and no further event for that row arrives. Merge `onCreated` / `onUpdated` / `onAccessGained` into your list by `id`, and remove the row on `onDeleted` or `onAccessLost`:
|
|
87
|
+
|
|
88
|
+
```jsx
|
|
89
|
+
const { data, refetch } = useDatastoreQuery(conversationsTableId);
|
|
90
|
+
const [live, setLive] = useState([]);
|
|
91
|
+
useEffect(() => setLive(data), [data]);
|
|
92
|
+
const upsert = (row) => setLive((list) => [row, ...list.filter((r) => r.id !== row.id)]);
|
|
93
|
+
const drop = ({ id }) => setLive((list) => list.filter((r) => r.id !== id));
|
|
94
|
+
const { status } = useDatastoreSubscription(
|
|
95
|
+
conversationsTableId,
|
|
96
|
+
{ onCreated: upsert, onUpdated: upsert, onAccessGained: upsert, onDeleted: drop, onAccessLost: drop },
|
|
97
|
+
{ scope: { kind: "granted" } },
|
|
98
|
+
);
|
|
99
|
+
// Poll with refetch() only while status === "fallback".
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A logged-out visitor's granted subscribe resolves to `{ status: "fallback" }`. Backed by datastore-client 0.21.0. Additive; works identically in the web Player and the native export.
|
|
103
|
+
|
|
104
|
+
### What's new in 0.192.0 (contract 1.153.0)
|
|
105
|
+
|
|
106
|
+
**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`.
|
|
107
|
+
|
|
108
|
+
`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`).
|
|
109
|
+
|
|
110
|
+
`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`).
|
|
111
|
+
|
|
112
|
+
```jsx
|
|
113
|
+
const recorder = useAudioRecorder();
|
|
114
|
+
const { upload } = useFilestoreUpload({ spaceType: "project" });
|
|
115
|
+
// start: <Pressable onPress={() => recorder.start().catch(() => {})}>
|
|
116
|
+
// stop: try { const clip = await recorder.stop(); if (clip) await upload(clip.file); } catch { /* show it failed */ }
|
|
117
|
+
|
|
118
|
+
const player = useAudioPlayer(url);
|
|
119
|
+
// <Pressable onPress={() => (player.playing ? player.pause() : player.play().catch(() => {}))}>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
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
123
|
|
|
82
124
|
### What's new in 0.191.0 (contract 1.152.0)
|
|
83
125
|
|
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
|
@@ -2060,6 +2060,13 @@ const HOOKS = [
|
|
|
2060
2060
|
"table governed by per-record grants, since a scope-less subscribe must " +
|
|
2061
2061
|
"prove read-every-row. A `parent` scope requires the column be an " +
|
|
2062
2062
|
"inheritAcl RELATION, else the subscribe resolves to " +
|
|
2063
|
+
"{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
|
|
2064
|
+
"signed-in user can read through a grant to them or to one of their " +
|
|
2065
|
+
"groups — the way to keep a list of 'my conversations' or 'my cases' " +
|
|
2066
|
+
"live — and adds two handlers: onAccessGained(record) when a row " +
|
|
2067
|
+
"becomes readable (the full row) and onAccessLost({ id }) when it stops " +
|
|
2068
|
+
"being readable, after which no further event for it arrives. A " +
|
|
2069
|
+
"logged-out visitor's granted subscribe resolves to " +
|
|
2063
2070
|
"{ status: 'fallback' }.",
|
|
2064
2071
|
returnShape: {
|
|
2065
2072
|
status:
|
|
@@ -2212,6 +2219,64 @@ const HOOKS = [
|
|
|
2212
2219
|
requiredContextSlice: [],
|
|
2213
2220
|
scopes: null,
|
|
2214
2221
|
},
|
|
2222
|
+
// sc-10590 — host-brokered audio recording + playback. Optional slices; the
|
|
2223
|
+
// hooks report supported:false rather than throwing at render.
|
|
2224
|
+
{
|
|
2225
|
+
name: "useAudioRecorder",
|
|
2226
|
+
signature: "useAudioRecorder()",
|
|
2227
|
+
description:
|
|
2228
|
+
"Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
|
|
2229
|
+
"cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
|
|
2230
|
+
"mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
|
|
2231
|
+
"durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
|
|
2232
|
+
"for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
|
|
2233
|
+
"useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
|
|
2234
|
+
"releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
|
|
2235
|
+
"`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
|
|
2236
|
+
"AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
|
|
2237
|
+
"where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
|
|
2238
|
+
"library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
|
|
2239
|
+
"upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
|
|
2240
|
+
"`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
|
|
2241
|
+
returnShape: {
|
|
2242
|
+
supported: "boolean // false when the host brokers no recorder",
|
|
2243
|
+
permission: "'granted' | 'denied' | 'undetermined'",
|
|
2244
|
+
recording: "boolean",
|
|
2245
|
+
durationMs: "number // length of the current (or last) recording",
|
|
2246
|
+
level: "number // input level 0..1 while recording",
|
|
2247
|
+
error: "AudioRecorderError | null",
|
|
2248
|
+
start: "() => Promise<void> // rejects with AudioRecorderError",
|
|
2249
|
+
stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
|
|
2250
|
+
cancel: "() => void // stop and discard the clip",
|
|
2251
|
+
},
|
|
2252
|
+
requiredContextSlice: [],
|
|
2253
|
+
scopes: null,
|
|
2254
|
+
},
|
|
2255
|
+
{
|
|
2256
|
+
name: "useAudioPlayer",
|
|
2257
|
+
signature: "useAudioPlayer(uri)",
|
|
2258
|
+
description:
|
|
2259
|
+
"Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
|
|
2260
|
+
"is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
|
|
2261
|
+
"resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
|
|
2262
|
+
"length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
|
|
2263
|
+
"the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
|
|
2264
|
+
"with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
|
|
2265
|
+
"WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
|
|
2266
|
+
returnShape: {
|
|
2267
|
+
supported: "boolean // false when the host brokers no player",
|
|
2268
|
+
loading: "boolean // true until the host has loaded the clip",
|
|
2269
|
+
playing: "boolean",
|
|
2270
|
+
positionMs: "number",
|
|
2271
|
+
durationMs: "number | null // null until known",
|
|
2272
|
+
error: "AudioPlayerError | null",
|
|
2273
|
+
play: "() => Promise<void> // rejects with AudioPlayerError",
|
|
2274
|
+
pause: "() => void",
|
|
2275
|
+
seek: "(positionMs) => Promise<void>",
|
|
2276
|
+
},
|
|
2277
|
+
requiredContextSlice: [],
|
|
2278
|
+
scopes: null,
|
|
2279
|
+
},
|
|
2215
2280
|
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
2216
2281
|
// the hook reports supported:false rather than throwing at render.
|
|
2217
2282
|
{
|
|
@@ -3154,10 +3219,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3154
3219
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
|
|
3155
3220
|
"imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
|
|
3156
3221
|
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
|
|
3157
|
-
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }
|
|
3158
|
-
"
|
|
3159
|
-
"
|
|
3160
|
-
"
|
|
3222
|
+
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
|
|
3223
|
+
"audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
|
|
3224
|
+
"start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
|
|
3225
|
+
"audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
|
|
3226
|
+
"{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
|
|
3227
|
+
"Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
|
|
3228
|
+
"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 " +
|
|
3229
|
+
"expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
|
|
3230
|
+
"audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
|
|
3231
|
+
"(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
|
|
3232
|
+
"still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
|
|
3161
3233
|
"pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
|
|
3162
3234
|
"(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
|
|
3163
3235
|
"cache and hands them to the OS print sheet. " +
|
|
@@ -3189,6 +3261,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3189
3261
|
imageEditor: "object",
|
|
3190
3262
|
barcode: "object",
|
|
3191
3263
|
pdfViewer: "object",
|
|
3264
|
+
audioRecorder: "object",
|
|
3265
|
+
audioPlayer: "object",
|
|
3192
3266
|
},
|
|
3193
3267
|
},
|
|
3194
3268
|
};
|
|
@@ -3380,7 +3454,8 @@ const VETTED_IMPORTS = [
|
|
|
3380
3454
|
platforms: ["native"],
|
|
3381
3455
|
category: "media",
|
|
3382
3456
|
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."
|
|
3457
|
+
"Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
|
|
3458
|
+
"To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
|
|
3384
3459
|
},
|
|
3385
3460
|
{
|
|
3386
3461
|
specifier: "expo-video",
|
|
@@ -4998,7 +5073,15 @@ const CONTRACT = deepFreeze({
|
|
|
4998
5073
|
// 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
|
|
4999
5074
|
// `{ preview, loading, error, available }` from the new optional
|
|
5000
5075
|
// `linkPreviews` slice (@colixsystems/link-preview-client).
|
|
5001
|
-
|
|
5076
|
+
// 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
|
|
5077
|
+
// `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
|
|
5078
|
+
// `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
|
|
5079
|
+
// on native), with `AudioRecorderError` / `AudioPlayerError`.
|
|
5080
|
+
// 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
|
|
5081
|
+
// `scope: { kind: "granted" }` (the rows the signed-in user reads through
|
|
5082
|
+
// a user or group grant) and the `onAccessGained` / `onAccessLost`
|
|
5083
|
+
// handlers that report rows entering and leaving that set.
|
|
5084
|
+
version: "1.154.0",
|
|
5002
5085
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5003
5086
|
hooks: HOOKS,
|
|
5004
5087
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -2060,6 +2060,13 @@ const HOOKS = [
|
|
|
2060
2060
|
"table governed by per-record grants, since a scope-less subscribe must " +
|
|
2061
2061
|
"prove read-every-row. A `parent` scope requires the column be an " +
|
|
2062
2062
|
"inheritAcl RELATION, else the subscribe resolves to " +
|
|
2063
|
+
"{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
|
|
2064
|
+
"signed-in user can read through a grant to them or to one of their " +
|
|
2065
|
+
"groups — the way to keep a list of 'my conversations' or 'my cases' " +
|
|
2066
|
+
"live — and adds two handlers: onAccessGained(record) when a row " +
|
|
2067
|
+
"becomes readable (the full row) and onAccessLost({ id }) when it stops " +
|
|
2068
|
+
"being readable, after which no further event for it arrives. A " +
|
|
2069
|
+
"logged-out visitor's granted subscribe resolves to " +
|
|
2063
2070
|
"{ status: 'fallback' }.",
|
|
2064
2071
|
returnShape: {
|
|
2065
2072
|
status:
|
|
@@ -2212,6 +2219,64 @@ const HOOKS = [
|
|
|
2212
2219
|
requiredContextSlice: [],
|
|
2213
2220
|
scopes: null,
|
|
2214
2221
|
},
|
|
2222
|
+
// sc-10590 — host-brokered audio recording + playback. Optional slices; the
|
|
2223
|
+
// hooks report supported:false rather than throwing at render.
|
|
2224
|
+
{
|
|
2225
|
+
name: "useAudioRecorder",
|
|
2226
|
+
signature: "useAudioRecorder()",
|
|
2227
|
+
description:
|
|
2228
|
+
"Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
|
|
2229
|
+
"cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
|
|
2230
|
+
"mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
|
|
2231
|
+
"durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
|
|
2232
|
+
"for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
|
|
2233
|
+
"useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
|
|
2234
|
+
"releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
|
|
2235
|
+
"`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
|
|
2236
|
+
"AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
|
|
2237
|
+
"where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
|
|
2238
|
+
"library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
|
|
2239
|
+
"upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
|
|
2240
|
+
"`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
|
|
2241
|
+
returnShape: {
|
|
2242
|
+
supported: "boolean // false when the host brokers no recorder",
|
|
2243
|
+
permission: "'granted' | 'denied' | 'undetermined'",
|
|
2244
|
+
recording: "boolean",
|
|
2245
|
+
durationMs: "number // length of the current (or last) recording",
|
|
2246
|
+
level: "number // input level 0..1 while recording",
|
|
2247
|
+
error: "AudioRecorderError | null",
|
|
2248
|
+
start: "() => Promise<void> // rejects with AudioRecorderError",
|
|
2249
|
+
stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
|
|
2250
|
+
cancel: "() => void // stop and discard the clip",
|
|
2251
|
+
},
|
|
2252
|
+
requiredContextSlice: [],
|
|
2253
|
+
scopes: null,
|
|
2254
|
+
},
|
|
2255
|
+
{
|
|
2256
|
+
name: "useAudioPlayer",
|
|
2257
|
+
signature: "useAudioPlayer(uri)",
|
|
2258
|
+
description:
|
|
2259
|
+
"Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
|
|
2260
|
+
"is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
|
|
2261
|
+
"resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
|
|
2262
|
+
"length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
|
|
2263
|
+
"the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
|
|
2264
|
+
"with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
|
|
2265
|
+
"WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
|
|
2266
|
+
returnShape: {
|
|
2267
|
+
supported: "boolean // false when the host brokers no player",
|
|
2268
|
+
loading: "boolean // true until the host has loaded the clip",
|
|
2269
|
+
playing: "boolean",
|
|
2270
|
+
positionMs: "number",
|
|
2271
|
+
durationMs: "number | null // null until known",
|
|
2272
|
+
error: "AudioPlayerError | null",
|
|
2273
|
+
play: "() => Promise<void> // rejects with AudioPlayerError",
|
|
2274
|
+
pause: "() => void",
|
|
2275
|
+
seek: "(positionMs) => Promise<void>",
|
|
2276
|
+
},
|
|
2277
|
+
requiredContextSlice: [],
|
|
2278
|
+
scopes: null,
|
|
2279
|
+
},
|
|
2215
2280
|
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
2216
2281
|
// the hook reports supported:false rather than throwing at render.
|
|
2217
2282
|
{
|
|
@@ -3150,10 +3215,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3150
3215
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
|
|
3151
3216
|
"imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
|
|
3152
3217
|
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
|
|
3153
|
-
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }
|
|
3154
|
-
"
|
|
3155
|
-
"
|
|
3156
|
-
"
|
|
3218
|
+
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
|
|
3219
|
+
"audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
|
|
3220
|
+
"start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
|
|
3221
|
+
"audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
|
|
3222
|
+
"{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
|
|
3223
|
+
"Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
|
|
3224
|
+
"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 " +
|
|
3225
|
+
"expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
|
|
3226
|
+
"audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
|
|
3227
|
+
"(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
|
|
3228
|
+
"still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
|
|
3157
3229
|
"pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
|
|
3158
3230
|
"(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
|
|
3159
3231
|
"cache and hands them to the OS print sheet. " +
|
|
@@ -3185,6 +3257,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3185
3257
|
imageEditor: "object",
|
|
3186
3258
|
barcode: "object",
|
|
3187
3259
|
pdfViewer: "object",
|
|
3260
|
+
audioRecorder: "object",
|
|
3261
|
+
audioPlayer: "object",
|
|
3188
3262
|
},
|
|
3189
3263
|
},
|
|
3190
3264
|
};
|
|
@@ -3376,7 +3450,8 @@ const VETTED_IMPORTS = [
|
|
|
3376
3450
|
platforms: ["native"],
|
|
3377
3451
|
category: "media",
|
|
3378
3452
|
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."
|
|
3453
|
+
"Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
|
|
3454
|
+
"To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
|
|
3380
3455
|
},
|
|
3381
3456
|
{
|
|
3382
3457
|
specifier: "expo-video",
|
|
@@ -4994,7 +5069,15 @@ const CONTRACT = deepFreeze({
|
|
|
4994
5069
|
// 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
|
|
4995
5070
|
// `{ preview, loading, error, available }` from the new optional
|
|
4996
5071
|
// `linkPreviews` slice (@colixsystems/link-preview-client).
|
|
4997
|
-
|
|
5072
|
+
// 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
|
|
5073
|
+
// `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
|
|
5074
|
+
// `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
|
|
5075
|
+
// on native), with `AudioRecorderError` / `AudioPlayerError`.
|
|
5076
|
+
// 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
|
|
5077
|
+
// `scope: { kind: "granted" }` (the rows the signed-in user reads through
|
|
5078
|
+
// a user or group grant) and the `onAccessGained` / `onAccessLost`
|
|
5079
|
+
// handlers that report rows entering and leaving that set.
|
|
5080
|
+
version: "1.154.0",
|
|
4998
5081
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4999
5082
|
hooks: HOOKS,
|
|
5000
5083
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -3979,10 +3979,13 @@ export function useCanWrite(tableId, options) {
|
|
|
3979
3979
|
* rather than crashing at render (widget-parity skill).
|
|
3980
3980
|
*
|
|
3981
3981
|
* @param {string} table Bound table id (falsy → no subscription, status "fallback").
|
|
3982
|
-
* @param {{ onCreated?, onUpdated?, onDeleted? }} [handlers] Per-event callbacks; each receives the snake_case record.
|
|
3983
|
-
* @param {{ fallbackAfterMs?: number, scope?: { kind: "record"|"parent", relation_column?: string, record_id
|
|
3982
|
+
* @param {{ onCreated?, onUpdated?, onDeleted?, onAccessGained?, onAccessLost? }} [handlers] Per-event callbacks; each receives the snake_case record.
|
|
3983
|
+
* @param {{ fallbackAfterMs?: number, scope?: { kind: "record"|"parent"|"granted", relation_column?: string, record_id?: string } }} [options]
|
|
3984
3984
|
* `scope` narrows the stream below whole-table — required for a table
|
|
3985
3985
|
* governed by per-record grants. Re-subscribes on its VALUES, not identity.
|
|
3986
|
+
* `{ kind: "granted" }` streams every row the signed-in user reads through
|
|
3987
|
+
* a user or group grant; `onAccessGained` / `onAccessLost` report rows
|
|
3988
|
+
* entering (the full row) and leaving (`{ id }`) that set.
|
|
3986
3989
|
* @returns {{ status: "connecting" | "live" | "reconnecting" | "fallback" }}
|
|
3987
3990
|
*/
|
|
3988
3991
|
export function useDatastoreSubscription(table, handlers, options) {
|
|
@@ -4051,6 +4054,8 @@ export function useDatastoreSubscription(table, handlers, options) {
|
|
|
4051
4054
|
onCreated: (r) => handlersRef.current?.onCreated?.(r),
|
|
4052
4055
|
onUpdated: (r) => handlersRef.current?.onUpdated?.(r),
|
|
4053
4056
|
onDeleted: (r) => handlersRef.current?.onDeleted?.(r),
|
|
4057
|
+
onAccessGained: (r) => handlersRef.current?.onAccessGained?.(r),
|
|
4058
|
+
onAccessLost: (r) => handlersRef.current?.onAccessLost?.(r),
|
|
4054
4059
|
onStatus: (s) => setStatus(s),
|
|
4055
4060
|
},
|
|
4056
4061
|
Object.keys(subscribeOptions).length > 0 ? subscribeOptions : undefined,
|
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
|
|
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(
|
|
@@ -1388,6 +1418,10 @@ export interface DatastoreSubscriptionHandlers {
|
|
|
1388
1418
|
onCreated?: (record: Record<string, unknown>) => void;
|
|
1389
1419
|
onUpdated?: (record: Record<string, unknown>) => void;
|
|
1390
1420
|
onDeleted?: (record: Record<string, unknown>) => void;
|
|
1421
|
+
/** Granted scope: a row the user can now read, in full. */
|
|
1422
|
+
onAccessGained?: (record: Record<string, unknown>) => void;
|
|
1423
|
+
/** Granted scope: a row the user can no longer read; nothing follows it. */
|
|
1424
|
+
onAccessLost?: (record: { id: string }) => void;
|
|
1391
1425
|
}
|
|
1392
1426
|
|
|
1393
1427
|
// sc-6270: see SubscriptionScope in @colixsystems/datastore-client. A scoped
|
|
@@ -1395,7 +1429,8 @@ export interface DatastoreSubscriptionHandlers {
|
|
|
1395
1429
|
// which is what lets a per-record-ACL table stream at all.
|
|
1396
1430
|
export type DatastoreSubscriptionScope =
|
|
1397
1431
|
| { kind: "record"; record_id: string }
|
|
1398
|
-
| { kind: "parent"; relation_column: string; record_id: string }
|
|
1432
|
+
| { kind: "parent"; relation_column: string; record_id: string }
|
|
1433
|
+
| { kind: "granted" };
|
|
1399
1434
|
|
|
1400
1435
|
export interface DatastoreSubscriptionOptions {
|
|
1401
1436
|
fallbackAfterMs?: number;
|
|
@@ -1410,9 +1445,10 @@ export interface DatastoreSubscriptionOptions {
|
|
|
1410
1445
|
* REST polling instead. Never throws — degrades to `{ status: "fallback" }` on
|
|
1411
1446
|
* a host whose datastore client predates realtime.
|
|
1412
1447
|
*
|
|
1413
|
-
* `options.scope` narrows the stream to one record
|
|
1414
|
-
* inheriting children
|
|
1415
|
-
*
|
|
1448
|
+
* `options.scope` narrows the stream to one record, to a parent's
|
|
1449
|
+
* inheriting children, or (`{ kind: "granted" }`) to the rows the signed-in
|
|
1450
|
+
* user reads through a user or group grant; re-subscribes when the scope's
|
|
1451
|
+
* values change, not on every render.
|
|
1416
1452
|
*/
|
|
1417
1453
|
export function useDatastoreSubscription(
|
|
1418
1454
|
tableId: string | null | undefined,
|
|
@@ -1930,6 +1966,94 @@ export class SpeechToTextError extends Error {
|
|
|
1930
1966
|
);
|
|
1931
1967
|
}
|
|
1932
1968
|
|
|
1969
|
+
/** Microphone permission as `useAudioRecorder()` reports it. */
|
|
1970
|
+
export type AudioPermissionState = "granted" | "denied" | "undetermined";
|
|
1971
|
+
|
|
1972
|
+
/** A finished recording, as `useAudioRecorder().stop()` resolves it. */
|
|
1973
|
+
export interface AudioClip {
|
|
1974
|
+
/**
|
|
1975
|
+
* The upload part for this host: a `File` on web, expo-file-system's `File`
|
|
1976
|
+
* on native (not `instanceof Blob` at runtime) — append it, don't branch on it.
|
|
1977
|
+
*/
|
|
1978
|
+
file: unknown;
|
|
1979
|
+
/** Plays the clip locally via `useAudioPlayer(uri)` until the hook releases it. */
|
|
1980
|
+
uri: string;
|
|
1981
|
+
durationMs: number;
|
|
1982
|
+
/** e.g. "audio/mp4" or "audio/webm". */
|
|
1983
|
+
mime: string;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
export interface AudioRecorderResult {
|
|
1987
|
+
/** False when the host brokers no recorder; disable the record control. */
|
|
1988
|
+
supported: boolean;
|
|
1989
|
+
permission: AudioPermissionState;
|
|
1990
|
+
recording: boolean;
|
|
1991
|
+
/** Length of the current (or last) recording. */
|
|
1992
|
+
durationMs: number;
|
|
1993
|
+
/** Input level from 0 (silence) to 1, for a live meter. */
|
|
1994
|
+
level: number;
|
|
1995
|
+
error: AudioRecorderError | null;
|
|
1996
|
+
/** Start recording — call from a user gesture. Rejects with AudioRecorderError. */
|
|
1997
|
+
start(): Promise<void>;
|
|
1998
|
+
/** Finish and keep the clip; `null` when nothing was recording. */
|
|
1999
|
+
stop(): Promise<AudioClip | null>;
|
|
2000
|
+
/** Stop and discard the clip. */
|
|
2001
|
+
cancel(): void;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* Record a clip from the microphone. The web Player brokers it via
|
|
2006
|
+
* MediaRecorder, the Expo export via `expo-audio`. Capture is imperative (call
|
|
2007
|
+
* `start()` from a user gesture). Safe to call on a host that brokers no
|
|
2008
|
+
* recorder: `supported` is then false and `start()` rejects with
|
|
2009
|
+
* `code: "UNSUPPORTED"`.
|
|
2010
|
+
*/
|
|
2011
|
+
export function useAudioRecorder(): AudioRecorderResult;
|
|
2012
|
+
|
|
2013
|
+
/** Error surfaced by `useAudioRecorder()`. */
|
|
2014
|
+
export class AudioRecorderError extends Error {
|
|
2015
|
+
code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL";
|
|
2016
|
+
constructor(
|
|
2017
|
+
code: AudioRecorderError["code"],
|
|
2018
|
+
message: string,
|
|
2019
|
+
opts?: { cause?: unknown },
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
export interface AudioPlayerResult {
|
|
2024
|
+
/** False when the host brokers no player. */
|
|
2025
|
+
supported: boolean;
|
|
2026
|
+
/** True from a new `uri` until the host has loaded it. */
|
|
2027
|
+
loading: boolean;
|
|
2028
|
+
playing: boolean;
|
|
2029
|
+
positionMs: number;
|
|
2030
|
+
/** `null` until the host knows the clip's length. */
|
|
2031
|
+
durationMs: number | null;
|
|
2032
|
+
error: AudioPlayerError | null;
|
|
2033
|
+
/** Start or resume — call from a user gesture. Rejects with AudioPlayerError. */
|
|
2034
|
+
play(): Promise<void>;
|
|
2035
|
+
pause(): void;
|
|
2036
|
+
seek(positionMs: number): Promise<void>;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
/**
|
|
2040
|
+
* Play one clip from `uri` (a stored file's URL, or a recording's local
|
|
2041
|
+
* `uri`). The web Player brokers it via an `<audio>` element, the Expo export
|
|
2042
|
+
* via `expo-audio`. When the clip ends, `playing` turns false and
|
|
2043
|
+
* `positionMs` returns to 0.
|
|
2044
|
+
*/
|
|
2045
|
+
export function useAudioPlayer(uri: string | null | undefined): AudioPlayerResult;
|
|
2046
|
+
|
|
2047
|
+
/** Error surfaced by `useAudioPlayer()`. */
|
|
2048
|
+
export class AudioPlayerError extends Error {
|
|
2049
|
+
code: "UNSUPPORTED" | "LOAD_FAILED" | "BLOCKED" | "INTERNAL";
|
|
2050
|
+
constructor(
|
|
2051
|
+
code: AudioPlayerError["code"],
|
|
2052
|
+
message: string,
|
|
2053
|
+
opts?: { cause?: unknown },
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
1933
2057
|
/**
|
|
1934
2058
|
* Options for `useCamera(...)`. Both are HINTS the host honours where it can:
|
|
1935
2059
|
* 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
|
package/dist/index.native.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.193.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
|
@@ -2060,6 +2060,13 @@ const HOOKS = [
|
|
|
2060
2060
|
"table governed by per-record grants, since a scope-less subscribe must " +
|
|
2061
2061
|
"prove read-every-row. A `parent` scope requires the column be an " +
|
|
2062
2062
|
"inheritAcl RELATION, else the subscribe resolves to " +
|
|
2063
|
+
"{ status: 'fallback' }. { kind: 'granted' } streams every row the " +
|
|
2064
|
+
"signed-in user can read through a grant to them or to one of their " +
|
|
2065
|
+
"groups — the way to keep a list of 'my conversations' or 'my cases' " +
|
|
2066
|
+
"live — and adds two handlers: onAccessGained(record) when a row " +
|
|
2067
|
+
"becomes readable (the full row) and onAccessLost({ id }) when it stops " +
|
|
2068
|
+
"being readable, after which no further event for it arrives. A " +
|
|
2069
|
+
"logged-out visitor's granted subscribe resolves to " +
|
|
2063
2070
|
"{ status: 'fallback' }.",
|
|
2064
2071
|
returnShape: {
|
|
2065
2072
|
status:
|
|
@@ -2212,6 +2219,64 @@ const HOOKS = [
|
|
|
2212
2219
|
requiredContextSlice: [],
|
|
2213
2220
|
scopes: null,
|
|
2214
2221
|
},
|
|
2222
|
+
// sc-10590 — host-brokered audio recording + playback. Optional slices; the
|
|
2223
|
+
// hooks report supported:false rather than throwing at render.
|
|
2224
|
+
{
|
|
2225
|
+
name: "useAudioRecorder",
|
|
2226
|
+
signature: "useAudioRecorder()",
|
|
2227
|
+
description:
|
|
2228
|
+
"Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
|
|
2229
|
+
"cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
|
|
2230
|
+
"mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
|
|
2231
|
+
"durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
|
|
2232
|
+
"for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
|
|
2233
|
+
"useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
|
|
2234
|
+
"releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
|
|
2235
|
+
"`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
|
|
2236
|
+
"AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
|
|
2237
|
+
"where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
|
|
2238
|
+
"library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
|
|
2239
|
+
"upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
|
|
2240
|
+
"`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
|
|
2241
|
+
returnShape: {
|
|
2242
|
+
supported: "boolean // false when the host brokers no recorder",
|
|
2243
|
+
permission: "'granted' | 'denied' | 'undetermined'",
|
|
2244
|
+
recording: "boolean",
|
|
2245
|
+
durationMs: "number // length of the current (or last) recording",
|
|
2246
|
+
level: "number // input level 0..1 while recording",
|
|
2247
|
+
error: "AudioRecorderError | null",
|
|
2248
|
+
start: "() => Promise<void> // rejects with AudioRecorderError",
|
|
2249
|
+
stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
|
|
2250
|
+
cancel: "() => void // stop and discard the clip",
|
|
2251
|
+
},
|
|
2252
|
+
requiredContextSlice: [],
|
|
2253
|
+
scopes: null,
|
|
2254
|
+
},
|
|
2255
|
+
{
|
|
2256
|
+
name: "useAudioPlayer",
|
|
2257
|
+
signature: "useAudioPlayer(uri)",
|
|
2258
|
+
description:
|
|
2259
|
+
"Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
|
|
2260
|
+
"is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
|
|
2261
|
+
"resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
|
|
2262
|
+
"length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
|
|
2263
|
+
"the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
|
|
2264
|
+
"with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
|
|
2265
|
+
"WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
|
|
2266
|
+
returnShape: {
|
|
2267
|
+
supported: "boolean // false when the host brokers no player",
|
|
2268
|
+
loading: "boolean // true until the host has loaded the clip",
|
|
2269
|
+
playing: "boolean",
|
|
2270
|
+
positionMs: "number",
|
|
2271
|
+
durationMs: "number | null // null until known",
|
|
2272
|
+
error: "AudioPlayerError | null",
|
|
2273
|
+
play: "() => Promise<void> // rejects with AudioPlayerError",
|
|
2274
|
+
pause: "() => void",
|
|
2275
|
+
seek: "(positionMs) => Promise<void>",
|
|
2276
|
+
},
|
|
2277
|
+
requiredContextSlice: [],
|
|
2278
|
+
scopes: null,
|
|
2279
|
+
},
|
|
2215
2280
|
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
2216
2281
|
// the hook reports supported:false rather than throwing at render.
|
|
2217
2282
|
{
|
|
@@ -3150,10 +3215,17 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3150
3215
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
|
|
3151
3216
|
"imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
|
|
3152
3217
|
"barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
|
|
3153
|
-
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }
|
|
3154
|
-
"
|
|
3155
|
-
"
|
|
3156
|
-
"
|
|
3218
|
+
"pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
|
|
3219
|
+
"audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
|
|
3220
|
+
"start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
|
|
3221
|
+
"audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
|
|
3222
|
+
"{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
|
|
3223
|
+
"Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
|
|
3224
|
+
"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 " +
|
|
3225
|
+
"expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
|
|
3226
|
+
"audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
|
|
3227
|
+
"(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
|
|
3228
|
+
"still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
|
|
3157
3229
|
"pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
|
|
3158
3230
|
"(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
|
|
3159
3231
|
"cache and hands them to the OS print sheet. " +
|
|
@@ -3185,6 +3257,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3185
3257
|
imageEditor: "object",
|
|
3186
3258
|
barcode: "object",
|
|
3187
3259
|
pdfViewer: "object",
|
|
3260
|
+
audioRecorder: "object",
|
|
3261
|
+
audioPlayer: "object",
|
|
3188
3262
|
},
|
|
3189
3263
|
},
|
|
3190
3264
|
};
|
|
@@ -3376,7 +3450,8 @@ const VETTED_IMPORTS = [
|
|
|
3376
3450
|
platforms: ["native"],
|
|
3377
3451
|
category: "media",
|
|
3378
3452
|
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."
|
|
3453
|
+
"Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
|
|
3454
|
+
"To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
|
|
3380
3455
|
},
|
|
3381
3456
|
{
|
|
3382
3457
|
specifier: "expo-video",
|
|
@@ -4994,7 +5069,15 @@ const CONTRACT = deepFreeze({
|
|
|
4994
5069
|
// 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
|
|
4995
5070
|
// `{ preview, loading, error, available }` from the new optional
|
|
4996
5071
|
// `linkPreviews` slice (@colixsystems/link-preview-client).
|
|
4997
|
-
|
|
5072
|
+
// 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
|
|
5073
|
+
// `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
|
|
5074
|
+
// `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
|
|
5075
|
+
// on native), with `AudioRecorderError` / `AudioPlayerError`.
|
|
5076
|
+
// 1.154.0: additive (sc-10589) — `useDatastoreSubscription` accepts
|
|
5077
|
+
// `scope: { kind: "granted" }` (the rows the signed-in user reads through
|
|
5078
|
+
// a user or group grant) and the `onAccessGained` / `onAccessLost`
|
|
5079
|
+
// handlers that report rows entering and leaving that set.
|
|
5080
|
+
version: "1.154.0",
|
|
4998
5081
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4999
5082
|
hooks: HOOKS,
|
|
5000
5083
|
primitives: PRIMITIVES,
|