@ttt-productions/file-input 0.11.15 → 0.11.17
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/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/image-utils.d.ts +3 -4
- package/dist/lib/image-utils.d.ts.map +1 -1
- package/dist/lib/image-utils.js +11 -12
- package/dist/lib/image-utils.js.map +1 -1
- package/dist/lib/infer-content-type.d.ts +1 -24
- package/dist/lib/infer-content-type.d.ts.map +1 -1
- package/dist/lib/infer-content-type.js +31 -85
- package/dist/lib/infer-content-type.js.map +1 -1
- package/dist/lib/read-media-meta.d.ts +1 -1
- package/dist/lib/read-media-meta.d.ts.map +1 -1
- package/dist/lib/read-media-meta.js +20 -13
- package/dist/lib/read-media-meta.js.map +1 -1
- package/dist/lib/validate-media-duration.d.ts.map +1 -1
- package/dist/lib/validate-media-duration.js +5 -10
- package/dist/lib/validate-media-duration.js.map +1 -1
- package/dist/lib/validate-upload-kind.d.ts +0 -34
- package/dist/lib/validate-upload-kind.d.ts.map +1 -1
- package/dist/lib/validate-upload-kind.js +0 -17
- package/dist/lib/validate-upload-kind.js.map +1 -1
- package/dist/progress-bar.d.ts +2 -0
- package/dist/progress-bar.d.ts.map +1 -0
- package/dist/progress-bar.js +2 -0
- package/dist/progress-bar.js.map +1 -0
- package/dist/react/components/audio-input.d.ts +2 -2
- package/dist/react/components/audio-input.js +2 -2
- package/dist/react/components/audio-input.js.map +1 -1
- package/dist/react/components/file-input.d.ts +1 -1
- package/dist/react/components/file-input.d.ts.map +1 -1
- package/dist/react/components/file-input.js +59 -44
- package/dist/react/components/file-input.js.map +1 -1
- package/dist/react/components/image-cropper-modal.d.ts +3 -3
- package/dist/react/components/image-cropper-modal.d.ts.map +1 -1
- package/dist/react/components/image-cropper-modal.js +6 -8
- package/dist/react/components/image-cropper-modal.js.map +1 -1
- package/dist/react/components/image-input.d.ts +2 -2
- package/dist/react/components/image-input.js +2 -2
- package/dist/react/components/image-input.js.map +1 -1
- package/dist/react/components/media-constraints-hint.d.ts +1 -1
- package/dist/react/components/media-constraints-hint.d.ts.map +1 -1
- package/dist/react/components/media-constraints-hint.js +26 -25
- package/dist/react/components/media-constraints-hint.js.map +1 -1
- package/dist/react/components/media-input.d.ts +2 -2
- package/dist/react/components/media-input.d.ts.map +1 -1
- package/dist/react/components/media-input.js +111 -158
- package/dist/react/components/media-input.js.map +1 -1
- package/dist/react/components/photo-capture-modal.d.ts +1 -1
- package/dist/react/components/photo-capture-modal.d.ts.map +1 -1
- package/dist/react/components/photo-capture-modal.js +16 -20
- package/dist/react/components/photo-capture-modal.js.map +1 -1
- package/dist/react/components/record-dialog.d.ts +3 -7
- package/dist/react/components/record-dialog.d.ts.map +1 -1
- package/dist/react/components/record-dialog.js +60 -138
- package/dist/react/components/record-dialog.js.map +1 -1
- package/dist/react/components/video-input.d.ts +2 -2
- package/dist/react/components/video-input.js +2 -2
- package/dist/react/components/video-input.js.map +1 -1
- package/dist/react/index.d.ts +8 -8
- package/dist/react/index.js +8 -8
- package/dist/react/index.js.map +1 -1
- package/dist/types.d.ts +3 -59
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,25 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from
|
|
4
|
-
import { Camera, Video, Mic, X, RotateCcw, Check } from
|
|
5
|
-
import { startWaveformLoop } from
|
|
6
|
-
import { MediaPreview } from
|
|
7
|
-
import { ensureFileWithContentType } from
|
|
8
|
-
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Spinner, useAsyncAction, } from
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// reports "audio/webm;codecs=opus" for audio); requesting an explicit
|
|
12
|
-
// supported type keeps the recorded container deterministic. Safari has no
|
|
13
|
-
// webm support and falls through to mp4. When isTypeSupported is unavailable
|
|
14
|
-
// or nothing matches (test environments), we fall back to the no-argument
|
|
15
|
-
// constructor and normalize whatever the recorder produced.
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Camera, Video, Mic, X, RotateCcw, Check } from 'lucide-react';
|
|
5
|
+
import { startWaveformLoop } from '@ttt-productions/media-viewer';
|
|
6
|
+
import { MediaPreview } from '@ttt-productions/media-viewer/react';
|
|
7
|
+
import { ensureFileWithContentType } from '../../lib/infer-content-type.js';
|
|
8
|
+
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Spinner, useAsyncAction, } from '@ttt-productions/ui-core/react';
|
|
9
|
+
// An explicit supported container keeps mr.mimeType deterministic; Safari has no webm and falls
|
|
10
|
+
// through to mp4.
|
|
16
11
|
const RECORDER_MIME_CANDIDATES = {
|
|
17
|
-
audio: [
|
|
18
|
-
video: [
|
|
12
|
+
audio: ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'],
|
|
13
|
+
video: ['video/webm;codecs=vp9,opus', 'video/webm', 'video/mp4'],
|
|
19
14
|
};
|
|
20
15
|
function pickRecorderMimeType(kind) {
|
|
21
|
-
if (typeof MediaRecorder ===
|
|
22
|
-
typeof MediaRecorder.isTypeSupported !== "function") {
|
|
16
|
+
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
|
|
23
17
|
return undefined;
|
|
24
18
|
}
|
|
25
19
|
for (const candidate of RECORDER_MIME_CANDIDATES[kind]) {
|
|
@@ -27,57 +21,26 @@ function pickRecorderMimeType(kind) {
|
|
|
27
21
|
if (MediaRecorder.isTypeSupported(candidate))
|
|
28
22
|
return candidate;
|
|
29
23
|
}
|
|
30
|
-
catch {
|
|
31
|
-
// Treat probe errors as "unsupported" and keep trying.
|
|
32
|
-
}
|
|
24
|
+
catch { }
|
|
33
25
|
}
|
|
34
26
|
return undefined;
|
|
35
27
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// extension-based fallback downstream). The mp4 container holds either audio
|
|
39
|
-
// (.m4a → audio/mp4) or video (.mp4 → video/mp4), and — unlike webm/ogg — mp4
|
|
40
|
-
// is NOT in the extension-inference ambiguous map (a user-picked .mp4 is trusted
|
|
41
|
-
// as video). Here the RecordDialog's selected `kind` IS authoritative: an audio
|
|
42
|
-
// recording uses an audio-only, `video: false` getUserMedia stream, so no video
|
|
43
|
-
// track can exist. A surprising `video/mp4` mimeType on that audio recording is
|
|
44
|
-
// therefore still named `.m4a`; ensureFileWithContentType distrusts the
|
|
45
|
-
// kind-mismatched video/mp4 type and the `.m4a` extension yields audio/mp4.
|
|
28
|
+
// mp4 is not in the ambiguous-extension map, so an audio recording is named .m4a whatever
|
|
29
|
+
// container type the recorder reports.
|
|
46
30
|
function recordingFileName(mime, kind) {
|
|
47
|
-
const base = mime.split(
|
|
48
|
-
const ext = base ===
|
|
49
|
-
? kind ===
|
|
50
|
-
?
|
|
51
|
-
:
|
|
52
|
-
: base ===
|
|
53
|
-
?
|
|
54
|
-
:
|
|
31
|
+
const base = mime.split(';')[0].trim().toLowerCase();
|
|
32
|
+
const ext = base === 'audio/mp4' || base === 'video/mp4'
|
|
33
|
+
? kind === 'audio'
|
|
34
|
+
? 'm4a'
|
|
35
|
+
: 'mp4'
|
|
36
|
+
: base === 'audio/ogg' || base === 'video/ogg'
|
|
37
|
+
? 'ogg'
|
|
38
|
+
: 'webm';
|
|
55
39
|
return `recording.${ext}`;
|
|
56
40
|
}
|
|
57
|
-
// A MediaRecorder blob
|
|
58
|
-
//
|
|
59
|
-
// 1. stop() flushes whatever the encoder/muxer still had buffered, so the
|
|
60
|
-
// last written packet extends past the stop() call (Chromium's webm muxer
|
|
61
|
-
// and Firefox's are both in the tens-to-low-hundreds of ms here; typical
|
|
62
|
-
// observed flush is well under 500ms).
|
|
63
|
-
// 2. The container duration is the END of the final frame/packet, not the
|
|
64
|
-
// moment capture stopped — one video frame is ~33ms at 30fps and one Opus
|
|
65
|
-
// packet is 20ms, and both readers round that way (the client gate's
|
|
66
|
-
// seek-to-end duration and ffprobe on the server agree with the container).
|
|
67
|
-
// 3. setTimeout fires LATE, never early — a busy main thread encoding video
|
|
68
|
-
// routinely adds tens of ms, more under load or in a hidden tab.
|
|
69
|
-
//
|
|
70
|
-
// Both duration gates are exact — the client upload gate (validateMediaDuration:
|
|
71
|
-
// `duration <= maxDurationSec`) and the server processor
|
|
72
|
-
// (media-processing-core validateDuration: `durationSec <= maxDurationSec`) —
|
|
73
|
-
// with no tolerance. So stopping the recorder exactly AT the cap produced blobs
|
|
74
|
-
// the product then rejected as its own "Media too long. Max N seconds." error.
|
|
75
|
-
// Stopping early by 750ms clears all three effects above with headroom for
|
|
76
|
-
// timer jitter, and costs 1.25% of the smallest real cap (60s).
|
|
41
|
+
// A MediaRecorder blob runs a little longer than start() to stop() (muxer flush, frame-end
|
|
42
|
+
// duration, late timers) and both duration gates are exact, so recording stops before the cap.
|
|
77
43
|
const AUTO_STOP_SAFETY_MARGIN_MS = 750;
|
|
78
|
-
// The auto-stop delay for a cap. The clamp keeps the margin from swallowing a
|
|
79
|
-
// very short cap (a 1s cap would otherwise stop at 250ms); real caps are >= 60s,
|
|
80
|
-
// where the clamp never applies.
|
|
81
44
|
export function autoStopDelayMs(maxRecordDurationSec) {
|
|
82
45
|
const capMs = Math.floor(maxRecordDurationSec * 1000);
|
|
83
46
|
return Math.max(capMs - AUTO_STOP_SAFETY_MARGIN_MS, Math.ceil(capMs / 2));
|
|
@@ -86,11 +49,11 @@ function formatDuration(ms) {
|
|
|
86
49
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
87
50
|
const m = Math.floor(totalSec / 60);
|
|
88
51
|
const s = totalSec % 60;
|
|
89
|
-
return `${m}:${s.toString().padStart(2,
|
|
52
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
90
53
|
}
|
|
91
54
|
export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRecVideo, canRecAudio, cameraFacingMode, maxRecordDurationSec, disabled = false, isLoading = false, onRecorded, onRequestPhoto, }) {
|
|
92
55
|
const [recordKind, setRecordKind] = useState(initialKind);
|
|
93
|
-
const [recorderState, setRecorderState] = useState(
|
|
56
|
+
const [recorderState, setRecorderState] = useState('idle');
|
|
94
57
|
const [recordPreviewUrl, setRecordPreviewUrl] = useState(null);
|
|
95
58
|
const [recordedFile, setRecordedFile] = useState(null);
|
|
96
59
|
const [elapsedMs, setElapsedMs] = useState(0);
|
|
@@ -104,8 +67,7 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
104
67
|
const lastRecordUrlRef = useRef(null);
|
|
105
68
|
const livePreviewRef = useRef(null);
|
|
106
69
|
const autoStopTimeoutRef = useRef(null);
|
|
107
|
-
//
|
|
108
|
-
// closed, kind switched, re-record) belongs to nobody and must not be attached.
|
|
70
|
+
// An acquisition that resolves after a teardown belongs to nobody and is never attached.
|
|
109
71
|
const acquireGenRef = useRef(0);
|
|
110
72
|
const previewReqRef = useRef(0);
|
|
111
73
|
const startedAtRef = useRef(0);
|
|
@@ -115,11 +77,10 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
115
77
|
const audioSourceRef = useRef(null);
|
|
116
78
|
const waveformStopRef = useRef(null);
|
|
117
79
|
const waveformCanvasRef = useRef(null);
|
|
118
|
-
// Reset all transient state whenever the dialog opens.
|
|
119
80
|
useEffect(() => {
|
|
120
81
|
if (open) {
|
|
121
82
|
setRecordKind(initialKind);
|
|
122
|
-
setRecorderState(
|
|
83
|
+
setRecorderState('idle');
|
|
123
84
|
setRecordedFile(null);
|
|
124
85
|
setRecordPreviewUrl(null);
|
|
125
86
|
setElapsedMs(0);
|
|
@@ -167,7 +128,7 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
167
128
|
audioAnalyserRef.current = null;
|
|
168
129
|
const ctx = audioContextRef.current;
|
|
169
130
|
audioContextRef.current = null;
|
|
170
|
-
if (ctx && ctx.state !==
|
|
131
|
+
if (ctx && ctx.state !== 'closed') {
|
|
171
132
|
ctx.close().catch(() => { });
|
|
172
133
|
}
|
|
173
134
|
}, []);
|
|
@@ -185,21 +146,14 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
185
146
|
audioContextRef.current = ctx;
|
|
186
147
|
audioSourceRef.current = source;
|
|
187
148
|
audioAnalyserRef.current = analyser;
|
|
188
|
-
// Draw loop is the shared media-viewer engine (the same one the
|
|
189
|
-
// playback AudioPlayer uses) in oscilloscope mode — one waveform
|
|
190
|
-
// implementation across recording and playback.
|
|
191
149
|
waveformStopRef.current = startWaveformLoop({
|
|
192
150
|
analyser,
|
|
193
151
|
getCanvas: () => waveformCanvasRef.current,
|
|
194
|
-
mode:
|
|
152
|
+
mode: 'line',
|
|
195
153
|
});
|
|
196
154
|
}
|
|
197
|
-
catch {
|
|
198
|
-
// Waveform is best-effort; silent failures are acceptable.
|
|
199
|
-
}
|
|
155
|
+
catch { }
|
|
200
156
|
}, [stopWaveform]);
|
|
201
|
-
// Teardown MediaRecorder / MediaStream / auto-stop timeout / timer / waveform.
|
|
202
|
-
// Leaves recorderState alone — callers decide the next state.
|
|
203
157
|
const teardownCapture = useCallback(() => {
|
|
204
158
|
acquireGenRef.current += 1;
|
|
205
159
|
if (autoStopTimeoutRef.current) {
|
|
@@ -219,11 +173,9 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
219
173
|
stopElapsedTimer();
|
|
220
174
|
stopWaveform();
|
|
221
175
|
}, [stopElapsedTimer, stopWaveform]);
|
|
222
|
-
// Acquire camera/mic and attach the live preview (video) or waveform (audio)
|
|
223
|
-
// WITHOUT starting the recorder. Returns the live stream for reuse on Start.
|
|
224
176
|
const acquireStream = useCallback(async (kind) => {
|
|
225
|
-
const wantVideo = kind ===
|
|
226
|
-
const facingMode = cameraFacingMode ??
|
|
177
|
+
const wantVideo = kind === 'video';
|
|
178
|
+
const facingMode = cameraFacingMode ?? 'user';
|
|
227
179
|
const gen = acquireGenRef.current;
|
|
228
180
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
229
181
|
audio: true,
|
|
@@ -249,8 +201,6 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
249
201
|
}
|
|
250
202
|
return stream;
|
|
251
203
|
}, [cameraFacingMode, startWaveform]);
|
|
252
|
-
// Start a live preview for the given kind (used on open and on kind switch),
|
|
253
|
-
// tearing down any existing stream first. Surfaces permission errors inline.
|
|
254
204
|
const startLivePreview = useCallback(async (kind) => {
|
|
255
205
|
teardownCapture();
|
|
256
206
|
const req = ++previewReqRef.current;
|
|
@@ -259,16 +209,13 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
259
209
|
await acquireStream(kind);
|
|
260
210
|
}
|
|
261
211
|
catch (e) {
|
|
262
|
-
setError(e instanceof Error ? e.message :
|
|
212
|
+
setError(e instanceof Error ? e.message : 'Camera/microphone access denied.');
|
|
263
213
|
}
|
|
264
214
|
finally {
|
|
265
215
|
if (req === previewReqRef.current)
|
|
266
216
|
setAcquiring(false);
|
|
267
217
|
}
|
|
268
218
|
}, [teardownCapture, acquireStream]);
|
|
269
|
-
// Request camera/mic as soon as the dialog opens (parity with PhotoCaptureModal)
|
|
270
|
-
// so the permission prompt fires on open and the user sees a live preview before
|
|
271
|
-
// pressing Start. Release all devices when the dialog closes or unmounts.
|
|
272
219
|
useEffect(() => {
|
|
273
220
|
if (open) {
|
|
274
221
|
void startLivePreview(initialKind);
|
|
@@ -279,29 +226,23 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
279
226
|
};
|
|
280
227
|
}, [open, initialKind, startLivePreview, teardownCapture, revokeLastRecordUrl]);
|
|
281
228
|
const startRecording = useCallback(async (kind) => {
|
|
282
|
-
// Discard any previous preview before starting fresh.
|
|
283
229
|
revokeLastRecordUrl();
|
|
284
230
|
setRecordPreviewUrl(null);
|
|
285
231
|
setRecordedFile(null);
|
|
286
232
|
setRecordKind(kind);
|
|
287
|
-
const wantVideo = kind ===
|
|
288
|
-
// Reuse the live-preview stream when it matches the requested kind;
|
|
289
|
-
// otherwise acquire a fresh one (also (re)triggers the permission prompt).
|
|
233
|
+
const wantVideo = kind === 'video';
|
|
290
234
|
let stream = recordStreamRef.current;
|
|
291
235
|
if (!stream || liveStreamKindRef.current !== kind) {
|
|
292
236
|
try {
|
|
293
237
|
stream = await acquireStream(kind);
|
|
294
238
|
}
|
|
295
239
|
catch (e) {
|
|
296
|
-
setError(e instanceof Error ? e.message :
|
|
240
|
+
setError(e instanceof Error ? e.message : 'Camera/microphone access denied.');
|
|
297
241
|
return;
|
|
298
242
|
}
|
|
299
243
|
}
|
|
300
244
|
if (!stream)
|
|
301
245
|
return;
|
|
302
|
-
// Request an explicit, supported container so mr.mimeType is
|
|
303
|
-
// deterministic; fall back to the browser default when the explicit
|
|
304
|
-
// request is unavailable or rejected.
|
|
305
246
|
const preferredMimeType = pickRecorderMimeType(kind);
|
|
306
247
|
let mr;
|
|
307
248
|
try {
|
|
@@ -320,32 +261,23 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
320
261
|
};
|
|
321
262
|
mr.onstop = () => {
|
|
322
263
|
const blob = new Blob(recordChunksRef.current, {
|
|
323
|
-
type: mr.mimeType ||
|
|
324
|
-
preferredMimeType ||
|
|
325
|
-
(wantVideo ? "video/webm" : "audio/webm"),
|
|
264
|
+
type: mr.mimeType || preferredMimeType || (wantVideo ? 'video/webm' : 'audio/webm'),
|
|
326
265
|
});
|
|
327
266
|
const rawFile = new File([blob], recordingFileName(blob.type, kind), { type: blob.type });
|
|
328
|
-
|
|
329
|
-
// ("audio/webm;codecs=opus" → "audio/webm"). The chosen kind is
|
|
330
|
-
// authoritative: an audio recording must never emit a video/* type
|
|
331
|
-
// even if the recorder reports a surprising container.
|
|
332
|
-
const file = ensureFileWithContentType(rawFile, wantVideo ? "video" : "audio");
|
|
267
|
+
const file = ensureFileWithContentType(rawFile, wantVideo ? 'video' : 'audio');
|
|
333
268
|
const url = makeRecordUrl(file);
|
|
334
269
|
setRecordedFile(file);
|
|
335
270
|
setRecordPreviewUrl(url);
|
|
336
|
-
setRecorderState(
|
|
337
|
-
// Release devices but keep the recorded file/url in state.
|
|
271
|
+
setRecorderState('preview');
|
|
338
272
|
teardownCapture();
|
|
339
273
|
};
|
|
340
274
|
mr.start();
|
|
341
|
-
setRecorderState(
|
|
275
|
+
setRecorderState('recording');
|
|
342
276
|
startElapsedTimer();
|
|
343
|
-
// Hard cap. Stops with deliberate headroom (autoStopDelayMs) so the blob
|
|
344
|
-
// this produces lands legally UNDER maxRecordDurationSec at both gates.
|
|
345
277
|
if (maxRecordDurationSec && maxRecordDurationSec > 0) {
|
|
346
278
|
autoStopTimeoutRef.current = setTimeout(() => {
|
|
347
279
|
try {
|
|
348
|
-
if (mediaRecorderRef.current && mediaRecorderRef.current.state ===
|
|
280
|
+
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
|
349
281
|
mediaRecorderRef.current.stop();
|
|
350
282
|
}
|
|
351
283
|
}
|
|
@@ -362,18 +294,18 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
362
294
|
]);
|
|
363
295
|
const stopRecording = useCallback(() => {
|
|
364
296
|
const mr = mediaRecorderRef.current;
|
|
365
|
-
if (mr && mr.state ===
|
|
297
|
+
if (mr && mr.state === 'recording') {
|
|
366
298
|
try {
|
|
367
299
|
mr.stop();
|
|
368
300
|
}
|
|
369
301
|
catch {
|
|
370
302
|
teardownCapture();
|
|
371
|
-
setRecorderState(
|
|
303
|
+
setRecorderState('idle');
|
|
372
304
|
}
|
|
373
305
|
}
|
|
374
306
|
else {
|
|
375
307
|
teardownCapture();
|
|
376
|
-
setRecorderState(
|
|
308
|
+
setRecorderState('idle');
|
|
377
309
|
}
|
|
378
310
|
}, [teardownCapture]);
|
|
379
311
|
const handleReRecord = useCallback(() => {
|
|
@@ -382,35 +314,29 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
382
314
|
setRecordedFile(null);
|
|
383
315
|
setElapsedMs(0);
|
|
384
316
|
setError(null);
|
|
385
|
-
setRecorderState(
|
|
317
|
+
setRecorderState('idle');
|
|
386
318
|
void startLivePreview(recordKind);
|
|
387
319
|
}, [revokeLastRecordUrl, startLivePreview, recordKind]);
|
|
388
|
-
// Switching kind in idle restarts the live preview for the new kind so the
|
|
389
|
-
// correct device prompt/preview shows before recording.
|
|
390
320
|
const handleSelectKind = useCallback((kind) => {
|
|
391
321
|
setRecordKind(kind);
|
|
392
322
|
setError(null);
|
|
393
323
|
void startLivePreview(kind);
|
|
394
324
|
}, [startLivePreview]);
|
|
395
325
|
const reportError = useCallback((e) => {
|
|
396
|
-
setError(e instanceof Error ? e.message :
|
|
326
|
+
setError(e instanceof Error ? e.message : 'Something went wrong with the recording.');
|
|
397
327
|
}, []);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Save hands the recording off (the consumer validates and emits it). Pending covers
|
|
402
|
-
// the hand-off and a repeat click is ignored, so a double click can never emit twice.
|
|
328
|
+
const start = useAsyncAction((kind) => startRecording(kind), {
|
|
329
|
+
onError: reportError,
|
|
330
|
+
});
|
|
403
331
|
const save = useAsyncAction(async () => {
|
|
404
332
|
if (!recordedFile || !recordPreviewUrl)
|
|
405
333
|
return;
|
|
406
334
|
await onRecorded(recordedFile, recordPreviewUrl, recordKind);
|
|
407
335
|
onOpenChange(false);
|
|
408
336
|
}, { onError: reportError });
|
|
409
|
-
// Close the dialog for real (no confirmation). Used for idle/recording
|
|
410
|
-
// states and as the "Discard" action in the confirm AlertDialog.
|
|
411
337
|
const closeDialogImmediately = useCallback(() => {
|
|
412
338
|
const mr = mediaRecorderRef.current;
|
|
413
|
-
if (mr && mr.state ===
|
|
339
|
+
if (mr && mr.state === 'recording') {
|
|
414
340
|
try {
|
|
415
341
|
mr.stop();
|
|
416
342
|
}
|
|
@@ -421,34 +347,30 @@ export function RecordDialog({ open, onOpenChange, initialKind, canPhoto, canRec
|
|
|
421
347
|
setRecordedFile(null);
|
|
422
348
|
setRecordPreviewUrl(null);
|
|
423
349
|
setElapsedMs(0);
|
|
424
|
-
setRecorderState(
|
|
350
|
+
setRecorderState('idle');
|
|
425
351
|
setDiscardConfirmOpen(false);
|
|
426
352
|
onOpenChange(false);
|
|
427
353
|
}, [teardownCapture, revokeLastRecordUrl, onOpenChange]);
|
|
428
|
-
// Dialog close requests. In "preview" state we ask for confirmation
|
|
429
|
-
// (the user has unsaved work). Other states close immediately.
|
|
430
354
|
const handleDialogOpenChange = useCallback((v) => {
|
|
431
355
|
if (v)
|
|
432
|
-
return;
|
|
356
|
+
return;
|
|
433
357
|
if (save.pending)
|
|
434
|
-
return;
|
|
435
|
-
if (recorderState ===
|
|
358
|
+
return;
|
|
359
|
+
if (recorderState === 'preview') {
|
|
436
360
|
setDiscardConfirmOpen(true);
|
|
437
361
|
return;
|
|
438
362
|
}
|
|
439
363
|
closeDialogImmediately();
|
|
440
364
|
}, [recorderState, save.pending, closeDialogImmediately]);
|
|
441
|
-
const isRecording = recorderState ===
|
|
442
|
-
const isPreview = recorderState ===
|
|
443
|
-
const isIdle = recorderState ===
|
|
444
|
-
// The label shows the REAL cap ("0:42 / 1:00"), not the earlier auto-stop
|
|
445
|
-
// point — the promise to the user is the cap; the margin is internal.
|
|
365
|
+
const isRecording = recorderState === 'recording';
|
|
366
|
+
const isPreview = recorderState === 'preview';
|
|
367
|
+
const isIdle = recorderState === 'idle';
|
|
446
368
|
const elapsedLabel = maxRecordDurationSec
|
|
447
369
|
? `${formatDuration(elapsedMs)} / ${formatDuration(maxRecordDurationSec * 1000)}`
|
|
448
370
|
: formatDuration(elapsedMs);
|
|
449
|
-
return (_jsxs(_Fragment, { children: [_jsx(Dialog, { open: open, onOpenChange: handleDialogOpenChange, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Record" }), _jsxs(DialogDescription, { children: [isIdle &&
|
|
371
|
+
return (_jsxs(_Fragment, { children: [_jsx(Dialog, { open: open, onOpenChange: handleDialogOpenChange, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Record" }), _jsxs(DialogDescription, { children: [isIdle && 'Choose audio or video, then press Start.', isRecording && 'Recording…', isPreview && 'Review your recording. Save to upload, or Re-record to try again.'] })] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [canRecVideo && (_jsx(Button, { type: "button", variant: recordKind === 'video' ? 'default' : 'secondary', "aria-pressed": recordKind === 'video', onClick: () => handleSelectKind('video'), disabled: !isIdle || acquiring, icon: _jsx(Video, { className: "icon-xs" }), children: "Video" })), canRecAudio && (_jsx(Button, { type: "button", variant: recordKind === 'audio' ? 'default' : 'secondary', "aria-pressed": recordKind === 'audio', onClick: () => handleSelectKind('audio'), disabled: !isIdle || acquiring, icon: _jsx(Mic, { className: "icon-xs" }), children: "Audio" })), canPhoto && onRequestPhoto && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => {
|
|
450
372
|
onRequestPhoto();
|
|
451
373
|
onOpenChange(false);
|
|
452
|
-
}, disabled: !isIdle, icon: _jsx(Camera, { className: "icon-xs" }), children: "Photo" }))] }), recordKind ===
|
|
374
|
+
}, disabled: !isIdle, icon: _jsx(Camera, { className: "icon-xs" }), children: "Photo" }))] }), recordKind === 'video' && !isPreview && (_jsxs("div", { className: "relative mt-2 rounded-md overflow-hidden bg-[hsl(var(--neutral-black))] aspect-video", children: [_jsx("video", { ref: livePreviewRef, className: "w-full h-full object-contain", playsInline: true, muted: true }), acquiring && (_jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsx(Spinner, { size: "lg", label: "Starting camera" }) }))] })), recordKind === 'audio' && !isPreview && (_jsxs("div", { className: "relative mt-2 rounded-md bg-muted p-2 text-primary", children: [_jsx("canvas", { ref: waveformCanvasRef, width: 480, height: 80, className: "w-full h-20", "aria-label": "Audio waveform" }), acquiring && (_jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsx(Spinner, { size: "lg", label: "Starting microphone" }) }))] })), error && (_jsx("p", { className: "text-sm text-destructive", role: "alert", children: error })), isRecording && (_jsxs("div", { className: "flex items-center gap-2 text-sm", "aria-live": "polite", children: [_jsx("span", { className: "inline-block h-2.5 w-2.5 rounded-full bg-[color:var(--recording-indicator)] animate-pulse", "aria-label": "Recording" }), _jsx("span", { className: "tabular-nums", children: elapsedLabel })] })), isPreview && recordPreviewUrl && (_jsx("div", { className: "mt-2", children: _jsx(MediaPreview, { url: recordPreviewUrl, type: recordKind, controls: true, priority: true, className: "w-full rounded-md" }) })), _jsxs(DialogFooter, { className: "flex-row justify-between gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => handleDialogOpenChange(false), disabled: save.pending, icon: _jsx(X, { className: "icon-xs" }), children: "Close" }), isIdle && (_jsx(Button, { type: "button", variant: "default", onClick: () => void start.run(recordKind), disabled: disabled || isLoading || acquiring, pending: start.pending, icon: recordKind === 'video' ? (_jsx(Video, { className: "icon-xs" })) : (_jsx(Mic, { className: "icon-xs" })), children: "Start" })), isRecording && (_jsx(Button, { type: "button", variant: "default", onClick: stopRecording, children: "Stop" })), isPreview && (_jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: handleReRecord, disabled: save.pending, icon: _jsx(RotateCcw, { className: "icon-xs" }), children: "Re-record" }), _jsx(Button, { type: "button", variant: "default", onClick: () => void save.run(), disabled: disabled || isLoading, pending: save.pending, icon: _jsx(Check, { className: "icon-xs" }), children: "Save" })] }))] })] }) }), _jsx(AlertDialog, { open: discardConfirmOpen, onOpenChange: setDiscardConfirmOpen, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "Discard recording?" }), _jsx(AlertDialogDescription, { children: "You have a recorded clip that hasn't been saved. Closing now will discard it." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: "Keep recording" }), _jsx(AlertDialogAction, { onClick: closeDialogImmediately, children: "Discard" })] })] }) })] }));
|
|
453
375
|
}
|
|
454
376
|
//# sourceMappingURL=record-dialog.js.map
|