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