@exode-team/react-recorder 1.1.7 → 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
@@ -274,6 +274,9 @@ const useMediaRecorder = (callbacks) => {
274
274
  if (!mediaRecorderRef.current || !isRecording) {
275
275
  return;
276
276
  }
277
+ if (mediaRecorderRef.current.state === 'paused') {
278
+ mediaRecorderRef.current.resume();
279
+ }
277
280
  mediaRecorderRef.current.stop();
278
281
  }, [isRecording]);
279
282
  const cancelRecording = useCallback(() => {
@@ -432,6 +435,7 @@ const useAudioPlayback = () => {
432
435
  * @author: exode <hello@exode.ru>
433
436
  */
434
437
  const useRecordingTiming = () => {
438
+ const [lastDuration, setLastDuration] = useState(0);
435
439
  const [recordingTime, setRecordingTime] = useState(0);
436
440
  const timerRef = useRef(null);
437
441
  const recordingStartRef = useRef(null);
@@ -459,6 +463,7 @@ const useRecordingTiming = () => {
459
463
  const startNewSession = useCallback(() => {
460
464
  recordingAccumulatedRef.current = 0;
461
465
  setRecordingTime(0);
466
+ setLastDuration(0);
462
467
  recordingStartRef.current = performance.now();
463
468
  startTicker();
464
469
  }, [startTicker]);
@@ -476,7 +481,21 @@ const useRecordingTiming = () => {
476
481
  setRecordingTime(roundTo(recordingAccumulatedRef.current));
477
482
  stopTicker();
478
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
+ }, []);
479
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
+ }
480
499
  stopTicker();
481
500
  recordingStartRef.current = null;
482
501
  recordingAccumulatedRef.current = 0;
@@ -488,6 +507,8 @@ const useRecordingTiming = () => {
488
507
  };
489
508
  }, [stopTicker]);
490
509
  return {
510
+ lastDuration,
511
+ getElapsedMs,
491
512
  pauseSession,
492
513
  resetSession,
493
514
  recordingTime,
@@ -589,7 +610,7 @@ const useRecorderVisualization = () => {
589
610
  * @author: exode <hello@exode.ru>
590
611
  */
591
612
  const useRecordingSender = (options) => {
592
- const { uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
613
+ const { uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, getDurationMs, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
593
614
  const sendRecording = useCallback(async () => {
594
615
  if (!audioBlob) {
595
616
  const error = new Error('Voice recording is not available');
@@ -602,11 +623,13 @@ const useRecordingSender = (options) => {
602
623
  throw error;
603
624
  }
604
625
  try {
626
+ const durationMs = getDurationMs();
627
+ const durationSec = durationMs / 1000;
605
628
  const file = new File([audioBlob], fileName, {
606
629
  type: audioBlob.type || mimeType,
607
630
  });
608
631
  const uploaded = await uploader(file, { uploadId });
609
- await onUploaded(uploaded);
632
+ await onUploaded(uploaded, durationSec);
610
633
  if (autoResetAfterUpload) {
611
634
  deleteRecording();
612
635
  }
@@ -622,8 +645,9 @@ const useRecordingSender = (options) => {
622
645
  audioBlob,
623
646
  mimeType,
624
647
  onUploaded,
625
- onRecordingError,
648
+ getDurationMs,
626
649
  deleteRecording,
650
+ onRecordingError,
627
651
  autoResetAfterUpload,
628
652
  ]);
629
653
  return { sendRecording };
@@ -639,7 +663,7 @@ function useVoiceRecorder(props) {
639
663
  const [audioBlob, setAudioBlob] = useState(null);
640
664
  const { audioURL, isPlaying, mountBlob, playbackTime, stopPlayback, resetPlayback, togglePlayback: _togglePlayback, playbackDuration, } = useAudioPlayback();
641
665
  const { audioLevels, previewLevels, resetAudioLevels, stopVisualization, resetPreviewLevels, resumeVisualization, cleanupVisualization, generatePreviewLevels, initializeVisualization, } = useRecorderVisualization();
642
- const { recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
666
+ const { lastDuration, getElapsedMs, recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
643
667
  const resetCurrentRecording = useCallback((opts) => {
644
668
  var _a;
645
669
  setAudioBlob(null);
@@ -667,10 +691,11 @@ function useVoiceRecorder(props) {
667
691
  mimeType,
668
692
  uploadId,
669
693
  uploader,
670
- autoResetAfterUpload,
671
694
  onUploaded,
672
- onRecordingError,
673
695
  deleteRecording,
696
+ onRecordingError,
697
+ autoResetAfterUpload,
698
+ getDurationMs: getElapsedMs,
674
699
  });
675
700
  const mountRecordingBlob = useCallback((blob) => {
676
701
  setAudioBlob(blob);
@@ -753,6 +778,7 @@ function useVoiceRecorder(props) {
753
778
  isPlaying,
754
779
  isRecording,
755
780
  audioLevels,
781
+ lastDuration,
756
782
  playbackTime,
757
783
  recordingTime,
758
784
  previewLevels,
@@ -1099,9 +1125,6 @@ const useAudioRecorderHandlers = (options) => {
1099
1125
  toggleRecordingPause,
1100
1126
  ]);
1101
1127
  const handleSend = useCallback(async () => {
1102
- if (isSending) {
1103
- return;
1104
- }
1105
1128
  shouldSendAfterStopRef.current = false;
1106
1129
  setIsSending(true);
1107
1130
  try {
@@ -1118,7 +1141,6 @@ const useAudioRecorderHandlers = (options) => {
1118
1141
  setIsSending(false);
1119
1142
  }
1120
1143
  }, [
1121
- isSending,
1122
1144
  stopPlayback,
1123
1145
  onUploadError,
1124
1146
  sendRecording,
@@ -1129,6 +1151,7 @@ const useAudioRecorderHandlers = (options) => {
1129
1151
  return;
1130
1152
  }
1131
1153
  if (isRecording) {
1154
+ setIsSending(true);
1132
1155
  shouldSendAfterStopRef.current = true;
1133
1156
  stopRecording();
1134
1157
  return;
@@ -1266,6 +1289,44 @@ const IdleControl = (props) => {
1266
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 })] }));
1267
1290
  };
1268
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
+
1269
1330
  /**
1270
1331
  * RecordingShell styles
1271
1332
  *
@@ -1360,21 +1421,6 @@ const Waveform = styled.div `
1360
1421
  align-items: center;
1361
1422
  overflow: hidden;
1362
1423
  `;
1363
- const Bars = styled.div `
1364
- position: absolute;
1365
- inset: 0;
1366
- display: flex;
1367
- align-items: flex-end;
1368
- gap: 3px;
1369
- `;
1370
- const Bar = styled.div `
1371
- flex: 1;
1372
- min-width: 2px;
1373
- border-radius: 9999px;
1374
- background: var(--white, #ffffff);
1375
- opacity: 0.6;
1376
- transition: height 120ms ease, opacity 120ms ease;
1377
- `;
1378
1424
  const Timer$1 = styled.span `
1379
1425
  text-align: right;
1380
1426
  font-size: 13px;
@@ -1436,31 +1482,33 @@ const Spinner = styled.span `
1436
1482
  }
1437
1483
  `;
1438
1484
 
1439
- const WaveformBars = (props) => {
1440
- const { values, styleInterpolator, scale = 40, minHeight = 6, divider = 100, } = props;
1441
- return (jsx(Bars, { children: values.map((value, index) => {
1442
- const normalized = clampValue(value);
1443
- const height = Math.max(minHeight, (normalized / divider) * scale);
1444
- const interpolated = styleInterpolator === null || styleInterpolator === void 0 ? void 0 : styleInterpolator({ normalized, height, index });
1445
- return (jsx(Bar, { style: {
1446
- height: `${height}px`,
1447
- ...interpolated,
1448
- } }, index));
1449
- }) }));
1450
- };
1451
-
1452
1485
  const RecordingShell = (props) => {
1453
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;
1454
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;
1455
1497
  const displayTime = useMemo(() => {
1456
- if (isRecording) {
1498
+ if (isPlaying) {
1499
+ return formatTime(playbackTime);
1500
+ }
1501
+ if (isRecording && !isPaused) {
1502
+ return formatTime(recordingTime);
1503
+ }
1504
+ if (isRecording && isPaused) {
1457
1505
  return formatTime(recordingTime);
1458
1506
  }
1507
+ if (lastDuration > 0) {
1508
+ return formatTime(lastDuration);
1509
+ }
1459
1510
  if (hasRecording) {
1460
- if (isPlaying) {
1461
- return formatTime(playbackTime);
1462
- }
1463
- if (playbackDuration > 0) {
1511
+ if (playbackDuration > 0.5) {
1464
1512
  return formatTime(playbackDuration);
1465
1513
  }
1466
1514
  if (recordingTime > 0) {
@@ -1468,7 +1516,17 @@ const RecordingShell = (props) => {
1468
1516
  }
1469
1517
  }
1470
1518
  return formatTime(0);
1471
- }, [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
+ ]);
1472
1530
  const recordButtonDisabled = isSending;
1473
1531
  const recordButtonIcon = !isRecording || isPaused
1474
1532
  ? jsx(Icon16Microphone, { width: 24, height: 24 })
@@ -1486,10 +1544,13 @@ const RecordingShell = (props) => {
1486
1544
  : jsx(Icon16Play, { width: 24, height: 24 });
1487
1545
  const playbackAria = isPlaying ? pausePlaybackAriaLabel : playAriaLabel;
1488
1546
  const sendDisabled = isSending || (!hasRecording && !isRecording);
1489
- 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 }) => ({
1490
1548
  height: `${12 + normalized / 2}px`,
1491
1549
  maxHeight: '34px',
1492
- 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',
1493
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 })) })] }));
1494
1555
  };
1495
1556
 
@@ -1572,9 +1633,9 @@ const AudioRecorder = (props) => {
1572
1633
  messages,
1573
1634
  onUploadError,
1574
1635
  });
1575
- const { isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1576
- const showRecorderBar = isRecording || hasRecording;
1577
- 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 })) }));
1578
1639
  };
1579
1640
 
1580
1641
  /**
@@ -2213,7 +2274,7 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2213
2274
  observer.disconnect();
2214
2275
  };
2215
2276
  }, []);
2216
- const displayTime = isPlaying
2277
+ const displayTime = (isPlaying || currentTime > 0)
2217
2278
  ? formatTime(currentTime)
2218
2279
  : formatTime(resolvedDuration);
2219
2280
  const handleTrackClick = useCallback((e) => {