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