@bendyline/squisq-editor-react 2.3.4 → 2.4.1
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 +4 -2
- package/dist/{chunk-GNIVYDZH.js → chunk-KR4VLXUM.js} +10998 -8247
- package/dist/{chunk-6VDYKI3L.js → chunk-LJRHKXDV.js} +3 -1
- package/dist/chunk-LOTY7AOS.js +1925 -0
- package/dist/{chunk-5JMHFAVW.js → chunk-PDCKJCOS.js} +1200 -868
- package/dist/chunk-V4NBQF5C.js +62 -0
- package/dist/chunk-V6Z7GG55.js +16 -0
- package/dist/{chunk-54UGTQBO.js → chunk-WDX6UDL5.js} +1 -1
- package/dist/{chunk-NITZVAXL.js → chunk-WLJ623UZ.js} +24 -0
- package/dist/index.d.ts +289 -118
- package/dist/index.js +52 -20
- package/dist/json-editor/index.js +2 -2
- package/dist/monaco.d.ts +26 -0
- package/dist/monaco.js +144 -10
- package/dist/monacoLanguageDetection-DEyUW-BS.d.ts +18 -0
- package/dist/monacoSuggestions-MDBODZ7F.js +3 -0
- package/dist/recorder/index.d.ts +7 -407
- package/dist/recorder/index.js +3 -3
- package/dist/recorder-C5tAYUE3.d.ts +508 -0
- package/dist/shell/index.d.ts +1 -1
- package/dist/shell/index.js +6 -5
- package/dist/{shell-C-KkTBz7.d.ts → shell-CU4GpGuq.d.ts} +76 -6
- package/dist/styles/index.css +845 -26
- package/dist/teleprompter/index.d.ts +57 -246
- package/dist/teleprompter/index.js +12 -3
- package/dist/useNarrationStage-Bqo18PBw.d.ts +313 -0
- package/package.json +8 -6
- package/dist/chunk-5Q4JN4I5.js +0 -132
- package/dist/chunk-MJJK7YQB.js +0 -949
|
@@ -0,0 +1,1925 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Icon
|
|
3
|
+
} from "./chunk-GS7QWYFT.js";
|
|
4
|
+
import {
|
|
5
|
+
NarrationStage,
|
|
6
|
+
buildFilename,
|
|
7
|
+
buildTimingJson,
|
|
8
|
+
encodeTimingJson,
|
|
9
|
+
requestCameraStream,
|
|
10
|
+
requestMicStream,
|
|
11
|
+
resolveFormat,
|
|
12
|
+
supportsDisplayMedia,
|
|
13
|
+
supportsMediaRecorder,
|
|
14
|
+
supportsSystemAudioCapture,
|
|
15
|
+
supportsUserMedia,
|
|
16
|
+
timingPathFor,
|
|
17
|
+
useNarrationStage,
|
|
18
|
+
useStreamPreview
|
|
19
|
+
} from "./chunk-PDCKJCOS.js";
|
|
20
|
+
|
|
21
|
+
// src/recorder/sources/screenStream.ts
|
|
22
|
+
function mixAudioTracks(streams) {
|
|
23
|
+
const sources = streams.map((s) => s.getAudioTracks()).flat().filter((t) => t.readyState === "live");
|
|
24
|
+
if (sources.length === 0) return null;
|
|
25
|
+
const AC = window.AudioContext;
|
|
26
|
+
if (typeof AC === "undefined") return null;
|
|
27
|
+
const ctx = new AC();
|
|
28
|
+
const dest = ctx.createMediaStreamDestination();
|
|
29
|
+
for (const track of sources) {
|
|
30
|
+
const src = ctx.createMediaStreamSource(new MediaStream([track]));
|
|
31
|
+
src.connect(dest);
|
|
32
|
+
}
|
|
33
|
+
const [mixed] = dest.stream.getAudioTracks();
|
|
34
|
+
if (!mixed) return null;
|
|
35
|
+
return { track: mixed, context: ctx };
|
|
36
|
+
}
|
|
37
|
+
async function requestScreenStream(options) {
|
|
38
|
+
if (!supportsDisplayMedia()) {
|
|
39
|
+
throw new Error("navigator.mediaDevices.getDisplayMedia is not available in this environment.");
|
|
40
|
+
}
|
|
41
|
+
const video = options?.video ?? true;
|
|
42
|
+
const systemAudio = options?.systemAudio ?? false;
|
|
43
|
+
const includeMic = options?.includeMicrophone ?? false;
|
|
44
|
+
const displayStream = await navigator.mediaDevices.getDisplayMedia({
|
|
45
|
+
video,
|
|
46
|
+
audio: systemAudio
|
|
47
|
+
});
|
|
48
|
+
if (!includeMic) {
|
|
49
|
+
return {
|
|
50
|
+
stream: displayStream,
|
|
51
|
+
dispose: () => {
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!supportsUserMedia()) {
|
|
56
|
+
return {
|
|
57
|
+
stream: displayStream,
|
|
58
|
+
dispose: () => {
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let micStream = null;
|
|
63
|
+
try {
|
|
64
|
+
micStream = await navigator.mediaDevices.getUserMedia({
|
|
65
|
+
audio: options?.microphoneConstraints ?? true,
|
|
66
|
+
video: false
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
displayStream.getTracks().forEach((t) => t.stop());
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
const mix = mixAudioTracks([displayStream, micStream]);
|
|
73
|
+
if (!mix) {
|
|
74
|
+
micStream.getTracks().forEach((t) => t.stop());
|
|
75
|
+
return {
|
|
76
|
+
stream: displayStream,
|
|
77
|
+
dispose: () => {
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const [videoTrack] = displayStream.getVideoTracks();
|
|
82
|
+
const output = new MediaStream();
|
|
83
|
+
if (videoTrack) output.addTrack(videoTrack);
|
|
84
|
+
output.addTrack(mix.track);
|
|
85
|
+
const systemAudioTracks = displayStream.getAudioTracks();
|
|
86
|
+
let disposed = false;
|
|
87
|
+
const dispose = () => {
|
|
88
|
+
if (disposed) return;
|
|
89
|
+
disposed = true;
|
|
90
|
+
micStream?.getTracks().forEach((t) => t.stop());
|
|
91
|
+
micStream = null;
|
|
92
|
+
systemAudioTracks.forEach((t) => t.stop());
|
|
93
|
+
void mix.context.close().catch(() => {
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return { stream: output, dispose };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/recorder/hooks/useMediaRecorder.ts
|
|
100
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
101
|
+
|
|
102
|
+
// src/recorder/sources/systemAudioStream.ts
|
|
103
|
+
async function requestSystemAudioStream() {
|
|
104
|
+
if (!supportsDisplayMedia()) {
|
|
105
|
+
throw new Error("navigator.mediaDevices.getDisplayMedia is not available in this environment.");
|
|
106
|
+
}
|
|
107
|
+
const display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
|
108
|
+
display.getVideoTracks().forEach((t) => t.stop());
|
|
109
|
+
const audio = display.getAudioTracks();
|
|
110
|
+
if (audio.length === 0) return null;
|
|
111
|
+
return new MediaStream(audio);
|
|
112
|
+
}
|
|
113
|
+
function mixSystemAudio(base, systemAudio) {
|
|
114
|
+
const AC = window.AudioContext;
|
|
115
|
+
if (typeof AC === "undefined") {
|
|
116
|
+
console.warn(
|
|
117
|
+
"[squisq-recorder] AudioContext unavailable \u2014 system audio could not be mixed into the recording."
|
|
118
|
+
);
|
|
119
|
+
systemAudio.getTracks().forEach((t) => t.stop());
|
|
120
|
+
return { stream: base, dispose: () => {
|
|
121
|
+
} };
|
|
122
|
+
}
|
|
123
|
+
const ctx = new AC();
|
|
124
|
+
const dest = ctx.createMediaStreamDestination();
|
|
125
|
+
const baseAudio = base.getAudioTracks();
|
|
126
|
+
const sources = [...baseAudio, ...systemAudio.getAudioTracks()].filter(
|
|
127
|
+
(t) => t.readyState === "live"
|
|
128
|
+
);
|
|
129
|
+
for (const track of sources) {
|
|
130
|
+
ctx.createMediaStreamSource(new MediaStream([track])).connect(dest);
|
|
131
|
+
}
|
|
132
|
+
const [mixed] = dest.stream.getAudioTracks();
|
|
133
|
+
const output = new MediaStream();
|
|
134
|
+
base.getVideoTracks().forEach((t) => output.addTrack(t));
|
|
135
|
+
if (mixed) output.addTrack(mixed);
|
|
136
|
+
else baseAudio.forEach((t) => output.addTrack(t));
|
|
137
|
+
let disposed = false;
|
|
138
|
+
const dispose = () => {
|
|
139
|
+
if (disposed) return;
|
|
140
|
+
disposed = true;
|
|
141
|
+
baseAudio.forEach((t) => t.stop());
|
|
142
|
+
systemAudio.getTracks().forEach((t) => t.stop());
|
|
143
|
+
void ctx.close().catch(() => {
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
return { stream: output, dispose };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/recorder/hooks/useMediaRecorder.ts
|
|
150
|
+
async function acquireStream(source, opts) {
|
|
151
|
+
switch (source) {
|
|
152
|
+
case "mic": {
|
|
153
|
+
const audio = typeof opts.audioConstraints === "object" ? opts.audioConstraints : void 0;
|
|
154
|
+
if (opts.systemAudio) {
|
|
155
|
+
const systemAudio = await requestSystemAudioStream();
|
|
156
|
+
let base;
|
|
157
|
+
try {
|
|
158
|
+
base = await requestMicStream(audio);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
systemAudio?.getTracks().forEach((t) => t.stop());
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
if (!systemAudio) return { stream: base, dispose: () => {
|
|
164
|
+
} };
|
|
165
|
+
return mixSystemAudio(base, systemAudio);
|
|
166
|
+
}
|
|
167
|
+
const stream = await requestMicStream(audio);
|
|
168
|
+
return { stream, dispose: () => {
|
|
169
|
+
} };
|
|
170
|
+
}
|
|
171
|
+
case "camera": {
|
|
172
|
+
const video = opts.videoConstraints ?? true;
|
|
173
|
+
const audio = opts.includeMicrophone === false ? false : opts.audioConstraints ?? true;
|
|
174
|
+
if (opts.systemAudio) {
|
|
175
|
+
const systemAudio = await requestSystemAudioStream();
|
|
176
|
+
let base;
|
|
177
|
+
try {
|
|
178
|
+
base = await requestCameraStream({ video, audio });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
systemAudio?.getTracks().forEach((t) => t.stop());
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
if (!systemAudio) return { stream: base, dispose: () => {
|
|
184
|
+
} };
|
|
185
|
+
return mixSystemAudio(base, systemAudio);
|
|
186
|
+
}
|
|
187
|
+
const stream = await requestCameraStream({ video, audio });
|
|
188
|
+
return { stream, dispose: () => {
|
|
189
|
+
} };
|
|
190
|
+
}
|
|
191
|
+
case "screen":
|
|
192
|
+
case "screen+mic": {
|
|
193
|
+
const handle = await requestScreenStream({
|
|
194
|
+
video: opts.videoConstraints ?? true,
|
|
195
|
+
systemAudio: opts.systemAudio ?? false,
|
|
196
|
+
includeMicrophone: source === "screen+mic",
|
|
197
|
+
microphoneConstraints: typeof opts.audioConstraints === "object" ? opts.audioConstraints : void 0
|
|
198
|
+
});
|
|
199
|
+
return { stream: handle.stream, dispose: handle.dispose };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function acquireDualStreams(opts, isStale) {
|
|
204
|
+
const screen = await requestScreenStream({
|
|
205
|
+
video: opts.videoConstraints ?? true,
|
|
206
|
+
systemAudio: opts.systemAudio ?? false,
|
|
207
|
+
includeMicrophone: false
|
|
208
|
+
});
|
|
209
|
+
const releaseScreen = () => {
|
|
210
|
+
screen.stream.getTracks().forEach((t) => t.stop());
|
|
211
|
+
screen.dispose();
|
|
212
|
+
};
|
|
213
|
+
if (isStale()) {
|
|
214
|
+
releaseScreen();
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
let camera;
|
|
218
|
+
try {
|
|
219
|
+
camera = await requestCameraStream({
|
|
220
|
+
video: true,
|
|
221
|
+
audio: opts.includeMicrophone === false ? false : opts.audioConstraints ?? true
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
releaseScreen();
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
if (isStale()) {
|
|
228
|
+
releaseScreen();
|
|
229
|
+
camera.getTracks().forEach((t) => t.stop());
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return { screen, camera };
|
|
233
|
+
}
|
|
234
|
+
function captureKindFor(source) {
|
|
235
|
+
return source === "mic" ? "audio" : "video";
|
|
236
|
+
}
|
|
237
|
+
function getCaptureKind(source) {
|
|
238
|
+
return captureKindFor(source);
|
|
239
|
+
}
|
|
240
|
+
function useMediaRecorder(options = {}) {
|
|
241
|
+
const [state, setState] = useState("idle");
|
|
242
|
+
const [stream, setStream] = useState(null);
|
|
243
|
+
const [blob, setBlob] = useState(null);
|
|
244
|
+
const [format, setFormat] = useState(null);
|
|
245
|
+
const [durationMs, setDurationMs] = useState(0);
|
|
246
|
+
const [error, setError] = useState(null);
|
|
247
|
+
const [camera, setCamera] = useState(null);
|
|
248
|
+
const [cameraOffsetSec, setCameraOffsetSec] = useState(null);
|
|
249
|
+
const recorderRef = useRef(null);
|
|
250
|
+
const chunksRef = useRef([]);
|
|
251
|
+
const disposeStreamRef = useRef(null);
|
|
252
|
+
const startTimestampRef = useRef(null);
|
|
253
|
+
const tickerRef = useRef(null);
|
|
254
|
+
const stopResolversRef = useRef([]);
|
|
255
|
+
const stopPromiseRef = useRef(null);
|
|
256
|
+
const requestPromiseRef = useRef(null);
|
|
257
|
+
const lifecycleRef = useRef(0);
|
|
258
|
+
const secondaryRef = useRef(null);
|
|
259
|
+
const pendingLanesRef = useRef(0);
|
|
260
|
+
const primaryStartMsRef = useRef(null);
|
|
261
|
+
const secondaryStartMsRef = useRef(null);
|
|
262
|
+
const stateRef = useRef("idle");
|
|
263
|
+
const stopFnRef = useRef(null);
|
|
264
|
+
const cancelFnRef = useRef(null);
|
|
265
|
+
const transition = useCallback((next) => {
|
|
266
|
+
stateRef.current = next;
|
|
267
|
+
setState(next);
|
|
268
|
+
}, []);
|
|
269
|
+
const optionsRef = useRef(options);
|
|
270
|
+
optionsRef.current = options;
|
|
271
|
+
const clearTicker = useCallback(() => {
|
|
272
|
+
if (tickerRef.current !== null) {
|
|
273
|
+
clearInterval(tickerRef.current);
|
|
274
|
+
tickerRef.current = null;
|
|
275
|
+
}
|
|
276
|
+
}, []);
|
|
277
|
+
const deactivateCapture = useCallback(() => {
|
|
278
|
+
recorderRef.current?.stream.getTracks().forEach((track) => track.stop());
|
|
279
|
+
secondaryRef.current?.stream.getTracks().forEach((track) => track.stop());
|
|
280
|
+
disposeStreamRef.current?.();
|
|
281
|
+
disposeStreamRef.current = null;
|
|
282
|
+
}, []);
|
|
283
|
+
const releaseStream = useCallback(() => {
|
|
284
|
+
const s = recorderRef.current?.stream;
|
|
285
|
+
if (s) {
|
|
286
|
+
s.getTracks().forEach((t) => t.stop());
|
|
287
|
+
}
|
|
288
|
+
setStream((current) => {
|
|
289
|
+
current?.getTracks().forEach((t) => t.stop());
|
|
290
|
+
return null;
|
|
291
|
+
});
|
|
292
|
+
disposeStreamRef.current?.();
|
|
293
|
+
disposeStreamRef.current = null;
|
|
294
|
+
}, []);
|
|
295
|
+
const releaseSecondary = useCallback(() => {
|
|
296
|
+
const lane = secondaryRef.current;
|
|
297
|
+
secondaryRef.current = null;
|
|
298
|
+
if (lane) {
|
|
299
|
+
if (lane.recorder.state !== "inactive") {
|
|
300
|
+
try {
|
|
301
|
+
lane.recorder.ondataavailable = null;
|
|
302
|
+
lane.recorder.onstart = null;
|
|
303
|
+
lane.recorder.onstop = null;
|
|
304
|
+
lane.recorder.onerror = null;
|
|
305
|
+
lane.recorder.stop();
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
lane.stream.getTracks().forEach((t) => t.stop());
|
|
310
|
+
}
|
|
311
|
+
pendingLanesRef.current = 0;
|
|
312
|
+
primaryStartMsRef.current = null;
|
|
313
|
+
secondaryStartMsRef.current = null;
|
|
314
|
+
setCamera(null);
|
|
315
|
+
setCameraOffsetSec(null);
|
|
316
|
+
}, []);
|
|
317
|
+
const reset = useCallback(() => {
|
|
318
|
+
setBlob(null);
|
|
319
|
+
setDurationMs(0);
|
|
320
|
+
setError(null);
|
|
321
|
+
chunksRef.current = [];
|
|
322
|
+
startTimestampRef.current = null;
|
|
323
|
+
clearTicker();
|
|
324
|
+
setCamera((prev) => prev ? { ...prev, blob: null } : null);
|
|
325
|
+
setCameraOffsetSec(null);
|
|
326
|
+
const rec = recorderRef.current;
|
|
327
|
+
const secondaryLive = !secondaryRef.current || secondaryRef.current.stream.active;
|
|
328
|
+
if (rec && rec.state === "inactive" && rec.stream.active && secondaryLive) {
|
|
329
|
+
transition("ready");
|
|
330
|
+
} else {
|
|
331
|
+
cancelFnRef.current?.();
|
|
332
|
+
}
|
|
333
|
+
}, [clearTicker, transition]);
|
|
334
|
+
const cancel = useCallback(() => {
|
|
335
|
+
lifecycleRef.current += 1;
|
|
336
|
+
const rec = recorderRef.current;
|
|
337
|
+
if (rec && rec.state !== "inactive") {
|
|
338
|
+
try {
|
|
339
|
+
rec.ondataavailable = null;
|
|
340
|
+
rec.onstart = null;
|
|
341
|
+
rec.onstop = null;
|
|
342
|
+
rec.onerror = null;
|
|
343
|
+
rec.stop();
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
recorderRef.current = null;
|
|
348
|
+
releaseStream();
|
|
349
|
+
releaseSecondary();
|
|
350
|
+
clearTicker();
|
|
351
|
+
chunksRef.current = [];
|
|
352
|
+
startTimestampRef.current = null;
|
|
353
|
+
stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
|
|
354
|
+
stopPromiseRef.current = null;
|
|
355
|
+
requestPromiseRef.current = null;
|
|
356
|
+
setBlob(null);
|
|
357
|
+
setDurationMs(0);
|
|
358
|
+
setError(null);
|
|
359
|
+
transition("idle");
|
|
360
|
+
}, [clearTicker, releaseStream, releaseSecondary, transition]);
|
|
361
|
+
const request = useCallback(async () => {
|
|
362
|
+
if (requestPromiseRef.current) return requestPromiseRef.current;
|
|
363
|
+
if (recorderRef.current?.stream.active) return;
|
|
364
|
+
if (!supportsMediaRecorder()) {
|
|
365
|
+
const err = new Error("MediaRecorder is not supported in this environment.");
|
|
366
|
+
setError(err);
|
|
367
|
+
transition("error");
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
const lifecycle = ++lifecycleRef.current;
|
|
371
|
+
const requestPromise = (async () => {
|
|
372
|
+
setError(null);
|
|
373
|
+
transition("requesting");
|
|
374
|
+
let acquired = null;
|
|
375
|
+
let dual = null;
|
|
376
|
+
try {
|
|
377
|
+
const source = optionsRef.current.source ?? "mic";
|
|
378
|
+
const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
|
|
379
|
+
const recorderOptions = {};
|
|
380
|
+
if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
|
|
381
|
+
if (optionsRef.current.bitsPerSecond) {
|
|
382
|
+
recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
|
|
383
|
+
}
|
|
384
|
+
if (source === "screen+camera") {
|
|
385
|
+
dual = await acquireDualStreams(
|
|
386
|
+
optionsRef.current,
|
|
387
|
+
() => lifecycle !== lifecycleRef.current
|
|
388
|
+
);
|
|
389
|
+
if (!dual) return;
|
|
390
|
+
const { screen, camera: cameraStream } = dual;
|
|
391
|
+
const primary = new MediaRecorder(screen.stream, recorderOptions);
|
|
392
|
+
const secondary = new MediaRecorder(cameraStream, recorderOptions);
|
|
393
|
+
const finalizeJoin = () => {
|
|
394
|
+
const primaryType = primary.mimeType || resolved.mimeType || "application/octet-stream";
|
|
395
|
+
const primaryBlob = new Blob(chunksRef.current, { type: primaryType });
|
|
396
|
+
chunksRef.current = [];
|
|
397
|
+
setBlob(primaryBlob);
|
|
398
|
+
const lane = secondaryRef.current;
|
|
399
|
+
if (lane) {
|
|
400
|
+
const camType = lane.recorder.mimeType || resolved.mimeType || "application/octet-stream";
|
|
401
|
+
const camBlob = new Blob(lane.chunks, { type: camType });
|
|
402
|
+
lane.chunks = [];
|
|
403
|
+
setCamera({
|
|
404
|
+
stream: lane.stream,
|
|
405
|
+
blob: camBlob,
|
|
406
|
+
mimeType: resolved.mimeType || null,
|
|
407
|
+
extension: resolved.extension
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const p = primaryStartMsRef.current;
|
|
411
|
+
const c = secondaryStartMsRef.current;
|
|
412
|
+
setCameraOffsetSec(p != null && c != null ? (c - p) / 1e3 : null);
|
|
413
|
+
deactivateCapture();
|
|
414
|
+
transition("stopped");
|
|
415
|
+
clearTicker();
|
|
416
|
+
stopResolversRef.current.splice(0).forEach((resolve) => resolve(primaryBlob));
|
|
417
|
+
stopPromiseRef.current = null;
|
|
418
|
+
};
|
|
419
|
+
const laneStopped = (rec) => {
|
|
420
|
+
if (lifecycle !== lifecycleRef.current) return;
|
|
421
|
+
if (recorderRef.current !== rec && secondaryRef.current?.recorder !== rec) return;
|
|
422
|
+
if (pendingLanesRef.current === 0) return;
|
|
423
|
+
pendingLanesRef.current -= 1;
|
|
424
|
+
if (pendingLanesRef.current > 0) return;
|
|
425
|
+
finalizeJoin();
|
|
426
|
+
};
|
|
427
|
+
const laneError = (rec, event) => {
|
|
428
|
+
if (lifecycle !== lifecycleRef.current) return;
|
|
429
|
+
const isPrimary = recorderRef.current === rec;
|
|
430
|
+
const isSecondary = secondaryRef.current?.recorder === rec;
|
|
431
|
+
if (!isPrimary && !isSecondary) return;
|
|
432
|
+
const sibling = isPrimary ? secondaryRef.current?.recorder : primary;
|
|
433
|
+
if (sibling && sibling.state !== "inactive") {
|
|
434
|
+
try {
|
|
435
|
+
sibling.ondataavailable = null;
|
|
436
|
+
sibling.onstart = null;
|
|
437
|
+
sibling.onstop = null;
|
|
438
|
+
sibling.onerror = null;
|
|
439
|
+
sibling.stop();
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const detail = event.error;
|
|
444
|
+
const err = detail instanceof Error ? detail : new Error("Recorder error");
|
|
445
|
+
setError(err);
|
|
446
|
+
deactivateCapture();
|
|
447
|
+
transition("error");
|
|
448
|
+
clearTicker();
|
|
449
|
+
pendingLanesRef.current = 0;
|
|
450
|
+
stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
|
|
451
|
+
stopPromiseRef.current = null;
|
|
452
|
+
};
|
|
453
|
+
primary.ondataavailable = (e) => {
|
|
454
|
+
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
|
|
455
|
+
};
|
|
456
|
+
primary.onstart = () => {
|
|
457
|
+
primaryStartMsRef.current = performance.now();
|
|
458
|
+
};
|
|
459
|
+
primary.onstop = () => laneStopped(primary);
|
|
460
|
+
primary.onerror = (e) => laneError(primary, e);
|
|
461
|
+
secondary.ondataavailable = (e) => {
|
|
462
|
+
if (e.data && e.data.size > 0) secondaryRef.current?.chunks.push(e.data);
|
|
463
|
+
};
|
|
464
|
+
secondary.onstart = () => {
|
|
465
|
+
secondaryStartMsRef.current = performance.now();
|
|
466
|
+
};
|
|
467
|
+
secondary.onstop = () => laneStopped(secondary);
|
|
468
|
+
secondary.onerror = (e) => laneError(secondary, e);
|
|
469
|
+
recorderRef.current = primary;
|
|
470
|
+
disposeStreamRef.current = screen.dispose;
|
|
471
|
+
secondaryRef.current = {
|
|
472
|
+
recorder: secondary,
|
|
473
|
+
chunks: [],
|
|
474
|
+
stream: cameraStream,
|
|
475
|
+
format: resolved
|
|
476
|
+
};
|
|
477
|
+
const [screenTrack] = screen.stream.getVideoTracks();
|
|
478
|
+
if (screenTrack) {
|
|
479
|
+
screenTrack.onended = () => {
|
|
480
|
+
if (lifecycle !== lifecycleRef.current) return;
|
|
481
|
+
if (stateRef.current === "recording") void stopFnRef.current?.();
|
|
482
|
+
else if (stateRef.current === "ready") cancelFnRef.current?.();
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
setStream(screen.stream);
|
|
486
|
+
setCamera({
|
|
487
|
+
stream: cameraStream,
|
|
488
|
+
blob: null,
|
|
489
|
+
mimeType: resolved.mimeType || null,
|
|
490
|
+
extension: resolved.extension
|
|
491
|
+
});
|
|
492
|
+
setFormat(resolved);
|
|
493
|
+
setBlob(null);
|
|
494
|
+
setDurationMs(0);
|
|
495
|
+
setCameraOffsetSec(null);
|
|
496
|
+
transition("ready");
|
|
497
|
+
dual = null;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
acquired = await acquireStream(source, optionsRef.current);
|
|
501
|
+
const { stream: nextStream, dispose } = acquired;
|
|
502
|
+
if (lifecycle !== lifecycleRef.current) {
|
|
503
|
+
nextStream.getTracks().forEach((track) => track.stop());
|
|
504
|
+
dispose();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const recorder = new MediaRecorder(nextStream, recorderOptions);
|
|
508
|
+
recorder.ondataavailable = (e) => {
|
|
509
|
+
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
|
|
510
|
+
};
|
|
511
|
+
recorder.onstop = () => {
|
|
512
|
+
if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
|
|
513
|
+
if (pendingLanesRef.current === 0) return;
|
|
514
|
+
pendingLanesRef.current -= 1;
|
|
515
|
+
if (pendingLanesRef.current > 0) return;
|
|
516
|
+
const recordedType = recorder.mimeType || resolved.mimeType || "application/octet-stream";
|
|
517
|
+
const finalBlob = new Blob(chunksRef.current, { type: recordedType });
|
|
518
|
+
chunksRef.current = [];
|
|
519
|
+
setBlob(finalBlob);
|
|
520
|
+
deactivateCapture();
|
|
521
|
+
transition("stopped");
|
|
522
|
+
clearTicker();
|
|
523
|
+
stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
|
|
524
|
+
stopPromiseRef.current = null;
|
|
525
|
+
};
|
|
526
|
+
recorder.onerror = (event) => {
|
|
527
|
+
if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
|
|
528
|
+
const detail = event.error;
|
|
529
|
+
const err = detail instanceof Error ? detail : new Error("Recorder error");
|
|
530
|
+
setError(err);
|
|
531
|
+
deactivateCapture();
|
|
532
|
+
transition("error");
|
|
533
|
+
clearTicker();
|
|
534
|
+
pendingLanesRef.current = 0;
|
|
535
|
+
stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
|
|
536
|
+
stopPromiseRef.current = null;
|
|
537
|
+
};
|
|
538
|
+
recorderRef.current = recorder;
|
|
539
|
+
disposeStreamRef.current = dispose;
|
|
540
|
+
setStream(nextStream);
|
|
541
|
+
setFormat(resolved);
|
|
542
|
+
setBlob(null);
|
|
543
|
+
setDurationMs(0);
|
|
544
|
+
transition("ready");
|
|
545
|
+
acquired = null;
|
|
546
|
+
} catch (err) {
|
|
547
|
+
if (acquired) {
|
|
548
|
+
acquired.stream.getTracks().forEach((track) => track.stop());
|
|
549
|
+
acquired.dispose();
|
|
550
|
+
}
|
|
551
|
+
if (dual) {
|
|
552
|
+
dual.screen.stream.getTracks().forEach((track) => track.stop());
|
|
553
|
+
dual.screen.dispose();
|
|
554
|
+
dual.camera.getTracks().forEach((track) => track.stop());
|
|
555
|
+
}
|
|
556
|
+
const normalized = err instanceof Error ? err : new Error("Stream acquisition failed");
|
|
557
|
+
if (lifecycle === lifecycleRef.current) {
|
|
558
|
+
setError(normalized);
|
|
559
|
+
transition("error");
|
|
560
|
+
}
|
|
561
|
+
throw normalized;
|
|
562
|
+
} finally {
|
|
563
|
+
if (lifecycle === lifecycleRef.current) requestPromiseRef.current = null;
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
requestPromiseRef.current = requestPromise;
|
|
567
|
+
return requestPromise;
|
|
568
|
+
}, [clearTicker, deactivateCapture, transition]);
|
|
569
|
+
const start = useCallback(() => {
|
|
570
|
+
const rec = recorderRef.current;
|
|
571
|
+
if (!rec) {
|
|
572
|
+
const err = new Error("Recorder is not ready. Call request() first.");
|
|
573
|
+
setError(err);
|
|
574
|
+
transition("error");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (rec.state === "recording") return;
|
|
578
|
+
chunksRef.current = [];
|
|
579
|
+
if (secondaryRef.current) secondaryRef.current.chunks = [];
|
|
580
|
+
setBlob(null);
|
|
581
|
+
setDurationMs(0);
|
|
582
|
+
setCamera((prev) => prev ? { ...prev, blob: null } : null);
|
|
583
|
+
setCameraOffsetSec(null);
|
|
584
|
+
primaryStartMsRef.current = null;
|
|
585
|
+
secondaryStartMsRef.current = null;
|
|
586
|
+
startTimestampRef.current = Date.now();
|
|
587
|
+
try {
|
|
588
|
+
rec.start(1e3);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
setError(err instanceof Error ? err : new Error("Failed to start recorder"));
|
|
591
|
+
deactivateCapture();
|
|
592
|
+
transition("error");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const secondary = secondaryRef.current;
|
|
596
|
+
if (secondary) {
|
|
597
|
+
try {
|
|
598
|
+
secondary.recorder.start(1e3);
|
|
599
|
+
} catch (err) {
|
|
600
|
+
try {
|
|
601
|
+
rec.stop();
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
setError(err instanceof Error ? err : new Error("Failed to start camera recorder"));
|
|
605
|
+
deactivateCapture();
|
|
606
|
+
transition("error");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
transition("recording");
|
|
611
|
+
clearTicker();
|
|
612
|
+
tickerRef.current = setInterval(() => {
|
|
613
|
+
if (startTimestampRef.current !== null) {
|
|
614
|
+
setDurationMs(Date.now() - startTimestampRef.current);
|
|
615
|
+
}
|
|
616
|
+
}, 100);
|
|
617
|
+
}, [clearTicker, deactivateCapture, transition]);
|
|
618
|
+
const stop = useCallback(() => {
|
|
619
|
+
if (stopPromiseRef.current) return stopPromiseRef.current;
|
|
620
|
+
const rec = recorderRef.current;
|
|
621
|
+
if (!rec || rec.state === "inactive") {
|
|
622
|
+
return Promise.resolve(blob);
|
|
623
|
+
}
|
|
624
|
+
transition("stopping");
|
|
625
|
+
const secondary = secondaryRef.current;
|
|
626
|
+
const secondaryActive = secondary != null && secondary.recorder.state !== "inactive";
|
|
627
|
+
pendingLanesRef.current = secondaryActive ? 2 : 1;
|
|
628
|
+
const stopPromise = new Promise((resolve) => {
|
|
629
|
+
stopResolversRef.current.push(resolve);
|
|
630
|
+
try {
|
|
631
|
+
rec.stop();
|
|
632
|
+
} catch (err) {
|
|
633
|
+
const normalized = err instanceof Error ? err : new Error("Failed to stop recorder");
|
|
634
|
+
setError(normalized);
|
|
635
|
+
deactivateCapture();
|
|
636
|
+
transition("error");
|
|
637
|
+
clearTicker();
|
|
638
|
+
pendingLanesRef.current = 0;
|
|
639
|
+
stopResolversRef.current.splice(0).forEach((r) => r(null));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (secondaryActive) {
|
|
643
|
+
try {
|
|
644
|
+
secondary.recorder.stop();
|
|
645
|
+
} catch {
|
|
646
|
+
pendingLanesRef.current = Math.max(0, pendingLanesRef.current - 1);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
stopPromiseRef.current = stopPromise;
|
|
651
|
+
void stopPromise.finally(() => {
|
|
652
|
+
if (stopPromiseRef.current === stopPromise) stopPromiseRef.current = null;
|
|
653
|
+
});
|
|
654
|
+
return stopPromise;
|
|
655
|
+
}, [blob, clearTicker, deactivateCapture, transition]);
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
stopFnRef.current = stop;
|
|
658
|
+
cancelFnRef.current = cancel;
|
|
659
|
+
});
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
const pendingResolvers = stopResolversRef.current;
|
|
662
|
+
return () => {
|
|
663
|
+
lifecycleRef.current += 1;
|
|
664
|
+
requestPromiseRef.current = null;
|
|
665
|
+
const rec = recorderRef.current;
|
|
666
|
+
if (rec && rec.state !== "inactive") {
|
|
667
|
+
try {
|
|
668
|
+
rec.ondataavailable = null;
|
|
669
|
+
rec.onstart = null;
|
|
670
|
+
rec.onstop = null;
|
|
671
|
+
rec.onerror = null;
|
|
672
|
+
rec.stop();
|
|
673
|
+
} catch {
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
releaseStream();
|
|
677
|
+
releaseSecondary();
|
|
678
|
+
clearTicker();
|
|
679
|
+
pendingResolvers.splice(0).forEach((resolve) => resolve(null));
|
|
680
|
+
stopPromiseRef.current = null;
|
|
681
|
+
};
|
|
682
|
+
}, [releaseStream, releaseSecondary, clearTicker]);
|
|
683
|
+
return {
|
|
684
|
+
state,
|
|
685
|
+
stream,
|
|
686
|
+
blob,
|
|
687
|
+
mimeType: format?.mimeType ?? null,
|
|
688
|
+
extension: format?.extension ?? null,
|
|
689
|
+
directory: format?.directory ?? null,
|
|
690
|
+
durationMs,
|
|
691
|
+
error,
|
|
692
|
+
camera,
|
|
693
|
+
cameraOffsetSec,
|
|
694
|
+
request,
|
|
695
|
+
start,
|
|
696
|
+
stop,
|
|
697
|
+
cancel,
|
|
698
|
+
reset
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/recorder/RecorderModal.tsx
|
|
703
|
+
import {
|
|
704
|
+
Fragment,
|
|
705
|
+
useCallback as useCallback2,
|
|
706
|
+
useEffect as useEffect2,
|
|
707
|
+
useId,
|
|
708
|
+
useRef as useRef2,
|
|
709
|
+
useState as useState2
|
|
710
|
+
} from "react";
|
|
711
|
+
|
|
712
|
+
// src/modal/useModalDialog.ts
|
|
713
|
+
import { useModalDialog } from "@bendyline/squisq-react";
|
|
714
|
+
|
|
715
|
+
// src/recorder/narrationModePolicy.ts
|
|
716
|
+
function narrationQuiescent(narrationState) {
|
|
717
|
+
return narrationState === "idle" || narrationState === "error";
|
|
718
|
+
}
|
|
719
|
+
function narrationToggleLocked(simpleState, simpleHasBlob, narrationState) {
|
|
720
|
+
const simpleBusy = simpleState === "recording" || simpleState === "requesting" || simpleState === "stopping" || simpleState === "stopped" && simpleHasBlob;
|
|
721
|
+
return simpleBusy || !narrationQuiescent(narrationState);
|
|
722
|
+
}
|
|
723
|
+
function escapeClosesDialog(narrationOn, narrationState, transport) {
|
|
724
|
+
if (!narrationOn) return true;
|
|
725
|
+
if (transport === "rolling" || transport === "countdown") return false;
|
|
726
|
+
return narrationQuiescent(narrationState);
|
|
727
|
+
}
|
|
728
|
+
function closeNeedsConfirm(narrationOn, narrationState, hasTake) {
|
|
729
|
+
if (!narrationOn) return false;
|
|
730
|
+
if (narrationState === "review") return hasTake;
|
|
731
|
+
return !narrationQuiescent(narrationState);
|
|
732
|
+
}
|
|
733
|
+
function narrationCaptureSummary(withCamera) {
|
|
734
|
+
return withCamera ? "Narration: microphone + camera video, timed to your reading." : "Narration: microphone audio, timed to your reading.";
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/recorder/RecorderModal.tsx
|
|
738
|
+
import { Fragment as Fragment2, jsx, jsxs } from "react/jsx-runtime";
|
|
739
|
+
var overlayStyle = {
|
|
740
|
+
position: "fixed",
|
|
741
|
+
inset: 0,
|
|
742
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
743
|
+
display: "flex",
|
|
744
|
+
alignItems: "center",
|
|
745
|
+
justifyContent: "center",
|
|
746
|
+
zIndex: 1e4
|
|
747
|
+
};
|
|
748
|
+
function recorderThemeStyle(colorScheme) {
|
|
749
|
+
const dark = colorScheme === "dark";
|
|
750
|
+
return {
|
|
751
|
+
colorScheme,
|
|
752
|
+
"--squisq-recorder-surface": `var(--squisq-bg, ${dark ? "#1f2937" : "#fffdf7"})`,
|
|
753
|
+
"--squisq-recorder-input": `var(--squisq-input-bg, ${dark ? "#374151" : "#fff"})`,
|
|
754
|
+
"--squisq-recorder-border": `var(--squisq-border, ${dark ? "#4b5563" : "#c9b98a"})`,
|
|
755
|
+
"--squisq-recorder-text": `var(--squisq-text, ${dark ? "#e5e7eb" : "#4a3c1f"})`,
|
|
756
|
+
"--squisq-recorder-muted": `var(--squisq-text-muted, ${dark ? "#9ca3af" : "#5a4a2a"})`,
|
|
757
|
+
"--squisq-recorder-accent": "var(--squisq-accent, #8b6914)",
|
|
758
|
+
"--squisq-recorder-accent-text": "#fff",
|
|
759
|
+
"--squisq-recorder-danger": dark ? "#dc4c4c" : "#b33a3a",
|
|
760
|
+
"--squisq-recorder-danger-border": dark ? "#ef6a6a" : "#902929",
|
|
761
|
+
"--squisq-recorder-error-bg": dark ? "#3f151b" : "#fceeee",
|
|
762
|
+
"--squisq-recorder-error-border": dark ? "#7f1d1d" : "#d88a8a",
|
|
763
|
+
"--squisq-recorder-error-text": dark ? "#fecdd3" : "#8c2a2a"
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
var modalStyle = {
|
|
767
|
+
background: "var(--squisq-recorder-surface)",
|
|
768
|
+
border: "1px solid var(--squisq-recorder-border)",
|
|
769
|
+
borderRadius: 0,
|
|
770
|
+
padding: "24px 28px",
|
|
771
|
+
width: "min(560px, calc(100vw - 48px))",
|
|
772
|
+
maxHeight: "calc(100vh - 48px)",
|
|
773
|
+
overflowY: "auto",
|
|
774
|
+
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
|
775
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
776
|
+
color: "var(--squisq-recorder-text)"
|
|
777
|
+
};
|
|
778
|
+
var titleStyle = {
|
|
779
|
+
margin: "0 0 16px 0",
|
|
780
|
+
fontSize: 18,
|
|
781
|
+
fontWeight: 600,
|
|
782
|
+
color: "var(--squisq-recorder-text)"
|
|
783
|
+
};
|
|
784
|
+
var labelStyle = {
|
|
785
|
+
display: "block",
|
|
786
|
+
fontSize: 13,
|
|
787
|
+
fontWeight: 500,
|
|
788
|
+
marginBottom: 4,
|
|
789
|
+
color: "var(--squisq-recorder-text)"
|
|
790
|
+
};
|
|
791
|
+
var inputStyle = {
|
|
792
|
+
width: "100%",
|
|
793
|
+
padding: "6px 8px",
|
|
794
|
+
fontSize: 13,
|
|
795
|
+
fontFamily: "inherit",
|
|
796
|
+
border: "1px solid var(--squisq-recorder-border)",
|
|
797
|
+
borderRadius: 0,
|
|
798
|
+
background: "var(--squisq-recorder-input)",
|
|
799
|
+
color: "var(--squisq-recorder-text)",
|
|
800
|
+
marginBottom: 12,
|
|
801
|
+
boxSizing: "border-box"
|
|
802
|
+
};
|
|
803
|
+
var textareaStyle = {
|
|
804
|
+
...inputStyle,
|
|
805
|
+
resize: "vertical",
|
|
806
|
+
minHeight: 72
|
|
807
|
+
};
|
|
808
|
+
var btnPrimary = {
|
|
809
|
+
padding: "8px 20px",
|
|
810
|
+
fontSize: 14,
|
|
811
|
+
fontFamily: "inherit",
|
|
812
|
+
fontWeight: 500,
|
|
813
|
+
cursor: "pointer",
|
|
814
|
+
background: "var(--squisq-recorder-accent)",
|
|
815
|
+
color: "var(--squisq-recorder-accent-text)",
|
|
816
|
+
border: "1px solid var(--squisq-recorder-accent)",
|
|
817
|
+
borderRadius: 0
|
|
818
|
+
};
|
|
819
|
+
var btnSecondary = {
|
|
820
|
+
padding: "8px 20px",
|
|
821
|
+
fontSize: 14,
|
|
822
|
+
fontFamily: "inherit",
|
|
823
|
+
fontWeight: 500,
|
|
824
|
+
cursor: "pointer",
|
|
825
|
+
background: "var(--squisq-recorder-input)",
|
|
826
|
+
color: "var(--squisq-recorder-text)",
|
|
827
|
+
border: "1px solid var(--squisq-recorder-border)",
|
|
828
|
+
borderRadius: 0
|
|
829
|
+
};
|
|
830
|
+
var btnDanger = {
|
|
831
|
+
...btnPrimary,
|
|
832
|
+
background: "var(--squisq-recorder-danger)",
|
|
833
|
+
borderColor: "var(--squisq-recorder-danger-border)"
|
|
834
|
+
};
|
|
835
|
+
var btnRecord = {
|
|
836
|
+
...btnPrimary,
|
|
837
|
+
display: "inline-flex",
|
|
838
|
+
alignItems: "center",
|
|
839
|
+
gap: 8
|
|
840
|
+
};
|
|
841
|
+
var recordDotFrameStyle = {
|
|
842
|
+
display: "inline-flex",
|
|
843
|
+
alignItems: "center",
|
|
844
|
+
justifyContent: "center",
|
|
845
|
+
flex: "0 0 auto",
|
|
846
|
+
padding: 2,
|
|
847
|
+
border: "1px solid #9ca3af",
|
|
848
|
+
borderRadius: "50%",
|
|
849
|
+
background: "#000"
|
|
850
|
+
};
|
|
851
|
+
var recordDotStyle = {
|
|
852
|
+
display: "block",
|
|
853
|
+
width: 9,
|
|
854
|
+
height: 9,
|
|
855
|
+
borderRadius: "50%",
|
|
856
|
+
background: "var(--squisq-recorder-danger)"
|
|
857
|
+
};
|
|
858
|
+
var toggleRowStyle = {
|
|
859
|
+
display: "flex",
|
|
860
|
+
gap: 8,
|
|
861
|
+
marginBottom: 16,
|
|
862
|
+
flexWrap: "wrap",
|
|
863
|
+
alignItems: "center"
|
|
864
|
+
};
|
|
865
|
+
var toggleGroupStyle = {
|
|
866
|
+
display: "flex",
|
|
867
|
+
gap: 8
|
|
868
|
+
};
|
|
869
|
+
var groupDividerStyle = {
|
|
870
|
+
alignSelf: "stretch",
|
|
871
|
+
width: 1,
|
|
872
|
+
background: "var(--squisq-recorder-border)",
|
|
873
|
+
margin: "0 4px"
|
|
874
|
+
};
|
|
875
|
+
var toggleBase = {
|
|
876
|
+
padding: "6px 14px",
|
|
877
|
+
fontSize: 13,
|
|
878
|
+
fontFamily: "inherit",
|
|
879
|
+
cursor: "pointer",
|
|
880
|
+
background: "transparent",
|
|
881
|
+
color: "var(--squisq-recorder-text)",
|
|
882
|
+
border: "1px solid var(--squisq-recorder-border)",
|
|
883
|
+
borderRadius: 999
|
|
884
|
+
};
|
|
885
|
+
var toggleActive = {
|
|
886
|
+
...toggleBase,
|
|
887
|
+
color: "var(--squisq-recorder-accent-text)",
|
|
888
|
+
fontWeight: 600,
|
|
889
|
+
background: "var(--squisq-recorder-accent)",
|
|
890
|
+
borderColor: "var(--squisq-recorder-accent)"
|
|
891
|
+
};
|
|
892
|
+
var previewBoxStyle = {
|
|
893
|
+
width: "100%",
|
|
894
|
+
background: "#000",
|
|
895
|
+
borderRadius: 0,
|
|
896
|
+
marginBottom: 12,
|
|
897
|
+
overflow: "hidden",
|
|
898
|
+
aspectRatio: "16 / 9",
|
|
899
|
+
display: "flex",
|
|
900
|
+
alignItems: "center",
|
|
901
|
+
justifyContent: "center",
|
|
902
|
+
color: "#888",
|
|
903
|
+
fontSize: 13
|
|
904
|
+
};
|
|
905
|
+
var playbackTimeStyle = {
|
|
906
|
+
position: "absolute",
|
|
907
|
+
top: 8,
|
|
908
|
+
right: 8,
|
|
909
|
+
padding: "3px 7px",
|
|
910
|
+
background: "rgba(0, 0, 0, 0.72)",
|
|
911
|
+
color: "#fff",
|
|
912
|
+
fontSize: 12,
|
|
913
|
+
fontVariantNumeric: "tabular-nums",
|
|
914
|
+
lineHeight: 1.4,
|
|
915
|
+
pointerEvents: "none"
|
|
916
|
+
};
|
|
917
|
+
var audioMeterStyle = {
|
|
918
|
+
width: "100%",
|
|
919
|
+
height: 56,
|
|
920
|
+
background: "var(--squisq-recorder-input)",
|
|
921
|
+
border: "1px solid var(--squisq-recorder-border)",
|
|
922
|
+
marginBottom: 12,
|
|
923
|
+
display: "flex",
|
|
924
|
+
alignItems: "center",
|
|
925
|
+
justifyContent: "center",
|
|
926
|
+
color: "var(--squisq-recorder-muted)",
|
|
927
|
+
fontSize: 13,
|
|
928
|
+
fontVariantNumeric: "tabular-nums"
|
|
929
|
+
};
|
|
930
|
+
var errorStyle = {
|
|
931
|
+
background: "var(--squisq-recorder-error-bg)",
|
|
932
|
+
border: "1px solid var(--squisq-recorder-error-border)",
|
|
933
|
+
color: "var(--squisq-recorder-error-text)",
|
|
934
|
+
padding: "8px 10px",
|
|
935
|
+
fontSize: 13,
|
|
936
|
+
marginBottom: 12
|
|
937
|
+
};
|
|
938
|
+
var buttonRowStyle = {
|
|
939
|
+
display: "flex",
|
|
940
|
+
gap: 8,
|
|
941
|
+
justifyContent: "flex-end",
|
|
942
|
+
marginTop: 8
|
|
943
|
+
};
|
|
944
|
+
var summaryStyle = {
|
|
945
|
+
margin: "0 0 12px 0",
|
|
946
|
+
fontSize: 12,
|
|
947
|
+
color: "var(--squisq-recorder-muted)"
|
|
948
|
+
};
|
|
949
|
+
var recordingStatusStyle = {
|
|
950
|
+
fontSize: 13,
|
|
951
|
+
fontVariantNumeric: "tabular-nums",
|
|
952
|
+
marginBottom: 12,
|
|
953
|
+
color: "var(--squisq-recorder-accent)",
|
|
954
|
+
fontWeight: 600
|
|
955
|
+
};
|
|
956
|
+
var modalExpandedStyle = {
|
|
957
|
+
...modalStyle,
|
|
958
|
+
width: "calc(100vw - 48px)",
|
|
959
|
+
height: "calc(100vh - 48px)",
|
|
960
|
+
maxHeight: "none",
|
|
961
|
+
overflowY: "hidden",
|
|
962
|
+
display: "flex",
|
|
963
|
+
flexDirection: "column"
|
|
964
|
+
};
|
|
965
|
+
var bodyRowStyle = {
|
|
966
|
+
display: "flex",
|
|
967
|
+
gap: 24,
|
|
968
|
+
flex: "1 1 auto",
|
|
969
|
+
minHeight: 0
|
|
970
|
+
};
|
|
971
|
+
var leftColStyle = {
|
|
972
|
+
flex: "0 0 min(420px, 38vw)",
|
|
973
|
+
overflowY: "auto",
|
|
974
|
+
minHeight: 0,
|
|
975
|
+
paddingRight: 4
|
|
976
|
+
};
|
|
977
|
+
var rightColStyle = {
|
|
978
|
+
flex: "1 1 auto",
|
|
979
|
+
minWidth: 0,
|
|
980
|
+
minHeight: 0,
|
|
981
|
+
display: "flex",
|
|
982
|
+
flexDirection: "column",
|
|
983
|
+
border: "1px solid var(--squisq-recorder-border)"
|
|
984
|
+
};
|
|
985
|
+
var checkboxRowStyle = {
|
|
986
|
+
display: "flex",
|
|
987
|
+
alignItems: "center",
|
|
988
|
+
gap: 6,
|
|
989
|
+
marginBottom: 12,
|
|
990
|
+
fontSize: 13
|
|
991
|
+
};
|
|
992
|
+
var meterLevelStyle = {
|
|
993
|
+
position: "absolute",
|
|
994
|
+
left: 0,
|
|
995
|
+
top: 0,
|
|
996
|
+
bottom: 0,
|
|
997
|
+
background: "var(--squisq-recorder-accent)",
|
|
998
|
+
opacity: 0.25,
|
|
999
|
+
transition: "width 80ms linear"
|
|
1000
|
+
};
|
|
1001
|
+
function formatDurationMs(ms) {
|
|
1002
|
+
const totalSec = Math.floor(ms / 1e3);
|
|
1003
|
+
const m = Math.floor(totalSec / 60);
|
|
1004
|
+
const s = totalSec % 60;
|
|
1005
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
1006
|
+
}
|
|
1007
|
+
function deriveSource({ micOn, cameraOn, screenOn }) {
|
|
1008
|
+
if (cameraOn && screenOn) return "screen+camera";
|
|
1009
|
+
if (cameraOn) return "camera";
|
|
1010
|
+
if (screenOn) return micOn ? "screen+mic" : "screen";
|
|
1011
|
+
return micOn ? "mic" : null;
|
|
1012
|
+
}
|
|
1013
|
+
function toggleStateFromMode(mode) {
|
|
1014
|
+
switch (mode) {
|
|
1015
|
+
case "mic":
|
|
1016
|
+
return { micOn: true, cameraOn: false, screenOn: false };
|
|
1017
|
+
case "camera":
|
|
1018
|
+
return { micOn: true, cameraOn: true, screenOn: false };
|
|
1019
|
+
case "screen":
|
|
1020
|
+
return { micOn: false, cameraOn: false, screenOn: true };
|
|
1021
|
+
case "screen+mic":
|
|
1022
|
+
return { micOn: true, cameraOn: false, screenOn: true };
|
|
1023
|
+
case "screen+camera":
|
|
1024
|
+
return { micOn: true, cameraOn: true, screenOn: true };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
var TOGGLE_GROUPS = [
|
|
1028
|
+
{
|
|
1029
|
+
label: "Voice and camera",
|
|
1030
|
+
toggles: [
|
|
1031
|
+
{ key: "mic", label: "Microphone" },
|
|
1032
|
+
{ key: "camera", label: "Camera" }
|
|
1033
|
+
]
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
label: "Screen capture",
|
|
1037
|
+
toggles: [
|
|
1038
|
+
{ key: "systemAudio", label: "System audio" },
|
|
1039
|
+
{ key: "screen", label: "Screen" }
|
|
1040
|
+
]
|
|
1041
|
+
}
|
|
1042
|
+
];
|
|
1043
|
+
function captureSummary(micOn, cameraOn, screenOn, systemAudioOn) {
|
|
1044
|
+
const systemNote = " Your computer\u2019s audio is mixed in \u2014 you\u2019ll pick a screen or tab to share it.";
|
|
1045
|
+
const withSystem = systemAudioOn && !screenOn && (micOn || cameraOn);
|
|
1046
|
+
if (cameraOn && screenOn) {
|
|
1047
|
+
return micOn ? "Screen capture plus your camera as picture-in-picture; microphone on the camera clip, system audio on the screen clip when available. Saved as two video clips." : "Screen capture plus your camera as picture-in-picture (no microphone). Saved as two video clips.";
|
|
1048
|
+
}
|
|
1049
|
+
if (cameraOn) {
|
|
1050
|
+
const base = micOn ? "Camera video with your microphone. Saved as a video clip." : "Camera video only (no microphone). Saved as a video clip.";
|
|
1051
|
+
return withSystem ? `${base}${systemNote}` : base;
|
|
1052
|
+
}
|
|
1053
|
+
if (screenOn) {
|
|
1054
|
+
return micOn ? "Screen capture with your microphone mixed in. System audio when available." : "Screen capture (no microphone). System audio when available.";
|
|
1055
|
+
}
|
|
1056
|
+
if (micOn) {
|
|
1057
|
+
return systemAudioOn ? `Voice plus your computer\u2019s audio.${systemNote} Saved as an audio clip.` : "Voice-only audio. Pairs with a written script for auto-mapping to blocks.";
|
|
1058
|
+
}
|
|
1059
|
+
return "Pick at least one source to record.";
|
|
1060
|
+
}
|
|
1061
|
+
function recordingFilenameSeed(source, stream, audioRequested) {
|
|
1062
|
+
if (source === "mic") return "audio";
|
|
1063
|
+
const hasAudio = stream ? stream.getAudioTracks().length > 0 : audioRequested;
|
|
1064
|
+
if (source === "camera") return hasAudio ? "camera+audio" : "camera";
|
|
1065
|
+
return hasAudio ? "screen+audio" : "screen";
|
|
1066
|
+
}
|
|
1067
|
+
function dualFilenameSeeds(screenStream, cameraStream, systemAudioRequested, micRequested) {
|
|
1068
|
+
const screenHasAudio = screenStream ? screenStream.getAudioTracks().length > 0 : systemAudioRequested;
|
|
1069
|
+
const cameraHasAudio = cameraStream ? cameraStream.getAudioTracks().length > 0 : micRequested;
|
|
1070
|
+
return {
|
|
1071
|
+
screen: screenHasAudio ? "screen+audio" : "screen",
|
|
1072
|
+
camera: cameraHasAudio ? "camera+audio" : "camera"
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
function RecorderModal({
|
|
1076
|
+
mediaProvider,
|
|
1077
|
+
container = null,
|
|
1078
|
+
initialMode = "mic",
|
|
1079
|
+
colorScheme = "light",
|
|
1080
|
+
onClose,
|
|
1081
|
+
onSave,
|
|
1082
|
+
narration = null
|
|
1083
|
+
}) {
|
|
1084
|
+
const initialToggles = toggleStateFromMode(initialMode);
|
|
1085
|
+
const [micOn, setMicOn] = useState2(initialToggles.micOn);
|
|
1086
|
+
const [cameraOn, setCameraOn] = useState2(initialToggles.cameraOn);
|
|
1087
|
+
const [screenOn, setScreenOn] = useState2(initialToggles.screenOn);
|
|
1088
|
+
const [sourceText, setSourceText] = useState2("");
|
|
1089
|
+
const [basename, setBasename] = useState2("");
|
|
1090
|
+
const [includeSystemAudio, setIncludeSystemAudio] = useState2(false);
|
|
1091
|
+
const [isSaving, setIsSaving] = useState2(false);
|
|
1092
|
+
const [saveError, setSaveError] = useState2(null);
|
|
1093
|
+
const [playbackUrl, setPlaybackUrl] = useState2(null);
|
|
1094
|
+
const [cameraPlaybackUrl, setCameraPlaybackUrl] = useState2(null);
|
|
1095
|
+
const [playbackPositionMs, setPlaybackPositionMs] = useState2(0);
|
|
1096
|
+
const [narrationOn, setNarrationOn] = useState2(false);
|
|
1097
|
+
const [narrationRequesting, setNarrationRequesting] = useState2(false);
|
|
1098
|
+
const [narrationPreview, setNarrationPreview] = useState2(null);
|
|
1099
|
+
const overlayRef = useRef2(null);
|
|
1100
|
+
const dialogRef = useRef2(null);
|
|
1101
|
+
const headingId = useId();
|
|
1102
|
+
const previewRef = useRef2(null);
|
|
1103
|
+
const cameraPreviewRef = useRef2(null);
|
|
1104
|
+
const derivedSource = deriveSource({ micOn, cameraOn, screenOn });
|
|
1105
|
+
const canCapture = derivedSource !== null;
|
|
1106
|
+
const source = derivedSource ?? "mic";
|
|
1107
|
+
const isDual = source === "screen+camera";
|
|
1108
|
+
const canIncludeSystemAudio = supportsSystemAudioCapture();
|
|
1109
|
+
const recorder = useMediaRecorder({
|
|
1110
|
+
source,
|
|
1111
|
+
includeMicrophone: cameraOn ? micOn : void 0,
|
|
1112
|
+
// System audio rides the screen capture when Screen is on, and is otherwise
|
|
1113
|
+
// captured via a separate (video-discarded) display capture mixed into the
|
|
1114
|
+
// mic/camera file — so it is no longer gated on Screen.
|
|
1115
|
+
systemAudio: canIncludeSystemAudio ? includeSystemAudio : false
|
|
1116
|
+
});
|
|
1117
|
+
const filenameSeed = recordingFilenameSeed(source, recorder.stream, micOn || includeSystemAudio);
|
|
1118
|
+
const narrationAvailable = Boolean(narration && narration.recording);
|
|
1119
|
+
const basenameRef = useRef2(basename);
|
|
1120
|
+
basenameRef.current = basename;
|
|
1121
|
+
const stage = useNarrationStage({
|
|
1122
|
+
doc: narration?.doc ?? null,
|
|
1123
|
+
recording: narration?.recording ?? null,
|
|
1124
|
+
getAudioBasename: () => basenameRef.current.trim() || void 0
|
|
1125
|
+
});
|
|
1126
|
+
const stageRef = useRef2(stage);
|
|
1127
|
+
stageRef.current = stage;
|
|
1128
|
+
const prevNarrationOnRef = useRef2(narrationOn);
|
|
1129
|
+
useEffect2(() => {
|
|
1130
|
+
const was = prevNarrationOnRef.current;
|
|
1131
|
+
prevNarrationOnRef.current = narrationOn;
|
|
1132
|
+
if (was === narrationOn) return;
|
|
1133
|
+
if (narrationOn) {
|
|
1134
|
+
recorder.cancel();
|
|
1135
|
+
} else {
|
|
1136
|
+
const s = stageRef.current;
|
|
1137
|
+
s.controller.pause();
|
|
1138
|
+
if (s.controller.mic.status === "live" || s.controller.mic.status === "starting") {
|
|
1139
|
+
s.controller.mic.stop();
|
|
1140
|
+
}
|
|
1141
|
+
if (s.float.isOpen) s.float.close();
|
|
1142
|
+
}
|
|
1143
|
+
}, [narrationOn, recorder]);
|
|
1144
|
+
useEffect2(() => {
|
|
1145
|
+
if (!narrationOn) return;
|
|
1146
|
+
const onKey = (event) => {
|
|
1147
|
+
if (event.key !== "Escape") return;
|
|
1148
|
+
const c = stageRef.current.controller;
|
|
1149
|
+
if (c.transport === "rolling" || c.transport === "countdown") c.pause();
|
|
1150
|
+
};
|
|
1151
|
+
document.addEventListener("keydown", onKey, true);
|
|
1152
|
+
return () => document.removeEventListener("keydown", onKey, true);
|
|
1153
|
+
}, [narrationOn]);
|
|
1154
|
+
const narrationMicLive = stage.controller.mic.status === "live";
|
|
1155
|
+
const narrationPreviewWanted = narrationOn && stage.recorder.withCamera && narrationMicLive && stage.recorder.cameraStream === null && stage.recorder.state !== "processing" && stage.recorder.state !== "review" && stage.recorder.state !== "saving";
|
|
1156
|
+
const narrationPreviewRef = useRef2(null);
|
|
1157
|
+
useEffect2(() => {
|
|
1158
|
+
if (!narrationPreviewWanted) {
|
|
1159
|
+
const existing = narrationPreviewRef.current;
|
|
1160
|
+
if (existing) {
|
|
1161
|
+
for (const track of existing.getTracks()) track.stop();
|
|
1162
|
+
narrationPreviewRef.current = null;
|
|
1163
|
+
setNarrationPreview(null);
|
|
1164
|
+
}
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (narrationPreviewRef.current) return;
|
|
1168
|
+
let cancelled = false;
|
|
1169
|
+
void (async () => {
|
|
1170
|
+
try {
|
|
1171
|
+
const stream = await requestCameraStream({ video: true, audio: false });
|
|
1172
|
+
if (cancelled) {
|
|
1173
|
+
for (const track of stream.getTracks()) track.stop();
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
narrationPreviewRef.current = stream;
|
|
1177
|
+
setNarrationPreview(stream);
|
|
1178
|
+
} catch {
|
|
1179
|
+
}
|
|
1180
|
+
})();
|
|
1181
|
+
return () => {
|
|
1182
|
+
cancelled = true;
|
|
1183
|
+
};
|
|
1184
|
+
}, [narrationPreviewWanted]);
|
|
1185
|
+
useEffect2(() => {
|
|
1186
|
+
return () => {
|
|
1187
|
+
const existing = narrationPreviewRef.current;
|
|
1188
|
+
if (existing) for (const track of existing.getTracks()) track.stop();
|
|
1189
|
+
};
|
|
1190
|
+
}, []);
|
|
1191
|
+
const handleNarrationPreview = useCallback2(async () => {
|
|
1192
|
+
setNarrationRequesting(true);
|
|
1193
|
+
try {
|
|
1194
|
+
const controller = stageRef.current.controller;
|
|
1195
|
+
await controller.mic.start(controller.prefs.micDeviceId);
|
|
1196
|
+
} finally {
|
|
1197
|
+
setNarrationRequesting(false);
|
|
1198
|
+
}
|
|
1199
|
+
}, []);
|
|
1200
|
+
const handleNarrationRecord = useCallback2(() => {
|
|
1201
|
+
void stageRef.current.recorder.start();
|
|
1202
|
+
}, []);
|
|
1203
|
+
const narrationCameraStream = narrationOn ? stage.recorder.cameraStream ?? narrationPreview : null;
|
|
1204
|
+
useStreamPreview(
|
|
1205
|
+
previewRef,
|
|
1206
|
+
narrationOn ? narrationCameraStream : recorder.state === "stopped" ? null : recorder.stream
|
|
1207
|
+
);
|
|
1208
|
+
useStreamPreview(
|
|
1209
|
+
cameraPreviewRef,
|
|
1210
|
+
!narrationOn && isDual && recorder.state !== "stopped" ? recorder.camera?.stream ?? null : null
|
|
1211
|
+
);
|
|
1212
|
+
useEffect2(() => {
|
|
1213
|
+
setPlaybackPositionMs(0);
|
|
1214
|
+
if (!recorder.blob) {
|
|
1215
|
+
setPlaybackUrl(null);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const url = URL.createObjectURL(recorder.blob);
|
|
1219
|
+
setPlaybackUrl(url);
|
|
1220
|
+
return () => {
|
|
1221
|
+
URL.revokeObjectURL(url);
|
|
1222
|
+
};
|
|
1223
|
+
}, [recorder.blob]);
|
|
1224
|
+
const cameraBlob = recorder.camera?.blob ?? null;
|
|
1225
|
+
useEffect2(() => {
|
|
1226
|
+
if (!cameraBlob) {
|
|
1227
|
+
setCameraPlaybackUrl(null);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const url = URL.createObjectURL(cameraBlob);
|
|
1231
|
+
setCameraPlaybackUrl(url);
|
|
1232
|
+
return () => {
|
|
1233
|
+
URL.revokeObjectURL(url);
|
|
1234
|
+
};
|
|
1235
|
+
}, [cameraBlob]);
|
|
1236
|
+
const captureKey = `${source}:${cameraOn ? micOn : ""}:${includeSystemAudio}`;
|
|
1237
|
+
const previousKeyRef = useRef2(captureKey);
|
|
1238
|
+
const dualSaveProgressRef = useRef2({});
|
|
1239
|
+
useEffect2(() => {
|
|
1240
|
+
if (previousKeyRef.current !== captureKey) {
|
|
1241
|
+
previousKeyRef.current = captureKey;
|
|
1242
|
+
dualSaveProgressRef.current = {};
|
|
1243
|
+
recorder.cancel();
|
|
1244
|
+
}
|
|
1245
|
+
}, [captureKey, recorder]);
|
|
1246
|
+
const handleClose = useCallback2(() => {
|
|
1247
|
+
const s = stageRef.current;
|
|
1248
|
+
if (closeNeedsConfirm(narrationOn, s.recorder.state, s.recorder.take !== null)) {
|
|
1249
|
+
if (!window.confirm("Discard the current narration take?")) return;
|
|
1250
|
+
s.handleDiscard();
|
|
1251
|
+
}
|
|
1252
|
+
if (narrationOn) {
|
|
1253
|
+
s.controller.pause();
|
|
1254
|
+
if (s.controller.mic.status === "live" || s.controller.mic.status === "starting") {
|
|
1255
|
+
s.controller.mic.stop();
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
recorder.cancel();
|
|
1259
|
+
onClose();
|
|
1260
|
+
}, [narrationOn, recorder, onClose]);
|
|
1261
|
+
useModalDialog({
|
|
1262
|
+
rootRef: overlayRef,
|
|
1263
|
+
dialogRef,
|
|
1264
|
+
onClose: handleClose,
|
|
1265
|
+
closeOnEscape: escapeClosesDialog(
|
|
1266
|
+
narrationOn,
|
|
1267
|
+
stage.recorder.state,
|
|
1268
|
+
stage.controller.transport
|
|
1269
|
+
)
|
|
1270
|
+
});
|
|
1271
|
+
const handleRequest = useCallback2(async () => {
|
|
1272
|
+
setSaveError(null);
|
|
1273
|
+
try {
|
|
1274
|
+
await recorder.request();
|
|
1275
|
+
} catch {
|
|
1276
|
+
}
|
|
1277
|
+
}, [recorder]);
|
|
1278
|
+
const handleStart = useCallback2(() => {
|
|
1279
|
+
setSaveError(null);
|
|
1280
|
+
dualSaveProgressRef.current = {};
|
|
1281
|
+
recorder.start();
|
|
1282
|
+
}, [recorder]);
|
|
1283
|
+
const handleStop = useCallback2(async () => {
|
|
1284
|
+
setSaveError(null);
|
|
1285
|
+
await recorder.stop();
|
|
1286
|
+
}, [recorder]);
|
|
1287
|
+
const handleSave = useCallback2(async () => {
|
|
1288
|
+
if (!recorder.blob || !recorder.mimeType || !recorder.extension || !recorder.directory) {
|
|
1289
|
+
setSaveError("Nothing to save yet \u2014 record something first.");
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (isDual) {
|
|
1293
|
+
const cam = recorder.camera;
|
|
1294
|
+
if (!cam?.blob || !cam.mimeType || !cam.extension) {
|
|
1295
|
+
setSaveError("Nothing to save yet \u2014 record something first.");
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
setIsSaving(true);
|
|
1299
|
+
setSaveError(null);
|
|
1300
|
+
try {
|
|
1301
|
+
const seeds = dualFilenameSeeds(
|
|
1302
|
+
recorder.stream,
|
|
1303
|
+
cam.stream,
|
|
1304
|
+
screenOn && includeSystemAudio,
|
|
1305
|
+
micOn
|
|
1306
|
+
);
|
|
1307
|
+
const trimmed = basename.trim();
|
|
1308
|
+
const screenFilename = buildFilename(
|
|
1309
|
+
"video",
|
|
1310
|
+
recorder.extension,
|
|
1311
|
+
trimmed ? `${trimmed}-screen` : void 0,
|
|
1312
|
+
seeds.screen
|
|
1313
|
+
);
|
|
1314
|
+
const cameraFilename = buildFilename(
|
|
1315
|
+
"video",
|
|
1316
|
+
cam.extension,
|
|
1317
|
+
trimmed ? `${trimmed}-camera` : void 0,
|
|
1318
|
+
seeds.camera
|
|
1319
|
+
);
|
|
1320
|
+
const progress = dualSaveProgressRef.current;
|
|
1321
|
+
const screenPath = progress.screenPath ?? await mediaProvider.addMedia(
|
|
1322
|
+
`${recorder.directory}/${screenFilename}`,
|
|
1323
|
+
recorder.blob,
|
|
1324
|
+
recorder.mimeType
|
|
1325
|
+
);
|
|
1326
|
+
progress.screenPath = screenPath;
|
|
1327
|
+
const cameraPath = progress.cameraPath ?? await mediaProvider.addMedia(
|
|
1328
|
+
`${recorder.directory}/${cameraFilename}`,
|
|
1329
|
+
cam.blob,
|
|
1330
|
+
cam.mimeType
|
|
1331
|
+
);
|
|
1332
|
+
progress.cameraPath = cameraPath;
|
|
1333
|
+
const duration = recorder.durationMs / 1e3;
|
|
1334
|
+
const offsetSec = recorder.cameraOffsetSec ?? 0;
|
|
1335
|
+
const result = {
|
|
1336
|
+
relativePath: screenPath,
|
|
1337
|
+
filename: screenFilename,
|
|
1338
|
+
source,
|
|
1339
|
+
mimeType: recorder.mimeType,
|
|
1340
|
+
duration,
|
|
1341
|
+
hasTimingSidecar: false,
|
|
1342
|
+
camera: {
|
|
1343
|
+
relativePath: cameraPath,
|
|
1344
|
+
filename: cameraFilename,
|
|
1345
|
+
mimeType: cam.mimeType,
|
|
1346
|
+
duration: Math.max(0, duration - offsetSec),
|
|
1347
|
+
offsetSec
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
dualSaveProgressRef.current = {};
|
|
1351
|
+
onSave?.(result);
|
|
1352
|
+
handleClose();
|
|
1353
|
+
} catch (err) {
|
|
1354
|
+
setSaveError(err instanceof Error ? err.message : "Failed to save recording");
|
|
1355
|
+
} finally {
|
|
1356
|
+
setIsSaving(false);
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
setIsSaving(true);
|
|
1361
|
+
setSaveError(null);
|
|
1362
|
+
try {
|
|
1363
|
+
const filename = buildFilename(
|
|
1364
|
+
source === "mic" ? "audio" : "video",
|
|
1365
|
+
recorder.extension,
|
|
1366
|
+
basename,
|
|
1367
|
+
filenameSeed
|
|
1368
|
+
);
|
|
1369
|
+
const relativeName = `${recorder.directory}/${filename}`;
|
|
1370
|
+
const relativePath = await mediaProvider.addMedia(
|
|
1371
|
+
relativeName,
|
|
1372
|
+
recorder.blob,
|
|
1373
|
+
recorder.mimeType
|
|
1374
|
+
);
|
|
1375
|
+
let hasTimingSidecar = false;
|
|
1376
|
+
if (source === "mic") {
|
|
1377
|
+
const timing = buildTimingJson(sourceText, recorder.durationMs / 1e3);
|
|
1378
|
+
const encoded = encodeTimingJson(timing);
|
|
1379
|
+
const sidecarPath = timingPathFor(relativePath);
|
|
1380
|
+
if (container) {
|
|
1381
|
+
await container.writeFile(sidecarPath, encoded, "application/json");
|
|
1382
|
+
hasTimingSidecar = true;
|
|
1383
|
+
} else {
|
|
1384
|
+
const written = await mediaProvider.addMedia(sidecarPath, encoded, "application/json");
|
|
1385
|
+
hasTimingSidecar = written === sidecarPath;
|
|
1386
|
+
if (!hasTimingSidecar) {
|
|
1387
|
+
console.warn(
|
|
1388
|
+
`[squisq-recorder] timing.json was saved as "${written}" instead of "${sidecarPath}" \u2014 auto-mapping may not pick it up.`
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const result = {
|
|
1394
|
+
relativePath,
|
|
1395
|
+
filename,
|
|
1396
|
+
source,
|
|
1397
|
+
mimeType: recorder.mimeType,
|
|
1398
|
+
duration: recorder.durationMs / 1e3,
|
|
1399
|
+
hasTimingSidecar
|
|
1400
|
+
};
|
|
1401
|
+
if (source === "mic") {
|
|
1402
|
+
result.sourceText = sourceText;
|
|
1403
|
+
}
|
|
1404
|
+
onSave?.(result);
|
|
1405
|
+
handleClose();
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
setSaveError(err instanceof Error ? err.message : "Failed to save recording");
|
|
1408
|
+
} finally {
|
|
1409
|
+
setIsSaving(false);
|
|
1410
|
+
}
|
|
1411
|
+
}, [
|
|
1412
|
+
recorder,
|
|
1413
|
+
source,
|
|
1414
|
+
isDual,
|
|
1415
|
+
micOn,
|
|
1416
|
+
screenOn,
|
|
1417
|
+
includeSystemAudio,
|
|
1418
|
+
basename,
|
|
1419
|
+
filenameSeed,
|
|
1420
|
+
sourceText,
|
|
1421
|
+
mediaProvider,
|
|
1422
|
+
container,
|
|
1423
|
+
onSave,
|
|
1424
|
+
handleClose
|
|
1425
|
+
]);
|
|
1426
|
+
const handleDiscard = useCallback2(() => {
|
|
1427
|
+
dualSaveProgressRef.current = {};
|
|
1428
|
+
recorder.reset();
|
|
1429
|
+
}, [recorder]);
|
|
1430
|
+
const handlePlaybackTimeUpdate = useCallback2(
|
|
1431
|
+
(media) => {
|
|
1432
|
+
const currentMs = Number.isFinite(media.currentTime) ? media.currentTime * 1e3 : 0;
|
|
1433
|
+
setPlaybackPositionMs(Math.min(recorder.durationMs, Math.max(0, currentMs)));
|
|
1434
|
+
},
|
|
1435
|
+
[recorder.durationMs]
|
|
1436
|
+
);
|
|
1437
|
+
const isAudioOnly = source === "mic";
|
|
1438
|
+
const showPreview = recorder.state !== "idle" && recorder.state !== "error";
|
|
1439
|
+
const canRecord = recorder.state === "ready";
|
|
1440
|
+
const canStop = recorder.state === "recording";
|
|
1441
|
+
const canSave = recorder.state === "stopped" && recorder.blob !== null;
|
|
1442
|
+
const isBusy = recorder.state === "requesting" || recorder.state === "stopping" || isSaving;
|
|
1443
|
+
const togglesLocked = recorder.state === "recording" || recorder.state === "requesting" || canSave;
|
|
1444
|
+
const toggleLockReason = canSave ? "Save or discard this recording before changing sources" : void 0;
|
|
1445
|
+
const systemAudioHasCompanion = micOn || cameraOn || screenOn;
|
|
1446
|
+
const simpleToggleProps = (key) => {
|
|
1447
|
+
const active = key === "mic" ? micOn : key === "camera" ? cameraOn : key === "screen" ? screenOn : includeSystemAudio;
|
|
1448
|
+
if (key === "systemAudio") {
|
|
1449
|
+
return {
|
|
1450
|
+
active,
|
|
1451
|
+
disabled: togglesLocked || !systemAudioHasCompanion,
|
|
1452
|
+
title: togglesLocked ? toggleLockReason : !systemAudioHasCompanion ? "Turn on Microphone, Camera, or Screen to add system audio" : screenOn ? "Capture your computer\u2019s audio with the screen recording" : "Capture your computer\u2019s audio \u2014 you\u2019ll pick a screen or tab to share it",
|
|
1453
|
+
onClick: () => setIncludeSystemAudio((on) => !on)
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
return {
|
|
1457
|
+
active,
|
|
1458
|
+
disabled: togglesLocked,
|
|
1459
|
+
title: toggleLockReason,
|
|
1460
|
+
onClick: () => {
|
|
1461
|
+
if (key === "mic") setMicOn((on) => !on);
|
|
1462
|
+
else if (key === "camera") setCameraOn((on) => !on);
|
|
1463
|
+
else setScreenOn((on) => !on);
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
};
|
|
1467
|
+
const narrationRecorderIdle = narrationQuiescent(stage.recorder.state);
|
|
1468
|
+
const narrationTakeDone = stage.recorder.state === "processing" || stage.recorder.state === "review" || stage.recorder.state === "saving";
|
|
1469
|
+
const narrationToggleDisabled = narrationToggleLocked(
|
|
1470
|
+
recorder.state,
|
|
1471
|
+
recorder.blob !== null || recorder.camera?.blob != null,
|
|
1472
|
+
stage.recorder.state
|
|
1473
|
+
);
|
|
1474
|
+
const narrationToggleFor = (key) => {
|
|
1475
|
+
switch (key) {
|
|
1476
|
+
case "mic":
|
|
1477
|
+
return {
|
|
1478
|
+
active: true,
|
|
1479
|
+
disabled: true,
|
|
1480
|
+
title: "Narration always records your microphone",
|
|
1481
|
+
onClick: () => {
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
case "camera":
|
|
1485
|
+
return {
|
|
1486
|
+
active: stage.recorder.withCamera,
|
|
1487
|
+
disabled: !narrationRecorderIdle,
|
|
1488
|
+
title: "Also capture your camera as a separate video file",
|
|
1489
|
+
onClick: () => stage.recorder.setWithCamera(!stage.recorder.withCamera)
|
|
1490
|
+
};
|
|
1491
|
+
case "screen":
|
|
1492
|
+
return {
|
|
1493
|
+
active: false,
|
|
1494
|
+
disabled: true,
|
|
1495
|
+
title: "Screen capture isn't available in narration mode \u2014 uncheck Show narration mode",
|
|
1496
|
+
onClick: () => {
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
case "systemAudio":
|
|
1500
|
+
return {
|
|
1501
|
+
active: false,
|
|
1502
|
+
disabled: true,
|
|
1503
|
+
title: "System audio isn't available in narration mode",
|
|
1504
|
+
onClick: () => {
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
return /* @__PURE__ */ jsx(
|
|
1510
|
+
"div",
|
|
1511
|
+
{
|
|
1512
|
+
ref: overlayRef,
|
|
1513
|
+
className: "squisq-editor-shell squisq-recorder-overlay",
|
|
1514
|
+
"data-theme": colorScheme,
|
|
1515
|
+
style: { ...overlayStyle, ...recorderThemeStyle(colorScheme) },
|
|
1516
|
+
children: /* @__PURE__ */ jsxs(
|
|
1517
|
+
"div",
|
|
1518
|
+
{
|
|
1519
|
+
ref: dialogRef,
|
|
1520
|
+
className: "squisq-editor-shell",
|
|
1521
|
+
"data-theme": colorScheme,
|
|
1522
|
+
"data-narration": narrationOn ? "true" : void 0,
|
|
1523
|
+
style: {
|
|
1524
|
+
...narrationOn ? modalExpandedStyle : modalStyle,
|
|
1525
|
+
...recorderThemeStyle(colorScheme)
|
|
1526
|
+
},
|
|
1527
|
+
onClick: (e) => e.stopPropagation(),
|
|
1528
|
+
role: "dialog",
|
|
1529
|
+
"aria-modal": "true",
|
|
1530
|
+
"aria-labelledby": headingId,
|
|
1531
|
+
tabIndex: -1,
|
|
1532
|
+
children: [
|
|
1533
|
+
/* @__PURE__ */ jsx("h2", { id: headingId, style: titleStyle, children: "Record media" }),
|
|
1534
|
+
/* @__PURE__ */ jsxs("div", { style: narrationOn ? bodyRowStyle : void 0, children: [
|
|
1535
|
+
/* @__PURE__ */ jsxs("div", { style: narrationOn ? leftColStyle : void 0, children: [
|
|
1536
|
+
/* @__PURE__ */ jsx("div", { style: toggleRowStyle, role: "group", "aria-label": "Capture sources", children: TOGGLE_GROUPS.map((group, groupIndex) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1537
|
+
groupIndex > 0 && /* @__PURE__ */ jsx("div", { "aria-hidden": "true", style: groupDividerStyle }),
|
|
1538
|
+
/* @__PURE__ */ jsx("div", { style: toggleGroupStyle, children: group.toggles.map((t) => {
|
|
1539
|
+
if (t.key === "systemAudio" && !canIncludeSystemAudio) return null;
|
|
1540
|
+
const props = narrationOn ? narrationToggleFor(t.key) : simpleToggleProps(t.key);
|
|
1541
|
+
return /* @__PURE__ */ jsx(
|
|
1542
|
+
"button",
|
|
1543
|
+
{
|
|
1544
|
+
type: "button",
|
|
1545
|
+
"aria-pressed": props.active,
|
|
1546
|
+
style: props.active ? toggleActive : toggleBase,
|
|
1547
|
+
onClick: props.onClick,
|
|
1548
|
+
disabled: props.disabled,
|
|
1549
|
+
title: props.title,
|
|
1550
|
+
children: t.label
|
|
1551
|
+
},
|
|
1552
|
+
t.key
|
|
1553
|
+
);
|
|
1554
|
+
}) })
|
|
1555
|
+
] }, group.label)) }),
|
|
1556
|
+
/* @__PURE__ */ jsx("p", { style: summaryStyle, children: narrationOn ? narrationCaptureSummary(stage.recorder.withCamera) : captureSummary(micOn, cameraOn, screenOn, includeSystemAudio) }),
|
|
1557
|
+
narrationAvailable && /* @__PURE__ */ jsxs("label", { style: checkboxRowStyle, children: [
|
|
1558
|
+
/* @__PURE__ */ jsx(
|
|
1559
|
+
"input",
|
|
1560
|
+
{
|
|
1561
|
+
type: "checkbox",
|
|
1562
|
+
style: { accentColor: "var(--squisq-recorder-accent)" },
|
|
1563
|
+
checked: narrationOn,
|
|
1564
|
+
onChange: (e) => setNarrationOn(e.target.checked),
|
|
1565
|
+
disabled: narrationToggleDisabled,
|
|
1566
|
+
title: narrationToggleDisabled ? narrationOn ? "Finish or discard the narration take first" : "Save or discard this recording before switching modes" : void 0
|
|
1567
|
+
}
|
|
1568
|
+
),
|
|
1569
|
+
"Show narration mode"
|
|
1570
|
+
] }),
|
|
1571
|
+
!narrationOn && recorder.error && /* @__PURE__ */ jsx("div", { style: errorStyle, children: recorder.error.message }),
|
|
1572
|
+
!narrationOn && saveError && /* @__PURE__ */ jsx("div", { style: errorStyle, children: saveError }),
|
|
1573
|
+
narrationOn && (stage.recorder.error ?? stage.controller.mic.error) && /* @__PURE__ */ jsx("div", { style: errorStyle, children: (stage.recorder.error ?? stage.controller.mic.error)?.message }),
|
|
1574
|
+
narrationOn && narrationCameraStream && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx(
|
|
1575
|
+
"video",
|
|
1576
|
+
{
|
|
1577
|
+
ref: previewRef,
|
|
1578
|
+
autoPlay: true,
|
|
1579
|
+
muted: true,
|
|
1580
|
+
playsInline: true,
|
|
1581
|
+
style: { width: "100%", height: "100%", objectFit: "contain" }
|
|
1582
|
+
}
|
|
1583
|
+
) }),
|
|
1584
|
+
narrationOn && !narrationCameraStream && narrationTakeDone && /* @__PURE__ */ jsx("div", { style: audioMeterStyle, children: stage.recorder.take ? `\u2713 Recorded ${formatDurationMs(stage.recorder.take.durationSec * 1e3)}` : "\u25CF Processing take\u2026" }),
|
|
1585
|
+
narrationOn && !narrationCameraStream && !narrationTakeDone && stage.recorder.withCamera && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx("span", { children: narrationPreviewWanted ? "Camera starting\u2026" : "Click Start preview to turn on your camera." }) }),
|
|
1586
|
+
narrationOn && !narrationCameraStream && !narrationTakeDone && !stage.recorder.withCamera && /* @__PURE__ */ jsxs("div", { style: { ...audioMeterStyle, position: "relative", overflow: "hidden" }, children: [
|
|
1587
|
+
/* @__PURE__ */ jsx(
|
|
1588
|
+
"div",
|
|
1589
|
+
{
|
|
1590
|
+
"aria-hidden": "true",
|
|
1591
|
+
style: {
|
|
1592
|
+
...meterLevelStyle,
|
|
1593
|
+
width: `${Math.round(Math.min(1, Math.max(0, stage.controller.micLevel)) * 100)}%`
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
),
|
|
1597
|
+
/* @__PURE__ */ jsx("span", { style: { position: "relative" }, children: stage.recorder.state === "recording" ? "\u25CF Recording narration" : narrationMicLive ? stage.controller.voiceActive ? "Voice detected" : "Microphone ready" : "Click Start preview to check your mic." })
|
|
1598
|
+
] }),
|
|
1599
|
+
!narrationOn && !showPreview && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx("span", { children: "Click Start Preview to start a recording." }) }),
|
|
1600
|
+
!narrationOn && showPreview && recorder.state !== "stopped" && !isAudioOnly && /* @__PURE__ */ jsxs("div", { style: isDual ? { ...previewBoxStyle, position: "relative" } : previewBoxStyle, children: [
|
|
1601
|
+
/* @__PURE__ */ jsx(
|
|
1602
|
+
"video",
|
|
1603
|
+
{
|
|
1604
|
+
ref: previewRef,
|
|
1605
|
+
autoPlay: true,
|
|
1606
|
+
muted: true,
|
|
1607
|
+
playsInline: true,
|
|
1608
|
+
style: { width: "100%", height: "100%", objectFit: "contain" }
|
|
1609
|
+
}
|
|
1610
|
+
),
|
|
1611
|
+
isDual && /* @__PURE__ */ jsx(
|
|
1612
|
+
"video",
|
|
1613
|
+
{
|
|
1614
|
+
ref: cameraPreviewRef,
|
|
1615
|
+
autoPlay: true,
|
|
1616
|
+
muted: true,
|
|
1617
|
+
playsInline: true,
|
|
1618
|
+
"aria-label": "Camera preview",
|
|
1619
|
+
style: {
|
|
1620
|
+
position: "absolute",
|
|
1621
|
+
right: "3%",
|
|
1622
|
+
bottom: "6%",
|
|
1623
|
+
width: "22%",
|
|
1624
|
+
aspectRatio: "16 / 9",
|
|
1625
|
+
objectFit: "cover",
|
|
1626
|
+
background: "#000",
|
|
1627
|
+
border: "1px solid rgba(255,255,255,0.6)"
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
)
|
|
1631
|
+
] }),
|
|
1632
|
+
!narrationOn && showPreview && recorder.state !== "stopped" && isAudioOnly && /* @__PURE__ */ jsx("div", { style: audioMeterStyle, children: recorder.state === "recording" ? /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1633
|
+
"\u25CF Recording ",
|
|
1634
|
+
formatDurationMs(recorder.durationMs)
|
|
1635
|
+
] }) : /* @__PURE__ */ jsx(Fragment2, { children: "Microphone ready" }) }),
|
|
1636
|
+
!narrationOn && recorder.state === "stopped" && playbackUrl && !isAudioOnly && /* @__PURE__ */ jsxs("div", { style: { ...previewBoxStyle, position: "relative" }, children: [
|
|
1637
|
+
/* @__PURE__ */ jsx(
|
|
1638
|
+
"video",
|
|
1639
|
+
{
|
|
1640
|
+
src: playbackUrl,
|
|
1641
|
+
controls: true,
|
|
1642
|
+
playsInline: true,
|
|
1643
|
+
onTimeUpdate: (event) => handlePlaybackTimeUpdate(event.currentTarget),
|
|
1644
|
+
onSeeking: (event) => handlePlaybackTimeUpdate(event.currentTarget),
|
|
1645
|
+
onEnded: () => setPlaybackPositionMs(recorder.durationMs),
|
|
1646
|
+
style: { width: "100%", height: "100%", objectFit: "contain" }
|
|
1647
|
+
}
|
|
1648
|
+
),
|
|
1649
|
+
/* @__PURE__ */ jsxs(
|
|
1650
|
+
"div",
|
|
1651
|
+
{
|
|
1652
|
+
role: "timer",
|
|
1653
|
+
"aria-label": `Playback time: ${formatDurationMs(playbackPositionMs)} of ${formatDurationMs(recorder.durationMs)}`,
|
|
1654
|
+
style: playbackTimeStyle,
|
|
1655
|
+
children: [
|
|
1656
|
+
formatDurationMs(playbackPositionMs),
|
|
1657
|
+
" / ",
|
|
1658
|
+
formatDurationMs(recorder.durationMs)
|
|
1659
|
+
]
|
|
1660
|
+
}
|
|
1661
|
+
)
|
|
1662
|
+
] }),
|
|
1663
|
+
!narrationOn && recorder.state === "stopped" && isDual && cameraPlaybackUrl && /* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
|
|
1664
|
+
/* @__PURE__ */ jsx("div", { style: summaryStyle, children: "Camera (picture-in-picture)" }),
|
|
1665
|
+
/* @__PURE__ */ jsx(
|
|
1666
|
+
"video",
|
|
1667
|
+
{
|
|
1668
|
+
src: cameraPlaybackUrl,
|
|
1669
|
+
controls: true,
|
|
1670
|
+
playsInline: true,
|
|
1671
|
+
"aria-label": "Camera recording",
|
|
1672
|
+
style: {
|
|
1673
|
+
width: "48%",
|
|
1674
|
+
display: "block",
|
|
1675
|
+
marginLeft: "auto",
|
|
1676
|
+
background: "#000"
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
)
|
|
1680
|
+
] }),
|
|
1681
|
+
!narrationOn && recorder.state === "stopped" && playbackUrl && isAudioOnly && /* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
|
|
1682
|
+
/* @__PURE__ */ jsxs("div", { style: { ...audioMeterStyle, marginBottom: 8 }, children: [
|
|
1683
|
+
"\u2713 Recorded ",
|
|
1684
|
+
formatDurationMs(recorder.durationMs)
|
|
1685
|
+
] }),
|
|
1686
|
+
/* @__PURE__ */ jsx("audio", { src: playbackUrl, controls: true, style: { width: "100%" } })
|
|
1687
|
+
] }),
|
|
1688
|
+
!narrationOn && source === "mic" && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1689
|
+
/* @__PURE__ */ jsx("label", { style: labelStyle, htmlFor: "recorder-source-text", children: "Script (used to auto-match this narration to a block)" }),
|
|
1690
|
+
/* @__PURE__ */ jsx(
|
|
1691
|
+
"textarea",
|
|
1692
|
+
{
|
|
1693
|
+
id: "recorder-source-text",
|
|
1694
|
+
style: textareaStyle,
|
|
1695
|
+
placeholder: "Type the text you're going to read aloud.",
|
|
1696
|
+
value: sourceText,
|
|
1697
|
+
onChange: (e) => setSourceText(e.target.value),
|
|
1698
|
+
disabled: recorder.state === "recording"
|
|
1699
|
+
}
|
|
1700
|
+
)
|
|
1701
|
+
] }),
|
|
1702
|
+
/* @__PURE__ */ jsx("label", { style: labelStyle, htmlFor: "recorder-basename", children: "Filename (optional)" }),
|
|
1703
|
+
/* @__PURE__ */ jsx(
|
|
1704
|
+
"input",
|
|
1705
|
+
{
|
|
1706
|
+
id: "recorder-basename",
|
|
1707
|
+
type: "text",
|
|
1708
|
+
style: inputStyle,
|
|
1709
|
+
placeholder: narrationOn ? "narration" : isDual ? "screen + camera" : filenameSeed,
|
|
1710
|
+
value: basename,
|
|
1711
|
+
onChange: (e) => setBasename(e.target.value),
|
|
1712
|
+
disabled: narrationOn ? !narrationRecorderIdle : recorder.state === "recording"
|
|
1713
|
+
}
|
|
1714
|
+
),
|
|
1715
|
+
!narrationOn && recorder.state === "recording" && !isAudioOnly && /* @__PURE__ */ jsxs("div", { style: recordingStatusStyle, children: [
|
|
1716
|
+
"\u25CF Recording ",
|
|
1717
|
+
formatDurationMs(recorder.durationMs)
|
|
1718
|
+
] }),
|
|
1719
|
+
/* @__PURE__ */ jsxs("div", { style: buttonRowStyle, children: [
|
|
1720
|
+
/* @__PURE__ */ jsx(
|
|
1721
|
+
"button",
|
|
1722
|
+
{
|
|
1723
|
+
type: "button",
|
|
1724
|
+
style: btnSecondary,
|
|
1725
|
+
onClick: handleClose,
|
|
1726
|
+
disabled: !narrationOn && isBusy,
|
|
1727
|
+
children: "Close"
|
|
1728
|
+
}
|
|
1729
|
+
),
|
|
1730
|
+
narrationOn && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1731
|
+
narrationRecorderIdle && !narrationMicLive && /* @__PURE__ */ jsx(
|
|
1732
|
+
"button",
|
|
1733
|
+
{
|
|
1734
|
+
type: "button",
|
|
1735
|
+
style: btnPrimary,
|
|
1736
|
+
onClick: () => void handleNarrationPreview(),
|
|
1737
|
+
disabled: narrationRequesting,
|
|
1738
|
+
children: narrationRequesting ? "Requesting\u2026" : "Start preview"
|
|
1739
|
+
}
|
|
1740
|
+
),
|
|
1741
|
+
narrationRecorderIdle && narrationMicLive && /* @__PURE__ */ jsxs("button", { type: "button", style: btnRecord, onClick: handleNarrationRecord, children: [
|
|
1742
|
+
/* @__PURE__ */ jsx(
|
|
1743
|
+
"span",
|
|
1744
|
+
{
|
|
1745
|
+
className: "squisq-recorder-record-dot",
|
|
1746
|
+
style: recordDotFrameStyle,
|
|
1747
|
+
"aria-hidden": "true",
|
|
1748
|
+
children: /* @__PURE__ */ jsx(
|
|
1749
|
+
"span",
|
|
1750
|
+
{
|
|
1751
|
+
className: "squisq-recorder-record-dot-center",
|
|
1752
|
+
style: recordDotStyle
|
|
1753
|
+
}
|
|
1754
|
+
)
|
|
1755
|
+
}
|
|
1756
|
+
),
|
|
1757
|
+
"Record"
|
|
1758
|
+
] }),
|
|
1759
|
+
(stage.recorder.state === "recording" || stage.recorder.state === "starting") && /* @__PURE__ */ jsx(
|
|
1760
|
+
"button",
|
|
1761
|
+
{
|
|
1762
|
+
type: "button",
|
|
1763
|
+
style: btnDanger,
|
|
1764
|
+
onClick: () => void stage.recorder.stop(),
|
|
1765
|
+
children: "Stop"
|
|
1766
|
+
}
|
|
1767
|
+
),
|
|
1768
|
+
stage.recorder.state === "processing" && /* @__PURE__ */ jsx("span", { style: recordingStatusStyle, children: "Aligning take\u2026" }),
|
|
1769
|
+
stage.recorder.state === "saving" && /* @__PURE__ */ jsx("span", { style: recordingStatusStyle, children: "Saving\u2026" }),
|
|
1770
|
+
stage.recorder.state === "review" && stage.recorder.take && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1771
|
+
/* @__PURE__ */ jsx("button", { type: "button", style: btnSecondary, onClick: stage.handleRetake, children: "Discard & re-record" }),
|
|
1772
|
+
/* @__PURE__ */ jsx(
|
|
1773
|
+
"button",
|
|
1774
|
+
{
|
|
1775
|
+
type: "button",
|
|
1776
|
+
style: btnPrimary,
|
|
1777
|
+
onClick: () => void stage.handleSave(),
|
|
1778
|
+
children: "Save to document"
|
|
1779
|
+
}
|
|
1780
|
+
)
|
|
1781
|
+
] })
|
|
1782
|
+
] }),
|
|
1783
|
+
!narrationOn && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1784
|
+
(recorder.state === "idle" || recorder.state === "error" || recorder.state === "requesting") && /* @__PURE__ */ jsx(
|
|
1785
|
+
"button",
|
|
1786
|
+
{
|
|
1787
|
+
type: "button",
|
|
1788
|
+
style: btnPrimary,
|
|
1789
|
+
onClick: handleRequest,
|
|
1790
|
+
disabled: isBusy || !canCapture,
|
|
1791
|
+
children: recorder.state === "requesting" ? "Requesting\u2026" : "Start preview"
|
|
1792
|
+
}
|
|
1793
|
+
),
|
|
1794
|
+
canRecord && /* @__PURE__ */ jsxs("button", { type: "button", style: btnRecord, onClick: handleStart, disabled: isBusy, children: [
|
|
1795
|
+
/* @__PURE__ */ jsx(
|
|
1796
|
+
"span",
|
|
1797
|
+
{
|
|
1798
|
+
className: "squisq-recorder-record-dot",
|
|
1799
|
+
style: recordDotFrameStyle,
|
|
1800
|
+
"aria-hidden": "true",
|
|
1801
|
+
children: /* @__PURE__ */ jsx(
|
|
1802
|
+
"span",
|
|
1803
|
+
{
|
|
1804
|
+
className: "squisq-recorder-record-dot-center",
|
|
1805
|
+
style: recordDotStyle
|
|
1806
|
+
}
|
|
1807
|
+
)
|
|
1808
|
+
}
|
|
1809
|
+
),
|
|
1810
|
+
"Record"
|
|
1811
|
+
] }),
|
|
1812
|
+
canStop && /* @__PURE__ */ jsx("button", { type: "button", style: btnDanger, onClick: handleStop, disabled: isBusy, children: "Stop" }),
|
|
1813
|
+
canSave && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
1814
|
+
/* @__PURE__ */ jsx(
|
|
1815
|
+
"button",
|
|
1816
|
+
{
|
|
1817
|
+
type: "button",
|
|
1818
|
+
style: btnSecondary,
|
|
1819
|
+
onClick: handleDiscard,
|
|
1820
|
+
disabled: isBusy,
|
|
1821
|
+
children: "Discard & re-record"
|
|
1822
|
+
}
|
|
1823
|
+
),
|
|
1824
|
+
/* @__PURE__ */ jsx(
|
|
1825
|
+
"button",
|
|
1826
|
+
{
|
|
1827
|
+
type: "button",
|
|
1828
|
+
style: btnPrimary,
|
|
1829
|
+
onClick: handleSave,
|
|
1830
|
+
disabled: isBusy,
|
|
1831
|
+
children: isSaving ? "Saving\u2026" : "Save to document"
|
|
1832
|
+
}
|
|
1833
|
+
)
|
|
1834
|
+
] })
|
|
1835
|
+
] })
|
|
1836
|
+
] })
|
|
1837
|
+
] }),
|
|
1838
|
+
narrationOn && narration && /* @__PURE__ */ jsx("div", { style: rightColStyle, children: /* @__PURE__ */ jsx(
|
|
1839
|
+
NarrationStage,
|
|
1840
|
+
{
|
|
1841
|
+
stage,
|
|
1842
|
+
theme: narration.theme,
|
|
1843
|
+
showSelfView: false,
|
|
1844
|
+
showCameraToggleInRecordSlot: false,
|
|
1845
|
+
showRecordSlot: false,
|
|
1846
|
+
showTransportPlay: false,
|
|
1847
|
+
showReviewActions: false
|
|
1848
|
+
}
|
|
1849
|
+
) })
|
|
1850
|
+
] })
|
|
1851
|
+
]
|
|
1852
|
+
}
|
|
1853
|
+
)
|
|
1854
|
+
}
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// src/recorder/RecorderPanel.tsx
|
|
1859
|
+
import { useCallback as useCallback3, useState as useState3 } from "react";
|
|
1860
|
+
import { createPortal } from "react-dom";
|
|
1861
|
+
import { Fragment as Fragment3, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1862
|
+
function RecorderPanel({
|
|
1863
|
+
mediaProvider,
|
|
1864
|
+
container = null,
|
|
1865
|
+
initialMode = "mic",
|
|
1866
|
+
colorScheme = "light",
|
|
1867
|
+
onSave,
|
|
1868
|
+
narration = null,
|
|
1869
|
+
tooltip = "Record media",
|
|
1870
|
+
className,
|
|
1871
|
+
open: controlledOpen,
|
|
1872
|
+
onOpenChange,
|
|
1873
|
+
showTrigger = true
|
|
1874
|
+
}) {
|
|
1875
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState3(false);
|
|
1876
|
+
const open = controlledOpen ?? uncontrolledOpen;
|
|
1877
|
+
const setOpen = useCallback3(
|
|
1878
|
+
(nextOpen) => {
|
|
1879
|
+
if (controlledOpen === void 0) setUncontrolledOpen(nextOpen);
|
|
1880
|
+
onOpenChange?.(nextOpen);
|
|
1881
|
+
},
|
|
1882
|
+
[controlledOpen, onOpenChange]
|
|
1883
|
+
);
|
|
1884
|
+
const handleClose = useCallback3(() => setOpen(false), [setOpen]);
|
|
1885
|
+
return /* @__PURE__ */ jsxs2(Fragment3, { children: [
|
|
1886
|
+
showTrigger && /* @__PURE__ */ jsx2(
|
|
1887
|
+
"button",
|
|
1888
|
+
{
|
|
1889
|
+
type: "button",
|
|
1890
|
+
className,
|
|
1891
|
+
"data-tooltip": tooltip,
|
|
1892
|
+
"aria-label": tooltip,
|
|
1893
|
+
"aria-expanded": open,
|
|
1894
|
+
onClick: () => setOpen(!open),
|
|
1895
|
+
children: /* @__PURE__ */ jsx2(Icon, { icon: "fa-solid fa-microphone" })
|
|
1896
|
+
}
|
|
1897
|
+
),
|
|
1898
|
+
open && typeof document !== "undefined" && createPortal(
|
|
1899
|
+
/* @__PURE__ */ jsx2(
|
|
1900
|
+
RecorderModal,
|
|
1901
|
+
{
|
|
1902
|
+
mediaProvider,
|
|
1903
|
+
container,
|
|
1904
|
+
initialMode,
|
|
1905
|
+
colorScheme,
|
|
1906
|
+
narration,
|
|
1907
|
+
onClose: handleClose,
|
|
1908
|
+
onSave: (result) => {
|
|
1909
|
+
onSave?.(result);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
),
|
|
1913
|
+
document.body
|
|
1914
|
+
)
|
|
1915
|
+
] });
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
export {
|
|
1919
|
+
requestScreenStream,
|
|
1920
|
+
getCaptureKind,
|
|
1921
|
+
useMediaRecorder,
|
|
1922
|
+
useModalDialog,
|
|
1923
|
+
RecorderModal,
|
|
1924
|
+
RecorderPanel
|
|
1925
|
+
};
|