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