@exode-team/react-recorder 1.1.12 → 1.1.15

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 (92) hide show
  1. package/README.md +2 -2
  2. package/dist/components/AudioRecorder.js +50 -0
  3. package/dist/components/AudioRecorder.js.map +1 -0
  4. package/dist/components/AudioRecorder.messages.js +47 -0
  5. package/dist/components/AudioRecorder.messages.js.map +1 -0
  6. package/dist/components/AudioRecorder.styled.js +16 -0
  7. package/dist/components/AudioRecorder.styled.js.map +1 -0
  8. package/dist/components/IdleControl/IdleControl.js +12 -0
  9. package/dist/components/IdleControl/IdleControl.js.map +1 -0
  10. package/dist/components/IdleControl/IdleControl.styled.js +68 -0
  11. package/dist/components/IdleControl/IdleControl.styled.js.map +1 -0
  12. package/dist/components/RecordingShell/RecordingShell.js +80 -0
  13. package/dist/components/RecordingShell/RecordingShell.js.map +1 -0
  14. package/dist/components/RecordingShell/RecordingShell.styled.js +159 -0
  15. package/dist/components/RecordingShell/RecordingShell.styled.js.map +1 -0
  16. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.js +151 -0
  17. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.js.map +1 -0
  18. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.styled.js +92 -0
  19. package/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.styled.js.map +1 -0
  20. package/dist/components/VoiceRecorderInput/PreviewBars.js +52 -0
  21. package/dist/components/VoiceRecorderInput/PreviewBars.js.map +1 -0
  22. package/dist/components/VoiceRecorderInput/TimelineBars.js +31 -0
  23. package/dist/components/VoiceRecorderInput/TimelineBars.js.map +1 -0
  24. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.js +51 -0
  25. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.js.map +1 -0
  26. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.styled.js +175 -0
  27. package/dist/components/VoiceRecorderInput/VoiceRecorderInput.styled.js.map +1 -0
  28. package/dist/components/WaveformBars/WaveformBars.js +29 -0
  29. package/dist/components/WaveformBars/WaveformBars.js.map +1 -0
  30. package/dist/components/WaveformBars/WaveformBars.styled.js +20 -0
  31. package/dist/components/WaveformBars/WaveformBars.styled.js.map +1 -0
  32. package/dist/constants.js +17 -0
  33. package/dist/constants.js.map +1 -0
  34. package/dist/hooks/useAudioBlobLoader.d.ts +1 -5
  35. package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
  36. package/dist/hooks/useAudioBlobLoader.js +175 -0
  37. package/dist/hooks/useAudioBlobLoader.js.map +1 -0
  38. package/dist/hooks/useAudioPlayback.js +119 -0
  39. package/dist/hooks/useAudioPlayback.js.map +1 -0
  40. package/dist/hooks/useAudioRecorderHandlers.js +137 -0
  41. package/dist/hooks/useAudioRecorderHandlers.js.map +1 -0
  42. package/dist/hooks/useMediaRecorder.js +127 -0
  43. package/dist/hooks/useMediaRecorder.js.map +1 -0
  44. package/dist/hooks/useRecorderVisualization.js +94 -0
  45. package/dist/hooks/useRecorderVisualization.js.map +1 -0
  46. package/dist/hooks/useRecordingSender.js +53 -0
  47. package/dist/hooks/useRecordingSender.js.map +1 -0
  48. package/dist/hooks/useRecordingTiming.js +93 -0
  49. package/dist/hooks/useRecordingTiming.js.map +1 -0
  50. package/dist/hooks/useVoiceMessagePlayer.js +177 -0
  51. package/dist/hooks/useVoiceMessagePlayer.js.map +1 -0
  52. package/dist/hooks/useVoiceRecorder.js +153 -0
  53. package/dist/hooks/useVoiceRecorder.js.map +1 -0
  54. package/dist/hooks/useVoiceRecorderInput.js +163 -0
  55. package/dist/hooks/useVoiceRecorderInput.js.map +1 -0
  56. package/dist/index.cjs +108 -61
  57. package/dist/index.cjs.map +1 -1
  58. package/dist/index.js +13 -2612
  59. package/dist/index.js.map +1 -1
  60. package/dist/platform/animation-frame-adapter.js +14 -0
  61. package/dist/platform/animation-frame-adapter.js.map +1 -0
  62. package/dist/platform/audio-analyzer-adapter.d.ts.map +1 -1
  63. package/dist/platform/audio-analyzer-adapter.js +74 -0
  64. package/dist/platform/audio-analyzer-adapter.js.map +1 -0
  65. package/dist/platform/audio-blob-fetcher.js +19 -0
  66. package/dist/platform/audio-blob-fetcher.js.map +1 -0
  67. package/dist/platform/audio-playback-adapter.d.ts.map +1 -1
  68. package/dist/platform/audio-playback-adapter.js +114 -0
  69. package/dist/platform/audio-playback-adapter.js.map +1 -0
  70. package/dist/platform/audio-playback-manager.js +26 -0
  71. package/dist/platform/audio-playback-manager.js.map +1 -0
  72. package/dist/platform/media-recorder-adapter.js +23 -0
  73. package/dist/platform/media-recorder-adapter.js.map +1 -0
  74. package/dist/platform/microphone.js +24 -0
  75. package/dist/platform/microphone.js.map +1 -0
  76. package/dist/platform/mpeg-frame.js +60 -0
  77. package/dist/platform/mpeg-frame.js.map +1 -0
  78. package/dist/utils/format.js +13 -0
  79. package/dist/utils/format.js.map +1 -0
  80. package/dist/utils/index.d.ts +1 -0
  81. package/dist/utils/index.d.ts.map +1 -1
  82. package/dist/utils/math.js +16 -0
  83. package/dist/utils/math.js.map +1 -0
  84. package/dist/utils/pseudo-waveform.d.ts +12 -0
  85. package/dist/utils/pseudo-waveform.d.ts.map +1 -0
  86. package/dist/utils/pseudo-waveform.js +40 -0
  87. package/dist/utils/pseudo-waveform.js.map +1 -0
  88. package/dist/utils/voice-blob-cache.js +35 -0
  89. package/dist/utils/voice-blob-cache.js.map +1 -0
  90. package/dist/utils/voice-playback-chain.js +42 -0
  91. package/dist/utils/voice-playback-chain.js.map +1 -0
  92. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,2613 +1,14 @@
1
- import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
- import React, { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useImperativeHandle } from 'react';
3
- import styled from 'styled-components';
4
- import { Icon32Microphone, Icon16Microphone, Icon16Pause, Icon16Play, Icon28DeleteOutlineAndroid, Icon28SendOutline, Icon20Voice, Icon24Pause, Icon24Play } from '@vkontakte/icons';
5
-
6
- /**
7
- * Math utilities
8
- *
9
- * @author: exode <hello@exode.ru>
10
- */
11
- const clampValue = (value, lowerBound = 0, upperBound = 100) => {
12
- const safe = Number.isFinite(value) ? value : 0;
13
- return Math.min(Math.max(safe, lowerBound), upperBound);
14
- };
15
- const roundTo = (value, precision = 2) => {
16
- const factor = Math.pow(10, precision);
17
- return Math.round(value * factor) / factor;
18
- };
19
-
20
- /**
21
- * Format utilities
22
- *
23
- * @author: exode <hello@exode.ru>
24
- */
25
- const formatTime = (seconds) => {
26
- const mins = Math.floor(seconds / 60);
27
- const secs = Math.floor(seconds % 60);
28
- return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
29
- };
30
-
31
- /**
32
- * Microphone platform adapter
33
- *
34
- * @author: exode <hello@exode.ru>
35
- */
36
- const classifyMicError = (error) => {
37
- const domError = error instanceof DOMException ? error : null;
38
- if ((domError === null || domError === void 0 ? void 0 : domError.name) === 'NotAllowedError'
39
- || (domError === null || domError === void 0 ? void 0 : domError.name) === 'NotFoundError'
40
- || (domError === null || domError === void 0 ? void 0 : domError.name) === 'NotReadableError') {
41
- return 'permission';
42
- }
43
- return 'unknown';
44
- };
45
- const requestMicrophoneAccess = async () => {
46
- var _a;
47
- if (typeof navigator === 'undefined' || !((_a = navigator.mediaDevices) === null || _a === void 0 ? void 0 : _a.getUserMedia)) {
48
- throw new Error('MediaRecorder is not supported in this browser');
49
- }
50
- return navigator.mediaDevices.getUserMedia({ audio: true });
51
- };
52
-
53
- /**
54
- * MediaRecorder platform adapter
55
- *
56
- * @author: exode <hello@exode.ru>
57
- */
58
- const createRecorderSession = (stream, callbacks, options) => {
59
- const recorderOptions = {};
60
- if ((options === null || options === void 0 ? void 0 : options.mimeType) && MediaRecorder.isTypeSupported(options.mimeType)) {
61
- recorderOptions.mimeType = options.mimeType;
62
- }
63
- const recorder = new MediaRecorder(stream, recorderOptions);
64
- recorder.ondataavailable = callbacks.onDataAvailable;
65
- recorder.onstop = callbacks.onStop;
66
- recorder.onpause = callbacks.onPause;
67
- recorder.onresume = callbacks.onResume;
68
- return { recorder, stream };
69
- };
70
- const stopStream = (stream) => {
71
- stream.getTracks().forEach((track) => track.stop());
72
- };
73
-
74
- /**
75
- * Audio playback platform adapter
76
- *
77
- * @author: exode <hello@exode.ru>
78
- */
79
- const SEEK_OFFSET = 5;
80
- const setupMediaSession = (audio, callbacks) => {
81
- if (!('mediaSession' in navigator)) {
82
- return;
83
- }
84
- navigator.mediaSession.setActionHandler('play', () => {
85
- audio.play().catch(() => { });
86
- });
87
- navigator.mediaSession.setActionHandler('pause', () => {
88
- audio.pause();
89
- });
90
- navigator.mediaSession.setActionHandler('stop', () => {
91
- var _a;
92
- audio.pause();
93
- audio.currentTime = 0;
94
- (_a = callbacks.onStop) === null || _a === void 0 ? void 0 : _a.call(callbacks);
95
- });
96
- navigator.mediaSession.setActionHandler('seekbackward', () => {
97
- var _a;
98
- audio.currentTime = Math.max(0, audio.currentTime - SEEK_OFFSET);
99
- (_a = callbacks.onSeekBackward) === null || _a === void 0 ? void 0 : _a.call(callbacks);
100
- });
101
- navigator.mediaSession.setActionHandler('seekforward', () => {
102
- var _a;
103
- audio.currentTime = Math.min(audio.duration, audio.currentTime + SEEK_OFFSET);
104
- (_a = callbacks.onSeekForward) === null || _a === void 0 ? void 0 : _a.call(callbacks);
105
- });
106
- navigator.mediaSession.setActionHandler('seekto', (details) => {
107
- if (details.seekTime != null && isFinite(audio.duration)) {
108
- audio.currentTime = details.seekTime;
109
- }
110
- });
111
- navigator.mediaSession.setActionHandler('previoustrack', () => {
112
- var _a;
113
- (_a = callbacks.onPreviousTrack) === null || _a === void 0 ? void 0 : _a.call(callbacks);
114
- });
115
- navigator.mediaSession.setActionHandler('nexttrack', () => {
116
- var _a;
117
- (_a = callbacks.onNextTrack) === null || _a === void 0 ? void 0 : _a.call(callbacks);
118
- });
119
- };
120
- const clearMediaSession = () => {
121
- if (!('mediaSession' in navigator)) {
122
- return;
123
- }
124
- const actions = [
125
- 'play', 'pause', 'stop',
126
- 'seekbackward', 'seekforward', 'seekto',
127
- 'previoustrack', 'nexttrack',
128
- ];
129
- actions.forEach((action) => {
130
- navigator.mediaSession.setActionHandler(action, null);
131
- });
132
- navigator.mediaSession.playbackState = 'none';
133
- };
134
- const createAudioPlayback = (url, callbacks) => {
135
- const audio = new Audio(url);
136
- audio.onloadedmetadata = () => {
137
- callbacks.onLoadedMetadata(audio.duration);
138
- };
139
- audio.ontimeupdate = () => {
140
- callbacks.onTimeUpdate(audio.currentTime);
141
- };
142
- audio.onended = () => {
143
- callbacks.onEnded();
144
- };
145
- audio.onplay = () => {
146
- var _a;
147
- setupMediaSession(audio, callbacks);
148
- if ('mediaSession' in navigator) {
149
- navigator.mediaSession.playbackState = 'playing';
150
- }
151
- (_a = callbacks.onPlay) === null || _a === void 0 ? void 0 : _a.call(callbacks);
152
- };
153
- audio.onpause = () => {
154
- var _a;
155
- if ('mediaSession' in navigator) {
156
- navigator.mediaSession.playbackState = 'paused';
157
- }
158
- (_a = callbacks.onPause) === null || _a === void 0 ? void 0 : _a.call(callbacks);
159
- };
160
- return { audio, url };
161
- };
162
- const cleanupAudio = (audio) => {
163
- audio.pause();
164
- audio.src = '';
165
- audio.onended = null;
166
- audio.ontimeupdate = null;
167
- audio.onloadedmetadata = null;
168
- audio.onplay = null;
169
- audio.onpause = null;
170
- clearMediaSession();
171
- };
172
- const createObjectURL = (blob) => {
173
- return URL.createObjectURL(blob);
174
- };
175
- const revokeObjectURL = (url) => {
176
- URL.revokeObjectURL(url);
177
- };
178
-
179
- /**
180
- * AudioRecorder constants
181
- *
182
- * @author: exode <hello@exode.ru>
183
- */
184
- const AUDIO_LEVEL_BARS = 40;
185
- /** Waveform decoding resamples to this rate — only the amplitude envelope matters */
186
- const WAVEFORM_SAMPLE_RATE = 44100;
187
- const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
188
- const TIMELINE_SAMPLE_INTERVAL = 150;
189
- const TIMELINE_BAR_WIDTH = 3;
190
- const TIMELINE_BAR_GAP = 2;
191
- const TIMELINE_BAR_MIN_HEIGHT = 3;
192
- const TIMELINE_BAR_MAX_HEIGHT = 24;
193
-
194
- /**
195
- * MPEG frame scanner
196
- *
197
- * @author: exode <hello@exode.ru>
198
- */
199
- const MPEG_LAYER_III = 1;
200
- const MPEG_SAMPLE_RATES = [44100, 48000, 32000];
201
- const MPEG_BITRATES_V1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
202
- const MPEG_BITRATES_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
203
- /** Length of the Layer III frame at the given offset, or 0 when it is not one */
204
- const readFrameLength = (bytes, offset) => {
205
- if (offset + 3 >= bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
206
- return 0;
207
- }
208
- /** Version ids: 0 = MPEG 2.5, 1 = reserved, 2 = MPEG 2, 3 = MPEG 1 */
209
- const version = (bytes[offset + 1] >> 3) & 0x03;
210
- const layer = (bytes[offset + 1] >> 1) & 0x03;
211
- const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
212
- const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
213
- const isReserved = version === 1
214
- || layer !== MPEG_LAYER_III
215
- || sampleRateIndex === 3
216
- || bitrateIndex === 0
217
- || bitrateIndex === 0x0f;
218
- if (isReserved) {
219
- return 0;
220
- }
221
- const isVersion1 = version === 3;
222
- const padding = (bytes[offset + 2] >> 1) & 0x01;
223
- const bitrate = (isVersion1 ? MPEG_BITRATES_V1 : MPEG_BITRATES_V2)[bitrateIndex] * 1000;
224
- const sampleRate = MPEG_SAMPLE_RATES[sampleRateIndex] / (isVersion1 ? 1 : version === 2 ? 2 : 4);
225
- return Math.floor((isVersion1 ? 144 : 72) * bitrate / sampleRate) + padding;
226
- };
227
- /**
228
- * Some mp3 (e.g. converted from wma) keep junk between the ID3 tag and the
229
- * first MPEG frame: media elements skip it, the WebAudio decoder does not.
230
- * Returns the offset of the first real frame, or 0 when there is none.
231
- */
232
- const findFirstFrameOffset = (bytes) => {
233
- let start = 0;
234
- /** ID3v2 header: "ID3" + version + flags + 4 syncsafe size bytes */
235
- if (bytes.length >= 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
236
- start = 10 + ((bytes[6] << 21) | (bytes[7] << 14) | (bytes[8] << 7) | bytes[9]);
237
- /** ID3v2.4 may append a 10 byte footer copy of the header */
238
- if (bytes[5] & 0x10) {
239
- start += 10;
240
- }
241
- }
242
- for (let i = start; i < bytes.length - 3; i += 1) {
243
- const length = readFrameLength(bytes, i);
244
- /** Junk can open with a valid looking header, so require the next frame to follow */
245
- if (length && readFrameLength(bytes, i + length)) {
246
- return i;
247
- }
248
- }
249
- return 0;
250
- };
251
-
252
- /**
253
- * Audio analyzer platform adapter
254
- *
255
- * @author: exode <hello@exode.ru>
256
- */
257
- const createStreamAnalyzer = (stream) => {
258
- const context = new AudioContext();
259
- const analyser = context.createAnalyser();
260
- const source = context.createMediaStreamSource(stream);
261
- source.connect(analyser);
262
- analyser.fftSize = 256;
263
- return { context, analyser };
264
- };
265
- const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
266
- var _a;
267
- const bufferLength = analyser.frequencyBinCount;
268
- const dataArray = new Uint8Array(bufferLength);
269
- analyser.getByteFrequencyData(dataArray);
270
- const step = Math.max(1, Math.floor(bufferLength / barCount));
271
- const levels = [];
272
- for (let i = 0; i < barCount; i += 1) {
273
- const value = (_a = dataArray[i * step]) !== null && _a !== void 0 ? _a : 0;
274
- levels.push((value / 255) * 100);
275
- }
276
- return levels;
277
- };
278
- /** decodeAudioData detaches the buffer, so the retry re-reads the blob */
279
- const decodeSkippingJunk = async (context, blob) => {
280
- try {
281
- return await context.decodeAudioData(await blob.arrayBuffer());
282
- }
283
- catch (error) {
284
- const bytes = new Uint8Array(await blob.arrayBuffer());
285
- const offset = findFirstFrameOffset(bytes);
286
- if (!offset) {
287
- throw error;
288
- }
289
- return context.decodeAudioData(bytes.buffer.slice(offset));
290
- }
291
- };
292
- const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
293
- /**
294
- * Offline context stays out of the audio session. A realtime AudioContext
295
- * registers as a WebAudio source, which pins WebKit to the AmbientSound
296
- * category and leaves media playback silent inside iOS apps — including
297
- * iPad apps running on macOS.
298
- */
299
- const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
300
- const audioBuffer = await decodeSkippingJunk(context, blob);
301
- const channelData = audioBuffer.getChannelData(0);
302
- if (!channelData.length) {
303
- return Array.from({ length: barCount }, () => 0);
304
- }
305
- const levels = [];
306
- const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
307
- for (let i = 0; i < barCount; i += 1) {
308
- const segmentStart = i * segmentLength;
309
- let sum = 0;
310
- for (let j = 0; j < segmentLength; j += 1) {
311
- const sample = channelData[segmentStart + j];
312
- if (sample !== undefined) {
313
- sum += Math.abs(sample);
314
- }
315
- }
316
- const average = sum / segmentLength;
317
- levels.push(Math.min(100, average * 200));
318
- }
319
- return levels;
320
- };
321
-
322
- /**
323
- * Audio blob fetcher platform adapter
324
- *
325
- * @author: exode <hello@exode.ru>
326
- */
327
- const fetchAudioBlob = async (url, signal) => {
328
- const response = await fetch(url, {
329
- signal,
330
- mode: 'cors',
331
- credentials: 'omit',
332
- });
333
- if (!response.ok) {
334
- throw new Error(`Failed to fetch audio: ${response.status}`);
335
- }
336
- return response.blob();
337
- };
338
-
339
- /**
340
- * Animation frame platform adapter
341
- *
342
- * @author: exode <hello@exode.ru>
343
- */
344
- const scheduleFrame = (callback) => {
345
- return requestAnimationFrame(callback);
346
- };
347
- const cancelFrame = (id) => {
348
- cancelAnimationFrame(id);
349
- };
350
-
351
- /**
352
- * useMediaRecorder
353
- *
354
- * @author: exode <hello@exode.ru>
355
- */
356
- const useMediaRecorder = (callbacks) => {
357
- const [isRecording, setIsRecording] = useState(false);
358
- const [isPaused, setIsPaused] = useState(false);
359
- const mediaRecorderRef = useRef(null);
360
- const mediaStreamRef = useRef(null);
361
- const audioChunksRef = useRef([]);
362
- const discardRef = useRef(false);
363
- const needsPreviewRef = useRef(false);
364
- const cleanupStream = useCallback(() => {
365
- if (mediaStreamRef.current) {
366
- stopStream(mediaStreamRef.current);
367
- mediaStreamRef.current = null;
368
- }
369
- }, []);
370
- const startRecording = useCallback(async (mimeType) => {
371
- try {
372
- const stream = await requestMicrophoneAccess();
373
- mediaStreamRef.current = stream;
374
- callbacks.onStreamAcquired(stream);
375
- audioChunksRef.current = [];
376
- discardRef.current = false;
377
- const { recorder } = createRecorderSession(stream, {
378
- onDataAvailable: (event) => {
379
- if (event.data.size > 0) {
380
- audioChunksRef.current.push(event.data);
381
- callbacks.onDataChunk(event.data);
382
- }
383
- if (needsPreviewRef.current) {
384
- callbacks.onNeedsPreview(audioChunksRef.current);
385
- needsPreviewRef.current = false;
386
- }
387
- },
388
- onStop: () => {
389
- const discarded = discardRef.current;
390
- discardRef.current = false;
391
- mediaRecorderRef.current = null;
392
- cleanupStream();
393
- setIsRecording(false);
394
- setIsPaused(false);
395
- callbacks.onStopped(audioChunksRef.current, discarded);
396
- },
397
- onPause: () => {
398
- setIsPaused(true);
399
- callbacks.onPaused();
400
- },
401
- onResume: () => {
402
- setIsPaused(false);
403
- needsPreviewRef.current = false;
404
- callbacks.onResumed();
405
- },
406
- }, { mimeType });
407
- mediaRecorderRef.current = recorder;
408
- recorder.start();
409
- setIsRecording(true);
410
- setIsPaused(false);
411
- }
412
- catch (error) {
413
- console.error('[audio-recorder] Error accessing microphone:', error);
414
- const kind = classifyMicError(error);
415
- if (kind === 'permission') {
416
- callbacks.onPermissionDenied(error);
417
- }
418
- else {
419
- callbacks.onError(error);
420
- }
421
- }
422
- }, [callbacks, cleanupStream]);
423
- const stopRecording = useCallback(() => {
424
- if (!mediaRecorderRef.current || !isRecording) {
425
- return;
426
- }
427
- if (mediaRecorderRef.current.state === 'paused') {
428
- mediaRecorderRef.current.resume();
429
- }
430
- mediaRecorderRef.current.stop();
431
- }, [isRecording]);
432
- const cancelRecording = useCallback(() => {
433
- if (!mediaRecorderRef.current) {
434
- return;
435
- }
436
- discardRef.current = true;
437
- needsPreviewRef.current = false;
438
- if (mediaRecorderRef.current.state !== 'inactive') {
439
- mediaRecorderRef.current.stop();
440
- }
441
- }, []);
442
- const togglePause = useCallback(() => {
443
- if (!mediaRecorderRef.current || !isRecording) {
444
- return;
445
- }
446
- if (mediaRecorderRef.current.state === 'recording') {
447
- needsPreviewRef.current = true;
448
- try {
449
- mediaRecorderRef.current.requestData();
450
- }
451
- catch (error) {
452
- console.warn('[audio-recorder] requestData failed', error);
453
- }
454
- mediaRecorderRef.current.pause();
455
- }
456
- else if (mediaRecorderRef.current.state === 'paused') {
457
- needsPreviewRef.current = false;
458
- mediaRecorderRef.current.resume();
459
- }
460
- }, [isRecording]);
461
- return {
462
- isPaused,
463
- isRecording,
464
- togglePause,
465
- cleanupStream,
466
- stopRecording,
467
- startRecording,
468
- cancelRecording,
469
- };
470
- };
471
-
472
- /**
473
- * useAudioPlayback
474
- *
475
- * @author: exode <hello@exode.ru>
476
- */
477
- const useAudioPlayback = () => {
478
- const [audioURL, setAudioURL] = useState(null);
479
- const [isPlaying, setIsPlaying] = useState(false);
480
- const [playbackTime, setPlaybackTime] = useState(0);
481
- const [playbackDuration, setPlaybackDuration] = useState(0);
482
- const audioRef = useRef(null);
483
- const cleanupAudioElement = useCallback(() => {
484
- const audio = audioRef.current;
485
- if (!audio) {
486
- return;
487
- }
488
- cleanupAudio(audio);
489
- audioRef.current = null;
490
- }, []);
491
- const setAudioUrlSafely = useCallback((value) => {
492
- setAudioURL((prev) => {
493
- if (prev) {
494
- revokeObjectURL(prev);
495
- }
496
- return value;
497
- });
498
- }, []);
499
- const resetPlayback = useCallback(() => {
500
- setPlaybackTime(0);
501
- setPlaybackDuration(0);
502
- setIsPlaying(false);
503
- setAudioUrlSafely(null);
504
- cleanupAudioElement();
505
- }, [cleanupAudioElement, setAudioUrlSafely]);
506
- const mountBlob = useCallback((blob) => {
507
- const url = createObjectURL(blob);
508
- setAudioUrlSafely(url);
509
- cleanupAudioElement();
510
- const { audio } = createAudioPlayback(url, {
511
- onLoadedMetadata: (duration) => setPlaybackDuration(duration),
512
- onTimeUpdate: (currentTime) => setPlaybackTime(currentTime),
513
- onEnded: () => {
514
- setIsPlaying(false);
515
- setPlaybackTime(0);
516
- },
517
- onPlay: () => setIsPlaying(true),
518
- onPause: () => setIsPlaying(false),
519
- });
520
- audioRef.current = audio;
521
- setPlaybackTime(0);
522
- setPlaybackDuration(0);
523
- setIsPlaying(false);
524
- return url;
525
- }, [cleanupAudioElement, setAudioUrlSafely]);
526
- const togglePlayback = useCallback((onError) => {
527
- if (!audioURL) {
528
- return;
529
- }
530
- if (!audioRef.current) {
531
- const { audio } = createAudioPlayback(audioURL, {
532
- onLoadedMetadata: (duration) => setPlaybackDuration(duration),
533
- onTimeUpdate: (currentTime) => setPlaybackTime(currentTime),
534
- onEnded: () => {
535
- setIsPlaying(false);
536
- setPlaybackTime(0);
537
- },
538
- onPlay: () => setIsPlaying(true),
539
- onPause: () => setIsPlaying(false),
540
- });
541
- audioRef.current = audio;
542
- }
543
- const audio = audioRef.current;
544
- if (!audio) {
545
- return;
546
- }
547
- if (audio.paused) {
548
- if (audio.duration > 0 && playbackDuration > 0) {
549
- audio.currentTime = Math.min(playbackTime, audio.duration);
550
- }
551
- audio.play()
552
- .then(() => setIsPlaying(true))
553
- .catch((error) => {
554
- setIsPlaying(false);
555
- onError === null || onError === void 0 ? void 0 : onError(error);
556
- });
557
- }
558
- else {
559
- audio.pause();
560
- setIsPlaying(false);
561
- }
562
- }, [audioURL, playbackDuration, playbackTime]);
563
- const stopPlayback = useCallback(() => {
564
- const audio = audioRef.current;
565
- if (!audio) {
566
- return;
567
- }
568
- audio.pause();
569
- audio.currentTime = 0;
570
- setIsPlaying(false);
571
- setPlaybackTime(0);
572
- }, []);
573
- return {
574
- audioURL,
575
- isPlaying,
576
- mountBlob,
577
- playbackTime,
578
- stopPlayback,
579
- resetPlayback,
580
- togglePlayback,
581
- playbackDuration,
582
- cleanupAudioElement,
583
- };
584
- };
585
-
586
- /**
587
- * useRecordingTiming
588
- *
589
- * @author: exode <hello@exode.ru>
590
- */
591
- const useRecordingTiming = () => {
592
- const [lastDuration, setLastDuration] = useState(0);
593
- const [recordingTime, setRecordingTime] = useState(0);
594
- const timerRef = useRef(null);
595
- const recordingStartRef = useRef(null);
596
- const recordingAccumulatedRef = useRef(0);
597
- const stopTicker = useCallback(() => {
598
- if (timerRef.current) {
599
- clearInterval(timerRef.current);
600
- timerRef.current = null;
601
- }
602
- }, []);
603
- const startTicker = useCallback(() => {
604
- if (timerRef.current) {
605
- clearInterval(timerRef.current);
606
- }
607
- timerRef.current = setInterval(() => {
608
- if (recordingStartRef.current === null) {
609
- setRecordingTime(roundTo(recordingAccumulatedRef.current));
610
- return;
611
- }
612
- const elapsed = (performance.now() - recordingStartRef.current) / 1000 +
613
- recordingAccumulatedRef.current;
614
- setRecordingTime(roundTo(elapsed));
615
- }, 100);
616
- }, []);
617
- const startNewSession = useCallback(() => {
618
- recordingAccumulatedRef.current = 0;
619
- setRecordingTime(0);
620
- setLastDuration(0);
621
- recordingStartRef.current = performance.now();
622
- startTicker();
623
- }, [startTicker]);
624
- const resumeSession = useCallback(() => {
625
- recordingStartRef.current = performance.now();
626
- startTicker();
627
- }, [startTicker]);
628
- const pauseSession = useCallback(() => {
629
- if (recordingStartRef.current === null) {
630
- return;
631
- }
632
- const elapsed = (performance.now() - recordingStartRef.current) / 1000;
633
- recordingAccumulatedRef.current += elapsed;
634
- recordingStartRef.current = null;
635
- setRecordingTime(roundTo(recordingAccumulatedRef.current));
636
- stopTicker();
637
- }, [stopTicker]);
638
- const getElapsedMs = useCallback(() => {
639
- const accumulated = recordingAccumulatedRef.current;
640
- const elapsed = recordingStartRef.current !== null
641
- ? (performance.now() - recordingStartRef.current) / 1000 + accumulated
642
- : accumulated;
643
- return Math.round(elapsed * 1000);
644
- }, []);
645
- const resetSession = useCallback(() => {
646
- const accumulated = recordingAccumulatedRef.current;
647
- const elapsed = recordingStartRef.current !== null
648
- ? (performance.now() - recordingStartRef.current) / 1000 + accumulated
649
- : accumulated;
650
- if (elapsed > 0) {
651
- setLastDuration(roundTo(elapsed));
652
- }
653
- stopTicker();
654
- recordingStartRef.current = null;
655
- recordingAccumulatedRef.current = 0;
656
- setRecordingTime(0);
657
- }, [stopTicker]);
658
- useEffect(() => {
659
- return () => {
660
- stopTicker();
661
- };
662
- }, [stopTicker]);
663
- return {
664
- lastDuration,
665
- getElapsedMs,
666
- pauseSession,
667
- resetSession,
668
- recordingTime,
669
- resumeSession,
670
- startNewSession,
671
- };
672
- };
673
-
674
- /**
675
- * useRecorderVisualization
676
- *
677
- * @author: exode <hello@exode.ru>
678
- */
679
- const useRecorderVisualization = () => {
680
- const [audioLevels, setAudioLevels] = useState(createInitialLevels);
681
- const [previewLevels, setPreviewLevels] = useState(createInitialLevels);
682
- const analyzerRef = useRef(null);
683
- const animationFrameRef = useRef(null);
684
- const resetAudioLevels = useCallback(() => {
685
- setAudioLevels(createInitialLevels);
686
- }, []);
687
- const resetPreviewLevels = useCallback(() => {
688
- setPreviewLevels(createInitialLevels);
689
- }, []);
690
- const stopVisualization = useCallback((options) => {
691
- var _a, _b;
692
- if (animationFrameRef.current) {
693
- cancelFrame(animationFrameRef.current);
694
- animationFrameRef.current = null;
695
- }
696
- if (((_a = options === null || options === void 0 ? void 0 : options.closeContext) !== null && _a !== void 0 ? _a : true) && analyzerRef.current) {
697
- void analyzerRef.current.context.close();
698
- analyzerRef.current = null;
699
- }
700
- if ((_b = options === null || options === void 0 ? void 0 : options.resetLevels) !== null && _b !== void 0 ? _b : true) {
701
- resetAudioLevels();
702
- }
703
- }, [resetAudioLevels]);
704
- const visualize = useCallback(() => {
705
- if (!analyzerRef.current) {
706
- return;
707
- }
708
- const { analyser } = analyzerRef.current;
709
- const draw = () => {
710
- animationFrameRef.current = scheduleFrame(draw);
711
- const levels = readFrequencyLevels(analyser);
712
- setAudioLevels(levels);
713
- };
714
- draw();
715
- }, []);
716
- const resumeVisualization = useCallback(() => {
717
- if (analyzerRef.current && !animationFrameRef.current) {
718
- visualize();
719
- }
720
- }, [visualize]);
721
- const initializeVisualization = useCallback((stream) => {
722
- analyzerRef.current = createStreamAnalyzer(stream);
723
- visualize();
724
- }, [visualize]);
725
- const generatePreviewLevels = useCallback(async (blob) => {
726
- try {
727
- const levels = await decodeAudioLevels(blob);
728
- setAudioLevels(levels);
729
- setPreviewLevels(levels);
730
- }
731
- catch (error) {
732
- console.warn('[audio-recorder] Failed to build waveform preview', error);
733
- setPreviewLevels(createInitialLevels());
734
- }
735
- }, []);
736
- const cleanupVisualization = useCallback(() => {
737
- if (animationFrameRef.current) {
738
- cancelFrame(animationFrameRef.current);
739
- }
740
- animationFrameRef.current = null;
741
- if (analyzerRef.current) {
742
- void analyzerRef.current.context.close();
743
- analyzerRef.current = null;
744
- }
745
- resetAudioLevels();
746
- resetPreviewLevels();
747
- }, [resetAudioLevels, resetPreviewLevels]);
748
- return {
749
- audioLevels,
750
- previewLevels,
751
- resetAudioLevels,
752
- stopVisualization,
753
- resetPreviewLevels,
754
- resumeVisualization,
755
- cleanupVisualization,
756
- generatePreviewLevels,
757
- initializeVisualization,
758
- };
759
- };
760
-
761
- /**
762
- * useRecordingSender — handles sending a recorded voice message via an upload strategy.
763
- *
764
- * @author: exode <hello@exode.ru>
765
- */
766
- const useRecordingSender = (options) => {
767
- const { uploader, fileName, mimeType, uploadId, audioBlob, onUploaded, getDurationMs, onRecordingError, deleteRecording, autoResetAfterUpload, } = options;
768
- const sendRecording = useCallback(async () => {
769
- if (!audioBlob) {
770
- const error = new Error('Voice recording is not available');
771
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
772
- return;
773
- }
774
- if (!uploader) {
775
- const error = new Error('Uploader strategy is not defined');
776
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
777
- throw error;
778
- }
779
- try {
780
- const durationMs = getDurationMs();
781
- const durationSec = durationMs / 1000;
782
- const file = new File([audioBlob], fileName, {
783
- type: audioBlob.type || mimeType,
784
- });
785
- const uploaded = await uploader(file, { uploadId });
786
- await onUploaded(uploaded, durationSec);
787
- if (autoResetAfterUpload) {
788
- deleteRecording();
789
- }
790
- }
791
- catch (error) {
792
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
793
- throw error;
794
- }
795
- }, [
796
- uploadId,
797
- uploader,
798
- fileName,
799
- audioBlob,
800
- mimeType,
801
- onUploaded,
802
- getDurationMs,
803
- deleteRecording,
804
- onRecordingError,
805
- autoResetAfterUpload,
806
- ]);
807
- return { sendRecording };
808
- };
809
-
810
- /**
811
- * useVoiceRecorder
812
- *
813
- * @author: exode <hello@exode.ru>
814
- */
815
- function useVoiceRecorder(props) {
816
- const { uploadId, uploader, onUploaded, onRecordingStop, onRecordingStart, onRecordingError, onRecordingDelete, onPermissionDenied, fileName = 'record.webm', mimeType = 'audio/webm', autoResetAfterUpload = true, } = props;
817
- const [audioBlob, setAudioBlob] = useState(null);
818
- const { audioURL, isPlaying, mountBlob, playbackTime, stopPlayback, resetPlayback, togglePlayback: _togglePlayback, playbackDuration, } = useAudioPlayback();
819
- const { audioLevels, previewLevels, resetAudioLevels, stopVisualization, resetPreviewLevels, resumeVisualization, cleanupVisualization, generatePreviewLevels, initializeVisualization, } = useRecorderVisualization();
820
- const { lastDuration, getElapsedMs, recordingTime, pauseSession, resetSession, resumeSession, startNewSession, } = useRecordingTiming();
821
- const resetCurrentRecording = useCallback((opts) => {
822
- var _a;
823
- setAudioBlob(null);
824
- resetPlayback();
825
- if ((_a = opts === null || opts === void 0 ? void 0 : opts.resetPreview) !== null && _a !== void 0 ? _a : true) {
826
- resetPreviewLevels();
827
- }
828
- if (opts === null || opts === void 0 ? void 0 : opts.resetLevels) {
829
- resetAudioLevels();
830
- }
831
- if (opts === null || opts === void 0 ? void 0 : opts.notifyDelete) {
832
- onRecordingDelete === null || onRecordingDelete === void 0 ? void 0 : onRecordingDelete();
833
- }
834
- }, [onRecordingDelete, resetAudioLevels, resetPlayback, resetPreviewLevels]);
835
- const deleteRecording = useCallback(() => {
836
- resetCurrentRecording({
837
- resetLevels: true,
838
- notifyDelete: true,
839
- });
840
- resetSession();
841
- }, [resetCurrentRecording, resetSession]);
842
- const { sendRecording } = useRecordingSender({
843
- audioBlob,
844
- fileName,
845
- mimeType,
846
- uploadId,
847
- uploader,
848
- onUploaded,
849
- deleteRecording,
850
- onRecordingError,
851
- autoResetAfterUpload,
852
- getDurationMs: getElapsedMs,
853
- });
854
- const mountRecordingBlob = useCallback((blob) => {
855
- setAudioBlob(blob);
856
- mountBlob(blob);
857
- }, [mountBlob]);
858
- const recorderCallbacks = useMemo(() => ({
859
- onDataChunk: () => { },
860
- onNeedsPreview: (chunks) => {
861
- const snapshot = new Blob(chunks, { type: mimeType });
862
- mountRecordingBlob(snapshot);
863
- void generatePreviewLevels(snapshot);
864
- },
865
- onStopped: (chunks, discarded) => {
866
- pauseSession();
867
- stopVisualization({ resetLevels: false });
868
- if (discarded) {
869
- resetCurrentRecording({ notifyDelete: true });
870
- resetSession();
871
- return;
872
- }
873
- const blob = new Blob(chunks, { type: mimeType });
874
- onRecordingStop === null || onRecordingStop === void 0 ? void 0 : onRecordingStop(blob);
875
- mountRecordingBlob(blob);
876
- void generatePreviewLevels(blob);
877
- resetSession();
878
- },
879
- onPaused: () => {
880
- pauseSession();
881
- stopVisualization({ resetLevels: false, closeContext: false });
882
- },
883
- onResumed: () => {
884
- resumeSession();
885
- resumeVisualization();
886
- },
887
- onStreamAcquired: (stream) => {
888
- initializeVisualization(stream);
889
- },
890
- onPermissionDenied: (error) => {
891
- onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
892
- },
893
- onError: (error) => {
894
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
895
- },
896
- }), [
897
- generatePreviewLevels,
898
- initializeVisualization,
899
- mimeType,
900
- mountRecordingBlob,
901
- onPermissionDenied,
902
- onRecordingError,
903
- onRecordingStop,
904
- pauseSession,
905
- resetCurrentRecording,
906
- resetSession,
907
- resumeSession,
908
- resumeVisualization,
909
- stopVisualization,
910
- ]);
911
- const { isPaused, isRecording, togglePause, cleanupStream, stopRecording, cancelRecording, startRecording: _startRecording, } = useMediaRecorder(recorderCallbacks);
912
- const startRecording = useCallback(async () => {
913
- resetCurrentRecording({ resetLevels: true });
914
- await _startRecording(mimeType);
915
- onRecordingStart === null || onRecordingStart === void 0 ? void 0 : onRecordingStart();
916
- startNewSession();
917
- }, [_startRecording, mimeType, onRecordingStart, resetCurrentRecording, startNewSession]);
918
- useEffect(() => {
919
- return () => {
920
- cleanupVisualization();
921
- cleanupStream();
922
- resetPlayback();
923
- resetSession();
924
- };
925
- }, [cleanupVisualization, cleanupStream, resetPlayback, resetSession]);
926
- const togglePlayback = useCallback(() => {
927
- _togglePlayback(onRecordingError);
928
- }, [_togglePlayback, onRecordingError]);
929
- return {
930
- audioURL,
931
- isPaused,
932
- isPlaying,
933
- isRecording,
934
- audioLevels,
935
- lastDuration,
936
- playbackTime,
937
- recordingTime,
938
- previewLevels,
939
- playbackDuration,
940
- formatTime,
941
- stopPlayback,
942
- sendRecording,
943
- stopRecording,
944
- togglePlayback,
945
- startRecording,
946
- cancelRecording,
947
- deleteRecording,
948
- toggleRecordingPause: togglePause,
949
- hasRecording: Boolean(audioURL),
950
- };
951
- }
952
-
953
- /**
954
- * useAudioBlobLoader — loads audio blob from URL or preloaded data,
955
- * creates object URL and decodes waveform levels.
956
- *
957
- * @author: exode <hello@exode.ru>
958
- */
959
- const useAudioBlobLoader = (options) => {
960
- const { src, waveformData, preloadedBlob, playbackCallbacks } = options;
961
- const [isLoading, setIsLoading] = useState(false);
962
- const [isLoaded, setIsLoaded] = useState(false);
963
- const [waveformLevels, setWaveformLevels] = useState(waveformData || createInitialLevels);
964
- const audioRef = useRef(null);
965
- const objectUrlRef = useRef(null);
966
- const abortControllerRef = useRef(null);
967
- const failedSrcRef = useRef(null);
968
- const waveformTokenRef = useRef(0);
969
- const waveformDataRef = useRef(waveformData);
970
- const callbacksRef = useRef(playbackCallbacks);
971
- waveformDataRef.current = waveformData;
972
- callbacksRef.current = playbackCallbacks;
973
- const createAudioFromBlob = useCallback((blob) => {
974
- if (objectUrlRef.current) {
975
- revokeObjectURL(objectUrlRef.current);
976
- }
977
- const url = createObjectURL(blob);
978
- objectUrlRef.current = url;
979
- const { audio } = createAudioPlayback(url, {
980
- onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
981
- onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
982
- onEnded: () => callbacksRef.current.onEnded(),
983
- onPlay: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPlay) === null || _b === void 0 ? void 0 : _b.call(_a); },
984
- onPause: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPause) === null || _b === void 0 ? void 0 : _b.call(_a); },
985
- onStop: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onStop) === null || _b === void 0 ? void 0 : _b.call(_a); },
986
- onSeekBackward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekBackward) === null || _b === void 0 ? void 0 : _b.call(_a); },
987
- onSeekForward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekForward) === null || _b === void 0 ? void 0 : _b.call(_a); },
988
- onPreviousTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPreviousTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
989
- onNextTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onNextTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
990
- });
991
- audioRef.current = audio;
992
- setIsLoaded(true);
993
- }, []);
994
- /**
995
- * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
996
- * frame) fail to decode in Safari while playing fine, so a decode error
997
- * must never block playback. Token keeps only the latest decode.
998
- */
999
- const applyWaveformLevels = useCallback(async (blob) => {
1000
- if (waveformData) {
1001
- return;
1002
- }
1003
- waveformTokenRef.current += 1;
1004
- const token = waveformTokenRef.current;
1005
- try {
1006
- const levels = await decodeAudioLevels(blob);
1007
- if (token === waveformTokenRef.current) {
1008
- setWaveformLevels(levels);
1009
- }
1010
- }
1011
- catch (error) {
1012
- console.warn('[voice-player] Failed to decode waveform', error);
1013
- }
1014
- }, [waveformData]);
1015
- const mountFromBlob = useCallback(async (blob) => {
1016
- if (audioRef.current) {
1017
- return;
1018
- }
1019
- setIsLoading(true);
1020
- try {
1021
- createAudioFromBlob(blob);
1022
- }
1023
- catch (error) {
1024
- console.warn('[voice-player] Failed to mount blob', error);
1025
- }
1026
- finally {
1027
- setIsLoading(false);
1028
- }
1029
- void applyWaveformLevels(blob);
1030
- }, [applyWaveformLevels, createAudioFromBlob]);
1031
- const ensureAudioLoaded = useCallback(async (options) => {
1032
- var _a;
1033
- if (audioRef.current) {
1034
- return;
1035
- }
1036
- if (failedSrcRef.current === src) {
1037
- if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1038
- return;
1039
- }
1040
- failedSrcRef.current = null;
1041
- }
1042
- if (preloadedBlob) {
1043
- return mountFromBlob(preloadedBlob);
1044
- }
1045
- if (!src) {
1046
- return;
1047
- }
1048
- (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1049
- const controller = new AbortController();
1050
- abortControllerRef.current = controller;
1051
- setIsLoading(true);
1052
- try {
1053
- const blob = await fetchAudioBlob(src, controller.signal);
1054
- if (controller.signal.aborted) {
1055
- return;
1056
- }
1057
- createAudioFromBlob(blob);
1058
- void applyWaveformLevels(blob);
1059
- }
1060
- catch (error) {
1061
- if (error instanceof DOMException && error.name === 'AbortError') {
1062
- return;
1063
- }
1064
- failedSrcRef.current = src;
1065
- console.warn('[voice-player] Failed to load audio', error);
1066
- }
1067
- finally {
1068
- if (!controller.signal.aborted) {
1069
- setIsLoading(false);
1070
- }
1071
- }
1072
- }, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
1073
- /** Cleanup on src change or unmount */
1074
- useEffect(() => {
1075
- failedSrcRef.current = null;
1076
- /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1077
- setWaveformLevels(waveformDataRef.current || createInitialLevels());
1078
- return () => {
1079
- var _a;
1080
- (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1081
- waveformTokenRef.current += 1;
1082
- if (audioRef.current) {
1083
- cleanupAudio(audioRef.current);
1084
- audioRef.current = null;
1085
- }
1086
- if (objectUrlRef.current) {
1087
- revokeObjectURL(objectUrlRef.current);
1088
- objectUrlRef.current = null;
1089
- }
1090
- setIsLoaded(false);
1091
- };
1092
- }, [src]);
1093
- useEffect(() => {
1094
- if (preloadedBlob && !audioRef.current) {
1095
- void mountFromBlob(preloadedBlob);
1096
- }
1097
- }, [preloadedBlob, mountFromBlob]);
1098
- useEffect(() => {
1099
- if (waveformData) {
1100
- waveformTokenRef.current += 1;
1101
- setWaveformLevels(waveformData);
1102
- }
1103
- }, [waveformData]);
1104
- return {
1105
- audioRef,
1106
- isLoading,
1107
- isLoaded,
1108
- waveformLevels,
1109
- ensureAudioLoaded,
1110
- };
1111
- };
1112
-
1113
- /**
1114
- * Global audio playback manager — ensures only one audio plays at a time
1115
- *
1116
- * @author: exode <hello@exode.ru>
1117
- */
1118
- const listeners = new Set();
1119
- const audioPlaybackManager = {
1120
- /** Register a stop callback for a player instance */
1121
- subscribe: (callback) => {
1122
- listeners.add(callback);
1123
- return () => {
1124
- listeners.delete(callback);
1125
- };
1126
- },
1127
- /** Stop all other players except the caller */
1128
- stopOthers: (except) => {
1129
- listeners.forEach((cb) => {
1130
- if (cb !== except) {
1131
- cb();
1132
- }
1133
- });
1134
- },
1135
- };
1136
-
1137
- /**
1138
- * useVoiceMessagePlayer
1139
- *
1140
- * @author: exode <hello@exode.ru>
1141
- */
1142
- const useVoiceMessagePlayer = (options) => {
1143
- const { src, onEnded, onPreviousTrack, onNextTrack, waveformData, preloadedBlob, duration: externalDuration, } = options;
1144
- const onEndedRef = useRef(onEnded);
1145
- const onPreviousTrackRef = useRef(onPreviousTrack);
1146
- const onNextTrackRef = useRef(onNextTrack);
1147
- onEndedRef.current = onEnded;
1148
- onPreviousTrackRef.current = onPreviousTrack;
1149
- onNextTrackRef.current = onNextTrack;
1150
- const [isPlaying, setIsPlaying] = useState(false);
1151
- const [currentTime, setCurrentTime] = useState(0);
1152
- const [duration, setDuration] = useState(externalDuration || 0);
1153
- const progress = duration > 0 ? currentTime / duration : 0;
1154
- /** Stop this player (called by manager when another player starts) */
1155
- const stopThis = useCallback(() => {
1156
- const audio = audioRef.current;
1157
- if (audio && !audio.paused) {
1158
- audio.pause();
1159
- }
1160
- setIsPlaying(false);
1161
- }, []);
1162
- /** Subscribe to global manager on mount */
1163
- useEffect(() => {
1164
- return audioPlaybackManager.subscribe(stopThis);
1165
- }, [stopThis]);
1166
- const { audioRef, isLoading, isLoaded, waveformLevels, ensureAudioLoaded, } = useAudioBlobLoader({
1167
- src,
1168
- waveformData,
1169
- preloadedBlob,
1170
- playbackCallbacks: {
1171
- onLoadedMetadata: (dur) => {
1172
- if (!externalDuration && dur && isFinite(dur) && dur > 0.5) {
1173
- setDuration(dur);
1174
- }
1175
- },
1176
- onTimeUpdate: (time) => setCurrentTime(time),
1177
- onEnded: () => {
1178
- var _a;
1179
- setIsPlaying(false);
1180
- setCurrentTime(0);
1181
- (_a = onEndedRef.current) === null || _a === void 0 ? void 0 : _a.call(onEndedRef);
1182
- },
1183
- onPlay: () => setIsPlaying(true),
1184
- onPause: () => setIsPlaying(false),
1185
- onStop: () => {
1186
- setIsPlaying(false);
1187
- setCurrentTime(0);
1188
- },
1189
- onPreviousTrack: () => { var _a; return (_a = onPreviousTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onPreviousTrackRef); },
1190
- onNextTrack: () => { var _a; return (_a = onNextTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onNextTrackRef); },
1191
- },
1192
- });
1193
- /** Pre-fetch audio without playing (for viewport preloading) */
1194
- const prefetch = useCallback(() => {
1195
- if (isLoading || audioRef.current) {
1196
- return;
1197
- }
1198
- void ensureAudioLoaded();
1199
- }, [isLoading, ensureAudioLoaded]);
1200
- /** Load audio and auto-play after loaded */
1201
- const preload = useCallback(() => {
1202
- if (isLoading || audioRef.current) {
1203
- return;
1204
- }
1205
- audioPlaybackManager.stopOthers(stopThis);
1206
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1207
- const audio = audioRef.current;
1208
- if (audio) {
1209
- audio.play()
1210
- .then(() => setIsPlaying(true))
1211
- .catch(() => setIsPlaying(false));
1212
- }
1213
- });
1214
- }, [isLoading, ensureAudioLoaded, stopThis, audioRef]);
1215
- /** Programmatically start playback (used for chained playback) */
1216
- const play = useCallback(() => {
1217
- const audio = audioRef.current;
1218
- if (audio) {
1219
- audioPlaybackManager.stopOthers(stopThis);
1220
- audio.play()
1221
- .then(() => setIsPlaying(true))
1222
- .catch(() => setIsPlaying(false));
1223
- }
1224
- else {
1225
- preload();
1226
- }
1227
- }, [stopThis, preload, audioRef]);
1228
- /** Toggle play/pause (only works after audio is loaded) */
1229
- const togglePlayback = useCallback(() => {
1230
- const audio = audioRef.current;
1231
- if (!audio || isLoading) {
1232
- return;
1233
- }
1234
- if (audio.paused) {
1235
- audioPlaybackManager.stopOthers(stopThis);
1236
- audio.play()
1237
- .then(() => setIsPlaying(true))
1238
- .catch(() => setIsPlaying(false));
1239
- }
1240
- else {
1241
- audio.pause();
1242
- setIsPlaying(false);
1243
- }
1244
- }, [isLoading, stopThis, audioRef]);
1245
- const seekTo = useCallback((seekPercent) => {
1246
- const audio = audioRef.current;
1247
- if (!audio || !isFinite(audio.duration)) {
1248
- return;
1249
- }
1250
- const time = seekPercent * audio.duration;
1251
- audio.currentTime = time;
1252
- setCurrentTime(time);
1253
- }, [audioRef]);
1254
- /** Seek to a specific time in seconds and start playback */
1255
- const seekToTime = useCallback((seconds) => {
1256
- const audio = audioRef.current;
1257
- if (audio && isFinite(audio.duration)) {
1258
- audioPlaybackManager.stopOthers(stopThis);
1259
- audio.currentTime = Math.max(0, Math.min(seconds, audio.duration));
1260
- setCurrentTime(audio.currentTime);
1261
- audio.play()
1262
- .then(() => setIsPlaying(true))
1263
- .catch(() => setIsPlaying(false));
1264
- }
1265
- else {
1266
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1267
- const loadedAudio = audioRef.current;
1268
- if (loadedAudio) {
1269
- audioPlaybackManager.stopOthers(stopThis);
1270
- loadedAudio.currentTime = Math.max(0, Math.min(seconds, loadedAudio.duration || seconds));
1271
- setCurrentTime(loadedAudio.currentTime);
1272
- loadedAudio.play()
1273
- .then(() => setIsPlaying(true))
1274
- .catch(() => setIsPlaying(false));
1275
- }
1276
- });
1277
- }
1278
- }, [
1279
- audioRef,
1280
- stopThis,
1281
- ensureAudioLoaded,
1282
- ]);
1283
- /** Sync external duration */
1284
- useEffect(() => {
1285
- if (externalDuration) {
1286
- setDuration(externalDuration);
1287
- }
1288
- }, [externalDuration]);
1289
- return {
1290
- play,
1291
- progress,
1292
- duration,
1293
- isLoaded,
1294
- isPlaying,
1295
- isLoading,
1296
- currentTime,
1297
- waveformLevels,
1298
- seekTo,
1299
- seekToTime,
1300
- prefetch,
1301
- preload,
1302
- formatTime,
1303
- togglePlayback,
1304
- };
1305
- };
1306
-
1307
- /**
1308
- * useAudioRecorderHandlers — recording/send/cancel handlers for AudioRecorder
1309
- *
1310
- * @author: exode <hello@exode.ru>
1311
- */
1312
- const useAudioRecorderHandlers = (options) => {
1313
- const { vibrate, recorder, messages, onUploadError, } = options;
1314
- const { audioURL, isPaused, isRecording, hasRecording, stopPlayback, stopRecording, sendRecording, togglePlayback, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1315
- const shouldSendAfterStopRef = useRef(false);
1316
- const [isSending, setIsSending] = useState(false);
1317
- const startNewRecording = useCallback(async () => {
1318
- if (isRecording) {
1319
- return;
1320
- }
1321
- try {
1322
- await startRecording();
1323
- vibrate(50);
1324
- }
1325
- catch (error) {
1326
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1327
- }
1328
- }, [
1329
- vibrate,
1330
- isRecording,
1331
- onUploadError,
1332
- startRecording,
1333
- ]);
1334
- const handleRecordToggle = useCallback(() => {
1335
- if (!isRecording) {
1336
- if (audioURL) {
1337
- stopPlayback();
1338
- deleteRecording();
1339
- }
1340
- void startNewRecording();
1341
- return;
1342
- }
1343
- if (isPaused) {
1344
- stopPlayback();
1345
- }
1346
- toggleRecordingPause();
1347
- }, [
1348
- audioURL,
1349
- isPaused,
1350
- isRecording,
1351
- stopPlayback,
1352
- deleteRecording,
1353
- startNewRecording,
1354
- toggleRecordingPause,
1355
- ]);
1356
- const handleSend = useCallback(async () => {
1357
- shouldSendAfterStopRef.current = false;
1358
- setIsSending(true);
1359
- try {
1360
- stopPlayback();
1361
- await sendRecording();
1362
- }
1363
- catch (error) {
1364
- const uploadError = error instanceof Error
1365
- ? error
1366
- : new Error(messages.errors.loadFileFailed);
1367
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(uploadError);
1368
- }
1369
- finally {
1370
- setIsSending(false);
1371
- }
1372
- }, [
1373
- stopPlayback,
1374
- onUploadError,
1375
- sendRecording,
1376
- messages.errors.loadFileFailed,
1377
- ]);
1378
- const handleSendRequest = useCallback(() => {
1379
- if (isSending) {
1380
- return;
1381
- }
1382
- if (isRecording) {
1383
- setIsSending(true);
1384
- shouldSendAfterStopRef.current = true;
1385
- stopRecording();
1386
- return;
1387
- }
1388
- void handleSend();
1389
- }, [
1390
- isSending,
1391
- handleSend,
1392
- isRecording,
1393
- stopRecording,
1394
- ]);
1395
- const handleCancel = useCallback(() => {
1396
- shouldSendAfterStopRef.current = false;
1397
- if (isRecording) {
1398
- cancelRecording();
1399
- return;
1400
- }
1401
- if (audioURL) {
1402
- stopPlayback();
1403
- deleteRecording();
1404
- }
1405
- }, [
1406
- audioURL,
1407
- isRecording,
1408
- stopPlayback,
1409
- deleteRecording,
1410
- cancelRecording,
1411
- ]);
1412
- const handlePlaybackToggle = useCallback(() => {
1413
- if (!hasRecording) {
1414
- return;
1415
- }
1416
- togglePlayback();
1417
- }, [hasRecording, togglePlayback]);
1418
- /** Auto-send after stop */
1419
- useEffect(() => {
1420
- if (!isRecording
1421
- && hasRecording
1422
- && shouldSendAfterStopRef.current) {
1423
- shouldSendAfterStopRef.current = false;
1424
- void handleSend();
1425
- }
1426
- }, [
1427
- handleSend,
1428
- isRecording,
1429
- hasRecording,
1430
- ]);
1431
- return {
1432
- isSending,
1433
- handleCancel,
1434
- handleSendRequest,
1435
- handleRecordToggle,
1436
- handlePlaybackToggle,
1437
- };
1438
- };
1439
-
1440
- /**
1441
- * AudioRecorder styled components
1442
- *
1443
- * @author: exode <hello@exode.ru>
1444
- */
1445
- const Container = styled.div `
1446
- position: relative;
1447
- width: 100%;
1448
- transition: color 200ms ease, background-color 200ms ease;
1449
- color: var(--text_primary, #0a0a0a);
1450
- `;
1451
-
1452
- /**
1453
- * IdleControl styles
1454
- *
1455
- * @author: exode <hello@exode.ru>
1456
- */
1457
- const IdleWrapper = styled.div `
1458
- display: flex;
1459
- flex-direction: column;
1460
- align-items: center;
1461
- gap: 16px;
1462
- `;
1463
- const IdleButton = styled.button `
1464
- position: relative;
1465
- width: 72px;
1466
- height: 72px;
1467
- display: inline-flex;
1468
- align-items: center;
1469
- justify-content: center;
1470
- border-radius: 36px;
1471
- border: 0;
1472
- background: var(--background_content, #ffffff);
1473
- color: color-mix(in srgb, var(--accent, #3f8ae0) 80%, white 20%);
1474
- cursor: pointer;
1475
- transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
1476
-
1477
- &:hover {
1478
- transform: translateY(-2px);
1479
- }
1480
-
1481
- &:active {
1482
- transform: translateY(0);
1483
- }
1484
-
1485
- &:focus-visible {
1486
- outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
1487
- outline-offset: 4px;
1488
- }
1489
- `;
1490
- const IdleButtonOutline = styled.span `
1491
- position: absolute;
1492
- inset: 0;
1493
- border-radius: 32px;
1494
- border: 1px solid color-mix(in srgb, var(--accent, #3f8ae0) 28%, transparent);
1495
- pointer-events: none;
1496
- `;
1497
- const IdleButtonGlow = styled.span `
1498
- position: absolute;
1499
- inset: 8px;
1500
- border-radius: 28px;
1501
- background: radial-gradient(
1502
- 120% 120% at 50% 50%,
1503
- color-mix(in srgb, var(--accent, #3f8ae0) 24%, transparent) 0%,
1504
- color-mix(in srgb, var(--accent, #3f8ae0) 10%, transparent) 65%,
1505
- transparent 100%
1506
- );
1507
- filter: blur(4px);
1508
- pointer-events: none;
1509
- `;
1510
- const IdleInstruction = styled.div `
1511
- font-size: 14px;
1512
- text-align: center;
1513
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
1514
- `;
1515
-
1516
- const IdleControl = (props) => {
1517
- const { onClick, instruction, startAriaLabel, } = props;
1518
- return (jsxs(IdleWrapper, { children: [jsxs(IdleButton, { type: "button", onClick: onClick, "aria-label": startAriaLabel, children: [jsx(IdleButtonOutline, { "aria-hidden": true }), jsx(IdleButtonGlow, { "aria-hidden": true }), jsx(Icon32Microphone, { height: 48, width: 48 })] }), jsx(IdleInstruction, { children: instruction })] }));
1519
- };
1520
-
1521
- const Bars = styled.div `
1522
- position: absolute;
1523
- inset: 0;
1524
- display: flex;
1525
- align-items: flex-end;
1526
- gap: 3px;
1527
- `;
1528
- const Bar = styled.div `
1529
- flex: 1;
1530
- min-width: 2px;
1531
- border-radius: 9999px;
1532
- background: var(--white, #ffffff);
1533
- opacity: 0.6;
1534
- transition: height 120ms ease, opacity 120ms ease;
1535
- `;
1536
-
1537
- const WaveformBars = (props) => {
1538
- const { values, progress, styleInterpolator, scale = 40, minHeight = 6, divider = 100, } = props;
1539
- return (jsx(Bars, { children: values.map((value, index) => {
1540
- const normalized = clampValue(value);
1541
- const height = Math.max(minHeight, (normalized / divider) * scale);
1542
- const barProgress = values.length > 1 ? index / (values.length - 1) : 0;
1543
- const isPlayed = (progress !== null && progress !== void 0 ? progress : 0) > 0 && barProgress <= (progress !== null && progress !== void 0 ? progress : 0);
1544
- const interpolated = styleInterpolator === null || styleInterpolator === void 0 ? void 0 : styleInterpolator({
1545
- normalized,
1546
- height,
1547
- index,
1548
- progress: progress !== null && progress !== void 0 ? progress : 0,
1549
- barProgress,
1550
- isPlayed,
1551
- });
1552
- return (jsx(Bar, { style: {
1553
- height: `${height}px`,
1554
- ...interpolated,
1555
- } }, index));
1556
- }) }));
1557
- };
1558
-
1559
- /**
1560
- * RecordingShell styles
1561
- *
1562
- * @author: exode <hello@exode.ru>
1563
- */
1564
- const Wrapper$1 = styled.div `
1565
- display: flex;
1566
- align-items: center;
1567
- gap: 12px;
1568
- padding: 10px 14px;
1569
- border-radius: 9999px;
1570
- background: var(--background_hover, color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent));
1571
- `;
1572
- const CancelButton$1 = styled.button `
1573
- width: 38px;
1574
- height: 38px;
1575
- display: inline-flex;
1576
- align-items: center;
1577
- justify-content: center;
1578
- border-radius: 19px;
1579
- background: color-mix(in srgb, var(--exode_red) 10%, transparent);
1580
- border: 0;
1581
- flex-shrink: 0;
1582
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 54%, transparent);
1583
- cursor: pointer;
1584
- transition: transform 160ms ease;
1585
-
1586
- &:hover:not(:disabled) {
1587
- transform: translateY(-1px);
1588
- }
1589
-
1590
- &:active:not(:disabled) {
1591
- transform: translateY(0);
1592
- }
1593
- `;
1594
- const Track = styled.div `
1595
- flex: 1;
1596
- display: flex;
1597
- align-items: center;
1598
- gap: 12px;
1599
- padding: 8px 10px;
1600
- border-radius: 9999px;
1601
- background: color-mix(in srgb, var(--text_primary, #0a0a0a) 10%, transparent);
1602
- `;
1603
- const CircleButton = styled.button `
1604
- width: 38px;
1605
- height: 38px;
1606
- display: inline-flex;
1607
- align-items: center;
1608
- justify-content: center;
1609
- border-radius: 19px;
1610
- border: 0;
1611
- cursor: pointer;
1612
- transition: transform 160ms ease, color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
1613
- flex-shrink: 0;
1614
- background: var(--background_content, #ffffff);
1615
-
1616
- color: ${({ $variant }) => {
1617
- if ($variant === 'record') {
1618
- return 'var(--exode_red, #ed1c24)';
1619
- }
1620
- if ($variant === 'resume') {
1621
- return 'color-mix(in srgb, var(--accent, #3f8ae0) 86%, white 14%)';
1622
- }
1623
- return 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)';
1624
- }};
1625
-
1626
- &:hover:not(:disabled) {
1627
- transform: translateY(-1px);
1628
- }
1629
-
1630
- &:active:not(:disabled) {
1631
- transform: translateY(0);
1632
- }
1633
-
1634
- &:focus-visible {
1635
- outline: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 70%, transparent);
1636
- outline-offset: 2px;
1637
- }
1638
-
1639
- &:disabled {
1640
- cursor: not-allowed;
1641
- opacity: 0.45;
1642
- transform: none;
1643
- }
1644
- `;
1645
- const Waveform = styled.div `
1646
- position: relative;
1647
- flex: ${({ $expanded }) => ($expanded ? 1.4 : 1)};
1648
- height: 34px;
1649
- display: flex;
1650
- align-items: center;
1651
- overflow: hidden;
1652
- `;
1653
- const Timer$1 = styled.span `
1654
- text-align: right;
1655
- font-size: 13px;
1656
- font-weight: 600;
1657
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 86%, transparent);
1658
- `;
1659
- const SendButton$1 = styled.button `
1660
- width: 40px;
1661
- height: 40px;
1662
- display: inline-flex;
1663
- align-items: center;
1664
- justify-content: center;
1665
- border-radius: 20px;
1666
- border: 0;
1667
- cursor: pointer;
1668
- background: var(--accent, #3f8ae0);
1669
- color: var(--white, #ffffff);
1670
- transition: transform 160ms ease, opacity 160ms ease;
1671
-
1672
- &:hover:not(:disabled) {
1673
- transform: translateY(-1px);
1674
- }
1675
-
1676
- &:active:not(:disabled) {
1677
- transform: translateY(0);
1678
- }
1679
-
1680
- &:disabled {
1681
- opacity: 0.45;
1682
- cursor: not-allowed;
1683
- }
1684
- `;
1685
- const SpinnerSlot = styled.div `
1686
- width: 22px;
1687
- height: 22px;
1688
- display: inline-flex;
1689
- align-items: center;
1690
- justify-content: center;
1691
- `;
1692
- const Spinner = styled.span `
1693
- width: 100%;
1694
- height: 100%;
1695
- display: inline-block;
1696
- box-sizing: border-box;
1697
- border-radius: 50%;
1698
- border: 2px solid rgba(255, 255, 255, 0.3);
1699
- border-top-color: var(--white, #ffffff);
1700
- border-right-color: var(--white, #ffffff);
1701
- animation: audioRecorderSpinner 0.75s linear infinite;
1702
-
1703
- @keyframes audioRecorderSpinner {
1704
- from {
1705
- transform: rotate(0deg);
1706
- }
1707
-
1708
- to {
1709
- transform: rotate(360deg);
1710
- }
1711
- }
1712
- `;
1713
-
1714
- const RecordingShell = (props) => {
1715
- 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;
1716
- const { lastDuration } = props;
1717
- const isPreview = !isRecording || isPaused;
1718
- const waveformValues = isRecording ? audioLevels : (hasRecording ? previewLevels : audioLevels);
1719
- const effectiveDuration = recordingTime
1720
- || playbackDuration
1721
- || lastDuration
1722
- || 1;
1723
- const progress = (isPreview && isPlaying)
1724
- ? Math.min(1, playbackTime / effectiveDuration)
1725
- : 0;
1726
- const displayTime = useMemo(() => {
1727
- if (isPlaying) {
1728
- return formatTime(playbackTime);
1729
- }
1730
- if (isRecording && !isPaused) {
1731
- return formatTime(recordingTime);
1732
- }
1733
- if (isRecording && isPaused) {
1734
- return formatTime(recordingTime);
1735
- }
1736
- if (lastDuration > 0) {
1737
- return formatTime(lastDuration);
1738
- }
1739
- if (hasRecording) {
1740
- if (playbackDuration > 0.5) {
1741
- return formatTime(playbackDuration);
1742
- }
1743
- if (recordingTime > 0) {
1744
- return formatTime(recordingTime);
1745
- }
1746
- }
1747
- return formatTime(0);
1748
- }, [
1749
- isPaused,
1750
- isPlaying,
1751
- formatTime,
1752
- isRecording,
1753
- lastDuration,
1754
- hasRecording,
1755
- playbackTime,
1756
- recordingTime,
1757
- playbackDuration,
1758
- ]);
1759
- const recordButtonDisabled = isSending;
1760
- const recordButtonIcon = !isRecording || isPaused
1761
- ? jsx(Icon16Microphone, { width: 24, height: 24 })
1762
- : jsx(Icon16Pause, { width: 24, height: 24 });
1763
- const recordButtonVariant = !isRecording || isPaused ? 'resume' : 'record';
1764
- const recordButtonAria = !isRecording
1765
- ? startAriaLabel
1766
- : isPaused
1767
- ? resumeRecordingAriaLabel
1768
- : pauseRecordingAriaLabel || stopAriaLabel;
1769
- const canShowPlayback = !isRecording || isPaused;
1770
- const playbackDisabled = !hasRecording && !isRecording;
1771
- const playbackIcon = isPlaying
1772
- ? jsx(Icon16Pause, { width: 24, height: 24 })
1773
- : jsx(Icon16Play, { width: 24, height: 24 });
1774
- const playbackAria = isPlaying ? pausePlaybackAriaLabel : playAriaLabel;
1775
- const sendDisabled = isSending || (!hasRecording && !isRecording);
1776
- return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 24, height: 24, style: { minWidth: 24 } }) }), jsxs(Track, { children: [jsx(CircleButton, { type: "button", "$variant": recordButtonVariant, onClick: onRecordToggle, "aria-label": recordButtonAria, disabled: recordButtonDisabled, children: recordButtonIcon }), !canShowPlayback ? null : (jsx(CircleButton, { type: "button", "$variant": "play", onClick: onTogglePlayback, "aria-label": playbackAria, disabled: playbackDisabled, children: playbackIcon })), jsx(Waveform, { "$expanded": canShowPlayback, children: jsx(WaveformBars, { scale: 42, minHeight: 6, divider: 100, progress: progress, values: waveformValues, styleInterpolator: ({ normalized, isPlayed, progress: p }) => ({
1777
- height: `${12 + normalized / 2}px`,
1778
- maxHeight: '34px',
1779
- opacity: p > 0
1780
- ? (isPlayed ? 0.80 : 0.22)
1781
- : 0.38 + Math.min(0.5, normalized / 120),
1782
- transition: 'opacity 80ms ease',
1783
- }) }) }), jsx(Timer$1, { children: displayTime })] }), jsx(SendButton$1, { type: "button", onClick: onSend, disabled: sendDisabled, "aria-label": sendAriaLabel, children: isSending ? (jsx(SpinnerSlot, { children: jsx(Spinner, {}) })) : (jsx(Icon28SendOutline, { width: 24, height: 24 })) })] }));
1784
- };
1785
-
1786
- /**
1787
- * AudioRecorder messages — i18n-ready message definitions and merge utility
1788
- *
1789
- * @author: exode <hello@exode.ru>
1790
- */
1791
- const DEFAULT_MESSAGES = {
1792
- buttons: {
1793
- cancel: 'Cancel',
1794
- send: 'Send',
1795
- },
1796
- instruction: 'Tap to start recording',
1797
- aria: {
1798
- startRecording: 'Start recording voice message',
1799
- stopRecording: 'Stop recording voice message',
1800
- pauseRecording: 'Pause recording',
1801
- resumeRecording: 'Resume recording',
1802
- playRecording: 'Play recording',
1803
- pausePlayback: 'Pause playback',
1804
- },
1805
- errors: {
1806
- loadFileFailed: 'Failed to upload audio file',
1807
- },
1808
- };
1809
- const mergeMessages = (custom) => {
1810
- var _a;
1811
- if (!custom) {
1812
- return DEFAULT_MESSAGES;
1813
- }
1814
- return {
1815
- buttons: {
1816
- ...DEFAULT_MESSAGES.buttons,
1817
- ...custom.buttons,
1818
- },
1819
- instruction: (_a = custom.instruction) !== null && _a !== void 0 ? _a : DEFAULT_MESSAGES.instruction,
1820
- aria: {
1821
- ...DEFAULT_MESSAGES.aria,
1822
- ...custom.aria,
1823
- },
1824
- errors: {
1825
- ...DEFAULT_MESSAGES.errors,
1826
- ...custom.errors,
1827
- },
1828
- };
1829
- };
1830
-
1831
- const AudioRecorder = (props) => {
1832
- const { fileName, uploadId, mimeType, className, uploader, onUploaded, onUploadError, onRecordingStop, onRecordingError, onRecordingStart, onRecordingDelete, onPermissionDenied, autoResetAfterUpload, messages: customMessages, } = props;
1833
- const messages = useMemo(() => mergeMessages(customMessages), [customMessages]);
1834
- const [resolvedUploadId] = useState(() => uploadId !== null && uploadId !== void 0 ? uploadId : (typeof crypto !== 'undefined' && 'randomUUID' in crypto
1835
- ? crypto.randomUUID()
1836
- : `upload-${Date.now()}`));
1837
- const handleRecorderError = useCallback((error) => {
1838
- onRecordingError === null || onRecordingError === void 0 ? void 0 : onRecordingError(error);
1839
- onUploadError === null || onUploadError === void 0 ? void 0 : onUploadError(error);
1840
- }, [onRecordingError, onUploadError]);
1841
- const vibrate = useCallback((pattern) => {
1842
- if (typeof navigator !== 'undefined' && 'vibrate' in navigator) {
1843
- navigator.vibrate(pattern);
1844
- }
1845
- }, []);
1846
- const recorder = useVoiceRecorder({
1847
- fileName,
1848
- mimeType,
1849
- onRecordingStop,
1850
- onRecordingStart,
1851
- onRecordingDelete,
1852
- onPermissionDenied,
1853
- autoResetAfterUpload,
1854
- uploader,
1855
- uploadId: resolvedUploadId,
1856
- onRecordingError: handleRecorderError,
1857
- onUploaded: onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { }),
1858
- });
1859
- const { isSending, handleCancel, handleSendRequest, handleRecordToggle, handlePlaybackToggle, } = useAudioRecorderHandlers({
1860
- vibrate,
1861
- recorder,
1862
- messages,
1863
- onUploadError,
1864
- });
1865
- const { isPaused, isPlaying, formatTime, audioLevels, isRecording, lastDuration, playbackTime, hasRecording, previewLevels, recordingTime, playbackDuration, } = recorder;
1866
- const showRecorderBar = isRecording || hasRecording || isSending;
1867
- return (jsx(Container, { className: className, children: showRecorderBar ? (jsx(RecordingShell, { isPaused: isPaused, isSending: isSending, isPlaying: isPlaying, formatTime: formatTime, onCancel: handleCancel, isRecording: isRecording, audioLevels: audioLevels, onSend: handleSendRequest, playbackTime: playbackTime, lastDuration: lastDuration, hasRecording: hasRecording, previewLevels: previewLevels, recordingTime: recordingTime, playbackDuration: playbackDuration, onRecordToggle: handleRecordToggle, sendAriaLabel: messages.buttons.send, onTogglePlayback: handlePlaybackToggle, cancelAriaLabel: messages.buttons.cancel, playAriaLabel: messages.aria.playRecording, stopAriaLabel: messages.aria.stopRecording, startAriaLabel: messages.aria.startRecording, pauseRecordingAriaLabel: messages.aria.pauseRecording, resumeRecordingAriaLabel: messages.aria.resumeRecording, pausePlaybackAriaLabel: messages.aria.pausePlayback })) : (jsx(IdleControl, { onClick: handleRecordToggle, startAriaLabel: messages.aria.startRecording, instruction: messages.instruction })) }));
1868
- };
1869
-
1870
- /**
1871
- * useVoiceRecorderInput — orchestrates recording, sending and timeline for VoiceRecorderInput
1872
- *
1873
- * @author: exode <hello@exode.ru>
1874
- */
1875
- const useVoiceRecorderInput = (options) => {
1876
- const { onSend, onError, onCancel, uploader, onUploaded, onPermissionDenied, autoStart = true, mimeType = 'audio/webm', fileName = 'voice-message.webm', } = options;
1877
- const [isSending, setIsSending] = useState(false);
1878
- const [timelineLevels, setTimelineLevels] = useState([]);
1879
- const audioBlobRef = useRef(null);
1880
- const audioLevelsRef = useRef([]);
1881
- const lastDurationRef = useRef(0);
1882
- const shouldSendAfterStopRef = useRef(false);
1883
- const doSendRef = useRef();
1884
- const autoStartedRef = useRef(false);
1885
- const [uploadId] = useState(() => typeof crypto !== 'undefined' && 'randomUUID' in crypto
1886
- ? crypto.randomUUID()
1887
- : `upload-${Date.now()}`);
1888
- const handleRecordingStop = useCallback((blob) => {
1889
- var _a;
1890
- audioBlobRef.current = blob;
1891
- if (shouldSendAfterStopRef.current) {
1892
- shouldSendAfterStopRef.current = false;
1893
- void ((_a = doSendRef.current) === null || _a === void 0 ? void 0 : _a.call(doSendRef));
1894
- }
1895
- }, []);
1896
- const recorder = useVoiceRecorder({
1897
- fileName,
1898
- mimeType,
1899
- uploadId,
1900
- onPermissionDenied,
1901
- onRecordingStop: handleRecordingStop,
1902
- onRecordingError: onError,
1903
- uploader: onSend ? undefined : uploader,
1904
- onUploaded: onSend ? (() => { }) : (onUploaded !== null && onUploaded !== void 0 ? onUploaded : (() => { })),
1905
- });
1906
- const { audioLevels, isPaused, isRecording, recordingTime, hasRecording, stopPlayback, sendRecording, stopRecording, startRecording, cancelRecording, deleteRecording, toggleRecordingPause, } = recorder;
1907
- audioLevelsRef.current = audioLevels;
1908
- useEffect(() => {
1909
- if (recordingTime > 0) {
1910
- lastDurationRef.current = recordingTime;
1911
- }
1912
- }, [recordingTime]);
1913
- const isPreview = isPaused || (!isRecording && hasRecording);
1914
- const isActiveRecording = isRecording && !isPaused;
1915
- useEffect(() => {
1916
- if (!isActiveRecording) {
1917
- return;
1918
- }
1919
- const interval = setInterval(() => {
1920
- const levels = audioLevelsRef.current;
1921
- const peak = levels.length > 0 ? Math.max(...levels) : 0;
1922
- setTimelineLevels((prev) => [...prev, peak]);
1923
- }, TIMELINE_SAMPLE_INTERVAL);
1924
- return () => clearInterval(interval);
1925
- }, [isActiveRecording]);
1926
- useEffect(() => {
1927
- if (isRecording && !isPaused) {
1928
- setTimelineLevels([]);
1929
- }
1930
- }, [isRecording]);
1931
- const handleResume = useCallback(() => {
1932
- stopPlayback();
1933
- toggleRecordingPause();
1934
- }, [
1935
- stopPlayback,
1936
- toggleRecordingPause,
1937
- ]);
1938
- const handlePause = toggleRecordingPause;
1939
- const handleCancel = useCallback(() => {
1940
- shouldSendAfterStopRef.current = false;
1941
- if (isRecording) {
1942
- cancelRecording();
1943
- }
1944
- else if (hasRecording) {
1945
- stopPlayback();
1946
- deleteRecording();
1947
- }
1948
- onCancel === null || onCancel === void 0 ? void 0 : onCancel();
1949
- }, [
1950
- onCancel,
1951
- isRecording,
1952
- hasRecording,
1953
- stopPlayback,
1954
- cancelRecording,
1955
- deleteRecording,
1956
- ]);
1957
- const doSend = useCallback(async () => {
1958
- setIsSending(true);
1959
- try {
1960
- stopPlayback();
1961
- if (onSend) {
1962
- if (!audioBlobRef.current) {
1963
- return;
1964
- }
1965
- const file = new File([audioBlobRef.current], fileName, { type: audioBlobRef.current.type || mimeType });
1966
- await onSend(file, recordingTime || lastDurationRef.current || 0);
1967
- deleteRecording();
1968
- }
1969
- else {
1970
- await sendRecording();
1971
- }
1972
- }
1973
- catch (error) {
1974
- onError === null || onError === void 0 ? void 0 : onError(error);
1975
- }
1976
- finally {
1977
- setIsSending(false);
1978
- }
1979
- }, [
1980
- onSend,
1981
- onError,
1982
- fileName,
1983
- mimeType,
1984
- recordingTime,
1985
- sendRecording,
1986
- stopPlayback,
1987
- deleteRecording,
1988
- ]);
1989
- doSendRef.current = doSend;
1990
- const handleSendRequest = useCallback(async () => {
1991
- if (isSending) {
1992
- return;
1993
- }
1994
- if (isRecording) {
1995
- shouldSendAfterStopRef.current = true;
1996
- stopRecording();
1997
- return;
1998
- }
1999
- await doSend();
2000
- }, [
2001
- doSend,
2002
- isRecording,
2003
- isSending,
2004
- stopRecording,
2005
- ]);
2006
- useEffect(() => {
2007
- if (autoStart && !autoStartedRef.current) {
2008
- autoStartedRef.current = true;
2009
- void startRecording().catch((error) => {
2010
- onPermissionDenied === null || onPermissionDenied === void 0 ? void 0 : onPermissionDenied(error);
2011
- });
2012
- }
2013
- }, [autoStart]);
2014
- return {
2015
- recorder,
2016
- isSending,
2017
- isPreview,
2018
- timelineLevels,
2019
- lastDurationRef,
2020
- handleResume,
2021
- handlePause,
2022
- handleCancel,
2023
- handleSendRequest,
2024
- };
2025
- };
2026
-
2027
- const PreviewBars = (props) => {
2028
- var _a, _b;
2029
- const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, visibleBarsCount, } = props;
2030
- const maxBars = visibleBarsCount;
2031
- const sourceLevels = timelineLevels.length > 0
2032
- ? timelineLevels
2033
- : previewLevels;
2034
- if (!sourceLevels.length) {
2035
- return (jsx(Fragment, { children: Array.from({ length: maxBars }, (_, i) => (jsx("div", { style: {
2036
- width: `${TIMELINE_BAR_WIDTH}px`,
2037
- height: `${TIMELINE_BAR_MIN_HEIGHT}px`,
2038
- borderRadius: '9999px',
2039
- flexShrink: 0,
2040
- background: 'color-mix(in srgb, var(--text_primary, #0a0a0a) 15%, transparent)',
2041
- } }, i))) }));
2042
- }
2043
- /** Interpolate source levels to fill container */
2044
- const bars = [];
2045
- for (let i = 0; i < maxBars; i++) {
2046
- const srcIndex = (i / maxBars) * sourceLevels.length;
2047
- const low = Math.floor(srcIndex);
2048
- const high = Math.min(low + 1, sourceLevels.length - 1);
2049
- const frac = srcIndex - low;
2050
- bars.push(((_a = sourceLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac) + ((_b = sourceLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
2051
- }
2052
- const totalDuration = recordingTime || lastDuration || 1;
2053
- const progress = isPlaying ? Math.min(1, playbackTime / totalDuration) : 0;
2054
- return (jsx(Fragment, { children: bars.map((level, i) => {
2055
- const normalized = Math.min(100, level);
2056
- const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
2057
- const isPlayed = progress > 0 && (i / maxBars) <= progress;
2058
- return (jsx("div", { style: {
2059
- flexShrink: 0,
2060
- height: `${height}px`,
2061
- borderRadius: '9999px',
2062
- width: `${TIMELINE_BAR_WIDTH}px`,
2063
- background: !isPlaying
2064
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
2065
- : isPlayed
2066
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 80%, transparent)'
2067
- : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 22%, transparent)',
2068
- transition: 'background 80ms ease',
2069
- } }, i));
2070
- }) }));
2071
- };
2072
-
2073
- const TimelineBars = (props) => {
2074
- const { timelineLevels, visibleBarsCount } = props;
2075
- const maxBars = visibleBarsCount;
2076
- const visible = timelineLevels.slice(-maxBars);
2077
- const padded = [
2078
- ...visible,
2079
- ...Array.from({ length: Math.max(0, maxBars - visible.length) }, () => 0),
2080
- ];
2081
- return (jsx(Fragment, { children: padded.map((level, i) => {
2082
- const normalized = Math.min(100, level);
2083
- const height = TIMELINE_BAR_MIN_HEIGHT + (normalized / 100) * (TIMELINE_BAR_MAX_HEIGHT - TIMELINE_BAR_MIN_HEIGHT);
2084
- const hasValue = i < visible.length;
2085
- return (jsx("div", { style: {
2086
- width: `${TIMELINE_BAR_WIDTH}px`,
2087
- height: `${height}px`,
2088
- borderRadius: '9999px',
2089
- flexShrink: 0,
2090
- transition: 'height 120ms ease',
2091
- background: hasValue
2092
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent)'
2093
- : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent)',
2094
- } }, i));
2095
- }) }));
2096
- };
2097
-
2098
- /**
2099
- * VoiceRecorderInput styles
2100
- *
2101
- * @author: exode <hello@exode.ru>
2102
- */
2103
- const Wrapper = styled.div `
2104
- display: flex;
2105
- align-items: center;
2106
- width: 100%;
2107
- gap: 6px;
2108
- padding: 0 4px;
2109
- color: var(--text_primary, #0a0a0a);
2110
- `;
2111
- const CancelButton = styled.button `
2112
- width: 32px;
2113
- height: 32px;
2114
- display: inline-flex;
2115
- align-items: center;
2116
- justify-content: center;
2117
- border-radius: 16px;
2118
- background: transparent;
2119
- border: 0;
2120
- margin-right: 4px;
2121
- flex-shrink: 0;
2122
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 45%, transparent);
2123
- cursor: pointer;
2124
- transition: color 160ms ease, background-color 160ms ease;
2125
- `;
2126
- const RecordTrack = styled.div `
2127
- flex: 1;
2128
- display: flex;
2129
- align-items: center;
2130
- gap: 4px;
2131
- padding: 4px 10px 4px 4px;
2132
- border-radius: 9999px;
2133
- background: color-mix(in srgb, var(--text_primary, #0a0a0a) 8%, transparent);
2134
- min-width: 0;
2135
- `;
2136
- /** Container for control buttons — auto-sized, no fixed width */
2137
- const ButtonSlot = styled.div `
2138
- display: flex;
2139
- align-items: center;
2140
- gap: 4px;
2141
- padding: 2px;
2142
- flex-shrink: 0;
2143
- `;
2144
- const StopButton = styled.button `
2145
- width: 26px;
2146
- height: 26px;
2147
- display: inline-flex;
2148
- align-items: center;
2149
- justify-content: center;
2150
- border-radius: 13px;
2151
- border: 0;
2152
- flex-shrink: 0;
2153
- cursor: pointer;
2154
- background: var(--background_content, #ffffff);
2155
- color: var(--exode_red, #ed1c24);
2156
- transition: transform 120ms ease;
2157
-
2158
- &:hover:not(:disabled) {
2159
- transform: scale(1.05);
2160
- }
2161
-
2162
- &:active:not(:disabled) {
2163
- transform: scale(0.96);
2164
- }
2165
-
2166
- &:disabled {
2167
- opacity: 0.4;
2168
- cursor: not-allowed;
2169
- }
2170
- `;
2171
- const WaveformArea = styled.div `
2172
- flex: 1;
2173
- height: 28px;
2174
- display: flex;
2175
- align-items: flex-end;
2176
- justify-content: flex-start;
2177
- gap: 2px;
2178
- overflow: hidden;
2179
- min-width: 0;
2180
- padding-bottom: 2px;
2181
- `;
2182
- const Timer = styled.span `
2183
- font-size: 13px;
2184
- font-weight: 500;
2185
- flex-shrink: 0;
2186
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 60%, transparent);
2187
- font-variant-numeric: tabular-nums;
2188
- `;
2189
- const SendButton = styled.button `
2190
- width: 32px;
2191
- height: 32px;
2192
- display: inline-flex;
2193
- align-items: center;
2194
- justify-content: center;
2195
- border-radius: 16px;
2196
- border: 0;
2197
- flex-shrink: 0;
2198
- cursor: pointer;
2199
- background: transparent;
2200
- color: var(--accent, #3f8ae0);
2201
- transition: opacity 140ms ease;
2202
- padding: 0;
2203
-
2204
- &:hover:not(:disabled) {
2205
- opacity: 0.75;
2206
- }
2207
-
2208
- &:disabled {
2209
- opacity: 0.35;
2210
- cursor: not-allowed;
2211
- }
2212
- `;
2213
- const MicIndicator = styled.button `
2214
- width: 26px;
2215
- height: 26px;
2216
- display: inline-flex;
2217
- align-items: center;
2218
- justify-content: center;
2219
- border-radius: 13px;
2220
- border: 0;
2221
- flex-shrink: 0;
2222
- cursor: pointer;
2223
- background: var(--background_content, #ffffff);
2224
- color: color-mix(in srgb, var(--accent, #3f8ae0) 86%, white 14%);
2225
- transition: background-color 140ms ease, transform 120ms ease;
2226
-
2227
- &:active {
2228
- transform: scale(0.94);
2229
- }
2230
- `;
2231
- const PlayPauseButton = styled.button `
2232
- width: 26px;
2233
- height: 26px;
2234
- display: inline-flex;
2235
- align-items: center;
2236
- justify-content: center;
2237
- border-radius: 13px;
2238
- border: 0;
2239
- flex-shrink: 0;
2240
- cursor: pointer;
2241
- background: var(--background_content, #ffffff);
2242
- color: var(--text_primary, #0a0a0a);
2243
- transition: transform 120ms ease;
2244
-
2245
- &:hover {
2246
- transform: scale(1.06);
2247
- }
2248
-
2249
- &:active {
2250
- transform: scale(0.94);
2251
- }
2252
- `;
2253
- const SpinnerIcon = styled.span `
2254
- width: 20px;
2255
- height: 20px;
2256
- display: inline-block;
2257
- border-radius: 50%;
2258
- border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
2259
- border-top-color: var(--accent, #3f8ae0);
2260
- border-right-color: var(--accent, #3f8ae0);
2261
- animation: voiceRecorderSpin 0.75s linear infinite;
2262
-
2263
- @keyframes voiceRecorderSpin {
2264
- from { transform: rotate(0deg); }
2265
- to { transform: rotate(360deg); }
2266
- }
2267
- `;
2268
-
2269
- const VoiceRecorderInput = (props) => {
2270
- const { className, ...inputOptions } = props;
2271
- const waveformRef = useRef(null);
2272
- const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
2273
- const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
2274
- const [visibleBarsCount, setVisibleBarsCount] = useState(0);
2275
- const measureBars = () => {
2276
- const el = waveformRef.current;
2277
- if (!el) {
2278
- return;
2279
- }
2280
- setVisibleBarsCount(Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP)));
2281
- };
2282
- /** Sync recalc before paint — catches layout shifts from button changes */
2283
- useLayoutEffect(measureBars);
2284
- /** ResizeObserver for external size changes (window resize, container resize) */
2285
- useEffect(() => {
2286
- const el = waveformRef.current;
2287
- if (!el) {
2288
- return;
2289
- }
2290
- let rafId = 0;
2291
- const observer = new ResizeObserver(() => {
2292
- cancelAnimationFrame(rafId);
2293
- rafId = requestAnimationFrame(measureBars);
2294
- });
2295
- observer.observe(el);
2296
- return () => {
2297
- cancelAnimationFrame(rafId);
2298
- observer.disconnect();
2299
- };
2300
- }, []);
2301
- const displayTime = (isPreview && isPlaying)
2302
- ? formatTime(playbackTime)
2303
- : formatTime(recordingTime || lastDurationRef.current);
2304
- return (jsxs(Wrapper, { className: className, children: [jsx(CancelButton, { type: "button", onClick: handleCancel, "aria-label": "Cancel", children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 28, height: 28, style: { minWidth: 28 } }) }), jsxs(RecordTrack, { children: [jsx(ButtonSlot, { children: isPreview ? (jsxs(Fragment, { children: [jsx(MicIndicator, { type: "button", onClick: handleResume, "aria-label": "Resume recording", children: jsx(Icon20Voice, { width: 14, height: 14 }) }), jsx(PlayPauseButton, { type: "button", onClick: togglePlayback, "aria-label": isPlaying ? 'Pause' : 'Play', children: isPlaying
2305
- ? jsx(Icon16Pause, { width: 14, height: 14 })
2306
- : jsx(Icon16Play, { width: 14, height: 14 }) })] })) : (jsx(StopButton, { type: "button", onClick: handlePause, "aria-label": "Pause recording", children: jsx(Icon16Pause, { width: 14, height: 14 }) })) }), jsx(WaveformArea, { ref: waveformRef, children: isPreview ? (jsx(PreviewBars, { timelineLevels: timelineLevels, previewLevels: previewLevels, isPlaying: isPlaying, playbackTime: playbackTime, recordingTime: recordingTime, lastDuration: lastDurationRef.current, visibleBarsCount: visibleBarsCount })) : (jsx(TimelineBars, { timelineLevels: timelineLevels, visibleBarsCount: visibleBarsCount })) }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
2307
- };
2308
-
2309
- /**
2310
- * VoicePlaybackChain
2311
- *
2312
- * @description Sequential voice message playback chain (like Telegram).
2313
- * Registers voice players by media ID and plays the next one when current ends.
2314
- *
2315
- * @author: exode <hello@exode.ru>
2316
- */
2317
- class VoicePlaybackChainClass {
2318
- constructor() {
2319
- this.entries = [];
2320
- /** Register a voice player. Returns cleanup function for useEffect. */
2321
- this.register = (id, play) => {
2322
- this.entries = this.entries.filter((e) => e.id !== id);
2323
- this.entries.push({ id, play });
2324
- this.entries.sort((a, b) => a.id - b.id);
2325
- return () => this.unregister(id);
2326
- };
2327
- /** Unregister a voice player by media ID */
2328
- this.unregister = (id) => {
2329
- this.entries = this.entries.filter((e) => e.id !== id);
2330
- };
2331
- /** Play the next voice message after the given media ID */
2332
- this.playNext = (currentId) => {
2333
- const idx = this.entries.findIndex((e) => e.id === currentId);
2334
- if (idx >= 0 && idx < this.entries.length - 1) {
2335
- this.entries[idx + 1].play();
2336
- }
2337
- };
2338
- /** Play the previous voice message before the given media ID */
2339
- this.playPrevious = (currentId) => {
2340
- const idx = this.entries.findIndex((e) => e.id === currentId);
2341
- if (idx > 0) {
2342
- this.entries[idx - 1].play();
2343
- }
2344
- };
2345
- }
2346
- }
2347
- const VoicePlaybackChain = new VoicePlaybackChainClass();
2348
-
2349
- /**
2350
- * VoiceMessagePlayer styles
2351
- *
2352
- * @author: exode <hello@exode.ru>
2353
- */
2354
- const PlayerWrapper = styled.div `
2355
- display: flex;
2356
- align-items: center;
2357
- gap: 8px;
2358
- min-width: 200px;
2359
- padding: 2px 0;
2360
- user-select: none;
2361
- `;
2362
- const PlayButton = styled.button `
2363
- width: 44px;
2364
- height: 44px;
2365
- display: inline-flex;
2366
- align-items: center;
2367
- justify-content: center;
2368
- border-radius: 22px;
2369
- border: 0;
2370
- cursor: pointer;
2371
- flex-shrink: 0;
2372
- background: var(--white, #ffffff);
2373
- color: var(--accent, #3f8ae0);
2374
- transition: transform 140ms ease, box-shadow 140ms ease;
2375
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
2376
- padding: 0;
2377
-
2378
- &:hover:not(:disabled) {
2379
- transform: scale(1.04);
2380
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.14);
2381
- }
2382
-
2383
- &:active:not(:disabled) {
2384
- transform: scale(0.97);
2385
- box-shadow: 0 0 2px rgba(0, 0, 0, 0.08);
2386
- }
2387
-
2388
- &:disabled {
2389
- opacity: 0.5;
2390
- cursor: not-allowed;
2391
- }
2392
- `;
2393
- const ContentColumn = styled.div `
2394
- display: flex;
2395
- flex-direction: column;
2396
- flex: 1;
2397
- min-width: 0;
2398
- gap: 4px;
2399
- `;
2400
- const WaveformTrack = styled.div `
2401
- height: 28px;
2402
- display: flex;
2403
- align-items: center;
2404
- gap: 2px;
2405
- overflow: hidden;
2406
- cursor: pointer;
2407
- min-width: 0;
2408
- `;
2409
- const WaveformBar = styled.div `
2410
- width: 3px;
2411
- border-radius: 1.5px;
2412
- flex-shrink: 0;
2413
- transition: background 100ms ease;
2414
- `;
2415
- const Duration = styled.span `
2416
- font-size: 12px;
2417
- font-weight: 500;
2418
- line-height: 1;
2419
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 55%, transparent);
2420
- font-variant-numeric: tabular-nums;
2421
- `;
2422
- const LoadingSpinner = styled.span `
2423
- width: 18px;
2424
- height: 18px;
2425
- display: inline-block;
2426
- border-radius: 50%;
2427
- border: 2px solid color-mix(in srgb, var(--accent, #3f8ae0) 25%, transparent);
2428
- border-top-color: var(--accent, #3f8ae0);
2429
- animation: voicePlayerSpin 0.7s linear infinite;
2430
-
2431
- @keyframes voicePlayerSpin {
2432
- from { transform: rotate(0deg); }
2433
- to { transform: rotate(360deg); }
2434
- }
2435
- `;
2436
-
2437
- const BAR_WIDTH = 3;
2438
- const BAR_GAP = 2;
2439
- const BAR_MIN_HEIGHT = 2;
2440
- const BAR_MAX_HEIGHT = 24;
2441
- const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2442
- const { src, onClick, onEnded, chainId, duration, className, waveformData, autoPreload = true, } = props;
2443
- const trackRef = useRef(null);
2444
- const wrapperRef = useRef(null);
2445
- const [trackWidth, setTrackWidth] = useState(0);
2446
- const handleEnded = useCallback(() => {
2447
- onEnded === null || onEnded === void 0 ? void 0 : onEnded();
2448
- if (chainId != null) {
2449
- VoicePlaybackChain.playNext(chainId);
2450
- }
2451
- }, [onEnded, chainId]);
2452
- const handleNextTrack = useCallback(() => {
2453
- if (chainId != null) {
2454
- VoicePlaybackChain.playNext(chainId);
2455
- }
2456
- }, [chainId]);
2457
- const handlePreviousTrack = useCallback(() => {
2458
- if (chainId != null) {
2459
- VoicePlaybackChain.playPrevious(chainId);
2460
- }
2461
- }, [chainId]);
2462
- const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, seekToTime, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2463
- src,
2464
- duration,
2465
- waveformData,
2466
- preloadedBlob: props.preloadedBlob,
2467
- onEnded: handleEnded,
2468
- onNextTrack: handleNextTrack,
2469
- onPreviousTrack: handlePreviousTrack,
2470
- });
2471
- useImperativeHandle(ref, () => ({ play, prefetch, seekToTime }), [
2472
- play,
2473
- prefetch,
2474
- seekToTime,
2475
- ]);
2476
- /** Auto-preload audio when player enters viewport */
2477
- useEffect(() => {
2478
- if (!autoPreload || isLoaded || isLoading) {
2479
- return;
2480
- }
2481
- const el = wrapperRef.current;
2482
- if (!el) {
2483
- return;
2484
- }
2485
- const observer = new IntersectionObserver(([entry]) => {
2486
- if (entry.isIntersecting) {
2487
- prefetch();
2488
- observer.disconnect();
2489
- }
2490
- }, { threshold: 0.1 });
2491
- observer.observe(el);
2492
- return () => observer.disconnect();
2493
- }, [
2494
- isLoaded,
2495
- prefetch,
2496
- isLoading,
2497
- autoPreload,
2498
- ]);
2499
- /** Register in playback chain for sequential playback */
2500
- useEffect(() => {
2501
- if (chainId != null) {
2502
- return VoicePlaybackChain.register(chainId, () => play());
2503
- }
2504
- }, [chainId, play]);
2505
- const measureTrackWidth = () => {
2506
- if (trackRef.current) {
2507
- setTrackWidth(trackRef.current.clientWidth);
2508
- }
2509
- };
2510
- /** Sync recalc before paint — catches layout shifts */
2511
- useLayoutEffect(measureTrackWidth);
2512
- /** ResizeObserver for external size changes (window resize, container resize) */
2513
- useEffect(() => {
2514
- const el = trackRef.current;
2515
- if (!el) {
2516
- return;
2517
- }
2518
- let rafId = 0;
2519
- const observer = new ResizeObserver(() => {
2520
- cancelAnimationFrame(rafId);
2521
- rafId = requestAnimationFrame(measureTrackWidth);
2522
- });
2523
- observer.observe(el);
2524
- return () => {
2525
- cancelAnimationFrame(rafId);
2526
- observer.disconnect();
2527
- };
2528
- }, []);
2529
- const displayTime = (isPlaying || currentTime > 0)
2530
- ? formatTime(currentTime)
2531
- : formatTime(resolvedDuration);
2532
- const handleTrackClick = useCallback((e) => {
2533
- const track = trackRef.current;
2534
- if (!track) {
2535
- return;
2536
- }
2537
- const rect = track.getBoundingClientRect();
2538
- const clickProgress = (e.clientX - rect.left) / rect.width;
2539
- seekTo(Math.max(0, Math.min(1, clickProgress)));
2540
- }, [seekTo]);
2541
- /** Interpolate waveform levels to fill container */
2542
- const interpolatedBars = useMemo(() => {
2543
- var _a, _b;
2544
- const maxBars = trackWidth > 0
2545
- ? Math.floor(trackWidth / (BAR_WIDTH + BAR_GAP))
2546
- : 48;
2547
- if (!waveformLevels.length) {
2548
- return Array.from({ length: maxBars }, () => 0);
2549
- }
2550
- const raw = [];
2551
- for (let i = 0; i < maxBars; i++) {
2552
- const srcIndex = (i / maxBars) * waveformLevels.length;
2553
- const low = Math.floor(srcIndex);
2554
- const high = Math.min(low + 1, waveformLevels.length - 1);
2555
- const frac = srcIndex - low;
2556
- raw.push(((_a = waveformLevels[low]) !== null && _a !== void 0 ? _a : 0) * (1 - frac)
2557
- + ((_b = waveformLevels[high]) !== null && _b !== void 0 ? _b : 0) * frac);
2558
- }
2559
- /** Normalize to 0-100 range so bars use full height */
2560
- const peak = Math.max(...raw, 1);
2561
- return raw.map((v) => (v / peak) * 100);
2562
- }, [waveformLevels, trackWidth]);
2563
- return (jsxs(PlayerWrapper, { ref: wrapperRef, onClick: onClick, className: className, children: [jsx(PlayButton, { type: "button", disabled: isLoading || (!src && !props.preloadedBlob), onClick: isLoaded ? togglePlayback : preload, "aria-label": isPlaying ? 'Pause' : 'Play', children: isLoading ? (jsx(LoadingSpinner, {})) : isPlaying ? (jsx(Icon24Pause, {})) : (jsx(Icon24Play, {})) }), jsxs(ContentColumn, { children: [jsx(WaveformTrack, { ref: trackRef, onClick: handleTrackClick, children: interpolatedBars.map((level, i) => {
2564
- const normalized = Math.min(100, level);
2565
- const height = BAR_MIN_HEIGHT + (normalized / 100) * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT);
2566
- const barProgress = interpolatedBars.length > 0
2567
- ? i / interpolatedBars.length
2568
- : 0;
2569
- const isPlayed = progress > 0 && barProgress <= progress;
2570
- return (jsx(WaveformBar, { style: {
2571
- height: `${height}px`,
2572
- background: isPlayed
2573
- ? 'color-mix(in srgb, var(--text_primary, #0a0a0a) 75%, transparent)'
2574
- : 'color-mix(in srgb, var(--text_primary, #0a0a0a) 25%, transparent)',
2575
- } }, i));
2576
- }) }), jsx(Duration, { children: displayTime })] })] }));
2577
- });
2578
-
2579
- /**
2580
- * VoiceBlobCache
2581
- *
2582
- * @description In-memory cache for voice message blobs.
2583
- * Allows instant playback of just-sent voice messages
2584
- * before the server URL becomes available.
2585
- *
2586
- * @author: exode <hello@exode.ru>
2587
- */
2588
- class VoiceBlobCacheClass {
2589
- constructor() {
2590
- this.cache = new Map();
2591
- this.set = (messageId, blob) => {
2592
- this.cache.set(messageId, blob);
2593
- };
2594
- this.get = (messageId) => {
2595
- return this.cache.get(messageId);
2596
- };
2597
- this.delete = (messageId) => {
2598
- this.cache.delete(messageId);
2599
- };
2600
- /** Transfer blob from optimistic ID to real server ID */
2601
- this.transfer = (fromId, toId) => {
2602
- const blob = this.cache.get(fromId);
2603
- if (blob) {
2604
- this.cache.delete(fromId);
2605
- this.cache.set(toId, blob);
2606
- }
2607
- };
2608
- }
2609
- }
2610
- const VoiceBlobCache = new VoiceBlobCacheClass();
2611
-
2612
- export { AUDIO_LEVEL_BARS, AudioRecorder, VoiceBlobCache, VoiceMessagePlayer, VoicePlaybackChain, VoiceRecorderInput, WaveformBars, clampValue, formatTime, useRecorderVisualization, useRecordingTiming, useVoiceMessagePlayer, useVoiceRecorder };
1
+ export { AudioRecorder } from './components/AudioRecorder.js';
2
+ export { VoiceRecorderInput } from './components/VoiceRecorderInput/VoiceRecorderInput.js';
3
+ export { VoiceMessagePlayer } from './components/VoiceMessagePlayer/VoiceMessagePlayer.js';
4
+ export { WaveformBars } from './components/WaveformBars/WaveformBars.js';
5
+ export { useVoiceRecorder } from './hooks/useVoiceRecorder.js';
6
+ export { useVoiceMessagePlayer } from './hooks/useVoiceMessagePlayer.js';
7
+ export { useRecorderVisualization } from './hooks/useRecorderVisualization.js';
8
+ export { useRecordingTiming } from './hooks/useRecordingTiming.js';
9
+ export { AUDIO_LEVEL_BARS } from './constants.js';
10
+ export { clampValue } from './utils/math.js';
11
+ export { formatTime } from './utils/format.js';
12
+ export { VoiceBlobCache } from './utils/voice-blob-cache.js';
13
+ export { VoicePlaybackChain } from './utils/voice-playback-chain.js';
2613
14
  //# sourceMappingURL=index.js.map