@exode-team/react-recorder 1.1.2 → 1.1.3

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
@@ -1,7 +1,7 @@
1
1
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
- import React, { useState, useRef, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect } from 'react';
2
+ import React, { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useImperativeHandle } from 'react';
3
3
  import styled from 'styled-components';
4
- import { Icon32Microphone, Icon16Microphone, Icon16Square, Icon16Pause, Icon16Play, Icon28CancelOutline, Icon28SendOutline, Icon20VoiceOutline, Icon24Pause, Icon24Play } from '@vkontakte/icons';
4
+ import { Icon32Microphone, Icon16Microphone, Icon16Pause, Icon16Play, Icon28DeleteOutlineAndroid, Icon28SendOutline, Icon20Voice, Icon24Pause, Icon24Play } from '@vkontakte/icons';
5
5
 
6
6
  /**
7
7
  * Math utilities
@@ -782,6 +782,7 @@ const useAudioBlobLoader = (options) => {
782
782
  const audioRef = useRef(null);
783
783
  const objectUrlRef = useRef(null);
784
784
  const abortControllerRef = useRef(null);
785
+ const failedSrcRef = useRef(null);
785
786
  const callbacksRef = useRef(playbackCallbacks);
786
787
  callbacksRef.current = playbackCallbacks;
787
788
  const createAudioFromBlob = useCallback((blob) => {
@@ -818,7 +819,7 @@ const useAudioBlobLoader = (options) => {
818
819
  }, [waveformData, createAudioFromBlob]);
819
820
  const ensureAudioLoaded = useCallback(async () => {
820
821
  var _a;
821
- if (audioRef.current) {
822
+ if (audioRef.current || failedSrcRef.current === src) {
822
823
  return;
823
824
  }
824
825
  if (preloadedBlob) {
@@ -849,6 +850,7 @@ const useAudioBlobLoader = (options) => {
849
850
  if (error instanceof DOMException && error.name === 'AbortError') {
850
851
  return;
851
852
  }
853
+ failedSrcRef.current = src;
852
854
  console.warn('[voice-player] Failed to load audio', error);
853
855
  }
854
856
  finally {
@@ -859,6 +861,7 @@ const useAudioBlobLoader = (options) => {
859
861
  }, [src, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
860
862
  /** Cleanup on src change or unmount */
861
863
  useEffect(() => {
864
+ failedSrcRef.current = null;
862
865
  return () => {
863
866
  var _a;
864
867
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
@@ -1188,7 +1191,6 @@ const useAudioRecorderHandlers = (options) => {
1188
1191
  const Container = styled.div `
1189
1192
  position: relative;
1190
1193
  width: 100%;
1191
- padding: 16px;
1192
1194
  transition: color 200ms ease, background-color 200ms ease;
1193
1195
  color: var(--text_primary, #0a0a0a);
1194
1196
  `;
@@ -1273,29 +1275,28 @@ const Wrapper$1 = styled.div `
1273
1275
  gap: 12px;
1274
1276
  padding: 10px 14px;
1275
1277
  border-radius: 9999px;
1276
- background: var(--background_content, #ffffff);
1277
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent);
1278
+ background: var(--background_hover, color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent));
1278
1279
  `;
1279
1280
  const CancelButton$1 = styled.button `
1280
- width: 36px;
1281
- height: 36px;
1281
+ width: 38px;
1282
+ height: 38px;
1282
1283
  display: inline-flex;
1283
1284
  align-items: center;
1284
1285
  justify-content: center;
1285
- border-radius: 18px;
1286
- background: transparent;
1286
+ border-radius: 19px;
1287
+ background: color-mix(in srgb, var(--exode_red) 10%, transparent);
1287
1288
  border: 0;
1289
+ flex-shrink: 0;
1288
1290
  color: color-mix(in srgb, var(--text_primary, #0a0a0a) 54%, transparent);
1289
1291
  cursor: pointer;
1290
- transition: color 160ms ease, background-color 160ms ease;
1292
+ transition: transform 160ms ease;
1291
1293
 
1292
- &:hover {
1293
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 12%, transparent);
1294
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 76%, transparent);
1294
+ &:hover:not(:disabled) {
1295
+ transform: translateY(-1px);
1295
1296
  }
1296
1297
 
1297
- &:active {
1298
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent);
1298
+ &:active:not(:disabled) {
1299
+ transform: translateY(0);
1299
1300
  }
1300
1301
  `;
1301
1302
  const Track = styled.div `
@@ -1303,7 +1304,7 @@ const Track = styled.div `
1303
1304
  display: flex;
1304
1305
  align-items: center;
1305
1306
  gap: 12px;
1306
- padding: 8px 12px;
1307
+ padding: 8px 10px;
1307
1308
  border-radius: 9999px;
1308
1309
  background: color-mix(in srgb, var(--text_primary, #0a0a0a) 10%, transparent);
1309
1310
  `;
@@ -1467,7 +1468,9 @@ const RecordingShell = (props) => {
1467
1468
  return formatTime(0);
1468
1469
  }, [formatTime, hasRecording, isPlaying, playbackDuration, playbackTime, recordingTime]);
1469
1470
  const recordButtonDisabled = isSending;
1470
- const recordButtonIcon = !isRecording || isPaused ? jsx(Icon16Microphone, {}) : jsx(Icon16Square, {});
1471
+ const recordButtonIcon = !isRecording || isPaused
1472
+ ? jsx(Icon16Microphone, { width: 24, height: 24 })
1473
+ : jsx(Icon16Pause, { width: 24, height: 24 });
1471
1474
  const recordButtonVariant = !isRecording || isPaused ? 'resume' : 'record';
1472
1475
  const recordButtonAria = !isRecording
1473
1476
  ? startAriaLabel
@@ -1476,14 +1479,16 @@ const RecordingShell = (props) => {
1476
1479
  : pauseRecordingAriaLabel || stopAriaLabel;
1477
1480
  const canShowPlayback = !isRecording || isPaused;
1478
1481
  const playbackDisabled = !hasRecording && !isRecording;
1479
- const playbackIcon = isPlaying ? jsx(Icon16Pause, {}) : jsx(Icon16Play, {});
1482
+ const playbackIcon = isPlaying
1483
+ ? jsx(Icon16Pause, { width: 24, height: 24 })
1484
+ : jsx(Icon16Play, { width: 24, height: 24 });
1480
1485
  const playbackAria = isPlaying ? pausePlaybackAriaLabel : playAriaLabel;
1481
1486
  const sendDisabled = isSending || (!hasRecording && !isRecording);
1482
- return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28CancelOutline, { width: 20, height: 20 }) }), 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 }) => ({
1487
+ 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 }) => ({
1483
1488
  height: `${12 + normalized / 2}px`,
1484
1489
  maxHeight: '34px',
1485
1490
  opacity: 0.38 + Math.min(0.5, normalized / 120),
1486
- }) }) }), 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: 20, height: 20 })) })] }));
1491
+ }) }) }), 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 })) })] }));
1487
1492
  };
1488
1493
 
1489
1494
  /**
@@ -1743,8 +1748,8 @@ const useVoiceRecorderInput = (options) => {
1743
1748
 
1744
1749
  const PreviewBars = (props) => {
1745
1750
  var _a, _b;
1746
- const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, getVisibleBarsCount, } = props;
1747
- const maxBars = getVisibleBarsCount();
1751
+ const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, visibleBarsCount, } = props;
1752
+ const maxBars = visibleBarsCount;
1748
1753
  const sourceLevels = timelineLevels.length > 0
1749
1754
  ? timelineLevels
1750
1755
  : previewLevels;
@@ -1788,8 +1793,8 @@ const PreviewBars = (props) => {
1788
1793
  };
1789
1794
 
1790
1795
  const TimelineBars = (props) => {
1791
- const { timelineLevels, getVisibleBarsCount } = props;
1792
- const maxBars = getVisibleBarsCount();
1796
+ const { timelineLevels, visibleBarsCount } = props;
1797
+ const maxBars = visibleBarsCount;
1793
1798
  const visible = timelineLevels.slice(-maxBars);
1794
1799
  const padded = [
1795
1800
  ...visible,
@@ -1834,15 +1839,11 @@ const CancelButton = styled.button `
1834
1839
  border-radius: 16px;
1835
1840
  background: transparent;
1836
1841
  border: 0;
1842
+ margin-right: 4px;
1837
1843
  flex-shrink: 0;
1838
1844
  color: color-mix(in srgb, var(--text_primary, #0a0a0a) 45%, transparent);
1839
1845
  cursor: pointer;
1840
1846
  transition: color 160ms ease, background-color 160ms ease;
1841
-
1842
- &:hover {
1843
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 8%, transparent);
1844
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 72%, transparent);
1845
- }
1846
1847
  `;
1847
1848
  const RecordTrack = styled.div `
1848
1849
  flex: 1;
@@ -1858,7 +1859,8 @@ const RecordTrack = styled.div `
1858
1859
  const ButtonSlot = styled.div `
1859
1860
  display: flex;
1860
1861
  align-items: center;
1861
- gap: 2px;
1862
+ gap: 4px;
1863
+ padding: 2px;
1862
1864
  flex-shrink: 0;
1863
1865
  `;
1864
1866
  const StopButton = styled.button `
@@ -1995,19 +1997,39 @@ const VoiceRecorderInput = (props) => {
1995
1997
  const waveformRef = useRef(null);
1996
1998
  const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
1997
1999
  const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
1998
- const getVisibleBarsCount = useCallback(() => {
2000
+ const [visibleBarsCount, setVisibleBarsCount] = useState(0);
2001
+ const measureBars = () => {
1999
2002
  const el = waveformRef.current;
2000
2003
  if (!el) {
2001
- return 60;
2004
+ return;
2002
2005
  }
2003
- return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
2006
+ setVisibleBarsCount(Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP)));
2007
+ };
2008
+ /** Sync recalc before paint — catches layout shifts from button changes */
2009
+ useLayoutEffect(measureBars);
2010
+ /** ResizeObserver for external size changes (window resize, container resize) */
2011
+ useEffect(() => {
2012
+ const el = waveformRef.current;
2013
+ if (!el) {
2014
+ return;
2015
+ }
2016
+ let rafId = 0;
2017
+ const observer = new ResizeObserver(() => {
2018
+ cancelAnimationFrame(rafId);
2019
+ rafId = requestAnimationFrame(measureBars);
2020
+ });
2021
+ observer.observe(el);
2022
+ return () => {
2023
+ cancelAnimationFrame(rafId);
2024
+ observer.disconnect();
2025
+ };
2004
2026
  }, []);
2005
2027
  const displayTime = (isPreview && isPlaying)
2006
2028
  ? formatTime(playbackTime)
2007
2029
  : formatTime(recordingTime || lastDurationRef.current);
2008
- return (jsxs(Wrapper, { className: className, children: [jsx(CancelButton, { type: "button", onClick: handleCancel, "aria-label": "Cancel", children: jsx(Icon28CancelOutline, { width: 20, height: 20 }) }), jsxs(RecordTrack, { children: [jsx(ButtonSlot, { children: isPreview ? (jsxs(Fragment, { children: [jsx(MicIndicator, { type: "button", onClick: handleResume, "aria-label": "Resume recording", children: jsx(Icon20VoiceOutline, { width: 14, height: 14 }) }), jsx(PlayPauseButton, { type: "button", onClick: togglePlayback, "aria-label": isPlaying ? 'Pause' : 'Play', children: isPlaying
2030
+ return (jsxs(Wrapper, { className: className, children: [jsx(CancelButton, { type: "button", onClick: handleCancel, "aria-label": "Cancel", children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 28, height: 28, style: { minWidth: 28 } }) }), jsxs(RecordTrack, { children: [jsx(ButtonSlot, { children: isPreview ? (jsxs(Fragment, { children: [jsx(MicIndicator, { type: "button", onClick: handleResume, "aria-label": "Resume recording", children: jsx(Icon20Voice, { width: 14, height: 14 }) }), jsx(PlayPauseButton, { type: "button", onClick: togglePlayback, "aria-label": isPlaying ? 'Pause' : 'Play', children: isPlaying
2009
2031
  ? jsx(Icon16Pause, { width: 14, height: 14 })
2010
- : jsx(Icon16Play, { width: 14, height: 14 }) })] })) : (jsx(StopButton, { type: "button", onClick: handlePause, "aria-label": "Pause recording", children: jsx(Icon16Square, { width: 12, height: 12 }) })) }), jsx(WaveformArea, { ref: waveformRef, children: isPreview ? (jsx(PreviewBars, { timelineLevels: timelineLevels, previewLevels: previewLevels, isPlaying: isPlaying, playbackTime: playbackTime, recordingTime: recordingTime, lastDuration: lastDurationRef.current, getVisibleBarsCount: getVisibleBarsCount })) : (jsx(TimelineBars, { timelineLevels: timelineLevels, getVisibleBarsCount: getVisibleBarsCount })) }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
2032
+ : jsx(Icon16Play, { width: 14, height: 14 }) })] })) : (jsx(StopButton, { type: "button", onClick: handlePause, "aria-label": "Pause recording", children: jsx(Icon16Pause, { width: 14, height: 14 }) })) }), jsx(WaveformArea, { ref: waveformRef, children: isPreview ? (jsx(PreviewBars, { timelineLevels: timelineLevels, previewLevels: previewLevels, isPlaying: isPlaying, playbackTime: playbackTime, recordingTime: recordingTime, lastDuration: lastDurationRef.current, visibleBarsCount: visibleBarsCount })) : (jsx(TimelineBars, { timelineLevels: timelineLevels, visibleBarsCount: visibleBarsCount })) }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
2011
2033
  };
2012
2034
 
2013
2035
  /**
@@ -2183,11 +2205,29 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2183
2205
  return VoicePlaybackChain.register(chainId, () => play());
2184
2206
  }
2185
2207
  }, [chainId, play]);
2186
- /** Measure track width once after mount */
2187
- useLayoutEffect(() => {
2208
+ const measureTrackWidth = () => {
2188
2209
  if (trackRef.current) {
2189
2210
  setTrackWidth(trackRef.current.clientWidth);
2190
2211
  }
2212
+ };
2213
+ /** Sync recalc before paint — catches layout shifts */
2214
+ useLayoutEffect(measureTrackWidth);
2215
+ /** ResizeObserver for external size changes (window resize, container resize) */
2216
+ useEffect(() => {
2217
+ const el = trackRef.current;
2218
+ if (!el) {
2219
+ return;
2220
+ }
2221
+ let rafId = 0;
2222
+ const observer = new ResizeObserver(() => {
2223
+ cancelAnimationFrame(rafId);
2224
+ rafId = requestAnimationFrame(measureTrackWidth);
2225
+ });
2226
+ observer.observe(el);
2227
+ return () => {
2228
+ cancelAnimationFrame(rafId);
2229
+ observer.disconnect();
2230
+ };
2191
2231
  }, []);
2192
2232
  const displayTime = isPlaying
2193
2233
  ? formatTime(currentTime)