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