@joewinke/jatui 0.22.0 → 0.24.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/CLAUDE.md +72 -2
- package/dist/actions/reveal.d.ts +37 -0
- package/dist/actions/reveal.js +42 -0
- package/dist/components/AudioRecorder.svelte +529 -0
- package/dist/components/AudioRecorder.svelte.d.ts +30 -0
- package/dist/components/BulkActionBar.svelte +40 -0
- package/dist/components/BulkActionBar.svelte.d.ts +9 -0
- package/dist/components/ConfirmDialog.svelte +39 -35
- package/dist/components/CopyButton.svelte +5 -2
- package/dist/components/FloatingActionBar.svelte +55 -1
- package/dist/components/FloatingActionBar.svelte.d.ts +8 -1
- package/dist/components/IconSelector.svelte +417 -0
- package/dist/components/IconSelector.svelte.d.ts +55 -0
- package/dist/components/InfiniteGrid.svelte +305 -0
- package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
- package/dist/components/InfiniteSearchGrid.svelte +131 -0
- package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
- package/dist/components/InputDialog.svelte +49 -38
- package/dist/components/KeyboardShortcutsOverlay.svelte +94 -3
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +61 -16
- package/dist/components/MediaCaptureIcon.svelte +23 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
- package/dist/components/ThumbnailGrid.svelte +137 -0
- package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
- package/dist/components/batch/BatchJobTable.svelte +549 -0
- package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
- package/dist/components/batch/QuotaMeter.svelte +78 -0
- package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
- package/dist/components/batch/batchQueue.d.ts +56 -0
- package/dist/components/batch/batchQueue.js +81 -0
- package/dist/components/batch/elapsedEstimate.d.ts +24 -0
- package/dist/components/batch/elapsedEstimate.js +25 -0
- package/dist/components/batch/pollOperation.d.ts +45 -0
- package/dist/components/batch/pollOperation.js +80 -0
- package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
- package/dist/components/batch/useElapsedEstimate.js +38 -0
- package/dist/components/bulkActionBarKeys.d.ts +40 -0
- package/dist/components/bulkActionBarKeys.js +38 -0
- package/dist/components/infiniteGridLoader.d.ts +70 -0
- package/dist/components/infiniteGridLoader.js +141 -0
- package/dist/components/linked-columns/LinkedColumns.svelte +10 -22
- package/dist/components/linked-columns/LinkedColumns.svelte.d.ts +1 -9
- package/dist/components/linked-columns/types.d.ts +9 -0
- package/dist/components/linked-columns/types.js +1 -0
- package/dist/components/replay/ChapterTimeline.svelte +43 -25
- package/dist/components/replay/ChapterTimeline.svelte.d.ts +26 -1
- package/dist/components/table/columnResize.d.ts +38 -0
- package/dist/components/table/columnResize.js +117 -0
- package/dist/components/table/index.d.ts +2 -0
- package/dist/components/table/index.js +1 -0
- package/dist/components/toast/ToastItem.svelte +47 -2
- package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
- package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
- package/dist/components/variant/variantCombinations.d.ts +75 -0
- package/dist/components/variant/variantCombinations.js +100 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.js +30 -1
- package/dist/stores/toast.d.ts +8 -0
- package/dist/stores/toast.js +2 -0
- package/dist/styles/motion.css +100 -0
- package/dist/utils/audioRecorder.d.ts +28 -0
- package/dist/utils/audioRecorder.js +58 -0
- package/dist/utils/audioRecorderCore.d.ts +132 -0
- package/dist/utils/audioRecorderCore.js +447 -0
- package/dist/utils/clipboard.d.ts +25 -0
- package/dist/utils/clipboard.js +59 -0
- package/dist/utils/dateFormatters.d.ts +11 -2
- package/dist/utils/dateFormatters.js +22 -2
- package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
- package/dist/utils/keyboardShortcutRegistry.js +365 -0
- package/package.json +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createAudioRecorderCore — headless audio-capture primitive (jatui-is2ow.8).
|
|
3
|
+
*
|
|
4
|
+
* The capture pipeline of `AudioRecorder.svelte` (permission request, MIME
|
|
5
|
+
* negotiation, MediaRecorder state machine, pause-aware wall-clock timing,
|
|
6
|
+
* AnalyserNode level meter, blob out) factored into a dependency-injected,
|
|
7
|
+
* DOM-free core so any host can drive it from its own UI — mic buttons,
|
|
8
|
+
* push-to-talk keys, overlays — without importing the component.
|
|
9
|
+
*
|
|
10
|
+
* NO transcription here by design: hosts that transcribe (the jat IDE's
|
|
11
|
+
* VoiceMicButton/VoiceInput, wave 3.5 of the adoption audit jat-1alfp) keep
|
|
12
|
+
* their POST-local capture shape and consume `onStop`'s blob payload.
|
|
13
|
+
*
|
|
14
|
+
* Two layers:
|
|
15
|
+
* • this plain module — every knob injectable via `deps` (tests drive a fake
|
|
16
|
+
* MediaRecorder backend in node:test, no DOM);
|
|
17
|
+
* • `audioRecorder.svelte.ts` — the `createAudioRecorder()` rune wrapper
|
|
18
|
+
* that wires the core's snapshot into `$state` so Svelte 5 templates react
|
|
19
|
+
* to it. Components and app code import the wrapper; tests import this.
|
|
20
|
+
*
|
|
21
|
+
* Modes:
|
|
22
|
+
* • `'toggle'` (default) — click-style: `start()` begins, `stop()` ends.
|
|
23
|
+
* • `'push'` — hold-to-talk: the host wires pointerdown → `start()`,
|
|
24
|
+
* pointerup → `stop()`. A `stop()` that arrives while the mic stream is
|
|
25
|
+
* still being acquired (a very quick tap) CANCELS — no blob, no error —
|
|
26
|
+
* matching the jat IDE VoiceMicButton's pointerup-before-stream behavior.
|
|
27
|
+
*
|
|
28
|
+
* Phases: idle → starting → recording ⇄(pause/resume) → processing → done,
|
|
29
|
+
* with `cancel()` from recording returning straight to idle and `reset()`
|
|
30
|
+
* returning a finished/errored recorder to idle so the host can go again.
|
|
31
|
+
*
|
|
32
|
+
* Timer discipline (lesson from MediaCapture, mos-ow4q4.37): elapsed time is
|
|
33
|
+
* derived from the wall clock minus accumulated pause, never an incremented
|
|
34
|
+
* counter — a throttled background tab can't freeze it.
|
|
35
|
+
*/
|
|
36
|
+
const MIME_CANDIDATES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];
|
|
37
|
+
/** First supported recording MIME type, or '' when none are. */
|
|
38
|
+
export function pickAudioMimeType(isSupported = (t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) {
|
|
39
|
+
for (const c of MIME_CANDIDATES) {
|
|
40
|
+
if (isSupported(c))
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
/** RMS of the time-domain data scaled to the recorder's 0–100 meter range. */
|
|
46
|
+
export function computeAudioLevel(timeData) {
|
|
47
|
+
if (!timeData || timeData.length === 0)
|
|
48
|
+
return 0;
|
|
49
|
+
let sum = 0;
|
|
50
|
+
for (let i = 0; i < timeData.length; i++) {
|
|
51
|
+
const s = (timeData[i] - 128) / 128;
|
|
52
|
+
sum += s * s;
|
|
53
|
+
}
|
|
54
|
+
return Math.min(100, Math.sqrt(sum / timeData.length) * 300);
|
|
55
|
+
}
|
|
56
|
+
/** Elapsed from the wall clock, pause-adjusted — see module header note. */
|
|
57
|
+
export function computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now) {
|
|
58
|
+
if (!startedAt)
|
|
59
|
+
return 0;
|
|
60
|
+
const pausedMs = pausedTotalMs + (pauseStartedAt ? now - pauseStartedAt : 0);
|
|
61
|
+
return Math.max(0, now - startedAt - pausedMs);
|
|
62
|
+
}
|
|
63
|
+
export function createAudioRecorderCore(options = {}, deps = {}) {
|
|
64
|
+
const { mode = 'toggle', maxDurationMs = 0, onStart, onPause, onResume, onStop, onCancel, onError, onFrame } = options;
|
|
65
|
+
// Lazy globalThis defaults — resolved per factory call, never at import time.
|
|
66
|
+
const g = (v) => v;
|
|
67
|
+
const getUserMedia = deps.getUserMedia ??
|
|
68
|
+
g(typeof navigator !== 'undefined'
|
|
69
|
+
? navigator.mediaDevices?.getUserMedia?.bind(navigator.mediaDevices)
|
|
70
|
+
: undefined);
|
|
71
|
+
const MediaRecorderCtor = deps.MediaRecorder ?? g(typeof MediaRecorder !== 'undefined' ? MediaRecorder : undefined);
|
|
72
|
+
const AudioContextCtor = deps.AudioContext ?? g(typeof AudioContext !== 'undefined' ? AudioContext : undefined);
|
|
73
|
+
const rafFn = deps.requestAnimationFrame ?? g(typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : undefined);
|
|
74
|
+
const rafCancel = deps.cancelAnimationFrame ?? g(typeof cancelAnimationFrame !== 'undefined' ? cancelAnimationFrame : undefined);
|
|
75
|
+
const setTimeoutFn = deps.setTimeout ?? setTimeout;
|
|
76
|
+
const clearTimeoutFn = deps.clearTimeout ?? clearTimeout;
|
|
77
|
+
const setIntervalFn = deps.setInterval ?? setInterval;
|
|
78
|
+
const clearIntervalFn = deps.clearInterval ?? clearInterval;
|
|
79
|
+
const now = deps.now ?? (() => Date.now());
|
|
80
|
+
const isTypeSupported = MediaRecorderCtor && MediaRecorderCtor.isTypeSupported
|
|
81
|
+
? (t) => !!MediaRecorderCtor.isTypeSupported(t)
|
|
82
|
+
: () => false;
|
|
83
|
+
const supported = !!(getUserMedia && MediaRecorderCtor);
|
|
84
|
+
// State — plain locals; mutations funnel through notify().
|
|
85
|
+
let phase = 'idle';
|
|
86
|
+
let paused = false;
|
|
87
|
+
let level = 0;
|
|
88
|
+
let sizeBytes = 0;
|
|
89
|
+
let error = null;
|
|
90
|
+
// Recorder internals
|
|
91
|
+
let recorder = null;
|
|
92
|
+
let stream = null;
|
|
93
|
+
let audioCtx = null;
|
|
94
|
+
let analyser = null;
|
|
95
|
+
let chunks = [];
|
|
96
|
+
let mimeType = '';
|
|
97
|
+
let startedAt = 0;
|
|
98
|
+
let pausedTotalMs = 0;
|
|
99
|
+
let pauseStartedAt = 0;
|
|
100
|
+
let raf = null;
|
|
101
|
+
let tickTimer = null;
|
|
102
|
+
let autoStopTimer = null;
|
|
103
|
+
let wasCancelled = false;
|
|
104
|
+
/** A stop()/cancel() arrived while the stream was still being acquired. */
|
|
105
|
+
let pendingCancel = false;
|
|
106
|
+
const listeners = new Set();
|
|
107
|
+
function snapshot() {
|
|
108
|
+
return { phase, paused, level, sizeBytes, elapsedMs: computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now()), error };
|
|
109
|
+
}
|
|
110
|
+
function notify() {
|
|
111
|
+
const s = snapshot();
|
|
112
|
+
listeners.forEach((fn) => fn(s));
|
|
113
|
+
}
|
|
114
|
+
function subscribe(fn) {
|
|
115
|
+
listeners.add(fn);
|
|
116
|
+
return () => listeners.delete(fn);
|
|
117
|
+
}
|
|
118
|
+
function fail(next) {
|
|
119
|
+
error = next;
|
|
120
|
+
notify();
|
|
121
|
+
onError?.(next);
|
|
122
|
+
}
|
|
123
|
+
function elapsed() {
|
|
124
|
+
return computeElapsedMs(startedAt, pausedTotalMs, pauseStartedAt, now());
|
|
125
|
+
}
|
|
126
|
+
function tick() {
|
|
127
|
+
// elapsedMs is derived, not stored — notify pushes the new value out.
|
|
128
|
+
notify();
|
|
129
|
+
}
|
|
130
|
+
function teardownStream() {
|
|
131
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
132
|
+
stream = null;
|
|
133
|
+
}
|
|
134
|
+
function stopFrames() {
|
|
135
|
+
if (raf !== null && rafCancel) {
|
|
136
|
+
rafCancel(raf);
|
|
137
|
+
raf = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function clearTimers() {
|
|
141
|
+
if (tickTimer) {
|
|
142
|
+
clearIntervalFn(tickTimer);
|
|
143
|
+
tickTimer = null;
|
|
144
|
+
}
|
|
145
|
+
if (autoStopTimer) {
|
|
146
|
+
clearTimeoutFn(autoStopTimer);
|
|
147
|
+
autoStopTimer = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function frameCb() {
|
|
151
|
+
if (phase !== 'recording' || !analyser) {
|
|
152
|
+
raf = null;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const frequency = new Uint8Array(analyser.frequencyBinCount);
|
|
156
|
+
const time = new Uint8Array(analyser.frequencyBinCount);
|
|
157
|
+
analyser.getByteFrequencyData(frequency);
|
|
158
|
+
analyser.getByteTimeDomainData(time);
|
|
159
|
+
level = computeAudioLevel(time);
|
|
160
|
+
notify();
|
|
161
|
+
onFrame?.({ level, frequency, time });
|
|
162
|
+
if (rafFn)
|
|
163
|
+
raf = rafFn(frameCb);
|
|
164
|
+
else
|
|
165
|
+
raf = null;
|
|
166
|
+
}
|
|
167
|
+
function pumpFrames() {
|
|
168
|
+
stopFrames();
|
|
169
|
+
if (rafFn)
|
|
170
|
+
raf = rafFn(frameCb);
|
|
171
|
+
}
|
|
172
|
+
/** Tear down every media resource. Idempotent — safe from cancel/stop/destroy. */
|
|
173
|
+
function teardown() {
|
|
174
|
+
wasCancelled = true;
|
|
175
|
+
if (recorder && recorder.state !== 'inactive') {
|
|
176
|
+
recorder.ondataavailable = null;
|
|
177
|
+
recorder.onstop = null;
|
|
178
|
+
try {
|
|
179
|
+
recorder.stop();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// already inactive
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
recorder = null;
|
|
186
|
+
teardownStream();
|
|
187
|
+
if (audioCtx && audioCtx.state !== 'closed') {
|
|
188
|
+
try {
|
|
189
|
+
const closing = audioCtx.close();
|
|
190
|
+
if (closing?.catch)
|
|
191
|
+
closing.catch(() => { });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// already closed
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
audioCtx = null;
|
|
198
|
+
analyser = null;
|
|
199
|
+
stopFrames();
|
|
200
|
+
clearTimers();
|
|
201
|
+
chunks = [];
|
|
202
|
+
sizeBytes = 0;
|
|
203
|
+
level = 0;
|
|
204
|
+
startedAt = 0;
|
|
205
|
+
pausedTotalMs = 0;
|
|
206
|
+
pauseStartedAt = 0;
|
|
207
|
+
paused = false;
|
|
208
|
+
}
|
|
209
|
+
function mapStartError(err) {
|
|
210
|
+
const name = err?.name ?? '';
|
|
211
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
|
212
|
+
return {
|
|
213
|
+
kind: 'denied',
|
|
214
|
+
message: 'Microphone access was denied. Allow mic permission in your browser settings and try again.'
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
|
218
|
+
return { kind: 'no-mic', message: 'No microphone was found. Connect one and try again.' };
|
|
219
|
+
}
|
|
220
|
+
return { kind: 'start-failed', message: 'Failed to start recording. Check the microphone and try again.' };
|
|
221
|
+
}
|
|
222
|
+
async function start() {
|
|
223
|
+
if (!supported) {
|
|
224
|
+
fail({
|
|
225
|
+
kind: 'unsupported',
|
|
226
|
+
message: 'Audio recording is not supported in this browser.'
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (phase === 'starting' || phase === 'recording' || phase === 'processing')
|
|
231
|
+
return;
|
|
232
|
+
error = null;
|
|
233
|
+
pendingCancel = false;
|
|
234
|
+
wasCancelled = false;
|
|
235
|
+
phase = 'starting';
|
|
236
|
+
notify();
|
|
237
|
+
try {
|
|
238
|
+
stream = await getUserMedia({
|
|
239
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
teardownStream();
|
|
244
|
+
phase = 'idle';
|
|
245
|
+
notify();
|
|
246
|
+
fail(mapStartError(err));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (pendingCancel) {
|
|
250
|
+
// Push-to-talk tap shorter than stream acquisition, or an explicit
|
|
251
|
+
// cancel mid-start: release the mic, produce nothing (no error).
|
|
252
|
+
teardownStream();
|
|
253
|
+
pendingCancel = false;
|
|
254
|
+
phase = 'idle';
|
|
255
|
+
notify();
|
|
256
|
+
onCancel?.();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
mimeType = pickAudioMimeType(isTypeSupported);
|
|
261
|
+
recorder = mimeType ? new MediaRecorderCtor(stream, { mimeType }) : new MediaRecorderCtor(stream);
|
|
262
|
+
mimeType = recorder.mimeType || mimeType || 'audio/webm';
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
teardownStream();
|
|
266
|
+
phase = 'idle';
|
|
267
|
+
notify();
|
|
268
|
+
fail({
|
|
269
|
+
kind: 'start-failed',
|
|
270
|
+
message: err instanceof Error ? err.message : 'Failed to start recording. Check the microphone and try again.'
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
chunks = [];
|
|
275
|
+
sizeBytes = 0;
|
|
276
|
+
recorder.ondataavailable = (e) => {
|
|
277
|
+
if (e.data && e.data.size > 0) {
|
|
278
|
+
chunks.push(e.data);
|
|
279
|
+
sizeBytes += e.data.size;
|
|
280
|
+
notify();
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
recorder.onstop = () => {
|
|
284
|
+
void finalize();
|
|
285
|
+
};
|
|
286
|
+
// Waveform analyser — visualization + level only, never touches the
|
|
287
|
+
// recorded data. Optional: a platform without AudioContext still records.
|
|
288
|
+
if (AudioContextCtor) {
|
|
289
|
+
try {
|
|
290
|
+
audioCtx = new AudioContextCtor();
|
|
291
|
+
analyser = audioCtx.createAnalyser();
|
|
292
|
+
analyser.fftSize = 512;
|
|
293
|
+
analyser.smoothingTimeConstant = 0.3;
|
|
294
|
+
analyser.minDecibels = -100;
|
|
295
|
+
analyser.maxDecibels = -20;
|
|
296
|
+
audioCtx.createMediaStreamSource(stream).connect(analyser);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
audioCtx = null;
|
|
300
|
+
analyser = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
recorder.start(1000);
|
|
304
|
+
startedAt = now();
|
|
305
|
+
pausedTotalMs = 0;
|
|
306
|
+
pauseStartedAt = 0;
|
|
307
|
+
paused = false;
|
|
308
|
+
phase = 'recording';
|
|
309
|
+
notify();
|
|
310
|
+
tickTimer = setIntervalFn(tick, 250);
|
|
311
|
+
if (maxDurationMs > 0)
|
|
312
|
+
autoStopTimer = setTimeoutFn(stop, maxDurationMs);
|
|
313
|
+
pumpFrames();
|
|
314
|
+
onStart?.();
|
|
315
|
+
}
|
|
316
|
+
function pause() {
|
|
317
|
+
if (phase !== 'recording' || paused || !recorder)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
recorder.pause();
|
|
321
|
+
paused = true;
|
|
322
|
+
pauseStartedAt = now();
|
|
323
|
+
stopFrames();
|
|
324
|
+
notify();
|
|
325
|
+
onPause?.();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// recorder already inactive — nothing to pause
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function resume() {
|
|
332
|
+
if (phase !== 'recording' || !paused || !recorder)
|
|
333
|
+
return;
|
|
334
|
+
try {
|
|
335
|
+
recorder.resume();
|
|
336
|
+
pausedTotalMs += now() - pauseStartedAt;
|
|
337
|
+
pauseStartedAt = 0;
|
|
338
|
+
paused = false;
|
|
339
|
+
pumpFrames();
|
|
340
|
+
notify();
|
|
341
|
+
onResume?.();
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
// recorder already inactive — nothing to resume
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function stop() {
|
|
348
|
+
if (phase === 'starting') {
|
|
349
|
+
// Stop arrived before the mic stream resolved (quick push-to-talk
|
|
350
|
+
// tap) — cancel at start completion rather than emit a stub clip.
|
|
351
|
+
pendingCancel = true;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (phase !== 'recording' || !recorder)
|
|
355
|
+
return;
|
|
356
|
+
if (pauseStartedAt) {
|
|
357
|
+
pausedTotalMs += now() - pauseStartedAt;
|
|
358
|
+
pauseStartedAt = 0;
|
|
359
|
+
}
|
|
360
|
+
clearTimers();
|
|
361
|
+
stopFrames();
|
|
362
|
+
if (recorder.state !== 'inactive') {
|
|
363
|
+
phase = 'processing';
|
|
364
|
+
notify();
|
|
365
|
+
recorder.stop();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function cancel() {
|
|
369
|
+
if (phase === 'starting') {
|
|
370
|
+
pendingCancel = true;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (phase !== 'recording')
|
|
374
|
+
return;
|
|
375
|
+
teardown();
|
|
376
|
+
phase = 'idle';
|
|
377
|
+
notify();
|
|
378
|
+
onCancel?.();
|
|
379
|
+
}
|
|
380
|
+
async function finalize() {
|
|
381
|
+
if (wasCancelled)
|
|
382
|
+
return;
|
|
383
|
+
if (chunks.length === 0) {
|
|
384
|
+
// Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
|
|
385
|
+
phase = 'idle';
|
|
386
|
+
notify();
|
|
387
|
+
fail({ kind: 'too-short', message: 'Recording was too short — nothing was captured. Try again.' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
phase = 'processing';
|
|
391
|
+
notify();
|
|
392
|
+
const blob = new Blob(chunks, { type: mimeType });
|
|
393
|
+
const result = {
|
|
394
|
+
blob,
|
|
395
|
+
mimeType,
|
|
396
|
+
durationSeconds: Math.max(1, Math.round(elapsed() / 1000)),
|
|
397
|
+
sizeBytes: blob.size
|
|
398
|
+
};
|
|
399
|
+
teardown();
|
|
400
|
+
// Stay in 'processing' while the host's handler settles (documented in
|
|
401
|
+
// AudioRecorderOptions.onStop) — the host may be uploading/transcribing
|
|
402
|
+
// and must not have the user re-trigger a recording mid-flight.
|
|
403
|
+
if (onStop) {
|
|
404
|
+
try {
|
|
405
|
+
await onStop(result);
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
fail({
|
|
409
|
+
kind: 'handler-failed',
|
|
410
|
+
message: `Recording kept, but the handler failed: ${err instanceof Error ? err.message : String(err)}`
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
phase = 'done';
|
|
415
|
+
notify();
|
|
416
|
+
}
|
|
417
|
+
function reset() {
|
|
418
|
+
teardown();
|
|
419
|
+
pendingCancel = false;
|
|
420
|
+
phase = 'idle';
|
|
421
|
+
error = null;
|
|
422
|
+
notify();
|
|
423
|
+
}
|
|
424
|
+
function destroy() {
|
|
425
|
+
teardown();
|
|
426
|
+
pendingCancel = false;
|
|
427
|
+
phase = 'idle';
|
|
428
|
+
error = null;
|
|
429
|
+
listeners.clear();
|
|
430
|
+
notify();
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
get supported() {
|
|
434
|
+
return supported;
|
|
435
|
+
},
|
|
436
|
+
mode,
|
|
437
|
+
snapshot,
|
|
438
|
+
subscribe,
|
|
439
|
+
start,
|
|
440
|
+
stop,
|
|
441
|
+
pause,
|
|
442
|
+
resume,
|
|
443
|
+
cancel,
|
|
444
|
+
reset,
|
|
445
|
+
destroy
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard write that works outside secure contexts.
|
|
3
|
+
*
|
|
4
|
+
* `navigator.clipboard` only exists on HTTPS or localhost origins. Apps using
|
|
5
|
+
* this library are routinely viewed over plain HTTP from another machine
|
|
6
|
+
* (Tailscale/LAN), where the API is undefined and a bare
|
|
7
|
+
* `navigator.clipboard.writeText()` throws synchronously — the classic
|
|
8
|
+
* "copy button silently does nothing" bug.
|
|
9
|
+
*
|
|
10
|
+
* Falls back to the legacy hidden-textarea + `document.execCommand('copy')`
|
|
11
|
+
* path, which still works in every browser the library targets.
|
|
12
|
+
*
|
|
13
|
+
* @returns true if the text was (as far as detectable) copied
|
|
14
|
+
*/
|
|
15
|
+
export declare function copyText(text: string): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Clipboard read for menu-triggered paste actions (e.g. a context menu "Paste"
|
|
18
|
+
* item), where there's no native ClipboardEvent to pull text/plain from.
|
|
19
|
+
*
|
|
20
|
+
* Requires navigator.clipboard.readText, which needs a secure context and a
|
|
21
|
+
* user-gesture-driven call (satisfied by a click handler). Returns null on
|
|
22
|
+
* any failure — missing API, denied permission, non-secure origin — so
|
|
23
|
+
* callers can fall back to telling the user to press Ctrl+V instead.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readClipboardText(): Promise<string | null>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard write that works outside secure contexts.
|
|
3
|
+
*
|
|
4
|
+
* `navigator.clipboard` only exists on HTTPS or localhost origins. Apps using
|
|
5
|
+
* this library are routinely viewed over plain HTTP from another machine
|
|
6
|
+
* (Tailscale/LAN), where the API is undefined and a bare
|
|
7
|
+
* `navigator.clipboard.writeText()` throws synchronously — the classic
|
|
8
|
+
* "copy button silently does nothing" bug.
|
|
9
|
+
*
|
|
10
|
+
* Falls back to the legacy hidden-textarea + `document.execCommand('copy')`
|
|
11
|
+
* path, which still works in every browser the library targets.
|
|
12
|
+
*
|
|
13
|
+
* @returns true if the text was (as far as detectable) copied
|
|
14
|
+
*/
|
|
15
|
+
export async function copyText(text) {
|
|
16
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.clipboard.writeText(text);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Permission denied or transient failure — try the legacy path
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const ta = document.createElement('textarea');
|
|
27
|
+
ta.value = text;
|
|
28
|
+
ta.setAttribute('readonly', '');
|
|
29
|
+
ta.style.position = 'fixed';
|
|
30
|
+
ta.style.left = '-9999px';
|
|
31
|
+
document.body.appendChild(ta);
|
|
32
|
+
ta.select();
|
|
33
|
+
const ok = document.execCommand('copy');
|
|
34
|
+
document.body.removeChild(ta);
|
|
35
|
+
return ok;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Clipboard read for menu-triggered paste actions (e.g. a context menu "Paste"
|
|
43
|
+
* item), where there's no native ClipboardEvent to pull text/plain from.
|
|
44
|
+
*
|
|
45
|
+
* Requires navigator.clipboard.readText, which needs a secure context and a
|
|
46
|
+
* user-gesture-driven call (satisfied by a click handler). Returns null on
|
|
47
|
+
* any failure — missing API, denied permission, non-secure origin — so
|
|
48
|
+
* callers can fall back to telling the user to press Ctrl+V instead.
|
|
49
|
+
*/
|
|
50
|
+
export async function readClipboardText() {
|
|
51
|
+
if (typeof navigator === 'undefined' || !navigator.clipboard?.readText)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
return await navigator.clipboard.readText();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -68,6 +68,10 @@ export declare function taskAge(dateStr: string): {
|
|
|
68
68
|
color: string;
|
|
69
69
|
};
|
|
70
70
|
export type PacingLevel = 'fresh' | 'aging' | 'stale';
|
|
71
|
+
export interface PacingOptions {
|
|
72
|
+
/** Return DaisyUI CSS-var tokens (`var(--color-success|warning|error)`) instead of fixed oklch literals, so colors follow the active theme. */
|
|
73
|
+
themeVars?: boolean;
|
|
74
|
+
}
|
|
71
75
|
export interface PacingResult {
|
|
72
76
|
level: PacingLevel;
|
|
73
77
|
color: string;
|
|
@@ -84,6 +88,11 @@ export interface PacingResult {
|
|
|
84
88
|
*
|
|
85
89
|
* Usage:
|
|
86
90
|
* const pacing = pacingColor(elapsedSeconds, 30, 120)
|
|
87
|
-
* // <span style="color: {pacing.color}">{elapsedSeconds}
|
|
91
|
+
* // <span style="color: {pacing.color}">{formatPacingDuration(elapsedSeconds)}</span>
|
|
92
|
+
*
|
|
93
|
+
* Pass { themeVars: true } to get DaisyUI CSS-var tokens (var(--color-success|warning|error))
|
|
94
|
+
* instead of fixed oklch literals — colors then follow the active DaisyUI theme.
|
|
88
95
|
*/
|
|
89
|
-
export declare function pacingColor(elapsedSeconds: number, warnAtSeconds: number, alertAtSeconds: number): PacingResult;
|
|
96
|
+
export declare function pacingColor(elapsedSeconds: number, warnAtSeconds: number, alertAtSeconds: number, options?: PacingOptions): PacingResult;
|
|
97
|
+
/** Compact duration format for pacing readouts: "45s", "3m 12s", "1h 05m". */
|
|
98
|
+
export declare function formatPacingDuration(seconds: number): string;
|
|
@@ -209,12 +209,32 @@ export function taskAge(dateStr) {
|
|
|
209
209
|
*
|
|
210
210
|
* Usage:
|
|
211
211
|
* const pacing = pacingColor(elapsedSeconds, 30, 120)
|
|
212
|
-
* // <span style="color: {pacing.color}">{elapsedSeconds}
|
|
212
|
+
* // <span style="color: {pacing.color}">{formatPacingDuration(elapsedSeconds)}</span>
|
|
213
|
+
*
|
|
214
|
+
* Pass { themeVars: true } to get DaisyUI CSS-var tokens (var(--color-success|warning|error))
|
|
215
|
+
* instead of fixed oklch literals — colors then follow the active DaisyUI theme.
|
|
213
216
|
*/
|
|
214
|
-
export function pacingColor(elapsedSeconds, warnAtSeconds, alertAtSeconds) {
|
|
217
|
+
export function pacingColor(elapsedSeconds, warnAtSeconds, alertAtSeconds, options) {
|
|
218
|
+
if (options?.themeVars) {
|
|
219
|
+
if (elapsedSeconds >= alertAtSeconds)
|
|
220
|
+
return { level: 'stale', color: 'var(--color-error)' };
|
|
221
|
+
if (elapsedSeconds >= warnAtSeconds)
|
|
222
|
+
return { level: 'aging', color: 'var(--color-warning)' };
|
|
223
|
+
return { level: 'fresh', color: 'var(--color-success)' };
|
|
224
|
+
}
|
|
215
225
|
if (elapsedSeconds >= alertAtSeconds)
|
|
216
226
|
return { level: 'stale', color: 'oklch(0.65 0.20 25)' }; // red
|
|
217
227
|
if (elapsedSeconds >= warnAtSeconds)
|
|
218
228
|
return { level: 'aging', color: 'oklch(0.75 0.15 85)' }; // amber
|
|
219
229
|
return { level: 'fresh', color: 'oklch(0.75 0.18 145)' }; // green
|
|
220
230
|
}
|
|
231
|
+
/** Compact duration format for pacing readouts: "45s", "3m 12s", "1h 05m". */
|
|
232
|
+
export function formatPacingDuration(seconds) {
|
|
233
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
234
|
+
if (s < 60)
|
|
235
|
+
return `${s}s`;
|
|
236
|
+
if (s < 3600)
|
|
237
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
238
|
+
const mins = (s % 3600) / 60;
|
|
239
|
+
return `${Math.floor(s / 3600)}h ${mins < 10 ? '0' : ''}${Math.floor(mins)}m`;
|
|
240
|
+
}
|