@exode-team/react-recorder 1.1.0 → 1.1.1

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 +82 -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 +3 -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 +1167 -876
  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 +1069 -779
  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,493 @@ 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
+ /** Load audio and auto-play after loaded */
964
+ const preload = useCallback(() => {
965
+ if (isLoading || audioRef.current) {
966
+ return;
967
+ }
968
+ audioPlaybackManager.stopOthers(stopThis);
969
+ void ensureAudioLoaded().then(() => {
970
+ const audio = audioRef.current;
971
+ if (audio) {
972
+ audio.play()
973
+ .then(() => setIsPlaying(true))
974
+ .catch(() => setIsPlaying(false));
975
+ }
976
+ });
977
+ }, [isLoading, ensureAudioLoaded, stopThis, audioRef]);
978
+ /** Programmatically start playback (used for chained playback) */
979
+ const play = useCallback(() => {
980
+ const audio = audioRef.current;
981
+ if (audio) {
982
+ audioPlaybackManager.stopOthers(stopThis);
983
+ audio.play()
984
+ .then(() => setIsPlaying(true))
985
+ .catch(() => setIsPlaying(false));
986
+ }
987
+ else {
988
+ preload();
989
+ }
990
+ }, [stopThis, preload, audioRef]);
991
+ /** Toggle play/pause (only works after audio is loaded) */
992
+ const togglePlayback = useCallback(() => {
993
+ const audio = audioRef.current;
994
+ if (!audio || isLoading) {
995
+ return;
996
+ }
997
+ if (audio.paused) {
998
+ audioPlaybackManager.stopOthers(stopThis);
999
+ audio.play()
1000
+ .then(() => setIsPlaying(true))
1001
+ .catch(() => setIsPlaying(false));
1002
+ }
1003
+ else {
1004
+ audio.pause();
1005
+ setIsPlaying(false);
1006
+ }
1007
+ }, [isLoading, stopThis, audioRef]);
1008
+ const seekTo = useCallback((seekProgress) => {
1009
+ const audio = audioRef.current;
1010
+ if (!audio || !isFinite(audio.duration)) {
1011
+ return;
1012
+ }
1013
+ const time = seekProgress * audio.duration;
1014
+ audio.currentTime = time;
1015
+ setCurrentTime(time);
1016
+ }, [audioRef]);
1017
+ /** Sync external duration */
1018
+ useEffect(() => {
1019
+ if (externalDuration) {
1020
+ setDuration(externalDuration);
1021
+ }
1022
+ }, [externalDuration]);
1023
+ return {
1024
+ play,
1025
+ progress,
1026
+ duration,
1027
+ isLoaded,
1028
+ isPlaying,
1029
+ isLoading,
1030
+ currentTime,
1031
+ waveformLevels,
1032
+ seekTo,
1033
+ preload,
1034
+ formatTime,
1035
+ togglePlayback,
1036
+ };
1037
+ };
1038
+
1039
+ /**
1040
+ * useAudioRecorderHandlers — recording/send/cancel handlers for AudioRecorder
1041
+ *
1042
+ * @author: exode <hello@exode.ru>
1043
+ */
1044
+ const useAudioRecorderHandlers = (options) => {
1045
+ const { vibrate, recorder, messages, onUploadError, } = options;
1046
+ const { audioURL, isPaused, isRecording, hasRecording, stopPlayback, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1047
+ const shouldSendAfterStopRef = useRef(false);
1048
+ const [isSending, setIsSending] = useState(false);
1049
+ const startNewRecording = useCallback(async () => {
1050
+ if (isRecording) {
1051
+ return;
1052
+ }
1053
+ try {
1054
+ await startRecording();
1055
+ vibrate(50);
1056
+ }
1057
+ catch (error) {
1058
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1059
+ }
1060
+ }, [
1061
+ vibrate,
1062
+ isRecording,
1063
+ onUploadError,
1064
+ startRecording,
1065
+ ]);
1066
+ const handleRecordToggle = useCallback(() => {
1067
+ if (!isRecording) {
1068
+ if (audioURL) {
1069
+ stopPlayback();
1070
+ deleteRecording();
1071
+ }
1072
+ void startNewRecording();
1073
+ return;
1074
+ }
1075
+ if (isPaused) {
1076
+ stopPlayback();
1077
+ }
1078
+ toggleRecordingPause();
1079
+ }, [
1080
+ audioURL,
1081
+ isPaused,
1082
+ isRecording,
1083
+ stopPlayback,
1084
+ deleteRecording,
1085
+ startNewRecording,
1086
+ toggleRecordingPause,
1087
+ ]);
1088
+ const handleSend = useCallback(async () => {
1089
+ if (isSending) {
1090
+ return;
1091
+ }
1092
+ shouldSendAfterStopRef.current = false;
1093
+ setIsSending(true);
1094
+ try {
1095
+ stopPlayback();
1096
+ await sendRecording();
1097
+ }
1098
+ catch (error) {
1099
+ const uploadError = error instanceof Error
1100
+ ? error
1101
+ : new Error(messages.errors.loadFileFailed);
1102
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1103
+ }
1104
+ finally {
1105
+ setIsSending(false);
1106
+ }
1107
+ }, [
1108
+ isSending,
1109
+ stopPlayback,
1110
+ onUploadError,
1111
+ sendRecording,
1112
+ messages.errors.loadFileFailed,
1113
+ ]);
1114
+ const handleSendRequest = useCallback(() => {
1115
+ if (isSending) {
1116
+ return;
1117
+ }
1118
+ if (isRecording) {
1119
+ shouldSendAfterStopRef.current = true;
1120
+ stopRecording();
1121
+ return;
1122
+ }
1123
+ void handleSend();
1124
+ }, [
1125
+ isSending,
1126
+ handleSend,
1127
+ isRecording,
1128
+ stopRecording,
1129
+ ]);
1130
+ const handleCancel = useCallback(() => {
1131
+ shouldSendAfterStopRef.current = false;
1132
+ if (isRecording) {
1133
+ cancelRecording();
1134
+ return;
1135
+ }
1136
+ if (audioURL) {
1137
+ stopPlayback();
1138
+ deleteRecording();
1139
+ }
1140
+ }, [
1141
+ audioURL,
1142
+ isRecording,
1143
+ stopPlayback,
1144
+ deleteRecording,
1145
+ cancelRecording,
1146
+ ]);
1147
+ const handlePlaybackToggle = useCallback(() => {
1148
+ if (!hasRecording) {
1149
+ return;
1150
+ }
1151
+ togglePlayback();
1152
+ }, [hasRecording, togglePlayback]);
1153
+ /** Auto-send after stop */
1154
+ useEffect(() => {
1155
+ if (!isRecording
1156
+ && hasRecording
1157
+ && shouldSendAfterStopRef.current) {
1158
+ shouldSendAfterStopRef.current = false;
1159
+ void handleSend();
1160
+ }
1161
+ }, [
1162
+ handleSend,
1163
+ isRecording,
1164
+ hasRecording,
1165
+ ]);
1166
+ return {
1167
+ isSending,
1168
+ handleCancel,
1169
+ handleSendRequest,
1170
+ handleRecordToggle,
1171
+ handlePlaybackToggle,
1172
+ };
1173
+ };
1174
+
1175
+ /**
1176
+ * AudioRecorder styled components
1177
+ *
1178
+ * @author: exode <hello@exode.ru>
1179
+ */
1180
+ const Container = styled.div `
1181
+ position: relative;
1182
+ width: 100%;
1183
+ padding: 16px;
1184
+ transition: color 200ms ease, background-color 200ms ease;
1185
+ color: var(--text_primary, #0a0a0a);
1186
+ `;
1187
+
1188
+ /**
1189
+ * IdleControl styles
1190
+ *
1191
+ * @author: exode <hello@exode.ru>
1192
+ */
1193
+ const IdleWrapper = styled.div `
1194
+ display: flex;
1195
+ flex-direction: column;
1196
+ align-items: center;
1197
+ gap: 16px;
1198
+ `;
1199
+ const IdleButton = styled.button `
1200
+ position: relative;
1201
+ width: 72px;
1202
+ height: 72px;
1203
+ display: inline-flex;
1204
+ align-items: center;
1205
+ justify-content: center;
1206
+ border-radius: 36px;
1207
+ border: 0;
1208
+ background: var(--background_content, #ffffff);
1209
+ color: color-mix(in srgb, var(--accent, #3f8ae0) 80%, white 20%);
1210
+ cursor: pointer;
1211
+ transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
1212
+
1213
+ &:hover {
1214
+ transform: translateY(-2px);
1215
+ }
1216
+
1217
+ &:active {
1218
+ transform: translateY(0);
1219
+ }
1220
+
1221
+ &:focus-visible {
1222
+ outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
1223
+ outline-offset: 4px;
1224
+ }
1225
+ `;
1226
+ const IdleButtonOutline = styled.span `
1227
+ position: absolute;
1228
+ inset: 0;
1229
+ border-radius: 32px;
1230
+ border: 1px solid color-mix(in srgb, var(--accent, #3f8ae0) 28%, transparent);
1231
+ pointer-events: none;
1232
+ `;
1233
+ const IdleButtonGlow = styled.span `
1234
+ position: absolute;
1235
+ inset: 8px;
1236
+ border-radius: 28px;
1237
+ background: radial-gradient(
1238
+ 120% 120% at 50% 50%,
1239
+ color-mix(in srgb, var(--accent, #3f8ae0) 24%, transparent) 0%,
1240
+ color-mix(in srgb, var(--accent, #3f8ae0) 10%, transparent) 65%,
1241
+ transparent 100%
1242
+ );
1243
+ filter: blur(4px);
1244
+ pointer-events: none;
1245
+ `;
1246
+ const IdleInstruction = styled.div `
1247
+ font-size: 14px;
1248
+ text-align: center;
1249
+ color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
1250
+ `;
1251
+
1252
+ const IdleControl = (props) => {
1253
+ const { onClick, instruction, startAriaLabel, } = props;
1254
+ 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 })] }));
1255
+ };
1256
+
1257
+ /**
1258
+ * RecordingShell styles
802
1259
  *
803
1260
  * @author: exode <hello@exode.ru>
804
1261
  */
@@ -1022,16 +1479,10 @@ const RecordingShell = (props) => {
1022
1479
  };
1023
1480
 
1024
1481
  /**
1025
- * Upload types
1482
+ * AudioRecorder messages — i18n-ready message definitions and merge utility
1026
1483
  *
1027
1484
  * @author: exode <hello@exode.ru>
1028
1485
  */
1029
- const StorageSpaceEnum = {
1030
- User: 'user',
1031
- Shared: 'shared',
1032
- System: 'system',
1033
- };
1034
-
1035
1486
  const DEFAULT_MESSAGES = {
1036
1487
  buttons: {
1037
1488
  cancel: 'Cancel',
@@ -1071,35 +1522,35 @@ const mergeMessages = (custom) => {
1071
1522
  },
1072
1523
  };
1073
1524
  };
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
- `;
1525
+
1526
+ /**
1527
+ * Upload types
1528
+ *
1529
+ * @author: exode <hello@exode.ru>
1530
+ */
1531
+ const StorageSpaceEnum = {
1532
+ User: 'user',
1533
+ Shared: 'shared',
1534
+ System: 'system',
1535
+ };
1536
+
1081
1537
  const AudioRecorder = (props) => {
1082
1538
  const { space, fileName, uploadId, mimeType, className, uploader, onUploaded, onUploadError, onRecordingStop, onRecordingError, onRecordingStart, onRecordingDelete, onPermissionDenied, autoResetAfterUpload, messages: customMessages, } = props;
1083
1539
  const messages = useMemo(() => mergeMessages(customMessages), [customMessages]);
1084
- const [isSending, setIsSending] = useState(false);
1085
- const shouldSendAfterStopRef = useRef(false);
1086
1540
  const [resolvedUploadId] = useState(() => uploadId !== null && uploadId !== void 0 ? uploadId : (typeof crypto !== 'undefined' && 'randomUUID' in crypto
1087
1541
  ? crypto.randomUUID()
1088
1542
  : `upload-${Date.now()}`));
1089
1543
  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
1544
  const handleRecorderError = useCallback((error) => {
1099
1545
  onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
1100
1546
  onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1101
1547
  }, [onRecordingError, onUploadError]);
1102
- const { audioURL, isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, stopPlayback, previewLevels, recordingTime, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, playbackDuration, toggleRecordingPause, hasRecording, } = useVoiceRecorder({
1548
+ const vibrate = useCallback((pattern) => {
1549
+ if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1550
+ navigator.vibrate(pattern);
1551
+ }
1552
+ }, []);
1553
+ const recorder = useVoiceRecorder({
1103
1554
  fileName,
1104
1555
  mimeType,
1105
1556
  onRecordingStop,
@@ -1111,93 +1562,246 @@ const AudioRecorder = (props) => {
1111
1562
  uploader,
1112
1563
  uploadId: resolvedUploadId,
1113
1564
  onRecordingError: handleRecorderError,
1114
- onUploaded: handleUploaded,
1565
+ onUploaded: onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { }),
1115
1566
  });
1116
- const vibrate = useCallback((pattern) => {
1117
- if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1118
- navigator.vibrate(pattern);
1567
+ const { isSending, handleCancel, handleSendRequest, handleRecordToggle, handlePlaybackToggle, } = useAudioRecorderHandlers({
1568
+ vibrate,
1569
+ recorder,
1570
+ messages,
1571
+ onUploadError,
1572
+ });
1573
+ const { isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1574
+ const showRecorderBar = isRecording || hasRecording;
1575
+ 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 })) }));
1576
+ };
1577
+
1578
+ /**
1579
+ * useVoiceRecorderInput — orchestrates recording, sending and timeline for VoiceRecorderInput
1580
+ *
1581
+ * @author: exode <hello@exode.ru>
1582
+ */
1583
+ const useVoiceRecorderInput = (options) => {
1584
+ const { onSend, onError, onCancel, uploader, onUploaded, onPermissionDenied, autoStart = true, space = StorageSpaceEnum.User, mimeType = 'audio/webm', fileName = 'voice-message.webm', } = options;
1585
+ const [isSending, setIsSending] = useState(false);
1586
+ const [timelineLevels, setTimelineLevels] = useState([]);
1587
+ const audioBlobRef = useRef(null);
1588
+ const audioLevelsRef = useRef([]);
1589
+ const lastDurationRef = useRef(0);
1590
+ const shouldSendAfterStopRef = useRef(false);
1591
+ const doSendRef = useRef();
1592
+ const autoStartedRef = useRef(false);
1593
+ const [uploadId] = useState(() => typeof crypto !== 'undefined' && 'randomUUID' in crypto
1594
+ ? crypto.randomUUID()
1595
+ : `upload-${Date.now()}`);
1596
+ const handleRecordingStop = useCallback((blob) => {
1597
+ var _a;
1598
+ audioBlobRef.current = blob;
1599
+ if (shouldSendAfterStopRef.current) {
1600
+ shouldSendAfterStopRef.current = false;
1601
+ void ((_a = doSendRef.current) === null || _a === void 0 ? void 0 : _a.call(doSendRef));
1119
1602
  }
1120
1603
  }, []);
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);
1604
+ const recorder = useVoiceRecorder({
1605
+ space,
1606
+ fileName,
1607
+ mimeType,
1608
+ uploadId,
1609
+ onPermissionDenied,
1610
+ onRecordingStop: handleRecordingStop,
1611
+ onRecordingError: onError,
1612
+ uploader: onSend ? undefined : uploader,
1613
+ onUploaded: onSend ? (() => { }) : (onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { })),
1614
+ });
1615
+ const { audioLevels, isPaused, isRecording, recordingTime, hasRecording, stopPlayback, sendRecording, stopRecording, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1616
+ audioLevelsRef.current = audioLevels;
1617
+ useEffect(() => {
1618
+ if (recordingTime > 0) {
1619
+ lastDurationRef.current = recordingTime;
1131
1620
  }
1132
- }, [handleRecorderError, isRecording, startRecording, vibrate]);
1133
- const handleRecordToggle = useCallback(() => {
1134
- if (!isRecording) {
1135
- if (audioURL) {
1136
- stopPlayback();
1137
- deleteRecording();
1138
- }
1139
- void startNewRecording();
1621
+ }, [recordingTime]);
1622
+ const isPreview = isPaused || (!isRecording && hasRecording);
1623
+ const isActiveRecording = isRecording && !isPaused;
1624
+ useEffect(() => {
1625
+ if (!isActiveRecording) {
1140
1626
  return;
1141
1627
  }
1142
- if (isPaused) {
1143
- stopPlayback();
1628
+ const interval = setInterval(() => {
1629
+ const levels = audioLevelsRef.current;
1630
+ const peak = levels.length > 0 ? Math.max(...levels) : 0;
1631
+ setTimelineLevels((prev) => [...prev, peak]);
1632
+ }, TIMELINE_SAMPLE_INTERVAL);
1633
+ return () => clearInterval(interval);
1634
+ }, [isActiveRecording]);
1635
+ useEffect(() => {
1636
+ if (isRecording && !isPaused) {
1637
+ setTimelineLevels([]);
1144
1638
  }
1639
+ }, [isRecording]);
1640
+ const handleResume = useCallback(() => {
1641
+ stopPlayback();
1145
1642
  toggleRecordingPause();
1146
- }, [audioURL, deleteRecording, isPaused, isRecording, startNewRecording, stopPlayback, toggleRecordingPause]);
1147
- const handleSend = useCallback(async () => {
1148
- if (isSending) {
1149
- return;
1150
- }
1643
+ }, [
1644
+ stopPlayback,
1645
+ toggleRecordingPause,
1646
+ ]);
1647
+ const handlePause = toggleRecordingPause;
1648
+ const handleCancel = useCallback(() => {
1151
1649
  shouldSendAfterStopRef.current = false;
1650
+ if (isRecording) {
1651
+ cancelRecording();
1652
+ }
1653
+ else if (hasRecording) {
1654
+ stopPlayback();
1655
+ deleteRecording();
1656
+ }
1657
+ onCancel === null || onCancel === void 0 ? void 0 : onCancel();
1658
+ }, [
1659
+ onCancel,
1660
+ isRecording,
1661
+ hasRecording,
1662
+ stopPlayback,
1663
+ cancelRecording,
1664
+ deleteRecording,
1665
+ ]);
1666
+ const doSend = useCallback(async () => {
1152
1667
  setIsSending(true);
1153
1668
  try {
1154
1669
  stopPlayback();
1155
- await sendRecording();
1670
+ if (onSend) {
1671
+ if (!audioBlobRef.current) {
1672
+ return;
1673
+ }
1674
+ const file = new File([audioBlobRef.current], fileName, { type: audioBlobRef.current.type || mimeType });
1675
+ await onSend(file, recordingTime || lastDurationRef.current || 0);
1676
+ deleteRecording();
1677
+ }
1678
+ else {
1679
+ await sendRecording();
1680
+ }
1156
1681
  }
1157
1682
  catch (error) {
1158
- const uploadError = error instanceof Error ? error : new Error(messages.errors.loadFileFailed);
1159
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1683
+ onError === null || onError === void 0 ? void 0 : onError(error);
1160
1684
  }
1161
1685
  finally {
1162
1686
  setIsSending(false);
1163
1687
  }
1164
- }, [isSending, messages.errors.loadFileFailed, onUploadError, sendRecording, stopPlayback]);
1165
- const handleSendRequest = useCallback(() => {
1166
- if (isSending) {
1167
- return;
1168
- }
1169
- if (isRecording) {
1170
- shouldSendAfterStopRef.current = true;
1171
- stopRecording();
1172
- return;
1173
- }
1174
- void handleSend();
1175
- }, [handleSend, isRecording, isSending, stopRecording]);
1176
- const handleCancel = useCallback(() => {
1177
- shouldSendAfterStopRef.current = false;
1178
- if (isRecording) {
1179
- cancelRecording();
1688
+ }, [
1689
+ onSend,
1690
+ onError,
1691
+ fileName,
1692
+ mimeType,
1693
+ recordingTime,
1694
+ sendRecording,
1695
+ stopPlayback,
1696
+ deleteRecording,
1697
+ ]);
1698
+ doSendRef.current = doSend;
1699
+ const handleSendRequest = useCallback(async () => {
1700
+ if (isSending) {
1180
1701
  return;
1181
1702
  }
1182
- if (audioURL) {
1183
- stopPlayback();
1184
- deleteRecording();
1185
- }
1186
- }, [audioURL, cancelRecording, deleteRecording, isRecording, stopPlayback]);
1187
- const handlePlaybackToggle = useCallback(() => {
1188
- if (!hasRecording) {
1703
+ if (isRecording) {
1704
+ shouldSendAfterStopRef.current = true;
1705
+ stopRecording();
1189
1706
  return;
1190
1707
  }
1191
- togglePlayback();
1192
- }, [hasRecording, togglePlayback]);
1193
- const showRecorderBar = isRecording || hasRecording;
1708
+ await doSend();
1709
+ }, [
1710
+ doSend,
1711
+ isRecording,
1712
+ isSending,
1713
+ stopRecording,
1714
+ ]);
1194
1715
  useEffect(() => {
1195
- if (!isRecording && hasRecording && shouldSendAfterStopRef.current) {
1196
- shouldSendAfterStopRef.current = false;
1197
- void handleSend();
1716
+ if (autoStart && !autoStartedRef.current) {
1717
+ autoStartedRef.current = true;
1718
+ void startRecording().catch((error) => {
1719
+ onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
1720
+ });
1198
1721
  }
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 })) }));
1722
+ }, [autoStart]);
1723
+ return {
1724
+ recorder,
1725
+ isSending,
1726
+ isPreview,
1727
+ timelineLevels,
1728
+ lastDurationRef,
1729
+ handleResume,
1730
+ handlePause,
1731
+ handleCancel,
1732
+ handleSendRequest,
1733
+ };
1734
+ };
1735
+
1736
+ const PreviewBars = (props) => {
1737
+ var _a, _b;
1738
+ const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, getVisibleBarsCount, } = props;
1739
+ const maxBars = getVisibleBarsCount();
1740
+ const sourceLevels = timelineLevels.length > 0
1741
+ ? timelineLevels
1742
+ : previewLevels;
1743
+ if (!sourceLevels.length) {
1744
+ return (jsx(Fragment, { children: Array.from({ length: maxBars }, (_, i) => (jsx("div", { style: {
1745
+ width: `${TIMELINE_BAR_WIDTH}px`,
1746
+ height: `${TIMELINE_BAR_MIN_HEIGHT}px`,
1747
+ borderRadius: '9999px',
1748
+ flexShrink: 0,
1749
+ background: 'color-mix(in srgb, var(--text_primary, #0a0a0a) 15%, transparent)',
1750
+ } }, i))) }));
1751
+ }
1752
+ /** Interpolate source levels to fill container */
1753
+ const bars = [];
1754
+ for (let i = 0; i < maxBars; i++) {
1755
+ const srcIndex = (i / maxBars) * sourceLevels.length;
1756
+ const low = Math.floor(srcIndex);
1757
+ const high = Math.min(low + 1, sourceLevels.length - 1);
1758
+ const frac = srcIndex - low;
1759
+ bars.push(((_a = sourceLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac) + ((_b = sourceLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
1760
+ }
1761
+ const totalDuration = recordingTime || lastDuration || 1;
1762
+ const progress = isPlaying ? Math.min(1, playbackTime / totalDuration) : 0;
1763
+ return (jsx(Fragment, { children: bars.map((level, i) => {
1764
+ const normalized = Math.min(100, level);
1765
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1766
+ const isPlayed = progress > 0 && (i / maxBars) <= progress;
1767
+ return (jsx("div", { style: {
1768
+ flexShrink: 0,
1769
+ height: `${height}px`,
1770
+ borderRadius: '9999px',
1771
+ width: `${TIMELINE_BAR_WIDTH}px`,
1772
+ background: !isPlaying
1773
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1774
+ : isPlayed
1775
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)'
1776
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 22%, transparent)',
1777
+ transition: 'background 80ms ease',
1778
+ } }, i));
1779
+ }) }));
1780
+ };
1781
+
1782
+ const TimelineBars = (props) => {
1783
+ const { timelineLevels, getVisibleBarsCount } = props;
1784
+ const maxBars = getVisibleBarsCount();
1785
+ const visible = timelineLevels.slice(-maxBars);
1786
+ const padded = [
1787
+ ...visible,
1788
+ ...Array.from({ length: Math.max(0, maxBars - visible.length) }, () => 0),
1789
+ ];
1790
+ return (jsx(Fragment, { children: padded.map((level, i) => {
1791
+ const normalized = Math.min(100, level);
1792
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1793
+ const hasValue = i < visible.length;
1794
+ return (jsx("div", { style: {
1795
+ width: `${TIMELINE_BAR_WIDTH}px`,
1796
+ height: `${height}px`,
1797
+ borderRadius: '9999px',
1798
+ flexShrink: 0,
1799
+ transition: 'height 120ms ease',
1800
+ background: hasValue
1801
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1802
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent)',
1803
+ } }, i));
1804
+ }) }));
1201
1805
  };
1202
1806
 
1203
1807
  /**
@@ -1336,466 +1940,100 @@ const MicIndicator = styled.button `
1336
1940
  background: color-mix(in srgb, var(--exode_red, #ed1c24) 22%, transparent);
1337
1941
  }
1338
1942
 
1339
- &:active {
1340
- transform: scale(0.94);
1341
- }
1342
- `;
1343
- const PlayPauseButton = styled.button `
1344
- width: 26px;
1345
- height: 26px;
1346
- display: inline-flex;
1347
- align-items: center;
1348
- justify-content: center;
1349
- 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);
1943
+ &:active {
1944
+ transform: scale(0.94);
1945
+ }
1946
+ `;
1947
+ const PlayPauseButton = styled.button `
1948
+ width: 26px;
1949
+ height: 26px;
1950
+ display: inline-flex;
1951
+ align-items: center;
1952
+ justify-content: center;
1953
+ border-radius: 13px;
1954
+ border: 0;
1955
+ flex-shrink: 0;
1956
+ cursor: pointer;
1957
+ background: var(--background_content, #ffffff);
1958
+ color: var(--text_primary, #0a0a0a);
1959
+ transition: transform 120ms ease;
1960
+
1961
+ &:hover {
1962
+ transform: scale(1.06);
1963
+ }
1964
+
1965
+ &:active {
1966
+ transform: scale(0.94);
1967
+ }
1968
+ `;
1969
+ const SpinnerIcon = styled.span `
1970
+ width: 20px;
1971
+ height: 20px;
1972
+ display: inline-block;
1973
+ border-radius: 50%;
1974
+ border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
1975
+ border-top-color: var(--accent, #3f8ae0);
1976
+ border-right-color: var(--accent, #3f8ae0);
1977
+ animation: voiceRecorderSpin 0.75s linear infinite;
1978
+
1979
+ @keyframes voiceRecorderSpin {
1980
+ from { transform: rotate(0deg); }
1981
+ to { transform: rotate(360deg); }
1982
+ }
1983
+ `;
1984
+
1985
+ const VoiceRecorderInput = (props) => {
1986
+ const { className, ...inputOptions } = props;
1987
+ const waveformRef = useRef(null);
1988
+ const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
1989
+ const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
1990
+ const getVisibleBarsCount = useCallback(() => {
1991
+ const el = waveformRef.current;
1992
+ if (!el) {
1993
+ return 60;
1576
1994
  }
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 */
1995
+ return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
1996
+ }, []);
1598
1997
  const displayTime = (isPreview && isPlaying)
1599
1998
  ? formatTime(playbackTime)
1600
1999
  : formatTime(recordingTime || lastDurationRef.current);
1601
2000
  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
2001
  ? 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, {})) })] }));
2002
+ : 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
2003
  };
1605
2004
 
1606
2005
  /**
1607
- * Global audio playback manager — ensures only one audio plays at a time
2006
+ * VoicePlaybackChain
1608
2007
  *
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
2008
+ * @description Sequential voice message playback chain (like Telegram).
2009
+ * Registers voice players by media ID and plays the next one when current ends.
1632
2010
  *
1633
2011
  * @author: exode <hello@exode.ru>
1634
2012
  */
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;
2013
+ class VoicePlaybackChainClass {
2014
+ constructor() {
2015
+ this.entries = [];
2016
+ /** Register a voice player. Returns cleanup function for useEffect. */
2017
+ this.register = (id, play) => {
2018
+ this.entries = this.entries.filter((e) => e.id !== id);
2019
+ this.entries.push({ id, play });
2020
+ this.entries.sort((a, b) => a.id - b.id);
2021
+ return () => this.unregister(id);
2022
+ };
2023
+ /** Unregister a voice player by media ID */
2024
+ this.unregister = (id) => {
2025
+ this.entries = this.entries.filter((e) => e.id !== id);
2026
+ };
2027
+ /** Play the next voice message after the given media ID */
2028
+ this.playNext = (currentId) => {
2029
+ const idx = this.entries.findIndex((e) => e.id === currentId);
2030
+ if (idx >= 0 && idx < this.entries.length - 1) {
2031
+ this.entries[idx + 1].play();
1771
2032
  }
1772
- setIsLoaded(false);
1773
2033
  };
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
- };
2034
+ }
2035
+ }
2036
+ const VoicePlaybackChain = new VoicePlaybackChainClass();
1799
2037
 
1800
2038
  /**
1801
2039
  * VoiceMessagePlayer styles
@@ -1889,11 +2127,30 @@ const BAR_WIDTH = 3;
1889
2127
  const BAR_GAP = 2;
1890
2128
  const BAR_MIN_HEIGHT = 2;
1891
2129
  const BAR_MAX_HEIGHT = 24;
1892
- const VoiceMessagePlayer = (props) => {
1893
- const { src, onClick, duration, className, waveformData, } = props;
2130
+ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2131
+ const { src, onClick, onEnded, chainId, duration, className, waveformData, } = props;
1894
2132
  const trackRef = useRef(null);
1895
2133
  const [trackWidth, setTrackWidth] = useState(0);
1896
- const { progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({ src, duration, waveformData });
2134
+ const handleEnded = useCallback(() => {
2135
+ onEnded === null || onEnded === void 0 ? void 0 : onEnded();
2136
+ if (chainId != null) {
2137
+ VoicePlaybackChain.playNext(chainId);
2138
+ }
2139
+ }, [onEnded, chainId]);
2140
+ const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2141
+ src,
2142
+ duration,
2143
+ waveformData,
2144
+ preloadedBlob: props.preloadedBlob,
2145
+ onEnded: handleEnded,
2146
+ });
2147
+ useImperativeHandle(ref, () => ({ play }), [play]);
2148
+ /** Register in playback chain for sequential playback */
2149
+ useEffect(() => {
2150
+ if (chainId != null) {
2151
+ return VoicePlaybackChain.register(chainId, () => play());
2152
+ }
2153
+ }, [chainId, play]);
1897
2154
  /** Measure track width once after mount */
1898
2155
  useLayoutEffect(() => {
1899
2156
  if (trackRef.current) {
@@ -1934,7 +2191,7 @@ const VoiceMessagePlayer = (props) => {
1934
2191
  const peak = Math.max(...raw, 1);
1935
2192
  return raw.map((v) => (v / peak) * 100);
1936
2193
  }, [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) => {
2194
+ return (jsxs(PlayerWrapper, { 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
2195
  const normalized = Math.min(100, level);
1939
2196
  const height = BAR_MIN_HEIGHT + (normalized / 100) * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT);
1940
2197
  const barProgress = interpolatedBars.length > 0
@@ -1948,7 +2205,40 @@ const VoiceMessagePlayer = (props) => {
1948
2205
  : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 25%, transparent)',
1949
2206
  } }, i));
1950
2207
  }) }), jsx(Duration, { children: displayTime })] })] }));
1951
- };
2208
+ });
2209
+
2210
+ /**
2211
+ * VoiceBlobCache
2212
+ *
2213
+ * @description In-memory cache for voice message blobs.
2214
+ * Allows instant playback of just-sent voice messages
2215
+ * before the server URL becomes available.
2216
+ *
2217
+ * @author: exode <hello@exode.ru>
2218
+ */
2219
+ class VoiceBlobCacheClass {
2220
+ constructor() {
2221
+ this.cache = new Map();
2222
+ this.set = (messageId, blob) => {
2223
+ this.cache.set(messageId, blob);
2224
+ };
2225
+ this.get = (messageId) => {
2226
+ return this.cache.get(messageId);
2227
+ };
2228
+ this.delete = (messageId) => {
2229
+ this.cache.delete(messageId);
2230
+ };
2231
+ /** Transfer blob from optimistic ID to real server ID */
2232
+ this.transfer = (fromId, toId) => {
2233
+ const blob = this.cache.get(fromId);
2234
+ if (blob) {
2235
+ this.cache.delete(fromId);
2236
+ this.cache.set(toId, blob);
2237
+ }
2238
+ };
2239
+ }
2240
+ }
2241
+ const VoiceBlobCache = new VoiceBlobCacheClass();
1952
2242
 
1953
- export { AUDIO_LEVEL_BARS, AudioRecorder, StorageSpaceEnum, VoiceMessagePlayer, VoiceRecorderInput, WaveformBars, clampValue, decodeAudioLevels, formatTime, useRecorderVisualization, useRecordingTiming, useVoiceMessagePlayer, useVoiceRecorder };
2243
+ export { AUDIO_LEVEL_BARS, AudioRecorder, StorageSpaceEnum, VoiceBlobCache, VoiceMessagePlayer, VoicePlaybackChain, VoiceRecorderInput, WaveformBars, clampValue, formatTime, useRecorderVisualization, useRecordingTiming, useVoiceMessagePlayer, useVoiceRecorder };
1954
2244
  //# sourceMappingURL=index.js.map