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