@bendyline/squisq-editor-react 2.4.0 → 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.
@@ -1,983 +0,0 @@
1
- import {
2
- Icon
3
- } from "./chunk-GS7QWYFT.js";
4
- import {
5
- buildFilename,
6
- buildTimingJson,
7
- encodeTimingJson,
8
- requestCameraStream,
9
- requestMicStream,
10
- resolveFormat,
11
- supportsDisplayMedia,
12
- supportsMediaRecorder,
13
- supportsUserMedia,
14
- timingPathFor,
15
- useStreamPreview
16
- } from "./chunk-5Q4JN4I5.js";
17
-
18
- // src/recorder/sources/screenStream.ts
19
- function mixAudioTracks(streams) {
20
- const sources = streams.map((s) => s.getAudioTracks()).flat().filter((t) => t.readyState === "live");
21
- if (sources.length === 0) return null;
22
- const AC = window.AudioContext;
23
- if (typeof AC === "undefined") return null;
24
- const ctx = new AC();
25
- const dest = ctx.createMediaStreamDestination();
26
- for (const track of sources) {
27
- const src = ctx.createMediaStreamSource(new MediaStream([track]));
28
- src.connect(dest);
29
- }
30
- const [mixed] = dest.stream.getAudioTracks();
31
- if (!mixed) return null;
32
- return { track: mixed, context: ctx };
33
- }
34
- async function requestScreenStream(options) {
35
- if (!supportsDisplayMedia()) {
36
- throw new Error("navigator.mediaDevices.getDisplayMedia is not available in this environment.");
37
- }
38
- const video = options?.video ?? true;
39
- const systemAudio = options?.systemAudio ?? false;
40
- const includeMic = options?.includeMicrophone ?? false;
41
- const displayStream = await navigator.mediaDevices.getDisplayMedia({
42
- video,
43
- audio: systemAudio
44
- });
45
- if (!includeMic) {
46
- return {
47
- stream: displayStream,
48
- dispose: () => {
49
- }
50
- };
51
- }
52
- if (!supportsUserMedia()) {
53
- return {
54
- stream: displayStream,
55
- dispose: () => {
56
- }
57
- };
58
- }
59
- let micStream = null;
60
- try {
61
- micStream = await navigator.mediaDevices.getUserMedia({
62
- audio: options?.microphoneConstraints ?? true,
63
- video: false
64
- });
65
- } catch (err) {
66
- displayStream.getTracks().forEach((t) => t.stop());
67
- throw err;
68
- }
69
- const mix = mixAudioTracks([displayStream, micStream]);
70
- if (!mix) {
71
- micStream.getTracks().forEach((t) => t.stop());
72
- return {
73
- stream: displayStream,
74
- dispose: () => {
75
- }
76
- };
77
- }
78
- const [videoTrack] = displayStream.getVideoTracks();
79
- const output = new MediaStream();
80
- if (videoTrack) output.addTrack(videoTrack);
81
- output.addTrack(mix.track);
82
- const systemAudioTracks = displayStream.getAudioTracks();
83
- let disposed = false;
84
- const dispose = () => {
85
- if (disposed) return;
86
- disposed = true;
87
- micStream?.getTracks().forEach((t) => t.stop());
88
- micStream = null;
89
- systemAudioTracks.forEach((t) => t.stop());
90
- void mix.context.close().catch(() => {
91
- });
92
- };
93
- return { stream: output, dispose };
94
- }
95
-
96
- // src/recorder/hooks/useMediaRecorder.ts
97
- import { useCallback, useEffect, useRef, useState } from "react";
98
- async function acquireStream(source, opts) {
99
- switch (source) {
100
- case "mic": {
101
- const audio = typeof opts.audioConstraints === "object" ? opts.audioConstraints : void 0;
102
- const stream = await requestMicStream(audio);
103
- return { stream, dispose: () => {
104
- } };
105
- }
106
- case "camera": {
107
- const stream = await requestCameraStream({
108
- video: opts.videoConstraints ?? true,
109
- audio: opts.includeMicrophone === false ? false : opts.audioConstraints ?? true
110
- });
111
- return { stream, dispose: () => {
112
- } };
113
- }
114
- case "screen":
115
- case "screen+mic": {
116
- const handle = await requestScreenStream({
117
- video: opts.videoConstraints ?? true,
118
- systemAudio: opts.systemAudio ?? false,
119
- includeMicrophone: source === "screen+mic",
120
- microphoneConstraints: typeof opts.audioConstraints === "object" ? opts.audioConstraints : void 0
121
- });
122
- return { stream: handle.stream, dispose: handle.dispose };
123
- }
124
- }
125
- }
126
- function captureKindFor(source) {
127
- return source === "mic" ? "audio" : "video";
128
- }
129
- function getCaptureKind(source) {
130
- return captureKindFor(source);
131
- }
132
- function useMediaRecorder(options = {}) {
133
- const [state, setState] = useState("idle");
134
- const [stream, setStream] = useState(null);
135
- const [blob, setBlob] = useState(null);
136
- const [format, setFormat] = useState(null);
137
- const [durationMs, setDurationMs] = useState(0);
138
- const [error, setError] = useState(null);
139
- const recorderRef = useRef(null);
140
- const chunksRef = useRef([]);
141
- const disposeStreamRef = useRef(null);
142
- const startTimestampRef = useRef(null);
143
- const tickerRef = useRef(null);
144
- const stopResolversRef = useRef([]);
145
- const stopPromiseRef = useRef(null);
146
- const requestPromiseRef = useRef(null);
147
- const lifecycleRef = useRef(0);
148
- const optionsRef = useRef(options);
149
- optionsRef.current = options;
150
- const clearTicker = useCallback(() => {
151
- if (tickerRef.current !== null) {
152
- clearInterval(tickerRef.current);
153
- tickerRef.current = null;
154
- }
155
- }, []);
156
- const releaseStream = useCallback(() => {
157
- const s = recorderRef.current?.stream;
158
- if (s) {
159
- s.getTracks().forEach((t) => t.stop());
160
- }
161
- setStream((current) => {
162
- current?.getTracks().forEach((t) => t.stop());
163
- return null;
164
- });
165
- disposeStreamRef.current?.();
166
- disposeStreamRef.current = null;
167
- }, []);
168
- const reset = useCallback(() => {
169
- setBlob(null);
170
- setDurationMs(0);
171
- setError(null);
172
- chunksRef.current = [];
173
- startTimestampRef.current = null;
174
- clearTicker();
175
- const rec = recorderRef.current;
176
- if (rec && rec.state === "inactive" && rec.stream.active) {
177
- setState("ready");
178
- } else {
179
- setState("idle");
180
- }
181
- }, [clearTicker]);
182
- const cancel = useCallback(() => {
183
- lifecycleRef.current += 1;
184
- const rec = recorderRef.current;
185
- if (rec && rec.state !== "inactive") {
186
- try {
187
- rec.ondataavailable = null;
188
- rec.onstop = null;
189
- rec.onerror = null;
190
- rec.stop();
191
- } catch {
192
- }
193
- }
194
- recorderRef.current = null;
195
- releaseStream();
196
- clearTicker();
197
- chunksRef.current = [];
198
- startTimestampRef.current = null;
199
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
200
- stopPromiseRef.current = null;
201
- requestPromiseRef.current = null;
202
- setBlob(null);
203
- setDurationMs(0);
204
- setError(null);
205
- setState("idle");
206
- }, [clearTicker, releaseStream]);
207
- const request = useCallback(async () => {
208
- if (requestPromiseRef.current) return requestPromiseRef.current;
209
- if (recorderRef.current?.stream.active) return;
210
- if (!supportsMediaRecorder()) {
211
- const err = new Error("MediaRecorder is not supported in this environment.");
212
- setError(err);
213
- setState("error");
214
- throw err;
215
- }
216
- const lifecycle = ++lifecycleRef.current;
217
- const requestPromise = (async () => {
218
- setError(null);
219
- setState("requesting");
220
- let acquired = null;
221
- try {
222
- const source = optionsRef.current.source ?? "mic";
223
- acquired = await acquireStream(source, optionsRef.current);
224
- const { stream: nextStream, dispose } = acquired;
225
- if (lifecycle !== lifecycleRef.current) {
226
- nextStream.getTracks().forEach((track) => track.stop());
227
- dispose();
228
- return;
229
- }
230
- const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
231
- const recorderOptions = {};
232
- if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
233
- if (optionsRef.current.bitsPerSecond) {
234
- recorderOptions.bitsPerSecond = optionsRef.current.bitsPerSecond;
235
- }
236
- const recorder = new MediaRecorder(nextStream, recorderOptions);
237
- recorder.ondataavailable = (e) => {
238
- if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
239
- };
240
- recorder.onstop = () => {
241
- if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
242
- const recordedType = recorder.mimeType || resolved.mimeType || "application/octet-stream";
243
- const finalBlob = new Blob(chunksRef.current, { type: recordedType });
244
- chunksRef.current = [];
245
- setBlob(finalBlob);
246
- setState("stopped");
247
- clearTicker();
248
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(finalBlob));
249
- stopPromiseRef.current = null;
250
- };
251
- recorder.onerror = (event) => {
252
- if (recorderRef.current !== recorder || lifecycle !== lifecycleRef.current) return;
253
- const detail = event.error;
254
- const err = detail instanceof Error ? detail : new Error("Recorder error");
255
- setError(err);
256
- setState("error");
257
- clearTicker();
258
- stopResolversRef.current.splice(0).forEach((resolve) => resolve(null));
259
- stopPromiseRef.current = null;
260
- };
261
- recorderRef.current = recorder;
262
- disposeStreamRef.current = dispose;
263
- setStream(nextStream);
264
- setFormat(resolved);
265
- setBlob(null);
266
- setDurationMs(0);
267
- setState("ready");
268
- acquired = null;
269
- } catch (err) {
270
- if (acquired) {
271
- acquired.stream.getTracks().forEach((track) => track.stop());
272
- acquired.dispose();
273
- }
274
- const normalized = err instanceof Error ? err : new Error("Stream acquisition failed");
275
- if (lifecycle === lifecycleRef.current) {
276
- setError(normalized);
277
- setState("error");
278
- }
279
- throw normalized;
280
- } finally {
281
- if (lifecycle === lifecycleRef.current) requestPromiseRef.current = null;
282
- }
283
- })();
284
- requestPromiseRef.current = requestPromise;
285
- return requestPromise;
286
- }, [clearTicker]);
287
- const start = useCallback(() => {
288
- const rec = recorderRef.current;
289
- if (!rec) {
290
- const err = new Error("Recorder is not ready. Call request() first.");
291
- setError(err);
292
- setState("error");
293
- return;
294
- }
295
- if (rec.state === "recording") return;
296
- chunksRef.current = [];
297
- setBlob(null);
298
- setDurationMs(0);
299
- startTimestampRef.current = Date.now();
300
- rec.start(1e3);
301
- setState("recording");
302
- clearTicker();
303
- tickerRef.current = setInterval(() => {
304
- if (startTimestampRef.current !== null) {
305
- setDurationMs(Date.now() - startTimestampRef.current);
306
- }
307
- }, 100);
308
- }, [clearTicker]);
309
- const stop = useCallback(() => {
310
- if (stopPromiseRef.current) return stopPromiseRef.current;
311
- const rec = recorderRef.current;
312
- if (!rec || rec.state === "inactive") {
313
- return Promise.resolve(blob);
314
- }
315
- setState("stopping");
316
- const stopPromise = new Promise((resolve) => {
317
- stopResolversRef.current.push(resolve);
318
- try {
319
- rec.stop();
320
- } catch (err) {
321
- const normalized = err instanceof Error ? err : new Error("Failed to stop recorder");
322
- setError(normalized);
323
- setState("error");
324
- clearTicker();
325
- stopResolversRef.current.splice(0).forEach((r) => r(null));
326
- }
327
- });
328
- stopPromiseRef.current = stopPromise;
329
- void stopPromise.finally(() => {
330
- if (stopPromiseRef.current === stopPromise) stopPromiseRef.current = null;
331
- });
332
- return stopPromise;
333
- }, [blob, clearTicker]);
334
- useEffect(() => {
335
- const pendingResolvers = stopResolversRef.current;
336
- return () => {
337
- lifecycleRef.current += 1;
338
- requestPromiseRef.current = null;
339
- const rec = recorderRef.current;
340
- if (rec && rec.state !== "inactive") {
341
- try {
342
- rec.ondataavailable = null;
343
- rec.onstop = null;
344
- rec.onerror = null;
345
- rec.stop();
346
- } catch {
347
- }
348
- }
349
- releaseStream();
350
- clearTicker();
351
- pendingResolvers.splice(0).forEach((resolve) => resolve(null));
352
- stopPromiseRef.current = null;
353
- };
354
- }, [releaseStream, clearTicker]);
355
- return {
356
- state,
357
- stream,
358
- blob,
359
- mimeType: format?.mimeType ?? null,
360
- extension: format?.extension ?? null,
361
- directory: format?.directory ?? null,
362
- durationMs,
363
- error,
364
- request,
365
- start,
366
- stop,
367
- cancel,
368
- reset
369
- };
370
- }
371
-
372
- // src/recorder/RecorderModal.tsx
373
- import { useCallback as useCallback2, useEffect as useEffect2, useId, useRef as useRef2, useState as useState2 } from "react";
374
-
375
- // src/modal/useModalDialog.ts
376
- import { useModalDialog } from "@bendyline/squisq-react";
377
-
378
- // src/recorder/RecorderModal.tsx
379
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
380
- var overlayStyle = {
381
- position: "fixed",
382
- inset: 0,
383
- background: "rgba(0, 0, 0, 0.5)",
384
- display: "flex",
385
- alignItems: "center",
386
- justifyContent: "center",
387
- zIndex: 1e4
388
- };
389
- function recorderThemeStyle(colorScheme) {
390
- const dark = colorScheme === "dark";
391
- return {
392
- colorScheme,
393
- "--squisq-recorder-surface": `var(--squisq-bg, ${dark ? "#1f2937" : "#fffdf7"})`,
394
- "--squisq-recorder-input": `var(--squisq-input-bg, ${dark ? "#374151" : "#fff"})`,
395
- "--squisq-recorder-border": `var(--squisq-border, ${dark ? "#4b5563" : "#c9b98a"})`,
396
- "--squisq-recorder-text": `var(--squisq-text, ${dark ? "#e5e7eb" : "#4a3c1f"})`,
397
- "--squisq-recorder-muted": `var(--squisq-text-muted, ${dark ? "#9ca3af" : "#5a4a2a"})`,
398
- "--squisq-recorder-accent": "var(--squisq-accent, #8b6914)",
399
- "--squisq-recorder-accent-text": "#fff",
400
- "--squisq-recorder-danger": dark ? "#dc4c4c" : "#b33a3a",
401
- "--squisq-recorder-danger-border": dark ? "#ef6a6a" : "#902929",
402
- "--squisq-recorder-error-bg": dark ? "#3f151b" : "#fceeee",
403
- "--squisq-recorder-error-border": dark ? "#7f1d1d" : "#d88a8a",
404
- "--squisq-recorder-error-text": dark ? "#fecdd3" : "#8c2a2a"
405
- };
406
- }
407
- var modalStyle = {
408
- background: "var(--squisq-recorder-surface)",
409
- border: "1px solid var(--squisq-recorder-border)",
410
- borderRadius: 0,
411
- padding: "24px 28px",
412
- width: "min(560px, calc(100vw - 48px))",
413
- maxHeight: "calc(100vh - 48px)",
414
- overflowY: "auto",
415
- boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
416
- fontFamily: "system-ui, -apple-system, sans-serif",
417
- color: "var(--squisq-recorder-text)"
418
- };
419
- var titleStyle = {
420
- margin: "0 0 16px 0",
421
- fontSize: 18,
422
- fontWeight: 600,
423
- color: "var(--squisq-recorder-text)"
424
- };
425
- var labelStyle = {
426
- display: "block",
427
- fontSize: 13,
428
- fontWeight: 500,
429
- marginBottom: 4,
430
- color: "var(--squisq-recorder-text)"
431
- };
432
- var inputStyle = {
433
- width: "100%",
434
- padding: "6px 8px",
435
- fontSize: 13,
436
- fontFamily: "inherit",
437
- border: "1px solid var(--squisq-recorder-border)",
438
- borderRadius: 0,
439
- background: "var(--squisq-recorder-input)",
440
- color: "var(--squisq-recorder-text)",
441
- marginBottom: 12,
442
- boxSizing: "border-box"
443
- };
444
- var textareaStyle = {
445
- ...inputStyle,
446
- resize: "vertical",
447
- minHeight: 72
448
- };
449
- var btnPrimary = {
450
- padding: "8px 20px",
451
- fontSize: 14,
452
- fontFamily: "inherit",
453
- fontWeight: 500,
454
- cursor: "pointer",
455
- background: "var(--squisq-recorder-accent)",
456
- color: "var(--squisq-recorder-accent-text)",
457
- border: "1px solid var(--squisq-recorder-accent)",
458
- borderRadius: 0
459
- };
460
- var btnSecondary = {
461
- padding: "8px 20px",
462
- fontSize: 14,
463
- fontFamily: "inherit",
464
- fontWeight: 500,
465
- cursor: "pointer",
466
- background: "var(--squisq-recorder-input)",
467
- color: "var(--squisq-recorder-text)",
468
- border: "1px solid var(--squisq-recorder-border)",
469
- borderRadius: 0
470
- };
471
- var btnDanger = {
472
- ...btnPrimary,
473
- background: "var(--squisq-recorder-danger)",
474
- borderColor: "var(--squisq-recorder-danger-border)"
475
- };
476
- var btnRecord = {
477
- ...btnPrimary,
478
- display: "inline-flex",
479
- alignItems: "center",
480
- gap: 8
481
- };
482
- var recordDotFrameStyle = {
483
- display: "inline-flex",
484
- alignItems: "center",
485
- justifyContent: "center",
486
- flex: "0 0 auto",
487
- padding: 2,
488
- border: "1px solid #9ca3af",
489
- borderRadius: "50%",
490
- background: "#000"
491
- };
492
- var recordDotStyle = {
493
- display: "block",
494
- width: 9,
495
- height: 9,
496
- borderRadius: "50%",
497
- background: "var(--squisq-recorder-danger)"
498
- };
499
- var toggleRowStyle = {
500
- display: "flex",
501
- gap: 8,
502
- marginBottom: 16
503
- };
504
- var toggleBase = {
505
- padding: "6px 14px",
506
- fontSize: 13,
507
- fontFamily: "inherit",
508
- cursor: "pointer",
509
- background: "transparent",
510
- color: "var(--squisq-recorder-text)",
511
- border: "1px solid var(--squisq-recorder-border)",
512
- borderRadius: 999
513
- };
514
- var toggleActive = {
515
- ...toggleBase,
516
- color: "var(--squisq-recorder-accent-text)",
517
- fontWeight: 600,
518
- background: "var(--squisq-recorder-accent)",
519
- borderColor: "var(--squisq-recorder-accent)"
520
- };
521
- var previewBoxStyle = {
522
- width: "100%",
523
- background: "#000",
524
- borderRadius: 0,
525
- marginBottom: 12,
526
- overflow: "hidden",
527
- aspectRatio: "16 / 9",
528
- display: "flex",
529
- alignItems: "center",
530
- justifyContent: "center",
531
- color: "#888",
532
- fontSize: 13
533
- };
534
- var audioMeterStyle = {
535
- width: "100%",
536
- height: 56,
537
- background: "var(--squisq-recorder-input)",
538
- border: "1px solid var(--squisq-recorder-border)",
539
- marginBottom: 12,
540
- display: "flex",
541
- alignItems: "center",
542
- justifyContent: "center",
543
- color: "var(--squisq-recorder-muted)",
544
- fontSize: 13,
545
- fontVariantNumeric: "tabular-nums"
546
- };
547
- var errorStyle = {
548
- background: "var(--squisq-recorder-error-bg)",
549
- border: "1px solid var(--squisq-recorder-error-border)",
550
- color: "var(--squisq-recorder-error-text)",
551
- padding: "8px 10px",
552
- fontSize: 13,
553
- marginBottom: 12
554
- };
555
- var buttonRowStyle = {
556
- display: "flex",
557
- gap: 8,
558
- justifyContent: "flex-end",
559
- marginTop: 8
560
- };
561
- var summaryStyle = {
562
- margin: "0 0 12px 0",
563
- fontSize: 12,
564
- color: "var(--squisq-recorder-muted)"
565
- };
566
- var recordingStatusStyle = {
567
- fontSize: 13,
568
- fontVariantNumeric: "tabular-nums",
569
- marginBottom: 12,
570
- color: "var(--squisq-recorder-accent)",
571
- fontWeight: 600
572
- };
573
- function formatDurationMs(ms) {
574
- const totalSec = Math.floor(ms / 1e3);
575
- const m = Math.floor(totalSec / 60);
576
- const s = totalSec % 60;
577
- return `${m}:${s.toString().padStart(2, "0")}`;
578
- }
579
- function deriveSource(micOn, video) {
580
- if (video === "camera") return "camera";
581
- if (video === "screen") return micOn ? "screen+mic" : "screen";
582
- return micOn ? "mic" : null;
583
- }
584
- function toggleStateFromMode(mode) {
585
- switch (mode) {
586
- case "mic":
587
- return { micOn: true, video: "none" };
588
- case "camera":
589
- return { micOn: true, video: "camera" };
590
- case "screen":
591
- return { micOn: false, video: "screen" };
592
- case "screen+mic":
593
- return { micOn: true, video: "screen" };
594
- }
595
- }
596
- var TOGGLES = [
597
- { key: "mic", label: "Microphone" },
598
- { key: "camera", label: "Camera" },
599
- { key: "screen", label: "Screen" }
600
- ];
601
- function captureSummary(micOn, video) {
602
- if (video === "camera") {
603
- return micOn ? "Camera video with your microphone. Saved as a video clip." : "Camera video only (no microphone). Saved as a video clip.";
604
- }
605
- if (video === "screen") {
606
- return micOn ? "Screen capture with your microphone mixed in. System audio when available." : "Screen capture (no microphone). System audio when available.";
607
- }
608
- return micOn ? "Voice-only audio. Pairs with a written script for auto-mapping to blocks." : "Pick at least one source to record.";
609
- }
610
- function RecorderModal({
611
- mediaProvider,
612
- container = null,
613
- initialMode = "mic",
614
- colorScheme = "light",
615
- onClose,
616
- onSave
617
- }) {
618
- const initialToggles = toggleStateFromMode(initialMode);
619
- const [micOn, setMicOn] = useState2(initialToggles.micOn);
620
- const [video, setVideo] = useState2(initialToggles.video);
621
- const [sourceText, setSourceText] = useState2("");
622
- const [basename, setBasename] = useState2("");
623
- const [includeSystemAudio, setIncludeSystemAudio] = useState2(false);
624
- const [isSaving, setIsSaving] = useState2(false);
625
- const [saveError, setSaveError] = useState2(null);
626
- const [playbackUrl, setPlaybackUrl] = useState2(null);
627
- const overlayRef = useRef2(null);
628
- const dialogRef = useRef2(null);
629
- const headingId = useId();
630
- const previewRef = useRef2(null);
631
- const derivedSource = deriveSource(micOn, video);
632
- const canCapture = derivedSource !== null;
633
- const source = derivedSource ?? "mic";
634
- const recorder = useMediaRecorder({
635
- source,
636
- includeMicrophone: video === "camera" ? micOn : void 0,
637
- systemAudio: video === "screen" ? includeSystemAudio : false
638
- });
639
- useStreamPreview(previewRef, recorder.state === "stopped" ? null : recorder.stream);
640
- useEffect2(() => {
641
- if (!recorder.blob) {
642
- setPlaybackUrl(null);
643
- return;
644
- }
645
- const url = URL.createObjectURL(recorder.blob);
646
- setPlaybackUrl(url);
647
- return () => {
648
- URL.revokeObjectURL(url);
649
- };
650
- }, [recorder.blob]);
651
- const captureKey = `${source}:${video === "camera" ? micOn : ""}:${includeSystemAudio}`;
652
- const previousKeyRef = useRef2(captureKey);
653
- useEffect2(() => {
654
- if (previousKeyRef.current !== captureKey) {
655
- previousKeyRef.current = captureKey;
656
- recorder.cancel();
657
- }
658
- }, [captureKey, recorder]);
659
- const handleClose = useCallback2(() => {
660
- recorder.cancel();
661
- onClose();
662
- }, [recorder, onClose]);
663
- useModalDialog({ rootRef: overlayRef, dialogRef, onClose: handleClose });
664
- const handleRequest = useCallback2(async () => {
665
- setSaveError(null);
666
- try {
667
- await recorder.request();
668
- } catch {
669
- }
670
- }, [recorder]);
671
- const handleStart = useCallback2(() => {
672
- setSaveError(null);
673
- recorder.start();
674
- }, [recorder]);
675
- const handleStop = useCallback2(async () => {
676
- setSaveError(null);
677
- await recorder.stop();
678
- }, [recorder]);
679
- const handleSave = useCallback2(async () => {
680
- if (!recorder.blob || !recorder.mimeType || !recorder.extension || !recorder.directory) {
681
- setSaveError("Nothing to save yet \u2014 record something first.");
682
- return;
683
- }
684
- setIsSaving(true);
685
- setSaveError(null);
686
- try {
687
- const filename = buildFilename(
688
- source === "mic" ? "audio" : "video",
689
- recorder.extension,
690
- basename
691
- );
692
- const relativeName = `${recorder.directory}/${filename}`;
693
- const relativePath = await mediaProvider.addMedia(
694
- relativeName,
695
- recorder.blob,
696
- recorder.mimeType
697
- );
698
- let hasTimingSidecar = false;
699
- if (source === "mic") {
700
- const timing = buildTimingJson(sourceText, recorder.durationMs / 1e3);
701
- const encoded = encodeTimingJson(timing);
702
- const sidecarPath = timingPathFor(relativePath);
703
- if (container) {
704
- await container.writeFile(sidecarPath, encoded, "application/json");
705
- hasTimingSidecar = true;
706
- } else {
707
- const written = await mediaProvider.addMedia(sidecarPath, encoded, "application/json");
708
- hasTimingSidecar = written === sidecarPath;
709
- if (!hasTimingSidecar) {
710
- console.warn(
711
- `[squisq-recorder] timing.json was saved as "${written}" instead of "${sidecarPath}" \u2014 auto-mapping may not pick it up.`
712
- );
713
- }
714
- }
715
- }
716
- const result = {
717
- relativePath,
718
- filename,
719
- source,
720
- mimeType: recorder.mimeType,
721
- duration: recorder.durationMs / 1e3,
722
- hasTimingSidecar
723
- };
724
- if (source === "mic") {
725
- result.sourceText = sourceText;
726
- }
727
- onSave?.(result);
728
- handleClose();
729
- } catch (err) {
730
- setSaveError(err instanceof Error ? err.message : "Failed to save recording");
731
- } finally {
732
- setIsSaving(false);
733
- }
734
- }, [recorder, source, basename, sourceText, mediaProvider, container, onSave, handleClose]);
735
- const handleDiscard = useCallback2(() => {
736
- recorder.reset();
737
- }, [recorder]);
738
- const isAudioOnly = source === "mic";
739
- const showPreview = recorder.state !== "idle" && recorder.state !== "error";
740
- const canRecord = recorder.state === "ready";
741
- const canStop = recorder.state === "recording";
742
- const canSave = recorder.state === "stopped" && recorder.blob !== null;
743
- const isBusy = recorder.state === "requesting" || recorder.state === "stopping" || isSaving;
744
- const togglesLocked = recorder.state === "recording" || recorder.state === "requesting" || canSave;
745
- const toggleLockReason = canSave ? "Save or discard this recording before changing sources" : void 0;
746
- const toggleActiveFor = (key) => key === "mic" ? micOn : video === key;
747
- const onToggle = (key) => {
748
- if (key === "mic") {
749
- setMicOn((on) => !on);
750
- } else {
751
- setVideo((v) => v === key ? "none" : key);
752
- }
753
- };
754
- return /* @__PURE__ */ jsx(
755
- "div",
756
- {
757
- ref: overlayRef,
758
- className: "squisq-editor-shell squisq-recorder-overlay",
759
- "data-theme": colorScheme,
760
- style: { ...overlayStyle, ...recorderThemeStyle(colorScheme) },
761
- children: /* @__PURE__ */ jsxs(
762
- "div",
763
- {
764
- ref: dialogRef,
765
- className: "squisq-editor-shell",
766
- "data-theme": colorScheme,
767
- style: { ...modalStyle, ...recorderThemeStyle(colorScheme) },
768
- onClick: (e) => e.stopPropagation(),
769
- role: "dialog",
770
- "aria-modal": "true",
771
- "aria-labelledby": headingId,
772
- tabIndex: -1,
773
- children: [
774
- /* @__PURE__ */ jsx("h2", { id: headingId, style: titleStyle, children: "Record media" }),
775
- /* @__PURE__ */ jsx("div", { style: toggleRowStyle, role: "group", "aria-label": "Capture sources", children: TOGGLES.map((t) => {
776
- const active = toggleActiveFor(t.key);
777
- return /* @__PURE__ */ jsx(
778
- "button",
779
- {
780
- type: "button",
781
- "aria-pressed": active,
782
- style: active ? toggleActive : toggleBase,
783
- onClick: () => onToggle(t.key),
784
- disabled: togglesLocked,
785
- title: toggleLockReason,
786
- children: t.label
787
- },
788
- t.key
789
- );
790
- }) }),
791
- /* @__PURE__ */ jsx("p", { style: summaryStyle, children: captureSummary(micOn, video) }),
792
- recorder.error && /* @__PURE__ */ jsx("div", { style: errorStyle, children: recorder.error.message }),
793
- saveError && /* @__PURE__ */ jsx("div", { style: errorStyle, children: saveError }),
794
- !showPreview && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx("span", { children: "Click Start Preview to start a recording." }) }),
795
- showPreview && recorder.state !== "stopped" && !isAudioOnly && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx(
796
- "video",
797
- {
798
- ref: previewRef,
799
- autoPlay: true,
800
- muted: true,
801
- playsInline: true,
802
- style: { width: "100%", height: "100%", objectFit: "contain" }
803
- }
804
- ) }),
805
- showPreview && recorder.state !== "stopped" && isAudioOnly && /* @__PURE__ */ jsx("div", { style: audioMeterStyle, children: recorder.state === "recording" ? /* @__PURE__ */ jsxs(Fragment, { children: [
806
- "\u25CF Recording ",
807
- formatDurationMs(recorder.durationMs)
808
- ] }) : /* @__PURE__ */ jsx(Fragment, { children: "Microphone ready" }) }),
809
- recorder.state === "stopped" && playbackUrl && !isAudioOnly && /* @__PURE__ */ jsx("div", { style: previewBoxStyle, children: /* @__PURE__ */ jsx(
810
- "video",
811
- {
812
- src: playbackUrl,
813
- controls: true,
814
- playsInline: true,
815
- style: { width: "100%", height: "100%", objectFit: "contain" }
816
- }
817
- ) }),
818
- recorder.state === "stopped" && playbackUrl && isAudioOnly && /* @__PURE__ */ jsxs("div", { style: { marginBottom: 12 }, children: [
819
- /* @__PURE__ */ jsxs("div", { style: { ...audioMeterStyle, marginBottom: 8 }, children: [
820
- "\u2713 Recorded ",
821
- formatDurationMs(recorder.durationMs)
822
- ] }),
823
- /* @__PURE__ */ jsx("audio", { src: playbackUrl, controls: true, style: { width: "100%" } })
824
- ] }),
825
- source === "mic" && /* @__PURE__ */ jsxs(Fragment, { children: [
826
- /* @__PURE__ */ jsx("label", { style: labelStyle, htmlFor: "recorder-source-text", children: "Script (used to auto-match this narration to a block)" }),
827
- /* @__PURE__ */ jsx(
828
- "textarea",
829
- {
830
- id: "recorder-source-text",
831
- style: textareaStyle,
832
- placeholder: "Type the text you're going to read aloud.",
833
- value: sourceText,
834
- onChange: (e) => setSourceText(e.target.value),
835
- disabled: recorder.state === "recording"
836
- }
837
- )
838
- ] }),
839
- video === "screen" && /* @__PURE__ */ jsxs(
840
- "label",
841
- {
842
- style: {
843
- display: "flex",
844
- alignItems: "center",
845
- gap: 6,
846
- marginBottom: 12,
847
- fontSize: 13
848
- },
849
- children: [
850
- /* @__PURE__ */ jsx(
851
- "input",
852
- {
853
- type: "checkbox",
854
- style: { accentColor: "var(--squisq-recorder-accent)" },
855
- checked: includeSystemAudio,
856
- onChange: (e) => setIncludeSystemAudio(e.target.checked),
857
- disabled: recorder.state === "recording" || recorder.state === "requesting"
858
- }
859
- ),
860
- "Include system audio (Chrome only)"
861
- ]
862
- }
863
- ),
864
- /* @__PURE__ */ jsx("label", { style: labelStyle, htmlFor: "recorder-basename", children: "Filename (optional)" }),
865
- /* @__PURE__ */ jsx(
866
- "input",
867
- {
868
- id: "recorder-basename",
869
- type: "text",
870
- style: inputStyle,
871
- placeholder: source === "mic" ? "narration" : "recording",
872
- value: basename,
873
- onChange: (e) => setBasename(e.target.value),
874
- disabled: recorder.state === "recording"
875
- }
876
- ),
877
- recorder.state === "recording" && !isAudioOnly && /* @__PURE__ */ jsxs("div", { style: recordingStatusStyle, children: [
878
- "\u25CF Recording ",
879
- formatDurationMs(recorder.durationMs)
880
- ] }),
881
- /* @__PURE__ */ jsxs("div", { style: buttonRowStyle, children: [
882
- /* @__PURE__ */ jsx("button", { type: "button", style: btnSecondary, onClick: handleClose, disabled: isBusy, children: "Close" }),
883
- (recorder.state === "idle" || recorder.state === "error" || recorder.state === "requesting") && /* @__PURE__ */ jsx(
884
- "button",
885
- {
886
- type: "button",
887
- style: btnPrimary,
888
- onClick: handleRequest,
889
- disabled: isBusy || !canCapture,
890
- children: recorder.state === "requesting" ? "Requesting\u2026" : "Start preview"
891
- }
892
- ),
893
- canRecord && /* @__PURE__ */ jsxs("button", { type: "button", style: btnRecord, onClick: handleStart, disabled: isBusy, children: [
894
- /* @__PURE__ */ jsx(
895
- "span",
896
- {
897
- className: "squisq-recorder-record-dot",
898
- style: recordDotFrameStyle,
899
- "aria-hidden": "true",
900
- children: /* @__PURE__ */ jsx("span", { className: "squisq-recorder-record-dot-center", style: recordDotStyle })
901
- }
902
- ),
903
- "Record"
904
- ] }),
905
- canStop && /* @__PURE__ */ jsx("button", { type: "button", style: btnDanger, onClick: handleStop, disabled: isBusy, children: "Stop" }),
906
- canSave && /* @__PURE__ */ jsxs(Fragment, { children: [
907
- /* @__PURE__ */ jsx("button", { type: "button", style: btnSecondary, onClick: handleDiscard, disabled: isBusy, children: "Discard & re-record" }),
908
- /* @__PURE__ */ jsx("button", { type: "button", style: btnPrimary, onClick: handleSave, disabled: isBusy, children: isSaving ? "Saving\u2026" : "Save to document" })
909
- ] })
910
- ] })
911
- ]
912
- }
913
- )
914
- }
915
- );
916
- }
917
-
918
- // src/recorder/RecorderPanel.tsx
919
- import { useCallback as useCallback3, useState as useState3 } from "react";
920
- import { createPortal } from "react-dom";
921
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
922
- function RecorderPanel({
923
- mediaProvider,
924
- container = null,
925
- initialMode = "mic",
926
- colorScheme = "light",
927
- onSave,
928
- tooltip = "Record media",
929
- className,
930
- open: controlledOpen,
931
- onOpenChange,
932
- showTrigger = true
933
- }) {
934
- const [uncontrolledOpen, setUncontrolledOpen] = useState3(false);
935
- const open = controlledOpen ?? uncontrolledOpen;
936
- const setOpen = useCallback3(
937
- (nextOpen) => {
938
- if (controlledOpen === void 0) setUncontrolledOpen(nextOpen);
939
- onOpenChange?.(nextOpen);
940
- },
941
- [controlledOpen, onOpenChange]
942
- );
943
- const handleClose = useCallback3(() => setOpen(false), [setOpen]);
944
- return /* @__PURE__ */ jsxs2(Fragment2, { children: [
945
- showTrigger && /* @__PURE__ */ jsx2(
946
- "button",
947
- {
948
- type: "button",
949
- className,
950
- "data-tooltip": tooltip,
951
- "aria-label": tooltip,
952
- "aria-expanded": open,
953
- onClick: () => setOpen(!open),
954
- children: /* @__PURE__ */ jsx2(Icon, { icon: "fa-solid fa-microphone" })
955
- }
956
- ),
957
- open && typeof document !== "undefined" && createPortal(
958
- /* @__PURE__ */ jsx2(
959
- RecorderModal,
960
- {
961
- mediaProvider,
962
- container,
963
- initialMode,
964
- colorScheme,
965
- onClose: handleClose,
966
- onSave: (result) => {
967
- onSave?.(result);
968
- }
969
- }
970
- ),
971
- document.body
972
- )
973
- ] });
974
- }
975
-
976
- export {
977
- requestScreenStream,
978
- getCaptureKind,
979
- useMediaRecorder,
980
- useModalDialog,
981
- RecorderModal,
982
- RecorderPanel
983
- };