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