@exode-team/react-recorder 1.1.0 → 1.1.2

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 (53) hide show
  1. package/README.md +84 -2
  2. package/dist/components/AudioRecorder.d.ts +1 -24
  3. package/dist/components/AudioRecorder.d.ts.map +1 -1
  4. package/dist/components/AudioRecorder.messages.d.ts +32 -0
  5. package/dist/components/AudioRecorder.messages.d.ts.map +1 -0
  6. package/dist/components/AudioRecorder.styled.d.ts +7 -0
  7. package/dist/components/AudioRecorder.styled.d.ts.map +1 -0
  8. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.d.ts +4 -8
  9. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.d.ts.map +1 -1
  10. package/dist/components/VoiceRecorderInput/PreviewBars.d.ts +17 -0
  11. package/dist/components/VoiceRecorderInput/PreviewBars.d.ts.map +1 -0
  12. package/dist/components/VoiceRecorderInput/TimelineBars.d.ts +12 -0
  13. package/dist/components/VoiceRecorderInput/TimelineBars.d.ts.map +1 -0
  14. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.d.ts +2 -2
  15. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.d.ts.map +1 -1
  16. package/dist/constants.d.ts +5 -0
  17. package/dist/constants.d.ts.map +1 -1
  18. package/dist/hooks/index.d.ts +3 -0
  19. package/dist/hooks/index.d.ts.map +1 -1
  20. package/dist/hooks/useAudioBlobLoader.d.ts +23 -0
  21. package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -0
  22. package/dist/hooks/useAudioPlayback.d.ts.map +1 -1
  23. package/dist/hooks/useAudioRecorderHandlers.d.ts +22 -0
  24. package/dist/hooks/useAudioRecorderHandlers.d.ts.map +1 -0
  25. package/dist/hooks/useMediaRecorder.d.ts +1 -10
  26. package/dist/hooks/useMediaRecorder.d.ts.map +1 -1
  27. package/dist/hooks/useRecorderVisualization.d.ts.map +1 -1
  28. package/dist/hooks/useRecordingSender.d.ts +23 -0
  29. package/dist/hooks/useRecordingSender.d.ts.map +1 -0
  30. package/dist/hooks/useVoiceMessagePlayer.d.ts +1 -18
  31. package/dist/hooks/useVoiceMessagePlayer.d.ts.map +1 -1
  32. package/dist/hooks/useVoiceRecorder.d.ts.map +1 -1
  33. package/dist/hooks/useVoiceRecorderInput.d.ts +31 -0
  34. package/dist/hooks/useVoiceRecorderInput.d.ts.map +1 -0
  35. package/dist/index.cjs +1185 -862
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1086 -764
  40. package/dist/index.js.map +1 -1
  41. package/dist/platform/audio-blob-fetcher.d.ts +7 -0
  42. package/dist/platform/audio-blob-fetcher.d.ts.map +1 -0
  43. package/dist/platform/index.d.ts +1 -0
  44. package/dist/platform/index.d.ts.map +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/recorder.d.ts +31 -0
  48. package/dist/types/recorder.d.ts.map +1 -1
  49. package/dist/utils/voice-blob-cache.d.ts +20 -0
  50. package/dist/utils/voice-blob-cache.d.ts.map +1 -0
  51. package/dist/utils/voice-playback-chain.d.ts +20 -0
  52. package/dist/utils/voice-playback-chain.d.ts.map +1 -0
  53. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,32 @@
1
1
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
- import { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
2
+ import React, { useState, useRef, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect } from 'react';
3
3
  import styled from 'styled-components';
4
- import { Icon32Microphone, Icon16Microphone, Icon16Square, Icon16Pause, Icon16Play, Icon28CancelOutline, Icon28SendOutline, Icon20VoiceOutline, Icon24DownloadOutline, Icon24Pause, Icon24Play } from '@vkontakte/icons';
4
+ import { Icon32Microphone, Icon16Microphone, Icon16Square, Icon16Pause, Icon16Play, Icon28CancelOutline, Icon28SendOutline, Icon20VoiceOutline, Icon24Pause, Icon24Play } from '@vkontakte/icons';
5
+
6
+ /**
7
+ * Math utilities
8
+ *
9
+ * @author: exode <hello@exode.ru>
10
+ */
11
+ const clampValue = (value, lowerBound = 0, upperBound = 100) => {
12
+ const safe = Number.isFinite(value) ? value : 0;
13
+ return Math.min(Math.max(safe, lowerBound), upperBound);
14
+ };
15
+ const roundTo = (value, precision = 2) => {
16
+ const factor = Math.pow(10, precision);
17
+ return Math.round(value * factor) / factor;
18
+ };
19
+
20
+ /**
21
+ * Format utilities
22
+ *
23
+ * @author: exode <hello@exode.ru>
24
+ */
25
+ const formatTime = (seconds) => {
26
+ const mins = Math.floor(seconds / 60);
27
+ const secs = Math.floor(seconds % 60);
28
+ return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
29
+ };
5
30
 
6
31
  /**
7
32
  * Microphone platform adapter
@@ -46,6 +71,129 @@ const stopStream = (stream) => {
46
71
  stream.getTracks().forEach((track) => track.stop());
47
72
  };
48
73
 
74
+ /**
75
+ * Audio playback platform adapter
76
+ *
77
+ * @author: exode <hello@exode.ru>
78
+ */
79
+ const createAudioPlayback = (url, callbacks) => {
80
+ const audio = new Audio(url);
81
+ audio.onloadedmetadata = () => {
82
+ callbacks.onLoadedMetadata(audio.duration);
83
+ };
84
+ audio.ontimeupdate = () => {
85
+ callbacks.onTimeUpdate(audio.currentTime);
86
+ };
87
+ audio.onended = () => {
88
+ callbacks.onEnded();
89
+ };
90
+ return { audio, url };
91
+ };
92
+ const cleanupAudio = (audio) => {
93
+ audio.pause();
94
+ audio.src = '';
95
+ audio.onended = null;
96
+ audio.ontimeupdate = null;
97
+ audio.onloadedmetadata = null;
98
+ };
99
+ const createObjectURL = (blob) => {
100
+ return URL.createObjectURL(blob);
101
+ };
102
+ const revokeObjectURL = (url) => {
103
+ URL.revokeObjectURL(url);
104
+ };
105
+
106
+ /**
107
+ * AudioRecorder constants
108
+ *
109
+ * @author: exode <hello@exode.ru>
110
+ */
111
+ const AUDIO_LEVEL_BARS = 40;
112
+ const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
113
+ const TIMELINE_SAMPLE_INTERVAL = 150;
114
+ const TIMELINE_BAR_WIDTH = 3;
115
+ const TIMELINE_BAR_GAP = 2;
116
+ const TIMELINE_BAR_MIN_HEIGHT = 3;
117
+ const TIMELINE_BAR_MAX_HEIGHT = 24;
118
+
119
+ /**
120
+ * Audio analyzer platform adapter
121
+ *
122
+ * @author: exode <hello@exode.ru>
123
+ */
124
+ const createStreamAnalyzer = (stream) => {
125
+ const context = new AudioContext();
126
+ const analyser = context.createAnalyser();
127
+ const source = context.createMediaStreamSource(stream);
128
+ source.connect(analyser);
129
+ analyser.fftSize = 256;
130
+ return { context, analyser };
131
+ };
132
+ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
133
+ var _a;
134
+ const bufferLength = analyser.frequencyBinCount;
135
+ const dataArray = new Uint8Array(bufferLength);
136
+ analyser.getByteFrequencyData(dataArray);
137
+ const step = Math.max(1, Math.floor(bufferLength / barCount));
138
+ const levels = [];
139
+ for (let i = 0; i < barCount; i += 1) {
140
+ const value = (_a = dataArray[i * step]) !== null && _a !== void 0 ? _a : 0;
141
+ levels.push((value / 255) * 100);
142
+ }
143
+ return levels;
144
+ };
145
+ const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
146
+ const context = new AudioContext();
147
+ try {
148
+ const arrayBuffer = await blob.arrayBuffer();
149
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
150
+ const channelData = audioBuffer.getChannelData(0);
151
+ if (!channelData.length) {
152
+ return Array.from({ length: barCount }, () => 0);
153
+ }
154
+ const levels = [];
155
+ const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
156
+ for (let i = 0; i < barCount; i += 1) {
157
+ const segmentStart = i * segmentLength;
158
+ let sum = 0;
159
+ for (let j = 0; j < segmentLength; j += 1) {
160
+ const sample = channelData[segmentStart + j];
161
+ if (sample !== undefined) {
162
+ sum += Math.abs(sample);
163
+ }
164
+ }
165
+ const average = sum / segmentLength;
166
+ levels.push(Math.min(100, average * 200));
167
+ }
168
+ return levels;
169
+ }
170
+ finally {
171
+ await context.close();
172
+ }
173
+ };
174
+
175
+ /**
176
+ * Audio blob fetcher platform adapter
177
+ *
178
+ * @author: exode <hello@exode.ru>
179
+ */
180
+ const fetchAudioBlob = async (url, signal) => {
181
+ const response = await fetch(url, { signal });
182
+ return response.blob();
183
+ };
184
+
185
+ /**
186
+ * Animation frame platform adapter
187
+ *
188
+ * @author: exode <hello@exode.ru>
189
+ */
190
+ const scheduleFrame = (callback) => {
191
+ return requestAnimationFrame(callback);
192
+ };
193
+ const cancelFrame = (id) => {
194
+ cancelAnimationFrame(id);
195
+ };
196
+
49
197
  /**
50
198
  * useMediaRecorder
51
199
  *
@@ -164,114 +312,6 @@ const useMediaRecorder = (callbacks) => {
164
312
  };
165
313
  };
166
314
 
167
- /**
168
- * Audio playback platform adapter
169
- *
170
- * @author: exode <hello@exode.ru>
171
- */
172
- const createAudioPlayback = (url, callbacks) => {
173
- const audio = new Audio(url);
174
- audio.onloadedmetadata = () => {
175
- callbacks.onLoadedMetadata(audio.duration);
176
- };
177
- audio.ontimeupdate = () => {
178
- callbacks.onTimeUpdate(audio.currentTime);
179
- };
180
- audio.onended = () => {
181
- callbacks.onEnded();
182
- };
183
- return { audio, url };
184
- };
185
- const cleanupAudio = (audio) => {
186
- audio.pause();
187
- audio.src = '';
188
- audio.onended = null;
189
- audio.ontimeupdate = null;
190
- audio.onloadedmetadata = null;
191
- };
192
- const createObjectURL = (blob) => {
193
- return URL.createObjectURL(blob);
194
- };
195
- const revokeObjectURL = (url) => {
196
- URL.revokeObjectURL(url);
197
- };
198
-
199
- /**
200
- * AudioRecorder constants
201
- *
202
- * @author: exode <hello@exode.ru>
203
- */
204
- const AUDIO_LEVEL_BARS = 40;
205
- const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
206
-
207
- /**
208
- * Audio analyzer platform adapter
209
- *
210
- * @author: exode <hello@exode.ru>
211
- */
212
- const createStreamAnalyzer = (stream) => {
213
- const context = new AudioContext();
214
- const analyser = context.createAnalyser();
215
- const source = context.createMediaStreamSource(stream);
216
- source.connect(analyser);
217
- analyser.fftSize = 256;
218
- return { context, analyser };
219
- };
220
- const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
221
- var _a;
222
- const bufferLength = analyser.frequencyBinCount;
223
- const dataArray = new Uint8Array(bufferLength);
224
- analyser.getByteFrequencyData(dataArray);
225
- const step = Math.max(1, Math.floor(bufferLength / barCount));
226
- const levels = [];
227
- for (let i = 0; i < barCount; i += 1) {
228
- const value = (_a = dataArray[i * step]) !== null && _a !== void 0 ? _a : 0;
229
- levels.push((value / 255) * 100);
230
- }
231
- return levels;
232
- };
233
- const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
234
- const context = new AudioContext();
235
- try {
236
- const arrayBuffer = await blob.arrayBuffer();
237
- const audioBuffer = await context.decodeAudioData(arrayBuffer);
238
- const channelData = audioBuffer.getChannelData(0);
239
- if (!channelData.length) {
240
- return Array.from({ length: barCount }, () => 0);
241
- }
242
- const levels = [];
243
- const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
244
- for (let i = 0; i < barCount; i += 1) {
245
- const segmentStart = i * segmentLength;
246
- let sum = 0;
247
- for (let j = 0; j < segmentLength; j += 1) {
248
- const sample = channelData[segmentStart + j];
249
- if (sample !== undefined) {
250
- sum += Math.abs(sample);
251
- }
252
- }
253
- const average = sum / segmentLength;
254
- levels.push(Math.min(100, average * 200));
255
- }
256
- return levels;
257
- }
258
- finally {
259
- await context.close();
260
- }
261
- };
262
-
263
- /**
264
- * Animation frame platform adapter
265
- *
266
- * @author: exode <hello@exode.ru>
267
- */
268
- const scheduleFrame = (callback) => {
269
- return requestAnimationFrame(callback);
270
- };
271
- const cancelFrame = (id) => {
272
- cancelAnimationFrame(id);
273
- };
274
-
275
315
  /**
276
316
  * useAudioPlayback
277
317
  *
@@ -383,21 +423,7 @@ const useAudioPlayback = () => {
383
423
  };
384
424
 
385
425
  /**
386
- * Math utilities
387
- *
388
- * @author: exode <hello@exode.ru>
389
- */
390
- const clampValue = (value, lowerBound = 0, upperBound = 100) => {
391
- const safe = Number.isFinite(value) ? value : 0;
392
- return Math.min(Math.max(safe, lowerBound), upperBound);
393
- };
394
- const roundTo = (value, precision = 2) => {
395
- const factor = Math.pow(10, precision);
396
- return Math.round(value * factor) / factor;
397
- };
398
-
399
- /**
400
- * useRecordingTiming
426
+ * useRecordingTiming
401
427
  *
402
428
  * @author: exode <hello@exode.ru>
403
429
  */
@@ -554,14 +580,50 @@ const useRecorderVisualization = () => {
554
580
  };
555
581
 
556
582
  /**
557
- * Format utilities
583
+ * useRecordingSender — handles sending a recorded voice message via an upload strategy.
558
584
  *
559
585
  * @author: exode <hello@exode.ru>
560
586
  */
561
- const formatTime = (seconds) => {
562
- const mins = Math.floor(seconds / 60);
563
- const secs = Math.floor(seconds % 60);
564
- return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
587
+ const useRecordingSender = (options) => {
588
+ const { space, uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
589
+ const sendRecording = useCallback(async () => {
590
+ if (!audioBlob) {
591
+ const error = new Error('Voice recording is not available');
592
+ onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
593
+ return;
594
+ }
595
+ if (!uploader) {
596
+ const error = new Error('Uploader strategy is not defined');
597
+ onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
598
+ throw error;
599
+ }
600
+ try {
601
+ const file = new File([audioBlob], fileName, {
602
+ type: audioBlob.type || mimeType,
603
+ });
604
+ const uploaded = await uploader(file, { space, uploadId });
605
+ await onUploaded(uploaded);
606
+ if (autoResetAfterUpload) {
607
+ deleteRecording();
608
+ }
609
+ }
610
+ catch (error) {
611
+ onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
612
+ throw error;
613
+ }
614
+ }, [
615
+ space,
616
+ uploadId,
617
+ uploader,
618
+ fileName,
619
+ audioBlob,
620
+ mimeType,
621
+ onUploaded,
622
+ onRecordingError,
623
+ deleteRecording,
624
+ autoResetAfterUpload,
625
+ ]);
626
+ return { sendRecording };
565
627
  };
566
628
 
567
629
  /**
@@ -572,23 +634,42 @@ const formatTime = (seconds) => {
572
634
  function useVoiceRecorder(props) {
573
635
  const { space, uploadId, uploader, onUploaded, onRecordingStop, onRecordingStart, onRecordingError, onRecordingDelete, onPermissionDenied, fileName = 'record.webm', mimeType = 'audio/webm', autoResetAfterUpload = true, } = props;
574
636
  const [audioBlob, setAudioBlob] = useState(null);
575
- const { audioURL, isPlaying, mountBlob, playbackTime, stopPlayback, resetPlayback, togglePlayback: _togglePlayback, playbackDuration} = useAudioPlayback();
637
+ const { audioURL, isPlaying, mountBlob, playbackTime, stopPlayback, resetPlayback, togglePlayback: _togglePlayback, playbackDuration, } = useAudioPlayback();
576
638
  const { audioLevels, previewLevels, resetAudioLevels, stopVisualization, resetPreviewLevels, resumeVisualization, cleanupVisualization, generatePreviewLevels, initializeVisualization, } = useRecorderVisualization();
577
639
  const { recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
578
- const resetCurrentRecording = useCallback((options) => {
640
+ const resetCurrentRecording = useCallback((opts) => {
579
641
  var _a;
580
642
  setAudioBlob(null);
581
643
  resetPlayback();
582
- if ((_a = options === null || options === void 0 ? void 0 : options.resetPreview) !== null && _a !== void 0 ? _a : true) {
644
+ if ((_a = opts === null || opts === void 0 ? void 0 : opts.resetPreview) !== null && _a !== void 0 ? _a : true) {
583
645
  resetPreviewLevels();
584
646
  }
585
- if (options === null || options === void 0 ? void 0 : options.resetLevels) {
647
+ if (opts === null || opts === void 0 ? void 0 : opts.resetLevels) {
586
648
  resetAudioLevels();
587
649
  }
588
- if (options === null || options === void 0 ? void 0 : options.notifyDelete) {
650
+ if (opts === null || opts === void 0 ? void 0 : opts.notifyDelete) {
589
651
  onRecordingDelete === null || onRecordingDelete === void 0 ? void 0 : onRecordingDelete();
590
652
  }
591
653
  }, [onRecordingDelete, resetAudioLevels, resetPlayback, resetPreviewLevels]);
654
+ const deleteRecording = useCallback(() => {
655
+ resetCurrentRecording({
656
+ resetLevels: true,
657
+ notifyDelete: true,
658
+ });
659
+ resetSession();
660
+ }, [resetCurrentRecording, resetSession]);
661
+ const { sendRecording } = useRecordingSender({
662
+ audioBlob,
663
+ fileName,
664
+ mimeType,
665
+ space,
666
+ uploadId,
667
+ uploader,
668
+ autoResetAfterUpload,
669
+ onUploaded,
670
+ onRecordingError,
671
+ deleteRecording,
672
+ });
592
673
  const mountRecordingBlob = useCallback((blob) => {
593
674
  setAudioBlob(blob);
594
675
  mountBlob(blob);
@@ -661,50 +742,9 @@ function useVoiceRecorder(props) {
661
742
  resetSession();
662
743
  };
663
744
  }, [cleanupVisualization, cleanupStream, resetPlayback, resetSession]);
664
- const deleteRecording = useCallback(() => {
665
- resetCurrentRecording({ resetLevels: true, notifyDelete: true });
666
- resetSession();
667
- }, [resetCurrentRecording, resetSession]);
668
745
  const togglePlayback = useCallback(() => {
669
746
  _togglePlayback(onRecordingError);
670
747
  }, [_togglePlayback, onRecordingError]);
671
- const sendRecording = useCallback(async () => {
672
- if (!audioBlob) {
673
- const error = new Error('Voice recording is not available');
674
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
675
- return;
676
- }
677
- if (!uploader) {
678
- const error = new Error('Uploader strategy is not defined');
679
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
680
- throw error;
681
- }
682
- try {
683
- const file = new File([audioBlob], fileName, {
684
- type: audioBlob.type || mimeType,
685
- });
686
- const uploaded = await uploader(file, { space, uploadId });
687
- await onUploaded(uploaded);
688
- if (autoResetAfterUpload) {
689
- deleteRecording();
690
- }
691
- }
692
- catch (error) {
693
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
694
- throw error;
695
- }
696
- }, [
697
- audioBlob,
698
- autoResetAfterUpload,
699
- deleteRecording,
700
- fileName,
701
- mimeType,
702
- onRecordingError,
703
- onUploaded,
704
- space,
705
- uploadId,
706
- uploader,
707
- ]);
708
748
  return {
709
749
  audioURL,
710
750
  isPaused,
@@ -729,76 +769,501 @@ function useVoiceRecorder(props) {
729
769
  }
730
770
 
731
771
  /**
732
- * IdleControl styles
772
+ * useAudioBlobLoader — loads audio blob from URL or preloaded data,
773
+ * creates object URL and decodes waveform levels.
733
774
  *
734
775
  * @author: exode <hello@exode.ru>
735
776
  */
736
- const IdleWrapper = styled.div `
737
- display: flex;
738
- flex-direction: column;
739
- align-items: center;
740
- gap: 16px;
741
- `;
742
- const IdleButton = styled.button `
743
- position: relative;
744
- width: 72px;
745
- height: 72px;
746
- display: inline-flex;
747
- align-items: center;
748
- justify-content: center;
749
- border-radius: 36px;
750
- border: 0;
751
- background: var(--background_content, #ffffff);
752
- color: color-mix(in srgb, var(--accent, #3f8ae0) 80%, white 20%);
753
- cursor: pointer;
754
- transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
755
-
756
- &:hover {
757
- transform: translateY(-2px);
758
- }
759
-
760
- &:active {
761
- transform: translateY(0);
762
- }
763
-
764
- &:focus-visible {
765
- outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
766
- outline-offset: 4px;
767
- }
768
- `;
769
- const IdleButtonOutline = styled.span `
770
- position: absolute;
771
- inset: 0;
772
- border-radius: 32px;
773
- border: 1px solid color-mix(in srgb, var(--accent, #3f8ae0) 28%, transparent);
774
- pointer-events: none;
775
- `;
776
- const IdleButtonGlow = styled.span `
777
- position: absolute;
778
- inset: 8px;
779
- border-radius: 28px;
780
- background: radial-gradient(
781
- 120% 120% at 50% 50%,
782
- color-mix(in srgb, var(--accent, #3f8ae0) 24%, transparent) 0%,
783
- color-mix(in srgb, var(--accent, #3f8ae0) 10%, transparent) 65%,
784
- transparent 100%
785
- );
786
- filter: blur(4px);
787
- pointer-events: none;
788
- `;
789
- const IdleInstruction = styled.div `
790
- font-size: 14px;
791
- text-align: center;
792
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
793
- `;
794
-
795
- const IdleControl = (props) => {
796
- const { onClick, instruction, startAriaLabel, } = props;
797
- 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 })] }));
777
+ const useAudioBlobLoader = (options) => {
778
+ const { src, waveformData, preloadedBlob, playbackCallbacks } = options;
779
+ const [isLoading, setIsLoading] = useState(false);
780
+ const [isLoaded, setIsLoaded] = useState(false);
781
+ const [waveformLevels, setWaveformLevels] = useState(waveformData || createInitialLevels);
782
+ const audioRef = useRef(null);
783
+ const objectUrlRef = useRef(null);
784
+ const abortControllerRef = useRef(null);
785
+ const callbacksRef = useRef(playbackCallbacks);
786
+ callbacksRef.current = playbackCallbacks;
787
+ const createAudioFromBlob = useCallback((blob) => {
788
+ if (objectUrlRef.current) {
789
+ revokeObjectURL(objectUrlRef.current);
790
+ }
791
+ const url = createObjectURL(blob);
792
+ objectUrlRef.current = url;
793
+ const { audio } = createAudioPlayback(url, {
794
+ onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
795
+ onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
796
+ onEnded: () => callbacksRef.current.onEnded(),
797
+ });
798
+ audioRef.current = audio;
799
+ setIsLoaded(true);
800
+ }, []);
801
+ const mountFromBlob = useCallback(async (blob) => {
802
+ if (audioRef.current) {
803
+ return;
804
+ }
805
+ setIsLoading(true);
806
+ try {
807
+ if (!waveformData) {
808
+ setWaveformLevels(await decodeAudioLevels(blob));
809
+ }
810
+ createAudioFromBlob(blob);
811
+ }
812
+ catch (error) {
813
+ console.warn('[voice-player] Failed to mount blob', error);
814
+ }
815
+ finally {
816
+ setIsLoading(false);
817
+ }
818
+ }, [waveformData, createAudioFromBlob]);
819
+ const ensureAudioLoaded = useCallback(async () => {
820
+ var _a;
821
+ if (audioRef.current) {
822
+ return;
823
+ }
824
+ if (preloadedBlob) {
825
+ return mountFromBlob(preloadedBlob);
826
+ }
827
+ if (!src) {
828
+ return;
829
+ }
830
+ (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
831
+ const controller = new AbortController();
832
+ abortControllerRef.current = controller;
833
+ setIsLoading(true);
834
+ try {
835
+ const blob = await fetchAudioBlob(src, controller.signal);
836
+ if (controller.signal.aborted) {
837
+ return;
838
+ }
839
+ if (!waveformData) {
840
+ const levels = await decodeAudioLevels(blob);
841
+ if (controller.signal.aborted) {
842
+ return;
843
+ }
844
+ setWaveformLevels(levels);
845
+ }
846
+ createAudioFromBlob(blob);
847
+ }
848
+ catch (error) {
849
+ if (error instanceof DOMException && error.name === 'AbortError') {
850
+ return;
851
+ }
852
+ console.warn('[voice-player] Failed to load audio', error);
853
+ }
854
+ finally {
855
+ if (!controller.signal.aborted) {
856
+ setIsLoading(false);
857
+ }
858
+ }
859
+ }, [src, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
860
+ /** Cleanup on src change or unmount */
861
+ useEffect(() => {
862
+ return () => {
863
+ var _a;
864
+ (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
865
+ if (audioRef.current) {
866
+ cleanupAudio(audioRef.current);
867
+ audioRef.current = null;
868
+ }
869
+ if (objectUrlRef.current) {
870
+ revokeObjectURL(objectUrlRef.current);
871
+ objectUrlRef.current = null;
872
+ }
873
+ setIsLoaded(false);
874
+ };
875
+ }, [src]);
876
+ useEffect(() => {
877
+ if (preloadedBlob && !audioRef.current) {
878
+ void mountFromBlob(preloadedBlob);
879
+ }
880
+ }, [preloadedBlob, mountFromBlob]);
881
+ useEffect(() => {
882
+ if (waveformData) {
883
+ setWaveformLevels(waveformData);
884
+ }
885
+ }, [waveformData]);
886
+ return {
887
+ audioRef,
888
+ isLoading,
889
+ isLoaded,
890
+ waveformLevels,
891
+ ensureAudioLoaded,
892
+ };
798
893
  };
799
894
 
800
895
  /**
801
- * RecordingShell styles
896
+ * Global audio playback manager — ensures only one audio plays at a time
897
+ *
898
+ * @author: exode <hello@exode.ru>
899
+ */
900
+ const listeners = new Set();
901
+ const audioPlaybackManager = {
902
+ /** Register a stop callback for a player instance */
903
+ subscribe: (callback) => {
904
+ listeners.add(callback);
905
+ return () => {
906
+ listeners.delete(callback);
907
+ };
908
+ },
909
+ /** Stop all other players except the caller */
910
+ stopOthers: (except) => {
911
+ listeners.forEach((cb) => {
912
+ if (cb !== except) {
913
+ cb();
914
+ }
915
+ });
916
+ },
917
+ };
918
+
919
+ /**
920
+ * useVoiceMessagePlayer
921
+ *
922
+ * @author: exode <hello@exode.ru>
923
+ */
924
+ const useVoiceMessagePlayer = (options) => {
925
+ const { src, onEnded, waveformData, preloadedBlob, duration: externalDuration, } = options;
926
+ const onEndedRef = useRef(onEnded);
927
+ onEndedRef.current = onEnded;
928
+ const [isPlaying, setIsPlaying] = useState(false);
929
+ const [currentTime, setCurrentTime] = useState(0);
930
+ const [duration, setDuration] = useState(externalDuration || 0);
931
+ const progress = duration > 0 ? currentTime / duration : 0;
932
+ /** Stop this player (called by manager when another player starts) */
933
+ const stopThis = useCallback(() => {
934
+ const audio = audioRef.current;
935
+ if (audio && !audio.paused) {
936
+ audio.pause();
937
+ }
938
+ setIsPlaying(false);
939
+ }, []);
940
+ /** Subscribe to global manager on mount */
941
+ useEffect(() => {
942
+ return audioPlaybackManager.subscribe(stopThis);
943
+ }, [stopThis]);
944
+ const { audioRef, isLoading, isLoaded, waveformLevels, ensureAudioLoaded, } = useAudioBlobLoader({
945
+ src,
946
+ waveformData,
947
+ preloadedBlob,
948
+ playbackCallbacks: {
949
+ onLoadedMetadata: (dur) => {
950
+ if (!externalDuration && dur && isFinite(dur) && dur > 0.5) {
951
+ setDuration(dur);
952
+ }
953
+ },
954
+ onTimeUpdate: (time) => setCurrentTime(time),
955
+ onEnded: () => {
956
+ var _a;
957
+ setIsPlaying(false);
958
+ setCurrentTime(0);
959
+ (_a = onEndedRef.current) === null || _a === void 0 ? void 0 : _a.call(onEndedRef);
960
+ },
961
+ },
962
+ });
963
+ /** Pre-fetch audio without playing (for viewport preloading) */
964
+ const prefetch = useCallback(() => {
965
+ if (isLoading || audioRef.current) {
966
+ return;
967
+ }
968
+ void ensureAudioLoaded();
969
+ }, [isLoading, ensureAudioLoaded]);
970
+ /** Load audio and auto-play after loaded */
971
+ const preload = useCallback(() => {
972
+ if (isLoading || audioRef.current) {
973
+ return;
974
+ }
975
+ audioPlaybackManager.stopOthers(stopThis);
976
+ void ensureAudioLoaded().then(() => {
977
+ const audio = audioRef.current;
978
+ if (audio) {
979
+ audio.play()
980
+ .then(() => setIsPlaying(true))
981
+ .catch(() => setIsPlaying(false));
982
+ }
983
+ });
984
+ }, [isLoading, ensureAudioLoaded, stopThis, audioRef]);
985
+ /** Programmatically start playback (used for chained playback) */
986
+ const play = useCallback(() => {
987
+ const audio = audioRef.current;
988
+ if (audio) {
989
+ audioPlaybackManager.stopOthers(stopThis);
990
+ audio.play()
991
+ .then(() => setIsPlaying(true))
992
+ .catch(() => setIsPlaying(false));
993
+ }
994
+ else {
995
+ preload();
996
+ }
997
+ }, [stopThis, preload, audioRef]);
998
+ /** Toggle play/pause (only works after audio is loaded) */
999
+ const togglePlayback = useCallback(() => {
1000
+ const audio = audioRef.current;
1001
+ if (!audio || isLoading) {
1002
+ return;
1003
+ }
1004
+ if (audio.paused) {
1005
+ audioPlaybackManager.stopOthers(stopThis);
1006
+ audio.play()
1007
+ .then(() => setIsPlaying(true))
1008
+ .catch(() => setIsPlaying(false));
1009
+ }
1010
+ else {
1011
+ audio.pause();
1012
+ setIsPlaying(false);
1013
+ }
1014
+ }, [isLoading, stopThis, audioRef]);
1015
+ const seekTo = useCallback((seekProgress) => {
1016
+ const audio = audioRef.current;
1017
+ if (!audio || !isFinite(audio.duration)) {
1018
+ return;
1019
+ }
1020
+ const time = seekProgress * audio.duration;
1021
+ audio.currentTime = time;
1022
+ setCurrentTime(time);
1023
+ }, [audioRef]);
1024
+ /** Sync external duration */
1025
+ useEffect(() => {
1026
+ if (externalDuration) {
1027
+ setDuration(externalDuration);
1028
+ }
1029
+ }, [externalDuration]);
1030
+ return {
1031
+ play,
1032
+ progress,
1033
+ duration,
1034
+ isLoaded,
1035
+ isPlaying,
1036
+ isLoading,
1037
+ currentTime,
1038
+ waveformLevels,
1039
+ seekTo,
1040
+ prefetch,
1041
+ preload,
1042
+ formatTime,
1043
+ togglePlayback,
1044
+ };
1045
+ };
1046
+
1047
+ /**
1048
+ * useAudioRecorderHandlers — recording/send/cancel handlers for AudioRecorder
1049
+ *
1050
+ * @author: exode <hello@exode.ru>
1051
+ */
1052
+ const useAudioRecorderHandlers = (options) => {
1053
+ const { vibrate, recorder, messages, onUploadError, } = options;
1054
+ const { audioURL, isPaused, isRecording, hasRecording, stopPlayback, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1055
+ const shouldSendAfterStopRef = useRef(false);
1056
+ const [isSending, setIsSending] = useState(false);
1057
+ const startNewRecording = useCallback(async () => {
1058
+ if (isRecording) {
1059
+ return;
1060
+ }
1061
+ try {
1062
+ await startRecording();
1063
+ vibrate(50);
1064
+ }
1065
+ catch (error) {
1066
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1067
+ }
1068
+ }, [
1069
+ vibrate,
1070
+ isRecording,
1071
+ onUploadError,
1072
+ startRecording,
1073
+ ]);
1074
+ const handleRecordToggle = useCallback(() => {
1075
+ if (!isRecording) {
1076
+ if (audioURL) {
1077
+ stopPlayback();
1078
+ deleteRecording();
1079
+ }
1080
+ void startNewRecording();
1081
+ return;
1082
+ }
1083
+ if (isPaused) {
1084
+ stopPlayback();
1085
+ }
1086
+ toggleRecordingPause();
1087
+ }, [
1088
+ audioURL,
1089
+ isPaused,
1090
+ isRecording,
1091
+ stopPlayback,
1092
+ deleteRecording,
1093
+ startNewRecording,
1094
+ toggleRecordingPause,
1095
+ ]);
1096
+ const handleSend = useCallback(async () => {
1097
+ if (isSending) {
1098
+ return;
1099
+ }
1100
+ shouldSendAfterStopRef.current = false;
1101
+ setIsSending(true);
1102
+ try {
1103
+ stopPlayback();
1104
+ await sendRecording();
1105
+ }
1106
+ catch (error) {
1107
+ const uploadError = error instanceof Error
1108
+ ? error
1109
+ : new Error(messages.errors.loadFileFailed);
1110
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1111
+ }
1112
+ finally {
1113
+ setIsSending(false);
1114
+ }
1115
+ }, [
1116
+ isSending,
1117
+ stopPlayback,
1118
+ onUploadError,
1119
+ sendRecording,
1120
+ messages.errors.loadFileFailed,
1121
+ ]);
1122
+ const handleSendRequest = useCallback(() => {
1123
+ if (isSending) {
1124
+ return;
1125
+ }
1126
+ if (isRecording) {
1127
+ shouldSendAfterStopRef.current = true;
1128
+ stopRecording();
1129
+ return;
1130
+ }
1131
+ void handleSend();
1132
+ }, [
1133
+ isSending,
1134
+ handleSend,
1135
+ isRecording,
1136
+ stopRecording,
1137
+ ]);
1138
+ const handleCancel = useCallback(() => {
1139
+ shouldSendAfterStopRef.current = false;
1140
+ if (isRecording) {
1141
+ cancelRecording();
1142
+ return;
1143
+ }
1144
+ if (audioURL) {
1145
+ stopPlayback();
1146
+ deleteRecording();
1147
+ }
1148
+ }, [
1149
+ audioURL,
1150
+ isRecording,
1151
+ stopPlayback,
1152
+ deleteRecording,
1153
+ cancelRecording,
1154
+ ]);
1155
+ const handlePlaybackToggle = useCallback(() => {
1156
+ if (!hasRecording) {
1157
+ return;
1158
+ }
1159
+ togglePlayback();
1160
+ }, [hasRecording, togglePlayback]);
1161
+ /** Auto-send after stop */
1162
+ useEffect(() => {
1163
+ if (!isRecording
1164
+ && hasRecording
1165
+ && shouldSendAfterStopRef.current) {
1166
+ shouldSendAfterStopRef.current = false;
1167
+ void handleSend();
1168
+ }
1169
+ }, [
1170
+ handleSend,
1171
+ isRecording,
1172
+ hasRecording,
1173
+ ]);
1174
+ return {
1175
+ isSending,
1176
+ handleCancel,
1177
+ handleSendRequest,
1178
+ handleRecordToggle,
1179
+ handlePlaybackToggle,
1180
+ };
1181
+ };
1182
+
1183
+ /**
1184
+ * AudioRecorder styled components
1185
+ *
1186
+ * @author: exode <hello@exode.ru>
1187
+ */
1188
+ const Container = styled.div `
1189
+ position: relative;
1190
+ width: 100%;
1191
+ padding: 16px;
1192
+ transition: color 200ms ease, background-color 200ms ease;
1193
+ color: var(--text_primary, #0a0a0a);
1194
+ `;
1195
+
1196
+ /**
1197
+ * IdleControl styles
1198
+ *
1199
+ * @author: exode <hello@exode.ru>
1200
+ */
1201
+ const IdleWrapper = styled.div `
1202
+ display: flex;
1203
+ flex-direction: column;
1204
+ align-items: center;
1205
+ gap: 16px;
1206
+ `;
1207
+ const IdleButton = styled.button `
1208
+ position: relative;
1209
+ width: 72px;
1210
+ height: 72px;
1211
+ display: inline-flex;
1212
+ align-items: center;
1213
+ justify-content: center;
1214
+ border-radius: 36px;
1215
+ border: 0;
1216
+ background: var(--background_content, #ffffff);
1217
+ color: color-mix(in srgb, var(--accent, #3f8ae0) 80%, white 20%);
1218
+ cursor: pointer;
1219
+ transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
1220
+
1221
+ &:hover {
1222
+ transform: translateY(-2px);
1223
+ }
1224
+
1225
+ &:active {
1226
+ transform: translateY(0);
1227
+ }
1228
+
1229
+ &:focus-visible {
1230
+ outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
1231
+ outline-offset: 4px;
1232
+ }
1233
+ `;
1234
+ const IdleButtonOutline = styled.span `
1235
+ position: absolute;
1236
+ inset: 0;
1237
+ border-radius: 32px;
1238
+ border: 1px solid color-mix(in srgb, var(--accent, #3f8ae0) 28%, transparent);
1239
+ pointer-events: none;
1240
+ `;
1241
+ const IdleButtonGlow = styled.span `
1242
+ position: absolute;
1243
+ inset: 8px;
1244
+ border-radius: 28px;
1245
+ background: radial-gradient(
1246
+ 120% 120% at 50% 50%,
1247
+ color-mix(in srgb, var(--accent, #3f8ae0) 24%, transparent) 0%,
1248
+ color-mix(in srgb, var(--accent, #3f8ae0) 10%, transparent) 65%,
1249
+ transparent 100%
1250
+ );
1251
+ filter: blur(4px);
1252
+ pointer-events: none;
1253
+ `;
1254
+ const IdleInstruction = styled.div `
1255
+ font-size: 14px;
1256
+ text-align: center;
1257
+ color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
1258
+ `;
1259
+
1260
+ const IdleControl = (props) => {
1261
+ const { onClick, instruction, startAriaLabel, } = props;
1262
+ 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
+ };
1264
+
1265
+ /**
1266
+ * RecordingShell styles
802
1267
  *
803
1268
  * @author: exode <hello@exode.ru>
804
1269
  */
@@ -1022,16 +1487,10 @@ const RecordingShell = (props) => {
1022
1487
  };
1023
1488
 
1024
1489
  /**
1025
- * Upload types
1490
+ * AudioRecorder messages — i18n-ready message definitions and merge utility
1026
1491
  *
1027
1492
  * @author: exode <hello@exode.ru>
1028
1493
  */
1029
- const StorageSpaceEnum = {
1030
- User: 'user',
1031
- Shared: 'shared',
1032
- System: 'system',
1033
- };
1034
-
1035
1494
  const DEFAULT_MESSAGES = {
1036
1495
  buttons: {
1037
1496
  cancel: 'Cancel',
@@ -1071,35 +1530,35 @@ const mergeMessages = (custom) => {
1071
1530
  },
1072
1531
  };
1073
1532
  };
1074
- const Container = styled.div `
1075
- position: relative;
1076
- width: 100%;
1077
- padding: 16px;
1078
- transition: color 200ms ease, background-color 200ms ease;
1079
- color: var(--text_primary, #0a0a0a);
1080
- `;
1533
+
1534
+ /**
1535
+ * Upload types
1536
+ *
1537
+ * @author: exode <hello@exode.ru>
1538
+ */
1539
+ const StorageSpaceEnum = {
1540
+ User: 'user',
1541
+ Shared: 'shared',
1542
+ System: 'system',
1543
+ };
1544
+
1081
1545
  const AudioRecorder = (props) => {
1082
1546
  const { space, fileName, uploadId, mimeType, className, uploader, onUploaded, onUploadError, onRecordingStop, onRecordingError, onRecordingStart, onRecordingDelete, onPermissionDenied, autoResetAfterUpload, messages: customMessages, } = props;
1083
1547
  const messages = useMemo(() => mergeMessages(customMessages), [customMessages]);
1084
- const [isSending, setIsSending] = useState(false);
1085
- const shouldSendAfterStopRef = useRef(false);
1086
1548
  const [resolvedUploadId] = useState(() => uploadId !== null && uploadId !== void 0 ? uploadId : (typeof crypto !== 'undefined' && 'randomUUID' in crypto
1087
1549
  ? crypto.randomUUID()
1088
1550
  : `upload-${Date.now()}`));
1089
1551
  const resolvedSpace = space !== null && space !== void 0 ? space : StorageSpaceEnum.User;
1090
- const handleUploaded = useCallback(async (file) => {
1091
- if (onUploaded) {
1092
- await onUploaded(file);
1093
- }
1094
- else {
1095
- console.info('[audio-recorder] Voice message uploaded', file);
1096
- }
1097
- }, [onUploaded]);
1098
1552
  const handleRecorderError = useCallback((error) => {
1099
1553
  onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
1100
1554
  onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1101
1555
  }, [onRecordingError, onUploadError]);
1102
- const { audioURL, isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, stopPlayback, previewLevels, recordingTime, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, playbackDuration, toggleRecordingPause, hasRecording, } = useVoiceRecorder({
1556
+ const vibrate = useCallback((pattern) => {
1557
+ if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1558
+ navigator.vibrate(pattern);
1559
+ }
1560
+ }, []);
1561
+ const recorder = useVoiceRecorder({
1103
1562
  fileName,
1104
1563
  mimeType,
1105
1564
  onRecordingStop,
@@ -1111,58 +1570,141 @@ const AudioRecorder = (props) => {
1111
1570
  uploader,
1112
1571
  uploadId: resolvedUploadId,
1113
1572
  onRecordingError: handleRecorderError,
1114
- onUploaded: handleUploaded,
1573
+ onUploaded: onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { }),
1115
1574
  });
1116
- const vibrate = useCallback((pattern) => {
1117
- if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1118
- navigator.vibrate(pattern);
1575
+ const { isSending, handleCancel, handleSendRequest, handleRecordToggle, handlePlaybackToggle, } = useAudioRecorderHandlers({
1576
+ vibrate,
1577
+ recorder,
1578
+ messages,
1579
+ onUploadError,
1580
+ });
1581
+ const { isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1582
+ const showRecorderBar = isRecording || hasRecording;
1583
+ 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 })) }));
1584
+ };
1585
+
1586
+ /**
1587
+ * useVoiceRecorderInput — orchestrates recording, sending and timeline for VoiceRecorderInput
1588
+ *
1589
+ * @author: exode <hello@exode.ru>
1590
+ */
1591
+ const useVoiceRecorderInput = (options) => {
1592
+ const { onSend, onError, onCancel, uploader, onUploaded, onPermissionDenied, autoStart = true, space = StorageSpaceEnum.User, mimeType = 'audio/webm', fileName = 'voice-message.webm', } = options;
1593
+ const [isSending, setIsSending] = useState(false);
1594
+ const [timelineLevels, setTimelineLevels] = useState([]);
1595
+ const audioBlobRef = useRef(null);
1596
+ const audioLevelsRef = useRef([]);
1597
+ const lastDurationRef = useRef(0);
1598
+ const shouldSendAfterStopRef = useRef(false);
1599
+ const doSendRef = useRef();
1600
+ const autoStartedRef = useRef(false);
1601
+ const [uploadId] = useState(() => typeof crypto !== 'undefined' && 'randomUUID' in crypto
1602
+ ? crypto.randomUUID()
1603
+ : `upload-${Date.now()}`);
1604
+ const handleRecordingStop = useCallback((blob) => {
1605
+ var _a;
1606
+ audioBlobRef.current = blob;
1607
+ if (shouldSendAfterStopRef.current) {
1608
+ shouldSendAfterStopRef.current = false;
1609
+ void ((_a = doSendRef.current) === null || _a === void 0 ? void 0 : _a.call(doSendRef));
1119
1610
  }
1120
1611
  }, []);
1121
- const startNewRecording = useCallback(async () => {
1122
- if (isRecording) {
1123
- return;
1124
- }
1125
- try {
1126
- await startRecording();
1127
- vibrate(50);
1128
- }
1129
- catch (error) {
1130
- handleRecorderError(error);
1612
+ const recorder = useVoiceRecorder({
1613
+ space,
1614
+ fileName,
1615
+ mimeType,
1616
+ uploadId,
1617
+ onPermissionDenied,
1618
+ onRecordingStop: handleRecordingStop,
1619
+ onRecordingError: onError,
1620
+ uploader: onSend ? undefined : uploader,
1621
+ onUploaded: onSend ? (() => { }) : (onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { })),
1622
+ });
1623
+ const { audioLevels, isPaused, isRecording, recordingTime, hasRecording, stopPlayback, sendRecording, stopRecording, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1624
+ audioLevelsRef.current = audioLevels;
1625
+ useEffect(() => {
1626
+ if (recordingTime > 0) {
1627
+ lastDurationRef.current = recordingTime;
1131
1628
  }
1132
- }, [handleRecorderError, isRecording, startRecording, vibrate]);
1133
- const handleRecordToggle = useCallback(() => {
1134
- if (!isRecording) {
1135
- if (audioURL) {
1136
- stopPlayback();
1137
- deleteRecording();
1138
- }
1139
- void startNewRecording();
1629
+ }, [recordingTime]);
1630
+ const isPreview = isPaused || (!isRecording && hasRecording);
1631
+ const isActiveRecording = isRecording && !isPaused;
1632
+ useEffect(() => {
1633
+ if (!isActiveRecording) {
1140
1634
  return;
1141
1635
  }
1142
- if (isPaused) {
1143
- stopPlayback();
1636
+ const interval = setInterval(() => {
1637
+ const levels = audioLevelsRef.current;
1638
+ const peak = levels.length > 0 ? Math.max(...levels) : 0;
1639
+ setTimelineLevels((prev) => [...prev, peak]);
1640
+ }, TIMELINE_SAMPLE_INTERVAL);
1641
+ return () => clearInterval(interval);
1642
+ }, [isActiveRecording]);
1643
+ useEffect(() => {
1644
+ if (isRecording && !isPaused) {
1645
+ setTimelineLevels([]);
1144
1646
  }
1647
+ }, [isRecording]);
1648
+ const handleResume = useCallback(() => {
1649
+ stopPlayback();
1145
1650
  toggleRecordingPause();
1146
- }, [audioURL, deleteRecording, isPaused, isRecording, startNewRecording, stopPlayback, toggleRecordingPause]);
1147
- const handleSend = useCallback(async () => {
1148
- if (isSending) {
1149
- return;
1150
- }
1651
+ }, [
1652
+ stopPlayback,
1653
+ toggleRecordingPause,
1654
+ ]);
1655
+ const handlePause = toggleRecordingPause;
1656
+ const handleCancel = useCallback(() => {
1151
1657
  shouldSendAfterStopRef.current = false;
1658
+ if (isRecording) {
1659
+ cancelRecording();
1660
+ }
1661
+ else if (hasRecording) {
1662
+ stopPlayback();
1663
+ deleteRecording();
1664
+ }
1665
+ onCancel === null || onCancel === void 0 ? void 0 : onCancel();
1666
+ }, [
1667
+ onCancel,
1668
+ isRecording,
1669
+ hasRecording,
1670
+ stopPlayback,
1671
+ cancelRecording,
1672
+ deleteRecording,
1673
+ ]);
1674
+ const doSend = useCallback(async () => {
1152
1675
  setIsSending(true);
1153
1676
  try {
1154
1677
  stopPlayback();
1155
- await sendRecording();
1678
+ if (onSend) {
1679
+ if (!audioBlobRef.current) {
1680
+ return;
1681
+ }
1682
+ const file = new File([audioBlobRef.current], fileName, { type: audioBlobRef.current.type || mimeType });
1683
+ await onSend(file, recordingTime || lastDurationRef.current || 0);
1684
+ deleteRecording();
1685
+ }
1686
+ else {
1687
+ await sendRecording();
1688
+ }
1156
1689
  }
1157
1690
  catch (error) {
1158
- const uploadError = error instanceof Error ? error : new Error(messages.errors.loadFileFailed);
1159
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1691
+ onError === null || onError === void 0 ? void 0 : onError(error);
1160
1692
  }
1161
1693
  finally {
1162
1694
  setIsSending(false);
1163
1695
  }
1164
- }, [isSending, messages.errors.loadFileFailed, onUploadError, sendRecording, stopPlayback]);
1165
- const handleSendRequest = useCallback(() => {
1696
+ }, [
1697
+ onSend,
1698
+ onError,
1699
+ fileName,
1700
+ mimeType,
1701
+ recordingTime,
1702
+ sendRecording,
1703
+ stopPlayback,
1704
+ deleteRecording,
1705
+ ]);
1706
+ doSendRef.current = doSend;
1707
+ const handleSendRequest = useCallback(async () => {
1166
1708
  if (isSending) {
1167
1709
  return;
1168
1710
  }
@@ -1171,33 +1713,103 @@ const AudioRecorder = (props) => {
1171
1713
  stopRecording();
1172
1714
  return;
1173
1715
  }
1174
- void handleSend();
1175
- }, [handleSend, isRecording, isSending, stopRecording]);
1176
- const handleCancel = useCallback(() => {
1177
- shouldSendAfterStopRef.current = false;
1178
- if (isRecording) {
1179
- cancelRecording();
1180
- return;
1181
- }
1182
- if (audioURL) {
1183
- stopPlayback();
1184
- deleteRecording();
1185
- }
1186
- }, [audioURL, cancelRecording, deleteRecording, isRecording, stopPlayback]);
1187
- const handlePlaybackToggle = useCallback(() => {
1188
- if (!hasRecording) {
1189
- return;
1190
- }
1191
- togglePlayback();
1192
- }, [hasRecording, togglePlayback]);
1193
- const showRecorderBar = isRecording || hasRecording;
1716
+ await doSend();
1717
+ }, [
1718
+ doSend,
1719
+ isRecording,
1720
+ isSending,
1721
+ stopRecording,
1722
+ ]);
1194
1723
  useEffect(() => {
1195
- if (!isRecording && hasRecording && shouldSendAfterStopRef.current) {
1196
- shouldSendAfterStopRef.current = false;
1197
- void handleSend();
1724
+ if (autoStart && !autoStartedRef.current) {
1725
+ autoStartedRef.current = true;
1726
+ void startRecording().catch((error) => {
1727
+ onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
1728
+ });
1198
1729
  }
1199
- }, [handleSend, hasRecording, isRecording]);
1200
- 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 })) }));
1730
+ }, [autoStart]);
1731
+ return {
1732
+ recorder,
1733
+ isSending,
1734
+ isPreview,
1735
+ timelineLevels,
1736
+ lastDurationRef,
1737
+ handleResume,
1738
+ handlePause,
1739
+ handleCancel,
1740
+ handleSendRequest,
1741
+ };
1742
+ };
1743
+
1744
+ const PreviewBars = (props) => {
1745
+ var _a, _b;
1746
+ const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, getVisibleBarsCount, } = props;
1747
+ const maxBars = getVisibleBarsCount();
1748
+ const sourceLevels = timelineLevels.length > 0
1749
+ ? timelineLevels
1750
+ : previewLevels;
1751
+ if (!sourceLevels.length) {
1752
+ return (jsx(Fragment, { children: Array.from({ length: maxBars }, (_, i) => (jsx("div", { style: {
1753
+ width: `${TIMELINE_BAR_WIDTH}px`,
1754
+ height: `${TIMELINE_BAR_MIN_HEIGHT}px`,
1755
+ borderRadius: '9999px',
1756
+ flexShrink: 0,
1757
+ background: 'color-mix(in srgb, var(--text_primary, #0a0a0a) 15%, transparent)',
1758
+ } }, i))) }));
1759
+ }
1760
+ /** Interpolate source levels to fill container */
1761
+ const bars = [];
1762
+ for (let i = 0; i < maxBars; i++) {
1763
+ const srcIndex = (i / maxBars) * sourceLevels.length;
1764
+ const low = Math.floor(srcIndex);
1765
+ const high = Math.min(low + 1, sourceLevels.length - 1);
1766
+ const frac = srcIndex - low;
1767
+ bars.push(((_a = sourceLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac) + ((_b = sourceLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
1768
+ }
1769
+ const totalDuration = recordingTime || lastDuration || 1;
1770
+ const progress = isPlaying ? Math.min(1, playbackTime / totalDuration) : 0;
1771
+ return (jsx(Fragment, { children: bars.map((level, i) => {
1772
+ const normalized = Math.min(100, level);
1773
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1774
+ const isPlayed = progress > 0 && (i / maxBars) <= progress;
1775
+ return (jsx("div", { style: {
1776
+ flexShrink: 0,
1777
+ height: `${height}px`,
1778
+ borderRadius: '9999px',
1779
+ width: `${TIMELINE_BAR_WIDTH}px`,
1780
+ background: !isPlaying
1781
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1782
+ : isPlayed
1783
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)'
1784
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 22%, transparent)',
1785
+ transition: 'background 80ms ease',
1786
+ } }, i));
1787
+ }) }));
1788
+ };
1789
+
1790
+ const TimelineBars = (props) => {
1791
+ const { timelineLevels, getVisibleBarsCount } = props;
1792
+ const maxBars = getVisibleBarsCount();
1793
+ const visible = timelineLevels.slice(-maxBars);
1794
+ const padded = [
1795
+ ...visible,
1796
+ ...Array.from({ length: Math.max(0, maxBars - visible.length) }, () => 0),
1797
+ ];
1798
+ return (jsx(Fragment, { children: padded.map((level, i) => {
1799
+ const normalized = Math.min(100, level);
1800
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1801
+ const hasValue = i < visible.length;
1802
+ return (jsx("div", { style: {
1803
+ width: `${TIMELINE_BAR_WIDTH}px`,
1804
+ height: `${height}px`,
1805
+ borderRadius: '9999px',
1806
+ flexShrink: 0,
1807
+ transition: 'height 120ms ease',
1808
+ background: hasValue
1809
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1810
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent)',
1811
+ } }, i));
1812
+ }) }));
1201
1813
  };
1202
1814
 
1203
1815
  /**
@@ -1347,455 +1959,89 @@ const PlayPauseButton = styled.button `
1347
1959
  align-items: center;
1348
1960
  justify-content: center;
1349
1961
  border-radius: 13px;
1350
- border: 0;
1351
- flex-shrink: 0;
1352
- cursor: pointer;
1353
- background: var(--background_content, #ffffff);
1354
- color: var(--text_primary, #0a0a0a);
1355
- transition: transform 120ms ease;
1356
-
1357
- &:hover {
1358
- transform: scale(1.06);
1359
- }
1360
-
1361
- &:active {
1362
- transform: scale(0.94);
1363
- }
1364
- `;
1365
- const SpinnerIcon = styled.span `
1366
- width: 20px;
1367
- height: 20px;
1368
- display: inline-block;
1369
- border-radius: 50%;
1370
- border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
1371
- border-top-color: var(--accent, #3f8ae0);
1372
- border-right-color: var(--accent, #3f8ae0);
1373
- animation: voiceRecorderSpin 0.75s linear infinite;
1374
-
1375
- @keyframes voiceRecorderSpin {
1376
- from { transform: rotate(0deg); }
1377
- to { transform: rotate(360deg); }
1378
- }
1379
- `;
1380
-
1381
- const TIMELINE_SAMPLE_INTERVAL = 150;
1382
- const TIMELINE_BAR_WIDTH = 3;
1383
- const TIMELINE_BAR_GAP = 2;
1384
- const TIMELINE_BAR_MIN_HEIGHT = 3;
1385
- const TIMELINE_BAR_MAX_HEIGHT = 24;
1386
- const VoiceRecorderInput = (props) => {
1387
- const { onSend, onError, onCancel, uploader, onUploaded, className, onPermissionDenied, space = StorageSpaceEnum.User, autoStart = true, mimeType = 'audio/webm', fileName = 'voice-message.webm', } = props;
1388
- const [isSending, setIsSending] = useState(false);
1389
- const [timelineLevels, setTimelineLevels] = useState([]);
1390
- const audioBlobRef = useRef(null);
1391
- const audioLevelsRef = useRef([]);
1392
- const lastDurationRef = useRef(0);
1393
- const shouldSendAfterStopRef = useRef(false);
1394
- const doSendRef = useRef();
1395
- const autoStartedRef = useRef(false);
1396
- const waveformRef = useRef(null);
1397
- const [uploadId] = useState(() => typeof crypto !== 'undefined' && 'randomUUID' in crypto
1398
- ? crypto.randomUUID()
1399
- : `upload-${Date.now()}`);
1400
- const handleUploaded = useCallback(async (file) => {
1401
- await (onUploaded === null || onUploaded === void 0 ? void 0 : onUploaded(file));
1402
- }, [onUploaded]);
1403
- const handleRecordingStop = useCallback((blob) => {
1404
- var _a;
1405
- audioBlobRef.current = blob;
1406
- if (shouldSendAfterStopRef.current) {
1407
- shouldSendAfterStopRef.current = false;
1408
- void ((_a = doSendRef.current) === null || _a === void 0 ? void 0 : _a.call(doSendRef));
1409
- }
1410
- }, []);
1411
- const { audioLevels, isPaused, isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, stopPlayback, sendRecording, stopRecording, startRecording, togglePlayback, cancelRecording, deleteRecording, toggleRecordingPause, } = useVoiceRecorder({
1412
- space,
1413
- fileName,
1414
- mimeType,
1415
- uploadId,
1416
- onPermissionDenied,
1417
- onRecordingStop: handleRecordingStop,
1418
- onRecordingError: onError,
1419
- uploader: onSend ? undefined : uploader,
1420
- onUploaded: onSend ? (() => { }) : handleUploaded,
1421
- });
1422
- /** Keep refs in sync */
1423
- audioLevelsRef.current = audioLevels;
1424
- /** Preserve duration before resetSession zeroes it */
1425
- useEffect(() => {
1426
- if (recordingTime > 0) {
1427
- lastDurationRef.current = recordingTime;
1428
- }
1429
- }, [recordingTime]);
1430
- /** Preview mode: paused or stopped with recording available */
1431
- const isPreview = isPaused || (!isRecording && hasRecording);
1432
- /** Actively recording (not paused) */
1433
- const isActiveRecording = isRecording && !isPaused;
1434
- /** Sample audioLevels periodically into timeline (only while actively recording) */
1435
- useEffect(() => {
1436
- if (!isActiveRecording) {
1437
- return;
1438
- }
1439
- const interval = setInterval(() => {
1440
- const levels = audioLevelsRef.current;
1441
- const peak = levels.length > 0 ? Math.max(...levels) : 0;
1442
- setTimelineLevels((prev) => [...prev, peak]);
1443
- }, TIMELINE_SAMPLE_INTERVAL);
1444
- return () => clearInterval(interval);
1445
- }, [isActiveRecording]);
1446
- /** Reset timeline only on fresh recording start */
1447
- useEffect(() => {
1448
- if (isRecording && !isPaused) {
1449
- setTimelineLevels([]);
1450
- }
1451
- // eslint-disable-next-line react-hooks/exhaustive-deps
1452
- }, [isRecording]);
1453
- /** Calculate visible bars count based on container width */
1454
- const getVisibleBarsCount = useCallback(() => {
1455
- const el = waveformRef.current;
1456
- if (!el) {
1457
- return 60;
1458
- }
1459
- return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
1460
- }, []);
1461
- /** Resume recording from paused state */
1462
- const handleResume = useCallback(() => {
1463
- stopPlayback();
1464
- toggleRecordingPause();
1465
- }, [stopPlayback, toggleRecordingPause]);
1466
- /** Pause recording (show preview) */
1467
- const handlePause = useCallback(() => {
1468
- toggleRecordingPause();
1469
- }, [toggleRecordingPause]);
1470
- const handleCancel = useCallback(() => {
1471
- shouldSendAfterStopRef.current = false;
1472
- if (isRecording) {
1473
- cancelRecording();
1474
- }
1475
- else if (hasRecording) {
1476
- stopPlayback();
1477
- deleteRecording();
1478
- }
1479
- onCancel === null || onCancel === void 0 ? void 0 : onCancel();
1480
- }, [cancelRecording, deleteRecording, hasRecording, isRecording, onCancel, stopPlayback]);
1481
- const doSend = useCallback(async () => {
1482
- setIsSending(true);
1483
- try {
1484
- stopPlayback();
1485
- if (onSend) {
1486
- if (!audioBlobRef.current) {
1487
- return;
1488
- }
1489
- const file = new File([audioBlobRef.current], fileName, {
1490
- type: audioBlobRef.current.type || mimeType,
1491
- });
1492
- const duration = recordingTime || lastDurationRef.current || 0;
1493
- await onSend(file, duration);
1494
- deleteRecording();
1495
- }
1496
- else {
1497
- await sendRecording();
1498
- }
1499
- }
1500
- catch (error) {
1501
- onError === null || onError === void 0 ? void 0 : onError(error);
1502
- }
1503
- finally {
1504
- setIsSending(false);
1505
- }
1506
- }, [deleteRecording, fileName, mimeType, onError, onSend, recordingTime, sendRecording, stopPlayback]);
1507
- /** Keep doSendRef in sync so handleRecordingStop always uses latest */
1508
- doSendRef.current = doSend;
1509
- const handleSendRequest = useCallback(async () => {
1510
- if (isSending) {
1511
- return;
1512
- }
1513
- /** Stop recording first (if active or paused), then send via handleRecordingStop */
1514
- if (isRecording) {
1515
- shouldSendAfterStopRef.current = true;
1516
- stopRecording();
1517
- return;
1518
- }
1519
- await doSend();
1520
- }, [doSend, isRecording, isSending, stopRecording]);
1521
- useEffect(() => {
1522
- if (autoStart && !autoStartedRef.current) {
1523
- autoStartedRef.current = true;
1524
- void startRecording().catch((error) => {
1525
- onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
1526
- });
1527
- }
1528
- // eslint-disable-next-line react-hooks/exhaustive-deps
1529
- }, [autoStart]);
1530
- /** Render recording timeline bars — last N bars that fit the container */
1531
- const renderTimelineBars = () => {
1532
- const maxBars = getVisibleBarsCount();
1533
- const visible = timelineLevels.slice(-maxBars);
1534
- const padded = [
1535
- ...visible,
1536
- ...Array.from({ length: Math.max(0, maxBars - visible.length) }, () => 0),
1537
- ];
1538
- return padded.map((level, i) => {
1539
- const normalized = Math.min(100, level);
1540
- const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1541
- const hasValue = i < visible.length;
1542
- return (jsx("div", { style: {
1543
- width: `${TIMELINE_BAR_WIDTH}px`,
1544
- height: `${height}px`,
1545
- borderRadius: '9999px',
1546
- flexShrink: 0,
1547
- background: hasValue
1548
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1549
- : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent)',
1550
- transition: 'height 120ms ease',
1551
- } }, i));
1552
- });
1553
- };
1554
- /** Render preview waveform bars — from previewLevels or fallback to timelineLevels */
1555
- const renderPreviewBars = () => {
1556
- var _a, _b;
1557
- const maxBars = getVisibleBarsCount();
1558
- const sourceLevels = timelineLevels.length > 0 ? timelineLevels : previewLevels;
1559
- if (!sourceLevels.length) {
1560
- return Array.from({ length: maxBars }, (_, i) => (jsx("div", { style: {
1561
- width: `${TIMELINE_BAR_WIDTH}px`,
1562
- height: `${TIMELINE_BAR_MIN_HEIGHT}px`,
1563
- borderRadius: '9999px',
1564
- flexShrink: 0,
1565
- background: 'color-mix(in srgb, var(--text_primary, #0a0a0a) 15%, transparent)',
1566
- } }, i)));
1567
- }
1568
- /** Interpolate source levels to fill container */
1569
- const bars = [];
1570
- for (let i = 0; i < maxBars; i++) {
1571
- const srcIndex = (i / maxBars) * sourceLevels.length;
1572
- const low = Math.floor(srcIndex);
1573
- const high = Math.min(low + 1, sourceLevels.length - 1);
1574
- const frac = srcIndex - low;
1575
- bars.push(((_a = sourceLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac) + ((_b = sourceLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
1962
+ border: 0;
1963
+ flex-shrink: 0;
1964
+ cursor: pointer;
1965
+ background: var(--background_content, #ffffff);
1966
+ color: var(--text_primary, #0a0a0a);
1967
+ transition: transform 120ms ease;
1968
+
1969
+ &:hover {
1970
+ transform: scale(1.06);
1971
+ }
1972
+
1973
+ &:active {
1974
+ transform: scale(0.94);
1975
+ }
1976
+ `;
1977
+ const SpinnerIcon = styled.span `
1978
+ width: 20px;
1979
+ height: 20px;
1980
+ display: inline-block;
1981
+ border-radius: 50%;
1982
+ border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
1983
+ border-top-color: var(--accent, #3f8ae0);
1984
+ border-right-color: var(--accent, #3f8ae0);
1985
+ animation: voiceRecorderSpin 0.75s linear infinite;
1986
+
1987
+ @keyframes voiceRecorderSpin {
1988
+ from { transform: rotate(0deg); }
1989
+ to { transform: rotate(360deg); }
1990
+ }
1991
+ `;
1992
+
1993
+ const VoiceRecorderInput = (props) => {
1994
+ const { className, ...inputOptions } = props;
1995
+ const waveformRef = useRef(null);
1996
+ const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
1997
+ const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
1998
+ const getVisibleBarsCount = useCallback(() => {
1999
+ const el = waveformRef.current;
2000
+ if (!el) {
2001
+ return 60;
1576
2002
  }
1577
- const totalDuration = recordingTime || lastDurationRef.current || 1;
1578
- const progress = isPlaying ? Math.min(1, playbackTime / totalDuration) : 0;
1579
- return bars.map((level, i) => {
1580
- const normalized = Math.min(100, level);
1581
- const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1582
- const isPlayed = progress > 0 && (i / maxBars) <= progress;
1583
- return (jsx("div", { style: {
1584
- width: `${TIMELINE_BAR_WIDTH}px`,
1585
- height: `${height}px`,
1586
- borderRadius: '9999px',
1587
- flexShrink: 0,
1588
- background: !isPlaying
1589
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1590
- : isPlayed
1591
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)'
1592
- : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 22%, transparent)',
1593
- transition: 'background 80ms ease',
1594
- } }, i));
1595
- });
1596
- };
1597
- /** Display time based on current mode */
2003
+ return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
2004
+ }, []);
1598
2005
  const displayTime = (isPreview && isPlaying)
1599
2006
  ? formatTime(playbackTime)
1600
2007
  : formatTime(recordingTime || lastDurationRef.current);
1601
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
1602
2009
  ? jsx(Icon16Pause, { width: 14, height: 14 })
1603
- : 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 ? renderPreviewBars() : renderTimelineBars() }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
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, {})) })] }));
1604
2011
  };
1605
2012
 
1606
2013
  /**
1607
- * Global audio playback manager — ensures only one audio plays at a time
2014
+ * VoicePlaybackChain
1608
2015
  *
1609
- * @author: exode <hello@exode.ru>
1610
- */
1611
- const listeners = new Set();
1612
- const audioPlaybackManager = {
1613
- /** Register a stop callback for a player instance */
1614
- subscribe: (callback) => {
1615
- listeners.add(callback);
1616
- return () => {
1617
- listeners.delete(callback);
1618
- };
1619
- },
1620
- /** Stop all other players except the caller */
1621
- stopOthers: (except) => {
1622
- listeners.forEach((cb) => {
1623
- if (cb !== except) {
1624
- cb();
1625
- }
1626
- });
1627
- },
1628
- };
1629
-
1630
- /**
1631
- * useVoiceMessagePlayer
2016
+ * @description Sequential voice message playback chain (like Telegram).
2017
+ * Registers voice players by media ID and plays the next one when current ends.
1632
2018
  *
1633
2019
  * @author: exode <hello@exode.ru>
1634
2020
  */
1635
- const useVoiceMessagePlayer = (options) => {
1636
- const { src, duration: externalDuration, waveformData } = options;
1637
- const [isPlaying, setIsPlaying] = useState(false);
1638
- const [isLoading, setIsLoading] = useState(false);
1639
- const [isLoaded, setIsLoaded] = useState(false);
1640
- const [currentTime, setCurrentTime] = useState(0);
1641
- const [duration, setDuration] = useState(externalDuration || 0);
1642
- const [waveformLevels, setWaveformLevels] = useState(waveformData || createInitialLevels);
1643
- const audioRef = useRef(null);
1644
- const blobRef = useRef(null);
1645
- const objectUrlRef = useRef(null);
1646
- const abortControllerRef = useRef(null);
1647
- const progress = duration > 0 ? currentTime / duration : 0;
1648
- /** Stop this player (called by manager when another player starts) */
1649
- const stopThis = useCallback(() => {
1650
- const audio = audioRef.current;
1651
- if (audio && !audio.paused) {
1652
- audio.pause();
1653
- }
1654
- setIsPlaying(false);
1655
- }, []);
1656
- /** Subscribe to global manager on mount */
1657
- useEffect(() => {
1658
- return audioPlaybackManager.subscribe(stopThis);
1659
- }, [stopThis]);
1660
- const ensureAudioLoaded = useCallback(async () => {
1661
- var _a;
1662
- if (audioRef.current) {
1663
- return;
1664
- }
1665
- if (!src) {
1666
- return;
1667
- }
1668
- (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1669
- const controller = new AbortController();
1670
- abortControllerRef.current = controller;
1671
- setIsLoading(true);
1672
- try {
1673
- const response = await fetch(src, { signal: controller.signal });
1674
- const blob = await response.blob();
1675
- if (controller.signal.aborted) {
1676
- return;
1677
- }
1678
- blobRef.current = blob;
1679
- if (!waveformData) {
1680
- const levels = await decodeAudioLevels(blob);
1681
- if (controller.signal.aborted) {
1682
- return;
1683
- }
1684
- setWaveformLevels(levels);
1685
- }
1686
- if (objectUrlRef.current) {
1687
- URL.revokeObjectURL(objectUrlRef.current);
1688
- }
1689
- const url = URL.createObjectURL(blob);
1690
- objectUrlRef.current = url;
1691
- const { audio } = createAudioPlayback(url, {
1692
- onLoadedMetadata: (dur) => {
1693
- if (!externalDuration && dur && isFinite(dur) && dur > 0.5) {
1694
- setDuration(dur);
1695
- }
1696
- },
1697
- onTimeUpdate: (time) => setCurrentTime(time),
1698
- onEnded: () => {
1699
- setIsPlaying(false);
1700
- setCurrentTime(0);
1701
- },
1702
- });
1703
- audioRef.current = audio;
1704
- setIsLoaded(true);
1705
- }
1706
- catch (error) {
1707
- if (error instanceof DOMException && error.name === 'AbortError') {
1708
- return;
1709
- }
1710
- console.warn('[voice-player] Failed to load audio', error);
1711
- }
1712
- finally {
1713
- if (!controller.signal.aborted) {
1714
- setIsLoading(false);
1715
- }
1716
- }
1717
- }, [src, waveformData, externalDuration]);
1718
- /** Load audio and auto-play after loaded */
1719
- const preload = useCallback(() => {
1720
- if (isLoading || audioRef.current) {
1721
- return;
1722
- }
1723
- audioPlaybackManager.stopOthers(stopThis);
1724
- void ensureAudioLoaded().then(() => {
1725
- const audio = audioRef.current;
1726
- if (audio) {
1727
- audio.play()
1728
- .then(() => setIsPlaying(true))
1729
- .catch(() => setIsPlaying(false));
1730
- }
1731
- });
1732
- }, [isLoading, ensureAudioLoaded, stopThis]);
1733
- /** Toggle play/pause (only works after audio is loaded) */
1734
- const togglePlayback = useCallback(() => {
1735
- const audio = audioRef.current;
1736
- if (!audio || isLoading) {
1737
- return;
1738
- }
1739
- if (audio.paused) {
1740
- audioPlaybackManager.stopOthers(stopThis);
1741
- audio.play()
1742
- .then(() => setIsPlaying(true))
1743
- .catch(() => setIsPlaying(false));
1744
- }
1745
- else {
1746
- audio.pause();
1747
- setIsPlaying(false);
1748
- }
1749
- }, [isLoading, stopThis]);
1750
- const seekTo = useCallback((seekProgress) => {
1751
- const audio = audioRef.current;
1752
- if (!audio || !isFinite(audio.duration)) {
1753
- return;
1754
- }
1755
- const time = seekProgress * audio.duration;
1756
- audio.currentTime = time;
1757
- setCurrentTime(time);
1758
- }, []);
1759
- /** Cleanup on src change or unmount */
1760
- useEffect(() => {
1761
- return () => {
1762
- var _a;
1763
- (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1764
- if (audioRef.current) {
1765
- cleanupAudio(audioRef.current);
1766
- audioRef.current = null;
1767
- }
1768
- if (objectUrlRef.current) {
1769
- URL.revokeObjectURL(objectUrlRef.current);
1770
- objectUrlRef.current = null;
2021
+ class VoicePlaybackChainClass {
2022
+ constructor() {
2023
+ this.entries = [];
2024
+ /** Register a voice player. Returns cleanup function for useEffect. */
2025
+ this.register = (id, play) => {
2026
+ this.entries = this.entries.filter((e) => e.id !== id);
2027
+ this.entries.push({ id, play });
2028
+ this.entries.sort((a, b) => a.id - b.id);
2029
+ return () => this.unregister(id);
2030
+ };
2031
+ /** Unregister a voice player by media ID */
2032
+ this.unregister = (id) => {
2033
+ this.entries = this.entries.filter((e) => e.id !== id);
2034
+ };
2035
+ /** Play the next voice message after the given media ID */
2036
+ this.playNext = (currentId) => {
2037
+ const idx = this.entries.findIndex((e) => e.id === currentId);
2038
+ if (idx >= 0 && idx < this.entries.length - 1) {
2039
+ this.entries[idx + 1].play();
1771
2040
  }
1772
- setIsLoaded(false);
1773
2041
  };
1774
- }, [src]);
1775
- useEffect(() => {
1776
- if (waveformData) {
1777
- setWaveformLevels(waveformData);
1778
- }
1779
- }, [waveformData]);
1780
- useEffect(() => {
1781
- if (externalDuration) {
1782
- setDuration(externalDuration);
1783
- }
1784
- }, [externalDuration]);
1785
- return {
1786
- progress,
1787
- duration,
1788
- isLoaded,
1789
- isPlaying,
1790
- isLoading,
1791
- currentTime,
1792
- waveformLevels,
1793
- seekTo,
1794
- preload,
1795
- formatTime,
1796
- togglePlayback,
1797
- };
1798
- };
2042
+ }
2043
+ }
2044
+ const VoicePlaybackChain = new VoicePlaybackChainClass();
1799
2045
 
1800
2046
  /**
1801
2047
  * VoiceMessagePlayer styles
@@ -1889,11 +2135,54 @@ const BAR_WIDTH = 3;
1889
2135
  const BAR_GAP = 2;
1890
2136
  const BAR_MIN_HEIGHT = 2;
1891
2137
  const BAR_MAX_HEIGHT = 24;
1892
- const VoiceMessagePlayer = (props) => {
1893
- const { src, onClick, duration, className, waveformData, } = props;
2138
+ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2139
+ const { src, onClick, onEnded, chainId, duration, className, waveformData, autoPreload = true, } = props;
1894
2140
  const trackRef = useRef(null);
2141
+ const wrapperRef = useRef(null);
1895
2142
  const [trackWidth, setTrackWidth] = useState(0);
1896
- const { progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({ src, duration, waveformData });
2143
+ const handleEnded = useCallback(() => {
2144
+ onEnded === null || onEnded === void 0 ? void 0 : onEnded();
2145
+ if (chainId != null) {
2146
+ VoicePlaybackChain.playNext(chainId);
2147
+ }
2148
+ }, [onEnded, chainId]);
2149
+ const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2150
+ src,
2151
+ duration,
2152
+ waveformData,
2153
+ preloadedBlob: props.preloadedBlob,
2154
+ onEnded: handleEnded,
2155
+ });
2156
+ useImperativeHandle(ref, () => ({ play, prefetch }), [play, prefetch]);
2157
+ /** Auto-preload audio when player enters viewport */
2158
+ useEffect(() => {
2159
+ if (!autoPreload || isLoaded || isLoading) {
2160
+ return;
2161
+ }
2162
+ const el = wrapperRef.current;
2163
+ if (!el) {
2164
+ return;
2165
+ }
2166
+ const observer = new IntersectionObserver(([entry]) => {
2167
+ if (entry.isIntersecting) {
2168
+ prefetch();
2169
+ observer.disconnect();
2170
+ }
2171
+ }, { threshold: 0.1 });
2172
+ observer.observe(el);
2173
+ return () => observer.disconnect();
2174
+ }, [
2175
+ isLoaded,
2176
+ prefetch,
2177
+ isLoading,
2178
+ autoPreload,
2179
+ ]);
2180
+ /** Register in playback chain for sequential playback */
2181
+ useEffect(() => {
2182
+ if (chainId != null) {
2183
+ return VoicePlaybackChain.register(chainId, () => play());
2184
+ }
2185
+ }, [chainId, play]);
1897
2186
  /** Measure track width once after mount */
1898
2187
  useLayoutEffect(() => {
1899
2188
  if (trackRef.current) {
@@ -1934,7 +2223,7 @@ const VoiceMessagePlayer = (props) => {
1934
2223
  const peak = Math.max(...raw, 1);
1935
2224
  return raw.map((v) => (v / peak) * 100);
1936
2225
  }, [waveformLevels, trackWidth]);
1937
- return (jsxs(PlayerWrapper, { onClick: onClick, className: className, children: [jsx(PlayButton, { type: "button", disabled: isLoading || !src, onClick: isLoaded ? togglePlayback : preload, "aria-label": !isLoaded ? 'Download' : isPlaying ? 'Pause' : 'Play', children: isLoading ? (jsx(LoadingSpinner, {})) : !isLoaded ? (jsx(Icon24DownloadOutline, {})) : isPlaying ? (jsx(Icon24Pause, {})) : (jsx(Icon24Play, {})) }), jsxs(ContentColumn, { children: [jsx(WaveformTrack, { ref: trackRef, onClick: handleTrackClick, children: interpolatedBars.map((level, i) => {
2226
+ return (jsxs(PlayerWrapper, { ref: wrapperRef, onClick: onClick, className: className, children: [jsx(PlayButton, { type: "button", disabled: isLoading || (!src && !props.preloadedBlob), onClick: isLoaded ? togglePlayback : preload, "aria-label": isPlaying ? 'Pause' : 'Play', children: isLoading ? (jsx(LoadingSpinner, {})) : isPlaying ? (jsx(Icon24Pause, {})) : (jsx(Icon24Play, {})) }), jsxs(ContentColumn, { children: [jsx(WaveformTrack, { ref: trackRef, onClick: handleTrackClick, children: interpolatedBars.map((level, i) => {
1938
2227
  const normalized = Math.min(100, level);
1939
2228
  const height = BAR_MIN_HEIGHT + (normalized / 100) * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT);
1940
2229
  const barProgress = interpolatedBars.length > 0
@@ -1948,7 +2237,40 @@ const VoiceMessagePlayer = (props) => {
1948
2237
  : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 25%, transparent)',
1949
2238
  } }, i));
1950
2239
  }) }), jsx(Duration, { children: displayTime })] })] }));
1951
- };
2240
+ });
2241
+
2242
+ /**
2243
+ * VoiceBlobCache
2244
+ *
2245
+ * @description In-memory cache for voice message blobs.
2246
+ * Allows instant playback of just-sent voice messages
2247
+ * before the server URL becomes available.
2248
+ *
2249
+ * @author: exode <hello@exode.ru>
2250
+ */
2251
+ class VoiceBlobCacheClass {
2252
+ constructor() {
2253
+ this.cache = new Map();
2254
+ this.set = (messageId, blob) => {
2255
+ this.cache.set(messageId, blob);
2256
+ };
2257
+ this.get = (messageId) => {
2258
+ return this.cache.get(messageId);
2259
+ };
2260
+ this.delete = (messageId) => {
2261
+ this.cache.delete(messageId);
2262
+ };
2263
+ /** Transfer blob from optimistic ID to real server ID */
2264
+ this.transfer = (fromId, toId) => {
2265
+ const blob = this.cache.get(fromId);
2266
+ if (blob) {
2267
+ this.cache.delete(fromId);
2268
+ this.cache.set(toId, blob);
2269
+ }
2270
+ };
2271
+ }
2272
+ }
2273
+ const VoiceBlobCache = new VoiceBlobCacheClass();
1952
2274
 
1953
- export { AUDIO_LEVEL_BARS, AudioRecorder, StorageSpaceEnum, VoiceMessagePlayer, VoiceRecorderInput, WaveformBars, clampValue, decodeAudioLevels, formatTime, useRecorderVisualization, useRecordingTiming, useVoiceMessagePlayer, useVoiceRecorder };
2275
+ export { AUDIO_LEVEL_BARS, AudioRecorder, StorageSpaceEnum, VoiceBlobCache, VoiceMessagePlayer, VoicePlaybackChain, VoiceRecorderInput, WaveformBars, clampValue, formatTime, useRecorderVisualization, useRecordingTiming, useVoiceMessagePlayer, useVoiceRecorder };
1954
2276
  //# sourceMappingURL=index.js.map