@exode-team/react-recorder 1.1.5 → 1.1.8

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.js CHANGED
@@ -178,7 +178,11 @@ const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
178
178
  * @author: exode <hello@exode.ru>
179
179
  */
180
180
  const fetchAudioBlob = async (url, signal) => {
181
- const response = await fetch(url, { signal });
181
+ const response = await fetch(url, {
182
+ signal,
183
+ mode: 'cors',
184
+ credentials: 'omit',
185
+ });
182
186
  return response.blob();
183
187
  };
184
188
 
@@ -270,6 +274,9 @@ const useMediaRecorder = (callbacks) => {
270
274
  if (!mediaRecorderRef.current || !isRecording) {
271
275
  return;
272
276
  }
277
+ if (mediaRecorderRef.current.state === 'paused') {
278
+ mediaRecorderRef.current.resume();
279
+ }
273
280
  mediaRecorderRef.current.stop();
274
281
  }, [isRecording]);
275
282
  const cancelRecording = useCallback(() => {
@@ -428,6 +435,7 @@ const useAudioPlayback = () => {
428
435
  * @author: exode <hello@exode.ru>
429
436
  */
430
437
  const useRecordingTiming = () => {
438
+ const [lastDuration, setLastDuration] = useState(0);
431
439
  const [recordingTime, setRecordingTime] = useState(0);
432
440
  const timerRef = useRef(null);
433
441
  const recordingStartRef = useRef(null);
@@ -455,6 +463,7 @@ const useRecordingTiming = () => {
455
463
  const startNewSession = useCallback(() => {
456
464
  recordingAccumulatedRef.current = 0;
457
465
  setRecordingTime(0);
466
+ setLastDuration(0);
458
467
  recordingStartRef.current = performance.now();
459
468
  startTicker();
460
469
  }, [startTicker]);
@@ -472,7 +481,21 @@ const useRecordingTiming = () => {
472
481
  setRecordingTime(roundTo(recordingAccumulatedRef.current));
473
482
  stopTicker();
474
483
  }, [stopTicker]);
484
+ const getElapsedMs = useCallback(() => {
485
+ const accumulated = recordingAccumulatedRef.current;
486
+ const elapsed = recordingStartRef.current !== null
487
+ ? (performance.now() - recordingStartRef.current) / 1000 + accumulated
488
+ : accumulated;
489
+ return Math.round(elapsed * 1000);
490
+ }, []);
475
491
  const resetSession = useCallback(() => {
492
+ const accumulated = recordingAccumulatedRef.current;
493
+ const elapsed = recordingStartRef.current !== null
494
+ ? (performance.now() - recordingStartRef.current) / 1000 + accumulated
495
+ : accumulated;
496
+ if (elapsed > 0) {
497
+ setLastDuration(roundTo(elapsed));
498
+ }
476
499
  stopTicker();
477
500
  recordingStartRef.current = null;
478
501
  recordingAccumulatedRef.current = 0;
@@ -484,6 +507,8 @@ const useRecordingTiming = () => {
484
507
  };
485
508
  }, [stopTicker]);
486
509
  return {
510
+ lastDuration,
511
+ getElapsedMs,
487
512
  pauseSession,
488
513
  resetSession,
489
514
  recordingTime,
@@ -585,7 +610,7 @@ const useRecorderVisualization = () => {
585
610
  * @author: exode <hello@exode.ru>
586
611
  */
587
612
  const useRecordingSender = (options) => {
588
- const { uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
613
+ const { uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, getDurationMs, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
589
614
  const sendRecording = useCallback(async () => {
590
615
  if (!audioBlob) {
591
616
  const error = new Error('Voice recording is not available');
@@ -598,11 +623,13 @@ const useRecordingSender = (options) => {
598
623
  throw error;
599
624
  }
600
625
  try {
626
+ const durationMs = getDurationMs();
627
+ const durationSec = durationMs / 1000;
601
628
  const file = new File([audioBlob], fileName, {
602
629
  type: audioBlob.type || mimeType,
603
630
  });
604
631
  const uploaded = await uploader(file, { uploadId });
605
- await onUploaded(uploaded);
632
+ await onUploaded(uploaded, durationSec);
606
633
  if (autoResetAfterUpload) {
607
634
  deleteRecording();
608
635
  }
@@ -618,8 +645,9 @@ const useRecordingSender = (options) => {
618
645
  audioBlob,
619
646
  mimeType,
620
647
  onUploaded,
621
- onRecordingError,
648
+ getDurationMs,
622
649
  deleteRecording,
650
+ onRecordingError,
623
651
  autoResetAfterUpload,
624
652
  ]);
625
653
  return { sendRecording };
@@ -635,7 +663,7 @@ function useVoiceRecorder(props) {
635
663
  const [audioBlob, setAudioBlob] = useState(null);
636
664
  const { audioURL, isPlaying, mountBlob, playbackTime, stopPlayback, resetPlayback, togglePlayback: _togglePlayback, playbackDuration, } = useAudioPlayback();
637
665
  const { audioLevels, previewLevels, resetAudioLevels, stopVisualization, resetPreviewLevels, resumeVisualization, cleanupVisualization, generatePreviewLevels, initializeVisualization, } = useRecorderVisualization();
638
- const { recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
666
+ const { lastDuration, getElapsedMs, recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
639
667
  const resetCurrentRecording = useCallback((opts) => {
640
668
  var _a;
641
669
  setAudioBlob(null);
@@ -663,10 +691,11 @@ function useVoiceRecorder(props) {
663
691
  mimeType,
664
692
  uploadId,
665
693
  uploader,
666
- autoResetAfterUpload,
667
694
  onUploaded,
668
- onRecordingError,
669
695
  deleteRecording,
696
+ onRecordingError,
697
+ autoResetAfterUpload,
698
+ getDurationMs: getElapsedMs,
670
699
  });
671
700
  const mountRecordingBlob = useCallback((blob) => {
672
701
  setAudioBlob(blob);
@@ -749,6 +778,7 @@ function useVoiceRecorder(props) {
749
778
  isPlaying,
750
779
  isRecording,
751
780
  audioLevels,
781
+ lastDuration,
752
782
  playbackTime,
753
783
  recordingTime,
754
784
  previewLevels,
@@ -1095,9 +1125,6 @@ const useAudioRecorderHandlers = (options) => {
1095
1125
  toggleRecordingPause,
1096
1126
  ]);
1097
1127
  const handleSend = useCallback(async () => {
1098
- if (isSending) {
1099
- return;
1100
- }
1101
1128
  shouldSendAfterStopRef.current = false;
1102
1129
  setIsSending(true);
1103
1130
  try {
@@ -1114,7 +1141,6 @@ const useAudioRecorderHandlers = (options) => {
1114
1141
  setIsSending(false);
1115
1142
  }
1116
1143
  }, [
1117
- isSending,
1118
1144
  stopPlayback,
1119
1145
  onUploadError,
1120
1146
  sendRecording,
@@ -1125,6 +1151,7 @@ const useAudioRecorderHandlers = (options) => {
1125
1151
  return;
1126
1152
  }
1127
1153
  if (isRecording) {
1154
+ setIsSending(true);
1128
1155
  shouldSendAfterStopRef.current = true;
1129
1156
  stopRecording();
1130
1157
  return;
@@ -1262,6 +1289,44 @@ const IdleControl = (props) => {
1262
1289
  return (jsxs(IdleWrapper, { children: [jsxs(IdleButton, { type: "button", onClick: onClick, "aria-label": startAriaLabel, children: [jsx(IdleButtonOutline, { "aria-hidden": true }), jsx(IdleButtonGlow, { "aria-hidden": true }), jsx(Icon32Microphone, { height: 48, width: 48 })] }), jsx(IdleInstruction, { children: instruction })] }));
1263
1290
  };
1264
1291
 
1292
+ const Bars = styled.div `
1293
+ position: absolute;
1294
+ inset: 0;
1295
+ display: flex;
1296
+ align-items: flex-end;
1297
+ gap: 3px;
1298
+ `;
1299
+ const Bar = styled.div `
1300
+ flex: 1;
1301
+ min-width: 2px;
1302
+ border-radius: 9999px;
1303
+ background: var(--white, #ffffff);
1304
+ opacity: 0.6;
1305
+ transition: height 120ms ease, opacity 120ms ease;
1306
+ `;
1307
+
1308
+ const WaveformBars = (props) => {
1309
+ const { values, progress, styleInterpolator, scale = 40, minHeight = 6, divider = 100, } = props;
1310
+ return (jsx(Bars, { children: values.map((value, index) => {
1311
+ const normalized = clampValue(value);
1312
+ const height = Math.max(minHeight, (normalized / divider) * scale);
1313
+ const barProgress = values.length > 1 ? index / (values.length - 1) : 0;
1314
+ const isPlayed = (progress !== null && progress !== void 0 ? progress : 0) > 0 && barProgress <= (progress !== null && progress !== void 0 ? progress : 0);
1315
+ const interpolated = styleInterpolator === null || styleInterpolator === void 0 ? void 0 : styleInterpolator({
1316
+ normalized,
1317
+ height,
1318
+ index,
1319
+ progress: progress !== null && progress !== void 0 ? progress : 0,
1320
+ barProgress,
1321
+ isPlayed,
1322
+ });
1323
+ return (jsx(Bar, { style: {
1324
+ height: `${height}px`,
1325
+ ...interpolated,
1326
+ } }, index));
1327
+ }) }));
1328
+ };
1329
+
1265
1330
  /**
1266
1331
  * RecordingShell styles
1267
1332
  *
@@ -1356,21 +1421,6 @@ const Waveform = styled.div `
1356
1421
  align-items: center;
1357
1422
  overflow: hidden;
1358
1423
  `;
1359
- const Bars = styled.div `
1360
- position: absolute;
1361
- inset: 0;
1362
- display: flex;
1363
- align-items: flex-end;
1364
- gap: 3px;
1365
- `;
1366
- const Bar = styled.div `
1367
- flex: 1;
1368
- min-width: 2px;
1369
- border-radius: 9999px;
1370
- background: var(--white, #ffffff);
1371
- opacity: 0.6;
1372
- transition: height 120ms ease, opacity 120ms ease;
1373
- `;
1374
1424
  const Timer$1 = styled.span `
1375
1425
  text-align: right;
1376
1426
  font-size: 13px;
@@ -1432,31 +1482,33 @@ const Spinner = styled.span `
1432
1482
  }
1433
1483
  `;
1434
1484
 
1435
- const WaveformBars = (props) => {
1436
- const { values, styleInterpolator, scale = 40, minHeight = 6, divider = 100, } = props;
1437
- return (jsx(Bars, { children: values.map((value, index) => {
1438
- const normalized = clampValue(value);
1439
- const height = Math.max(minHeight, (normalized / divider) * scale);
1440
- const interpolated = styleInterpolator === null || styleInterpolator === void 0 ? void 0 : styleInterpolator({ normalized, height, index });
1441
- return (jsx(Bar, { style: {
1442
- height: `${height}px`,
1443
- ...interpolated,
1444
- } }, index));
1445
- }) }));
1446
- };
1447
-
1448
1485
  const RecordingShell = (props) => {
1449
1486
  const { isPaused, isPlaying, isSending, isRecording, audioLevels, hasRecording, playbackTime, previewLevels, recordingTime, stopAriaLabel, playAriaLabel, sendAriaLabel, startAriaLabel, cancelAriaLabel, playbackDuration, pausePlaybackAriaLabel, pauseRecordingAriaLabel, resumeRecordingAriaLabel, onSend, onCancel, formatTime, onRecordToggle, onTogglePlayback, } = props;
1487
+ const { lastDuration } = props;
1488
+ const isPreview = !isRecording || isPaused;
1450
1489
  const waveformValues = isRecording ? audioLevels : (hasRecording ? previewLevels : audioLevels);
1490
+ const effectiveDuration = recordingTime
1491
+ || playbackDuration
1492
+ || lastDuration
1493
+ || 1;
1494
+ const progress = (isPreview && isPlaying)
1495
+ ? Math.min(1, playbackTime / effectiveDuration)
1496
+ : 0;
1451
1497
  const displayTime = useMemo(() => {
1452
- if (isRecording) {
1498
+ if (isPlaying) {
1499
+ return formatTime(playbackTime);
1500
+ }
1501
+ if (isRecording && !isPaused) {
1502
+ return formatTime(recordingTime);
1503
+ }
1504
+ if (isRecording && isPaused) {
1453
1505
  return formatTime(recordingTime);
1454
1506
  }
1507
+ if (lastDuration > 0) {
1508
+ return formatTime(lastDuration);
1509
+ }
1455
1510
  if (hasRecording) {
1456
- if (isPlaying) {
1457
- return formatTime(playbackTime);
1458
- }
1459
- if (playbackDuration > 0) {
1511
+ if (playbackDuration > 0.5) {
1460
1512
  return formatTime(playbackDuration);
1461
1513
  }
1462
1514
  if (recordingTime > 0) {
@@ -1464,7 +1516,17 @@ const RecordingShell = (props) => {
1464
1516
  }
1465
1517
  }
1466
1518
  return formatTime(0);
1467
- }, [formatTime, hasRecording, isPlaying, playbackDuration, playbackTime, recordingTime]);
1519
+ }, [
1520
+ isPaused,
1521
+ isPlaying,
1522
+ formatTime,
1523
+ isRecording,
1524
+ lastDuration,
1525
+ hasRecording,
1526
+ playbackTime,
1527
+ recordingTime,
1528
+ playbackDuration,
1529
+ ]);
1468
1530
  const recordButtonDisabled = isSending;
1469
1531
  const recordButtonIcon = !isRecording || isPaused
1470
1532
  ? jsx(Icon16Microphone, { width: 24, height: 24 })
@@ -1482,10 +1544,13 @@ const RecordingShell = (props) => {
1482
1544
  : jsx(Icon16Play, { width: 24, height: 24 });
1483
1545
  const playbackAria = isPlaying ? pausePlaybackAriaLabel : playAriaLabel;
1484
1546
  const sendDisabled = isSending || (!hasRecording && !isRecording);
1485
- return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 24, height: 24, style: { minWidth: 24 } }) }), jsxs(Track, { children: [jsx(CircleButton, { type: "button", "$variant": recordButtonVariant, onClick: onRecordToggle, "aria-label": recordButtonAria, disabled: recordButtonDisabled, children: recordButtonIcon }), !canShowPlayback ? null : (jsx(CircleButton, { type: "button", "$variant": "play", onClick: onTogglePlayback, "aria-label": playbackAria, disabled: playbackDisabled, children: playbackIcon })), jsx(Waveform, { "$expanded": canShowPlayback, children: jsx(WaveformBars, { values: waveformValues, divider: 100, scale: 42, minHeight: 6, styleInterpolator: ({ normalized }) => ({
1547
+ return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 24, height: 24, style: { minWidth: 24 } }) }), jsxs(Track, { children: [jsx(CircleButton, { type: "button", "$variant": recordButtonVariant, onClick: onRecordToggle, "aria-label": recordButtonAria, disabled: recordButtonDisabled, children: recordButtonIcon }), !canShowPlayback ? null : (jsx(CircleButton, { type: "button", "$variant": "play", onClick: onTogglePlayback, "aria-label": playbackAria, disabled: playbackDisabled, children: playbackIcon })), jsx(Waveform, { "$expanded": canShowPlayback, children: jsx(WaveformBars, { scale: 42, minHeight: 6, divider: 100, progress: progress, values: waveformValues, styleInterpolator: ({ normalized, isPlayed, progress: p }) => ({
1486
1548
  height: `${12 + normalized / 2}px`,
1487
1549
  maxHeight: '34px',
1488
- opacity: 0.38 + Math.min(0.5, normalized / 120),
1550
+ opacity: p > 0
1551
+ ? (isPlayed ? 0.80 : 0.22)
1552
+ : 0.38 + Math.min(0.5, normalized / 120),
1553
+ transition: 'opacity 80ms ease',
1489
1554
  }) }) }), jsx(Timer$1, { children: displayTime })] }), jsx(SendButton$1, { type: "button", onClick: onSend, disabled: sendDisabled, "aria-label": sendAriaLabel, children: isSending ? (jsx(SpinnerSlot, { children: jsx(Spinner, {}) })) : (jsx(Icon28SendOutline, { width: 24, height: 24 })) })] }));
1490
1555
  };
1491
1556
 
@@ -1568,9 +1633,9 @@ const AudioRecorder = (props) => {
1568
1633
  messages,
1569
1634
  onUploadError,
1570
1635
  });
1571
- const { isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1572
- const showRecorderBar = isRecording || hasRecording;
1573
- return (jsx(Container, { className: className, children: showRecorderBar ? (jsx(RecordingShell, { isPaused: isPaused, isSending: isSending, isPlaying: isPlaying, formatTime: formatTime, onCancel: handleCancel, isRecording: isRecording, audioLevels: audioLevels, onSend: handleSendRequest, playbackTime: playbackTime, hasRecording: hasRecording, previewLevels: previewLevels, recordingTime: recordingTime, playbackDuration: playbackDuration, onRecordToggle: handleRecordToggle, sendAriaLabel: messages.buttons.send, onTogglePlayback: handlePlaybackToggle, cancelAriaLabel: messages.buttons.cancel, playAriaLabel: messages.aria.playRecording, stopAriaLabel: messages.aria.stopRecording, startAriaLabel: messages.aria.startRecording, pauseRecordingAriaLabel: messages.aria.pauseRecording, resumeRecordingAriaLabel: messages.aria.resumeRecording, pausePlaybackAriaLabel: messages.aria.pausePlayback })) : (jsx(IdleControl, { onClick: handleRecordToggle, startAriaLabel: messages.aria.startRecording, instruction: messages.instruction })) }));
1636
+ const { isPaused, isPlaying, formatTime, audioLevels, isRecording, lastDuration, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1637
+ const showRecorderBar = isRecording || hasRecording || isSending;
1638
+ return (jsx(Container, { className: className, children: showRecorderBar ? (jsx(RecordingShell, { isPaused: isPaused, isSending: isSending, isPlaying: isPlaying, formatTime: formatTime, onCancel: handleCancel, isRecording: isRecording, audioLevels: audioLevels, onSend: handleSendRequest, playbackTime: playbackTime, lastDuration: lastDuration, hasRecording: hasRecording, previewLevels: previewLevels, recordingTime: recordingTime, playbackDuration: playbackDuration, onRecordToggle: handleRecordToggle, sendAriaLabel: messages.buttons.send, onTogglePlayback: handlePlaybackToggle, cancelAriaLabel: messages.buttons.cancel, playAriaLabel: messages.aria.playRecording, stopAriaLabel: messages.aria.stopRecording, startAriaLabel: messages.aria.startRecording, pauseRecordingAriaLabel: messages.aria.pauseRecording, resumeRecordingAriaLabel: messages.aria.resumeRecording, pausePlaybackAriaLabel: messages.aria.pausePlayback })) : (jsx(IdleControl, { onClick: handleRecordToggle, startAriaLabel: messages.aria.startRecording, instruction: messages.instruction })) }));
1574
1639
  };
1575
1640
 
1576
1641
  /**
@@ -2209,7 +2274,7 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2209
2274
  observer.disconnect();
2210
2275
  };
2211
2276
  }, []);
2212
- const displayTime = isPlaying
2277
+ const displayTime = (isPlaying || currentTime > 0)
2213
2278
  ? formatTime(currentTime)
2214
2279
  : formatTime(resolvedDuration);
2215
2280
  const handleTrackClick = useCallback((e) => {