@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.
Files changed (67) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/image-utils.d.ts +3 -4
  6. package/dist/lib/image-utils.d.ts.map +1 -1
  7. package/dist/lib/image-utils.js +11 -12
  8. package/dist/lib/image-utils.js.map +1 -1
  9. package/dist/lib/infer-content-type.d.ts +1 -24
  10. package/dist/lib/infer-content-type.d.ts.map +1 -1
  11. package/dist/lib/infer-content-type.js +31 -85
  12. package/dist/lib/infer-content-type.js.map +1 -1
  13. package/dist/lib/read-media-meta.d.ts +1 -1
  14. package/dist/lib/read-media-meta.d.ts.map +1 -1
  15. package/dist/lib/read-media-meta.js +20 -13
  16. package/dist/lib/read-media-meta.js.map +1 -1
  17. package/dist/lib/validate-media-duration.d.ts.map +1 -1
  18. package/dist/lib/validate-media-duration.js +5 -10
  19. package/dist/lib/validate-media-duration.js.map +1 -1
  20. package/dist/lib/validate-upload-kind.d.ts +0 -34
  21. package/dist/lib/validate-upload-kind.d.ts.map +1 -1
  22. package/dist/lib/validate-upload-kind.js +0 -17
  23. package/dist/lib/validate-upload-kind.js.map +1 -1
  24. package/dist/progress-bar.d.ts +2 -0
  25. package/dist/progress-bar.d.ts.map +1 -0
  26. package/dist/progress-bar.js +2 -0
  27. package/dist/progress-bar.js.map +1 -0
  28. package/dist/react/components/audio-input.d.ts +2 -2
  29. package/dist/react/components/audio-input.js +2 -2
  30. package/dist/react/components/audio-input.js.map +1 -1
  31. package/dist/react/components/file-input.d.ts +1 -1
  32. package/dist/react/components/file-input.d.ts.map +1 -1
  33. package/dist/react/components/file-input.js +59 -44
  34. package/dist/react/components/file-input.js.map +1 -1
  35. package/dist/react/components/image-cropper-modal.d.ts +3 -3
  36. package/dist/react/components/image-cropper-modal.d.ts.map +1 -1
  37. package/dist/react/components/image-cropper-modal.js +6 -8
  38. package/dist/react/components/image-cropper-modal.js.map +1 -1
  39. package/dist/react/components/image-input.d.ts +2 -2
  40. package/dist/react/components/image-input.js +2 -2
  41. package/dist/react/components/image-input.js.map +1 -1
  42. package/dist/react/components/media-constraints-hint.d.ts +1 -1
  43. package/dist/react/components/media-constraints-hint.d.ts.map +1 -1
  44. package/dist/react/components/media-constraints-hint.js +26 -25
  45. package/dist/react/components/media-constraints-hint.js.map +1 -1
  46. package/dist/react/components/media-input.d.ts +2 -2
  47. package/dist/react/components/media-input.d.ts.map +1 -1
  48. package/dist/react/components/media-input.js +111 -158
  49. package/dist/react/components/media-input.js.map +1 -1
  50. package/dist/react/components/photo-capture-modal.d.ts +1 -1
  51. package/dist/react/components/photo-capture-modal.d.ts.map +1 -1
  52. package/dist/react/components/photo-capture-modal.js +16 -20
  53. package/dist/react/components/photo-capture-modal.js.map +1 -1
  54. package/dist/react/components/record-dialog.d.ts +3 -7
  55. package/dist/react/components/record-dialog.d.ts.map +1 -1
  56. package/dist/react/components/record-dialog.js +60 -138
  57. package/dist/react/components/record-dialog.js.map +1 -1
  58. package/dist/react/components/video-input.d.ts +2 -2
  59. package/dist/react/components/video-input.js +2 -2
  60. package/dist/react/components/video-input.js.map +1 -1
  61. package/dist/react/index.d.ts +8 -8
  62. package/dist/react/index.js +8 -8
  63. package/dist/react/index.js.map +1 -1
  64. package/dist/types.d.ts +3 -59
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +4 -4
@@ -1,25 +1,19 @@
1
- "use client";
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 "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
- // Explicit MediaRecorder container preferences, probed via isTypeSupported.
10
- // A no-argument MediaRecorder leaves the container to the browser (Chrome
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: ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"],
18
- video: ["video/webm;codecs=vp9,opus", "video/webm", "video/mp4"],
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 === "undefined" ||
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
- // Name the recording after its actual container so the filename never
37
- // contradicts the emitted contentType (a webm-named mp4 would mislead any
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(";")[0].trim().toLowerCase();
48
- const ext = base === "audio/mp4" || base === "video/mp4"
49
- ? kind === "audio"
50
- ? "m4a"
51
- : "mp4"
52
- : base === "audio/ogg" || base === "video/ogg"
53
- ? "ogg"
54
- : "webm";
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 is always a little LONGER than the wall-clock window
58
- // between start() and stop(). Three effects stack, all in the same direction:
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, "0")}`;
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("idle");
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
- // Bumped by every teardown. An acquisition that resolves after a teardown (dialog
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("idle");
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 !== "closed") {
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: "line",
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 === "video";
226
- const facingMode = cameraFacingMode ?? "user";
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 : "Camera/microphone access denied.");
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 === "video";
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 : "Camera/microphone access denied.");
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
- // Normalize to the valid, parameter-less base type
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("preview");
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("recording");
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 === "recording") {
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 === "recording") {
297
+ if (mr && mr.state === 'recording') {
366
298
  try {
367
299
  mr.stop();
368
300
  }
369
301
  catch {
370
302
  teardownCapture();
371
- setRecorderState("idle");
303
+ setRecorderState('idle');
372
304
  }
373
305
  }
374
306
  else {
375
307
  teardownCapture();
376
- setRecorderState("idle");
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("idle");
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 : "Something went wrong with the recording.");
326
+ setError(e instanceof Error ? e.message : 'Something went wrong with the recording.');
397
327
  }, []);
398
- // Start may need to acquire a device first; pending covers that and a repeat click
399
- // is ignored, so two clicks can never start two recorders.
400
- const start = useAsyncAction((kind) => startRecording(kind), { onError: reportError });
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 === "recording") {
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("idle");
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; // opening is parent-driven via the `open` prop
356
+ return;
433
357
  if (save.pending)
434
- return; // never drop a recording mid-hand-off
435
- if (recorderState === "preview") {
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 === "recording";
442
- const isPreview = recorderState === "preview";
443
- const isIdle = recorderState === "idle";
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 && "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: () => {
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 === "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" })] })] }) })] }));
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