@exode-team/react-recorder 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +84 -2
  2. package/dist/components/AudioRecorder.d.ts +1 -24
  3. package/dist/components/AudioRecorder.d.ts.map +1 -1
  4. package/dist/components/AudioRecorder.messages.d.ts +32 -0
  5. package/dist/components/AudioRecorder.messages.d.ts.map +1 -0
  6. package/dist/components/AudioRecorder.styled.d.ts +7 -0
  7. package/dist/components/AudioRecorder.styled.d.ts.map +1 -0
  8. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.d.ts +4 -8
  9. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.d.ts.map +1 -1
  10. package/dist/components/VoiceRecorderInput/PreviewBars.d.ts +17 -0
  11. package/dist/components/VoiceRecorderInput/PreviewBars.d.ts.map +1 -0
  12. package/dist/components/VoiceRecorderInput/TimelineBars.d.ts +12 -0
  13. package/dist/components/VoiceRecorderInput/TimelineBars.d.ts.map +1 -0
  14. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.d.ts +2 -2
  15. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.d.ts.map +1 -1
  16. package/dist/constants.d.ts +5 -0
  17. package/dist/constants.d.ts.map +1 -1
  18. package/dist/hooks/index.d.ts +3 -0
  19. package/dist/hooks/index.d.ts.map +1 -1
  20. package/dist/hooks/useAudioBlobLoader.d.ts +23 -0
  21. package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -0
  22. package/dist/hooks/useAudioPlayback.d.ts.map +1 -1
  23. package/dist/hooks/useAudioRecorderHandlers.d.ts +22 -0
  24. package/dist/hooks/useAudioRecorderHandlers.d.ts.map +1 -0
  25. package/dist/hooks/useMediaRecorder.d.ts +1 -10
  26. package/dist/hooks/useMediaRecorder.d.ts.map +1 -1
  27. package/dist/hooks/useRecorderVisualization.d.ts.map +1 -1
  28. package/dist/hooks/useRecordingSender.d.ts +23 -0
  29. package/dist/hooks/useRecordingSender.d.ts.map +1 -0
  30. package/dist/hooks/useVoiceMessagePlayer.d.ts +1 -18
  31. package/dist/hooks/useVoiceMessagePlayer.d.ts.map +1 -1
  32. package/dist/hooks/useVoiceRecorder.d.ts.map +1 -1
  33. package/dist/hooks/useVoiceRecorderInput.d.ts +31 -0
  34. package/dist/hooks/useVoiceRecorderInput.d.ts.map +1 -0
  35. package/dist/index.cjs +1185 -862
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1086 -764
  40. package/dist/index.js.map +1 -1
  41. package/dist/platform/audio-blob-fetcher.d.ts +7 -0
  42. package/dist/platform/audio-blob-fetcher.d.ts.map +1 -0
  43. package/dist/platform/index.d.ts +1 -0
  44. package/dist/platform/index.d.ts.map +1 -1
  45. package/dist/types/index.d.ts +1 -1
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/dist/types/recorder.d.ts +31 -0
  48. package/dist/types/recorder.d.ts.map +1 -1
  49. package/dist/utils/voice-blob-cache.d.ts +20 -0
  50. package/dist/utils/voice-blob-cache.d.ts.map +1 -0
  51. package/dist/utils/voice-playback-chain.d.ts +20 -0
  52. package/dist/utils/voice-playback-chain.d.ts.map +1 -0
  53. package/package.json +1 -1
package/dist/index.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,521 @@ 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
+ /** Pre-fetch audio without playing (for viewport preloading) */
966
+ const prefetch = React.useCallback(() => {
967
+ if (isLoading || audioRef.current) {
968
+ return;
969
+ }
970
+ void ensureAudioLoaded();
971
+ }, [isLoading, ensureAudioLoaded]);
972
+ /** Load audio and auto-play after loaded */
973
+ const preload = React.useCallback(() => {
974
+ if (isLoading || audioRef.current) {
975
+ return;
976
+ }
977
+ audioPlaybackManager.stopOthers(stopThis);
978
+ void ensureAudioLoaded().then(() => {
979
+ const audio = audioRef.current;
980
+ if (audio) {
981
+ audio.play()
982
+ .then(() => setIsPlaying(true))
983
+ .catch(() => setIsPlaying(false));
984
+ }
985
+ });
986
+ }, [isLoading, ensureAudioLoaded, stopThis, audioRef]);
987
+ /** Programmatically start playback (used for chained playback) */
988
+ const play = React.useCallback(() => {
989
+ const audio = audioRef.current;
990
+ if (audio) {
991
+ audioPlaybackManager.stopOthers(stopThis);
992
+ audio.play()
993
+ .then(() => setIsPlaying(true))
994
+ .catch(() => setIsPlaying(false));
995
+ }
996
+ else {
997
+ preload();
998
+ }
999
+ }, [stopThis, preload, audioRef]);
1000
+ /** Toggle play/pause (only works after audio is loaded) */
1001
+ const togglePlayback = React.useCallback(() => {
1002
+ const audio = audioRef.current;
1003
+ if (!audio || isLoading) {
1004
+ return;
1005
+ }
1006
+ if (audio.paused) {
1007
+ audioPlaybackManager.stopOthers(stopThis);
1008
+ audio.play()
1009
+ .then(() => setIsPlaying(true))
1010
+ .catch(() => setIsPlaying(false));
1011
+ }
1012
+ else {
1013
+ audio.pause();
1014
+ setIsPlaying(false);
1015
+ }
1016
+ }, [isLoading, stopThis, audioRef]);
1017
+ const seekTo = React.useCallback((seekProgress) => {
1018
+ const audio = audioRef.current;
1019
+ if (!audio || !isFinite(audio.duration)) {
1020
+ return;
1021
+ }
1022
+ const time = seekProgress * audio.duration;
1023
+ audio.currentTime = time;
1024
+ setCurrentTime(time);
1025
+ }, [audioRef]);
1026
+ /** Sync external duration */
1027
+ React.useEffect(() => {
1028
+ if (externalDuration) {
1029
+ setDuration(externalDuration);
1030
+ }
1031
+ }, [externalDuration]);
1032
+ return {
1033
+ play,
1034
+ progress,
1035
+ duration,
1036
+ isLoaded,
1037
+ isPlaying,
1038
+ isLoading,
1039
+ currentTime,
1040
+ waveformLevels,
1041
+ seekTo,
1042
+ prefetch,
1043
+ preload,
1044
+ formatTime,
1045
+ togglePlayback,
1046
+ };
1047
+ };
1048
+
1049
+ /**
1050
+ * useAudioRecorderHandlers — recording/send/cancel handlers for AudioRecorder
1051
+ *
1052
+ * @author: exode <hello@exode.ru>
1053
+ */
1054
+ const useAudioRecorderHandlers = (options) => {
1055
+ const { vibrate, recorder, messages, onUploadError, } = options;
1056
+ const { audioURL, isPaused, isRecording, hasRecording, stopPlayback, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1057
+ const shouldSendAfterStopRef = React.useRef(false);
1058
+ const [isSending, setIsSending] = React.useState(false);
1059
+ const startNewRecording = React.useCallback(async () => {
1060
+ if (isRecording) {
1061
+ return;
1062
+ }
1063
+ try {
1064
+ await startRecording();
1065
+ vibrate(50);
1066
+ }
1067
+ catch (error) {
1068
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1069
+ }
1070
+ }, [
1071
+ vibrate,
1072
+ isRecording,
1073
+ onUploadError,
1074
+ startRecording,
1075
+ ]);
1076
+ const handleRecordToggle = React.useCallback(() => {
1077
+ if (!isRecording) {
1078
+ if (audioURL) {
1079
+ stopPlayback();
1080
+ deleteRecording();
1081
+ }
1082
+ void startNewRecording();
1083
+ return;
1084
+ }
1085
+ if (isPaused) {
1086
+ stopPlayback();
1087
+ }
1088
+ toggleRecordingPause();
1089
+ }, [
1090
+ audioURL,
1091
+ isPaused,
1092
+ isRecording,
1093
+ stopPlayback,
1094
+ deleteRecording,
1095
+ startNewRecording,
1096
+ toggleRecordingPause,
1097
+ ]);
1098
+ const handleSend = React.useCallback(async () => {
1099
+ if (isSending) {
1100
+ return;
1101
+ }
1102
+ shouldSendAfterStopRef.current = false;
1103
+ setIsSending(true);
1104
+ try {
1105
+ stopPlayback();
1106
+ await sendRecording();
1107
+ }
1108
+ catch (error) {
1109
+ const uploadError = error instanceof Error
1110
+ ? error
1111
+ : new Error(messages.errors.loadFileFailed);
1112
+ onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1113
+ }
1114
+ finally {
1115
+ setIsSending(false);
1116
+ }
1117
+ }, [
1118
+ isSending,
1119
+ stopPlayback,
1120
+ onUploadError,
1121
+ sendRecording,
1122
+ messages.errors.loadFileFailed,
1123
+ ]);
1124
+ const handleSendRequest = React.useCallback(() => {
1125
+ if (isSending) {
1126
+ return;
1127
+ }
1128
+ if (isRecording) {
1129
+ shouldSendAfterStopRef.current = true;
1130
+ stopRecording();
1131
+ return;
1132
+ }
1133
+ void handleSend();
1134
+ }, [
1135
+ isSending,
1136
+ handleSend,
1137
+ isRecording,
1138
+ stopRecording,
1139
+ ]);
1140
+ const handleCancel = React.useCallback(() => {
1141
+ shouldSendAfterStopRef.current = false;
1142
+ if (isRecording) {
1143
+ cancelRecording();
1144
+ return;
1145
+ }
1146
+ if (audioURL) {
1147
+ stopPlayback();
1148
+ deleteRecording();
1149
+ }
1150
+ }, [
1151
+ audioURL,
1152
+ isRecording,
1153
+ stopPlayback,
1154
+ deleteRecording,
1155
+ cancelRecording,
1156
+ ]);
1157
+ const handlePlaybackToggle = React.useCallback(() => {
1158
+ if (!hasRecording) {
1159
+ return;
1160
+ }
1161
+ togglePlayback();
1162
+ }, [hasRecording, togglePlayback]);
1163
+ /** Auto-send after stop */
1164
+ React.useEffect(() => {
1165
+ if (!isRecording
1166
+ && hasRecording
1167
+ && shouldSendAfterStopRef.current) {
1168
+ shouldSendAfterStopRef.current = false;
1169
+ void handleSend();
1170
+ }
1171
+ }, [
1172
+ handleSend,
1173
+ isRecording,
1174
+ hasRecording,
1175
+ ]);
1176
+ return {
1177
+ isSending,
1178
+ handleCancel,
1179
+ handleSendRequest,
1180
+ handleRecordToggle,
1181
+ handlePlaybackToggle,
1182
+ };
1183
+ };
1184
+
1185
+ /**
1186
+ * AudioRecorder styled components
1187
+ *
1188
+ * @author: exode <hello@exode.ru>
1189
+ */
1190
+ const Container = styled.div `
1191
+ position: relative;
1192
+ width: 100%;
1193
+ padding: 16px;
1194
+ transition: color 200ms ease, background-color 200ms ease;
1195
+ color: var(--text_primary, #0a0a0a);
1196
+ `;
1197
+
1198
+ /**
1199
+ * IdleControl styles
1200
+ *
1201
+ * @author: exode <hello@exode.ru>
1202
+ */
1203
+ const IdleWrapper = styled.div `
1204
+ display: flex;
1205
+ flex-direction: column;
1206
+ align-items: center;
1207
+ gap: 16px;
1208
+ `;
1209
+ const IdleButton = styled.button `
1210
+ position: relative;
1211
+ width: 72px;
1212
+ height: 72px;
1213
+ display: inline-flex;
1214
+ align-items: center;
1215
+ justify-content: center;
1216
+ border-radius: 36px;
1217
+ border: 0;
1218
+ background: var(--background_content, #ffffff);
1219
+ color: color-mix(in srgb, var(--accent, #3f8ae0) 80%, white 20%);
1220
+ cursor: pointer;
1221
+ transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
1222
+
1223
+ &:hover {
1224
+ transform: translateY(-2px);
1225
+ }
1226
+
1227
+ &:active {
1228
+ transform: translateY(0);
1229
+ }
1230
+
1231
+ &:focus-visible {
1232
+ outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
1233
+ outline-offset: 4px;
1234
+ }
1235
+ `;
1236
+ const IdleButtonOutline = styled.span `
1237
+ position: absolute;
1238
+ inset: 0;
1239
+ border-radius: 32px;
1240
+ border: 1px solid color-mix(in srgb, var(--accent, #3f8ae0) 28%, transparent);
1241
+ pointer-events: none;
1242
+ `;
1243
+ const IdleButtonGlow = styled.span `
1244
+ position: absolute;
1245
+ inset: 8px;
1246
+ border-radius: 28px;
1247
+ background: radial-gradient(
1248
+ 120% 120% at 50% 50%,
1249
+ color-mix(in srgb, var(--accent, #3f8ae0) 24%, transparent) 0%,
1250
+ color-mix(in srgb, var(--accent, #3f8ae0) 10%, transparent) 65%,
1251
+ transparent 100%
1252
+ );
1253
+ filter: blur(4px);
1254
+ pointer-events: none;
1255
+ `;
1256
+ const IdleInstruction = styled.div `
1257
+ font-size: 14px;
1258
+ text-align: center;
1259
+ color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
1260
+ `;
1261
+
1262
+ const IdleControl = (props) => {
1263
+ const { onClick, instruction, startAriaLabel, } = props;
1264
+ 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 })] }));
1265
+ };
1266
+
1267
+ /**
1268
+ * RecordingShell styles
1269
+ *
1270
+ * @author: exode <hello@exode.ru>
1271
+ */
1272
+ const Wrapper$1 = styled.div `
1273
+ display: flex;
1274
+ align-items: center;
1275
+ gap: 12px;
1276
+ padding: 10px 14px;
1277
+ border-radius: 9999px;
1278
+ background: var(--background_content, #ffffff);
1279
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent);
1280
+ `;
1281
+ const CancelButton$1 = styled.button `
1282
+ width: 36px;
1283
+ height: 36px;
1284
+ display: inline-flex;
1285
+ align-items: center;
1286
+ justify-content: center;
1287
+ border-radius: 18px;
1288
+ background: transparent;
824
1289
  border: 0;
825
1290
  color: color-mix(in srgb, var(--text_primary, #0a0a0a) 54%, transparent);
826
1291
  cursor: pointer;
@@ -986,7 +1451,7 @@ const WaveformBars = (props) => {
986
1451
  const RecordingShell = (props) => {
987
1452
  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
1453
  const waveformValues = isRecording ? audioLevels : (hasRecording ? previewLevels : audioLevels);
989
- const displayTime = react.useMemo(() => {
1454
+ const displayTime = React.useMemo(() => {
990
1455
  if (isRecording) {
991
1456
  return formatTime(recordingTime);
992
1457
  }
@@ -1024,16 +1489,10 @@ const RecordingShell = (props) => {
1024
1489
  };
1025
1490
 
1026
1491
  /**
1027
- * Upload types
1492
+ * AudioRecorder messages — i18n-ready message definitions and merge utility
1028
1493
  *
1029
1494
  * @author: exode <hello@exode.ru>
1030
1495
  */
1031
- const StorageSpaceEnum = {
1032
- User: 'user',
1033
- Shared: 'shared',
1034
- System: 'system',
1035
- };
1036
-
1037
1496
  const DEFAULT_MESSAGES = {
1038
1497
  buttons: {
1039
1498
  cancel: 'Cancel',
@@ -1073,35 +1532,35 @@ const mergeMessages = (custom) => {
1073
1532
  },
1074
1533
  };
1075
1534
  };
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
- `;
1535
+
1536
+ /**
1537
+ * Upload types
1538
+ *
1539
+ * @author: exode <hello@exode.ru>
1540
+ */
1541
+ const StorageSpaceEnum = {
1542
+ User: 'user',
1543
+ Shared: 'shared',
1544
+ System: 'system',
1545
+ };
1546
+
1083
1547
  const AudioRecorder = (props) => {
1084
1548
  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
1549
+ const messages = React.useMemo(() => mergeMessages(customMessages), [customMessages]);
1550
+ const [resolvedUploadId] = React.useState(() => uploadId !== null && uploadId !== void 0 ? uploadId : (typeof crypto !== 'undefined' && 'randomUUID' in crypto
1089
1551
  ? crypto.randomUUID()
1090
1552
  : `upload-${Date.now()}`));
1091
1553
  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) => {
1554
+ const handleRecorderError = React.useCallback((error) => {
1101
1555
  onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
1102
1556
  onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1103
1557
  }, [onRecordingError, onUploadError]);
1104
- const { audioURL, isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, stopPlayback, previewLevels, recordingTime, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, playbackDuration, toggleRecordingPause, hasRecording, } = useVoiceRecorder({
1558
+ const vibrate = React.useCallback((pattern) => {
1559
+ if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1560
+ navigator.vibrate(pattern);
1561
+ }
1562
+ }, []);
1563
+ const recorder = useVoiceRecorder({
1105
1564
  fileName,
1106
1565
  mimeType,
1107
1566
  onRecordingStop,
@@ -1113,58 +1572,141 @@ const AudioRecorder = (props) => {
1113
1572
  uploader,
1114
1573
  uploadId: resolvedUploadId,
1115
1574
  onRecordingError: handleRecorderError,
1116
- onUploaded: handleUploaded,
1575
+ onUploaded: onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { }),
1117
1576
  });
1118
- const vibrate = react.useCallback((pattern) => {
1119
- if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1120
- navigator.vibrate(pattern);
1577
+ const { isSending, handleCancel, handleSendRequest, handleRecordToggle, handlePlaybackToggle, } = useAudioRecorderHandlers({
1578
+ vibrate,
1579
+ recorder,
1580
+ messages,
1581
+ onUploadError,
1582
+ });
1583
+ const { isPaused, isPlaying, formatTime, audioLevels, isRecording, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1584
+ const showRecorderBar = isRecording || hasRecording;
1585
+ 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 })) }));
1586
+ };
1587
+
1588
+ /**
1589
+ * useVoiceRecorderInput — orchestrates recording, sending and timeline for VoiceRecorderInput
1590
+ *
1591
+ * @author: exode <hello@exode.ru>
1592
+ */
1593
+ const useVoiceRecorderInput = (options) => {
1594
+ const { onSend, onError, onCancel, uploader, onUploaded, onPermissionDenied, autoStart = true, space = StorageSpaceEnum.User, mimeType = 'audio/webm', fileName = 'voice-message.webm', } = options;
1595
+ const [isSending, setIsSending] = React.useState(false);
1596
+ const [timelineLevels, setTimelineLevels] = React.useState([]);
1597
+ const audioBlobRef = React.useRef(null);
1598
+ const audioLevelsRef = React.useRef([]);
1599
+ const lastDurationRef = React.useRef(0);
1600
+ const shouldSendAfterStopRef = React.useRef(false);
1601
+ const doSendRef = React.useRef();
1602
+ const autoStartedRef = React.useRef(false);
1603
+ const [uploadId] = React.useState(() => typeof crypto !== 'undefined' && 'randomUUID' in crypto
1604
+ ? crypto.randomUUID()
1605
+ : `upload-${Date.now()}`);
1606
+ const handleRecordingStop = React.useCallback((blob) => {
1607
+ var _a;
1608
+ audioBlobRef.current = blob;
1609
+ if (shouldSendAfterStopRef.current) {
1610
+ shouldSendAfterStopRef.current = false;
1611
+ void ((_a = doSendRef.current) === null || _a === void 0 ? void 0 : _a.call(doSendRef));
1121
1612
  }
1122
1613
  }, []);
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);
1614
+ const recorder = useVoiceRecorder({
1615
+ space,
1616
+ fileName,
1617
+ mimeType,
1618
+ uploadId,
1619
+ onPermissionDenied,
1620
+ onRecordingStop: handleRecordingStop,
1621
+ onRecordingError: onError,
1622
+ uploader: onSend ? undefined : uploader,
1623
+ onUploaded: onSend ? (() => { }) : (onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { })),
1624
+ });
1625
+ const { audioLevels, isPaused, isRecording, recordingTime, hasRecording, stopPlayback, sendRecording, stopRecording, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1626
+ audioLevelsRef.current = audioLevels;
1627
+ React.useEffect(() => {
1628
+ if (recordingTime > 0) {
1629
+ lastDurationRef.current = recordingTime;
1133
1630
  }
1134
- }, [handleRecorderError, isRecording, startRecording, vibrate]);
1135
- const handleRecordToggle = react.useCallback(() => {
1136
- if (!isRecording) {
1137
- if (audioURL) {
1138
- stopPlayback();
1139
- deleteRecording();
1140
- }
1141
- void startNewRecording();
1631
+ }, [recordingTime]);
1632
+ const isPreview = isPaused || (!isRecording && hasRecording);
1633
+ const isActiveRecording = isRecording && !isPaused;
1634
+ React.useEffect(() => {
1635
+ if (!isActiveRecording) {
1142
1636
  return;
1143
1637
  }
1144
- if (isPaused) {
1145
- stopPlayback();
1638
+ const interval = setInterval(() => {
1639
+ const levels = audioLevelsRef.current;
1640
+ const peak = levels.length > 0 ? Math.max(...levels) : 0;
1641
+ setTimelineLevels((prev) => [...prev, peak]);
1642
+ }, TIMELINE_SAMPLE_INTERVAL);
1643
+ return () => clearInterval(interval);
1644
+ }, [isActiveRecording]);
1645
+ React.useEffect(() => {
1646
+ if (isRecording && !isPaused) {
1647
+ setTimelineLevels([]);
1146
1648
  }
1649
+ }, [isRecording]);
1650
+ const handleResume = React.useCallback(() => {
1651
+ stopPlayback();
1147
1652
  toggleRecordingPause();
1148
- }, [audioURL, deleteRecording, isPaused, isRecording, startNewRecording, stopPlayback, toggleRecordingPause]);
1149
- const handleSend = react.useCallback(async () => {
1150
- if (isSending) {
1151
- return;
1152
- }
1653
+ }, [
1654
+ stopPlayback,
1655
+ toggleRecordingPause,
1656
+ ]);
1657
+ const handlePause = toggleRecordingPause;
1658
+ const handleCancel = React.useCallback(() => {
1153
1659
  shouldSendAfterStopRef.current = false;
1660
+ if (isRecording) {
1661
+ cancelRecording();
1662
+ }
1663
+ else if (hasRecording) {
1664
+ stopPlayback();
1665
+ deleteRecording();
1666
+ }
1667
+ onCancel === null || onCancel === void 0 ? void 0 : onCancel();
1668
+ }, [
1669
+ onCancel,
1670
+ isRecording,
1671
+ hasRecording,
1672
+ stopPlayback,
1673
+ cancelRecording,
1674
+ deleteRecording,
1675
+ ]);
1676
+ const doSend = React.useCallback(async () => {
1154
1677
  setIsSending(true);
1155
1678
  try {
1156
1679
  stopPlayback();
1157
- await sendRecording();
1680
+ if (onSend) {
1681
+ if (!audioBlobRef.current) {
1682
+ return;
1683
+ }
1684
+ const file = new File([audioBlobRef.current], fileName, { type: audioBlobRef.current.type || mimeType });
1685
+ await onSend(file, recordingTime || lastDurationRef.current || 0);
1686
+ deleteRecording();
1687
+ }
1688
+ else {
1689
+ await sendRecording();
1690
+ }
1158
1691
  }
1159
1692
  catch (error) {
1160
- const uploadError = error instanceof Error ? error : new Error(messages.errors.loadFileFailed);
1161
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1693
+ onError === null || onError === void 0 ? void 0 : onError(error);
1162
1694
  }
1163
1695
  finally {
1164
1696
  setIsSending(false);
1165
1697
  }
1166
- }, [isSending, messages.errors.loadFileFailed, onUploadError, sendRecording, stopPlayback]);
1167
- const handleSendRequest = react.useCallback(() => {
1698
+ }, [
1699
+ onSend,
1700
+ onError,
1701
+ fileName,
1702
+ mimeType,
1703
+ recordingTime,
1704
+ sendRecording,
1705
+ stopPlayback,
1706
+ deleteRecording,
1707
+ ]);
1708
+ doSendRef.current = doSend;
1709
+ const handleSendRequest = React.useCallback(async () => {
1168
1710
  if (isSending) {
1169
1711
  return;
1170
1712
  }
@@ -1173,33 +1715,103 @@ const AudioRecorder = (props) => {
1173
1715
  stopRecording();
1174
1716
  return;
1175
1717
  }
1176
- void handleSend();
1177
- }, [handleSend, isRecording, isSending, stopRecording]);
1178
- const handleCancel = react.useCallback(() => {
1179
- shouldSendAfterStopRef.current = false;
1180
- if (isRecording) {
1181
- cancelRecording();
1182
- return;
1183
- }
1184
- if (audioURL) {
1185
- stopPlayback();
1186
- deleteRecording();
1187
- }
1188
- }, [audioURL, cancelRecording, deleteRecording, isRecording, stopPlayback]);
1189
- const handlePlaybackToggle = react.useCallback(() => {
1190
- if (!hasRecording) {
1191
- return;
1192
- }
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();
1718
+ await doSend();
1719
+ }, [
1720
+ doSend,
1721
+ isRecording,
1722
+ isSending,
1723
+ stopRecording,
1724
+ ]);
1725
+ React.useEffect(() => {
1726
+ if (autoStart && !autoStartedRef.current) {
1727
+ autoStartedRef.current = true;
1728
+ void startRecording().catch((error) => {
1729
+ onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
1730
+ });
1200
1731
  }
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 })) }));
1732
+ }, [autoStart]);
1733
+ return {
1734
+ recorder,
1735
+ isSending,
1736
+ isPreview,
1737
+ timelineLevels,
1738
+ lastDurationRef,
1739
+ handleResume,
1740
+ handlePause,
1741
+ handleCancel,
1742
+ handleSendRequest,
1743
+ };
1744
+ };
1745
+
1746
+ const PreviewBars = (props) => {
1747
+ var _a, _b;
1748
+ const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, getVisibleBarsCount, } = props;
1749
+ const maxBars = getVisibleBarsCount();
1750
+ const sourceLevels = timelineLevels.length > 0
1751
+ ? timelineLevels
1752
+ : previewLevels;
1753
+ if (!sourceLevels.length) {
1754
+ return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: Array.from({ length: maxBars }, (_, i) => (jsxRuntime.jsx("div", { style: {
1755
+ width: `${TIMELINE_BAR_WIDTH}px`,
1756
+ height: `${TIMELINE_BAR_MIN_HEIGHT}px`,
1757
+ borderRadius: '9999px',
1758
+ flexShrink: 0,
1759
+ background: 'color-mix(in srgb, var(--text_primary, #0a0a0a) 15%, transparent)',
1760
+ } }, i))) }));
1761
+ }
1762
+ /** Interpolate source levels to fill container */
1763
+ const bars = [];
1764
+ for (let i = 0; i < maxBars; i++) {
1765
+ const srcIndex = (i / maxBars) * sourceLevels.length;
1766
+ const low = Math.floor(srcIndex);
1767
+ const high = Math.min(low + 1, sourceLevels.length - 1);
1768
+ const frac = srcIndex - low;
1769
+ bars.push(((_a = sourceLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac) + ((_b = sourceLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
1770
+ }
1771
+ const totalDuration = recordingTime || lastDuration || 1;
1772
+ const progress = isPlaying ? Math.min(1, playbackTime / totalDuration) : 0;
1773
+ return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: bars.map((level, i) => {
1774
+ const normalized = Math.min(100, level);
1775
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1776
+ const isPlayed = progress > 0 && (i / maxBars) <= progress;
1777
+ return (jsxRuntime.jsx("div", { style: {
1778
+ flexShrink: 0,
1779
+ height: `${height}px`,
1780
+ borderRadius: '9999px',
1781
+ width: `${TIMELINE_BAR_WIDTH}px`,
1782
+ background: !isPlaying
1783
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1784
+ : isPlayed
1785
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)'
1786
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 22%, transparent)',
1787
+ transition: 'background 80ms ease',
1788
+ } }, i));
1789
+ }) }));
1790
+ };
1791
+
1792
+ const TimelineBars = (props) => {
1793
+ const { timelineLevels, getVisibleBarsCount } = props;
1794
+ const maxBars = getVisibleBarsCount();
1795
+ const visible = timelineLevels.slice(-maxBars);
1796
+ const padded = [
1797
+ ...visible,
1798
+ ...Array.from({ length: Math.max(0, maxBars - visible.length) }, () => 0),
1799
+ ];
1800
+ return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: padded.map((level, i) => {
1801
+ const normalized = Math.min(100, level);
1802
+ const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
1803
+ const hasValue = i < visible.length;
1804
+ return (jsxRuntime.jsx("div", { style: {
1805
+ width: `${TIMELINE_BAR_WIDTH}px`,
1806
+ height: `${height}px`,
1807
+ borderRadius: '9999px',
1808
+ flexShrink: 0,
1809
+ transition: 'height 120ms ease',
1810
+ background: hasValue
1811
+ ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
1812
+ : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent)',
1813
+ } }, i));
1814
+ }) }));
1203
1815
  };
1204
1816
 
1205
1817
  /**
@@ -1327,477 +1939,111 @@ const MicIndicator = styled.button `
1327
1939
  align-items: center;
1328
1940
  justify-content: center;
1329
1941
  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);
1942
+ border: 0;
1943
+ flex-shrink: 0;
1944
+ cursor: pointer;
1945
+ background: color-mix(in srgb, var(--exode_red, #ed1c24) 14%, transparent);
1946
+ color: var(--exode_red, #ed1c24);
1947
+ transition: background-color 140ms ease, transform 120ms ease;
1948
+
1949
+ &:hover {
1950
+ background: color-mix(in srgb, var(--exode_red, #ed1c24) 22%, transparent);
1951
+ }
1952
+
1953
+ &:active {
1954
+ transform: scale(0.94);
1955
+ }
1956
+ `;
1957
+ const PlayPauseButton = styled.button `
1958
+ width: 26px;
1959
+ height: 26px;
1960
+ display: inline-flex;
1961
+ align-items: center;
1962
+ justify-content: center;
1963
+ border-radius: 13px;
1964
+ border: 0;
1965
+ flex-shrink: 0;
1966
+ cursor: pointer;
1967
+ background: var(--background_content, #ffffff);
1968
+ color: var(--text_primary, #0a0a0a);
1969
+ transition: transform 120ms ease;
1970
+
1971
+ &:hover {
1972
+ transform: scale(1.06);
1973
+ }
1974
+
1975
+ &:active {
1976
+ transform: scale(0.94);
1977
+ }
1978
+ `;
1979
+ const SpinnerIcon = styled.span `
1980
+ width: 20px;
1981
+ height: 20px;
1982
+ display: inline-block;
1983
+ border-radius: 50%;
1984
+ border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
1985
+ border-top-color: var(--accent, #3f8ae0);
1986
+ border-right-color: var(--accent, #3f8ae0);
1987
+ animation: voiceRecorderSpin 0.75s linear infinite;
1988
+
1989
+ @keyframes voiceRecorderSpin {
1990
+ from { transform: rotate(0deg); }
1991
+ to { transform: rotate(360deg); }
1992
+ }
1993
+ `;
1994
+
1995
+ const VoiceRecorderInput = (props) => {
1996
+ const { className, ...inputOptions } = props;
1997
+ const waveformRef = React.useRef(null);
1998
+ const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
1999
+ const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
2000
+ const getVisibleBarsCount = React.useCallback(() => {
2001
+ const el = waveformRef.current;
2002
+ if (!el) {
2003
+ return 60;
1578
2004
  }
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 */
2005
+ return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
2006
+ }, []);
1600
2007
  const displayTime = (isPreview && isPlaying)
1601
2008
  ? formatTime(playbackTime)
1602
2009
  : formatTime(recordingTime || lastDurationRef.current);
1603
2010
  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
2011
  ? 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, {})) })] }));
2012
+ : 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
2013
  };
1607
2014
 
1608
2015
  /**
1609
- * Global audio playback manager — ensures only one audio plays at a time
2016
+ * VoicePlaybackChain
1610
2017
  *
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
2018
+ * @description Sequential voice message playback chain (like Telegram).
2019
+ * Registers voice players by media ID and plays the next one when current ends.
1634
2020
  *
1635
2021
  * @author: exode <hello@exode.ru>
1636
2022
  */
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;
2023
+ class VoicePlaybackChainClass {
2024
+ constructor() {
2025
+ this.entries = [];
2026
+ /** Register a voice player. Returns cleanup function for useEffect. */
2027
+ this.register = (id, play) => {
2028
+ this.entries = this.entries.filter((e) => e.id !== id);
2029
+ this.entries.push({ id, play });
2030
+ this.entries.sort((a, b) => a.id - b.id);
2031
+ return () => this.unregister(id);
2032
+ };
2033
+ /** Unregister a voice player by media ID */
2034
+ this.unregister = (id) => {
2035
+ this.entries = this.entries.filter((e) => e.id !== id);
2036
+ };
2037
+ /** Play the next voice message after the given media ID */
2038
+ this.playNext = (currentId) => {
2039
+ const idx = this.entries.findIndex((e) => e.id === currentId);
2040
+ if (idx >= 0 && idx < this.entries.length - 1) {
2041
+ this.entries[idx + 1].play();
1773
2042
  }
1774
- setIsLoaded(false);
1775
2043
  };
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
- };
2044
+ }
2045
+ }
2046
+ const VoicePlaybackChain = new VoicePlaybackChainClass();
1801
2047
 
1802
2048
  /**
1803
2049
  * VoiceMessagePlayer styles
@@ -1891,13 +2137,56 @@ const BAR_WIDTH = 3;
1891
2137
  const BAR_GAP = 2;
1892
2138
  const BAR_MIN_HEIGHT = 2;
1893
2139
  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 });
2140
+ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2141
+ const { src, onClick, onEnded, chainId, duration, className, waveformData, autoPreload = true, } = props;
2142
+ const trackRef = React.useRef(null);
2143
+ const wrapperRef = React.useRef(null);
2144
+ const [trackWidth, setTrackWidth] = React.useState(0);
2145
+ const handleEnded = React.useCallback(() => {
2146
+ onEnded === null || onEnded === void 0 ? void 0 : onEnded();
2147
+ if (chainId != null) {
2148
+ VoicePlaybackChain.playNext(chainId);
2149
+ }
2150
+ }, [onEnded, chainId]);
2151
+ const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2152
+ src,
2153
+ duration,
2154
+ waveformData,
2155
+ preloadedBlob: props.preloadedBlob,
2156
+ onEnded: handleEnded,
2157
+ });
2158
+ React.useImperativeHandle(ref, () => ({ play, prefetch }), [play, prefetch]);
2159
+ /** Auto-preload audio when player enters viewport */
2160
+ React.useEffect(() => {
2161
+ if (!autoPreload || isLoaded || isLoading) {
2162
+ return;
2163
+ }
2164
+ const el = wrapperRef.current;
2165
+ if (!el) {
2166
+ return;
2167
+ }
2168
+ const observer = new IntersectionObserver(([entry]) => {
2169
+ if (entry.isIntersecting) {
2170
+ prefetch();
2171
+ observer.disconnect();
2172
+ }
2173
+ }, { threshold: 0.1 });
2174
+ observer.observe(el);
2175
+ return () => observer.disconnect();
2176
+ }, [
2177
+ isLoaded,
2178
+ prefetch,
2179
+ isLoading,
2180
+ autoPreload,
2181
+ ]);
2182
+ /** Register in playback chain for sequential playback */
2183
+ React.useEffect(() => {
2184
+ if (chainId != null) {
2185
+ return VoicePlaybackChain.register(chainId, () => play());
2186
+ }
2187
+ }, [chainId, play]);
1899
2188
  /** Measure track width once after mount */
1900
- react.useLayoutEffect(() => {
2189
+ React.useLayoutEffect(() => {
1901
2190
  if (trackRef.current) {
1902
2191
  setTrackWidth(trackRef.current.clientWidth);
1903
2192
  }
@@ -1905,7 +2194,7 @@ const VoiceMessagePlayer = (props) => {
1905
2194
  const displayTime = isPlaying
1906
2195
  ? formatTime(currentTime)
1907
2196
  : formatTime(resolvedDuration);
1908
- const handleTrackClick = react.useCallback((e) => {
2197
+ const handleTrackClick = React.useCallback((e) => {
1909
2198
  const track = trackRef.current;
1910
2199
  if (!track) {
1911
2200
  return;
@@ -1915,7 +2204,7 @@ const VoiceMessagePlayer = (props) => {
1915
2204
  seekTo(Math.max(0, Math.min(1, clickProgress)));
1916
2205
  }, [seekTo]);
1917
2206
  /** Interpolate waveform levels to fill container */
1918
- const interpolatedBars = react.useMemo(() => {
2207
+ const interpolatedBars = React.useMemo(() => {
1919
2208
  var _a, _b;
1920
2209
  const maxBars = trackWidth > 0
1921
2210
  ? Math.floor(trackWidth / (BAR_WIDTH + BAR_GAP))
@@ -1936,7 +2225,7 @@ const VoiceMessagePlayer = (props) => {
1936
2225
  const peak = Math.max(...raw, 1);
1937
2226
  return raw.map((v) => (v / peak) * 100);
1938
2227
  }, [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) => {
2228
+ return (jsxRuntime.jsxs(PlayerWrapper, { ref: wrapperRef, 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
2229
  const normalized = Math.min(100, level);
1941
2230
  const height = BAR_MIN_HEIGHT + (normalized / 100) * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT);
1942
2231
  const barProgress = interpolatedBars.length > 0
@@ -1950,16 +2239,50 @@ const VoiceMessagePlayer = (props) => {
1950
2239
  : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 25%, transparent)',
1951
2240
  } }, i));
1952
2241
  }) }), jsxRuntime.jsx(Duration, { children: displayTime })] })] }));
1953
- };
2242
+ });
2243
+
2244
+ /**
2245
+ * VoiceBlobCache
2246
+ *
2247
+ * @description In-memory cache for voice message blobs.
2248
+ * Allows instant playback of just-sent voice messages
2249
+ * before the server URL becomes available.
2250
+ *
2251
+ * @author: exode <hello@exode.ru>
2252
+ */
2253
+ class VoiceBlobCacheClass {
2254
+ constructor() {
2255
+ this.cache = new Map();
2256
+ this.set = (messageId, blob) => {
2257
+ this.cache.set(messageId, blob);
2258
+ };
2259
+ this.get = (messageId) => {
2260
+ return this.cache.get(messageId);
2261
+ };
2262
+ this.delete = (messageId) => {
2263
+ this.cache.delete(messageId);
2264
+ };
2265
+ /** Transfer blob from optimistic ID to real server ID */
2266
+ this.transfer = (fromId, toId) => {
2267
+ const blob = this.cache.get(fromId);
2268
+ if (blob) {
2269
+ this.cache.delete(fromId);
2270
+ this.cache.set(toId, blob);
2271
+ }
2272
+ };
2273
+ }
2274
+ }
2275
+ const VoiceBlobCache = new VoiceBlobCacheClass();
1954
2276
 
1955
2277
  exports.AUDIO_LEVEL_BARS = AUDIO_LEVEL_BARS;
1956
2278
  exports.AudioRecorder = AudioRecorder;
1957
2279
  exports.StorageSpaceEnum = StorageSpaceEnum;
2280
+ exports.VoiceBlobCache = VoiceBlobCache;
1958
2281
  exports.VoiceMessagePlayer = VoiceMessagePlayer;
2282
+ exports.VoicePlaybackChain = VoicePlaybackChain;
1959
2283
  exports.VoiceRecorderInput = VoiceRecorderInput;
1960
2284
  exports.WaveformBars = WaveformBars;
1961
2285
  exports.clampValue = clampValue;
1962
- exports.decodeAudioLevels = decodeAudioLevels;
1963
2286
  exports.formatTime = formatTime;
1964
2287
  exports.useRecorderVisualization = useRecorderVisualization;
1965
2288
  exports.useRecordingTiming = useRecordingTiming;