@exode-team/react-recorder 1.1.9 → 1.1.11
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/dist/components/VoiceMessagePlayer/VoiceMessagePlayer.d.ts.map +1 -1
- package/dist/hooks/useAudioBlobLoader.d.ts +5 -1
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/hooks/useAudioPlayback.d.ts.map +1 -1
- package/dist/hooks/useVoiceMessagePlayer.d.ts.map +1 -1
- package/dist/index.cjs +234 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +234 -19
- package/dist/index.js.map +1 -1
- package/dist/platform/audio-analyzer-adapter.d.ts.map +1 -1
- package/dist/platform/audio-blob-fetcher.d.ts.map +1 -1
- package/dist/platform/audio-playback-adapter.d.ts +7 -0
- package/dist/platform/audio-playback-adapter.d.ts.map +1 -1
- package/dist/platform/mpeg-frame.d.ts +12 -0
- package/dist/platform/mpeg-frame.d.ts.map +1 -0
- package/dist/types/recorder.d.ts +2 -0
- package/dist/types/recorder.d.ts.map +1 -1
- package/dist/utils/voice-playback-chain.d.ts +2 -0
- package/dist/utils/voice-playback-chain.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VoiceMessagePlayer.d.ts","sourceRoot":"","sources":["../../../src/components/VoiceMessagePlayer/VoiceMessagePlayer.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AA0BH,MAAM,WAAW,qBAAqB;IAClC,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAeD,QAAA,MAAM,kBAAkB,
|
|
1
|
+
{"version":3,"file":"VoiceMessagePlayer.d.ts","sourceRoot":"","sources":["../../../src/components/VoiceMessagePlayer/VoiceMessagePlayer.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AA0BH,MAAM,WAAW,qBAAqB;IAClC,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAeD,QAAA,MAAM,kBAAkB,KAqOtB,CAAC;AAGH,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
|
@@ -11,12 +11,16 @@ interface UseAudioBlobLoaderOptions {
|
|
|
11
11
|
preloadedBlob?: Blob | null;
|
|
12
12
|
playbackCallbacks: PlaybackCallbacks;
|
|
13
13
|
}
|
|
14
|
+
interface EnsureAudioLoadedOptions {
|
|
15
|
+
/** Retry a src that already failed — only on explicit user intent, not on auto-prefetch */
|
|
16
|
+
retryFailed?: boolean;
|
|
17
|
+
}
|
|
14
18
|
interface UseAudioBlobLoaderResult {
|
|
15
19
|
audioRef: React.MutableRefObject<HTMLAudioElement | null>;
|
|
16
20
|
isLoading: boolean;
|
|
17
21
|
isLoaded: boolean;
|
|
18
22
|
waveformLevels: number[];
|
|
19
|
-
ensureAudioLoaded: () => Promise<void>;
|
|
23
|
+
ensureAudioLoaded: (options?: EnsureAudioLoadedOptions) => Promise<void>;
|
|
20
24
|
}
|
|
21
25
|
declare const useAudioBlobLoader: (options: UseAudioBlobLoaderOptions) => UseAudioBlobLoaderResult;
|
|
22
26
|
export { useAudioBlobLoader };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAYrD,UAAU,yBAAyB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,iBAAiB,EAAE,iBAAiB,CAAC;CACxC;AAED,UAAU,wBAAwB;IAC9B,QAAQ,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC1D,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,
|
|
1
|
+
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAYrD,UAAU,yBAAyB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,iBAAiB,EAAE,iBAAiB,CAAC;CACxC;AAED,UAAU,wBAAwB;IAC9B,2FAA2F;IAC3F,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,UAAU,wBAAwB;IAC9B,QAAQ,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC1D,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,CAAC,OAAO,CAAC,EAAE,wBAAwB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5E;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBA4KhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAudioPlayback.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioPlayback.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,UAAU,sBAAsB;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IAClC,cAAc,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;IAC7D,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACnC;AAED,QAAA,MAAM,gBAAgB,QAAO,
|
|
1
|
+
{"version":3,"file":"useAudioPlayback.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioPlayback.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAOH,UAAU,sBAAsB;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IAClC,cAAc,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;IAC7D,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,IAAI,CAAC;IAC1B,mBAAmB,EAAE,MAAM,IAAI,CAAC;CACnC;AAED,QAAA,MAAM,gBAAgB,QAAO,sBA+H5B,CAAC;AAGF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useVoiceMessagePlayer.d.ts","sourceRoot":"","sources":["../../src/hooks/useVoiceMessagePlayer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AASH,OAAO,KAAK,EAAE,4BAA4B,EAAE,2BAA2B,EAAE,MAAM,UAAU,CAAC;AAG1F,QAAA,MAAM,qBAAqB,GAAI,SAAS,4BAA4B,KAAG,
|
|
1
|
+
{"version":3,"file":"useVoiceMessagePlayer.d.ts","sourceRoot":"","sources":["../../src/hooks/useVoiceMessagePlayer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AASH,OAAO,KAAK,EAAE,4BAA4B,EAAE,2BAA2B,EAAE,MAAM,UAAU,CAAC;AAG1F,QAAA,MAAM,qBAAqB,GAAI,SAAS,4BAA4B,KAAG,2BAmNtE,CAAC;AAGF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
|
package/dist/index.cjs
CHANGED
|
@@ -78,6 +78,61 @@ const stopStream = (stream) => {
|
|
|
78
78
|
*
|
|
79
79
|
* @author: exode <hello@exode.ru>
|
|
80
80
|
*/
|
|
81
|
+
const SEEK_OFFSET = 5;
|
|
82
|
+
const setupMediaSession = (audio, callbacks) => {
|
|
83
|
+
if (!('mediaSession' in navigator)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
navigator.mediaSession.setActionHandler('play', () => {
|
|
87
|
+
audio.play().catch(() => { });
|
|
88
|
+
});
|
|
89
|
+
navigator.mediaSession.setActionHandler('pause', () => {
|
|
90
|
+
audio.pause();
|
|
91
|
+
});
|
|
92
|
+
navigator.mediaSession.setActionHandler('stop', () => {
|
|
93
|
+
var _a;
|
|
94
|
+
audio.pause();
|
|
95
|
+
audio.currentTime = 0;
|
|
96
|
+
(_a = callbacks.onStop) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
97
|
+
});
|
|
98
|
+
navigator.mediaSession.setActionHandler('seekbackward', () => {
|
|
99
|
+
var _a;
|
|
100
|
+
audio.currentTime = Math.max(0, audio.currentTime - SEEK_OFFSET);
|
|
101
|
+
(_a = callbacks.onSeekBackward) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
102
|
+
});
|
|
103
|
+
navigator.mediaSession.setActionHandler('seekforward', () => {
|
|
104
|
+
var _a;
|
|
105
|
+
audio.currentTime = Math.min(audio.duration, audio.currentTime + SEEK_OFFSET);
|
|
106
|
+
(_a = callbacks.onSeekForward) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
107
|
+
});
|
|
108
|
+
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
|
109
|
+
if (details.seekTime != null && isFinite(audio.duration)) {
|
|
110
|
+
audio.currentTime = details.seekTime;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
|
114
|
+
var _a;
|
|
115
|
+
(_a = callbacks.onPreviousTrack) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
116
|
+
});
|
|
117
|
+
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
|
118
|
+
var _a;
|
|
119
|
+
(_a = callbacks.onNextTrack) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
const clearMediaSession = () => {
|
|
123
|
+
if (!('mediaSession' in navigator)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const actions = [
|
|
127
|
+
'play', 'pause', 'stop',
|
|
128
|
+
'seekbackward', 'seekforward', 'seekto',
|
|
129
|
+
'previoustrack', 'nexttrack',
|
|
130
|
+
];
|
|
131
|
+
actions.forEach((action) => {
|
|
132
|
+
navigator.mediaSession.setActionHandler(action, null);
|
|
133
|
+
});
|
|
134
|
+
navigator.mediaSession.playbackState = 'none';
|
|
135
|
+
};
|
|
81
136
|
const createAudioPlayback = (url, callbacks) => {
|
|
82
137
|
const audio = new Audio(url);
|
|
83
138
|
audio.onloadedmetadata = () => {
|
|
@@ -89,6 +144,21 @@ const createAudioPlayback = (url, callbacks) => {
|
|
|
89
144
|
audio.onended = () => {
|
|
90
145
|
callbacks.onEnded();
|
|
91
146
|
};
|
|
147
|
+
audio.onplay = () => {
|
|
148
|
+
var _a;
|
|
149
|
+
setupMediaSession(audio, callbacks);
|
|
150
|
+
if ('mediaSession' in navigator) {
|
|
151
|
+
navigator.mediaSession.playbackState = 'playing';
|
|
152
|
+
}
|
|
153
|
+
(_a = callbacks.onPlay) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
154
|
+
};
|
|
155
|
+
audio.onpause = () => {
|
|
156
|
+
var _a;
|
|
157
|
+
if ('mediaSession' in navigator) {
|
|
158
|
+
navigator.mediaSession.playbackState = 'paused';
|
|
159
|
+
}
|
|
160
|
+
(_a = callbacks.onPause) === null || _a === void 0 ? void 0 : _a.call(callbacks);
|
|
161
|
+
};
|
|
92
162
|
return { audio, url };
|
|
93
163
|
};
|
|
94
164
|
const cleanupAudio = (audio) => {
|
|
@@ -97,6 +167,9 @@ const cleanupAudio = (audio) => {
|
|
|
97
167
|
audio.onended = null;
|
|
98
168
|
audio.ontimeupdate = null;
|
|
99
169
|
audio.onloadedmetadata = null;
|
|
170
|
+
audio.onplay = null;
|
|
171
|
+
audio.onpause = null;
|
|
172
|
+
clearMediaSession();
|
|
100
173
|
};
|
|
101
174
|
const createObjectURL = (blob) => {
|
|
102
175
|
return URL.createObjectURL(blob);
|
|
@@ -118,6 +191,64 @@ const TIMELINE_BAR_GAP = 2;
|
|
|
118
191
|
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
119
192
|
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
120
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
|
+
|
|
121
252
|
/**
|
|
122
253
|
* Audio analyzer platform adapter
|
|
123
254
|
*
|
|
@@ -144,11 +275,24 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
|
|
|
144
275
|
}
|
|
145
276
|
return levels;
|
|
146
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
|
+
};
|
|
147
292
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
148
293
|
const context = new AudioContext();
|
|
149
294
|
try {
|
|
150
|
-
const
|
|
151
|
-
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
|
295
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
152
296
|
const channelData = audioBuffer.getChannelData(0);
|
|
153
297
|
if (!channelData.length) {
|
|
154
298
|
return Array.from({ length: barCount }, () => 0);
|
|
@@ -185,6 +329,9 @@ const fetchAudioBlob = async (url, signal) => {
|
|
|
185
329
|
mode: 'cors',
|
|
186
330
|
credentials: 'omit',
|
|
187
331
|
});
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error(`Failed to fetch audio: ${response.status}`);
|
|
334
|
+
}
|
|
188
335
|
return response.blob();
|
|
189
336
|
};
|
|
190
337
|
|
|
@@ -366,6 +513,8 @@ const useAudioPlayback = () => {
|
|
|
366
513
|
setIsPlaying(false);
|
|
367
514
|
setPlaybackTime(0);
|
|
368
515
|
},
|
|
516
|
+
onPlay: () => setIsPlaying(true),
|
|
517
|
+
onPause: () => setIsPlaying(false),
|
|
369
518
|
});
|
|
370
519
|
audioRef.current = audio;
|
|
371
520
|
setPlaybackTime(0);
|
|
@@ -385,6 +534,8 @@ const useAudioPlayback = () => {
|
|
|
385
534
|
setIsPlaying(false);
|
|
386
535
|
setPlaybackTime(0);
|
|
387
536
|
},
|
|
537
|
+
onPlay: () => setIsPlaying(true),
|
|
538
|
+
onPause: () => setIsPlaying(false),
|
|
388
539
|
});
|
|
389
540
|
audioRef.current = audio;
|
|
390
541
|
}
|
|
@@ -813,7 +964,10 @@ const useAudioBlobLoader = (options) => {
|
|
|
813
964
|
const objectUrlRef = React.useRef(null);
|
|
814
965
|
const abortControllerRef = React.useRef(null);
|
|
815
966
|
const failedSrcRef = React.useRef(null);
|
|
967
|
+
const waveformTokenRef = React.useRef(0);
|
|
968
|
+
const waveformDataRef = React.useRef(waveformData);
|
|
816
969
|
const callbacksRef = React.useRef(playbackCallbacks);
|
|
970
|
+
waveformDataRef.current = waveformData;
|
|
817
971
|
callbacksRef.current = playbackCallbacks;
|
|
818
972
|
const createAudioFromBlob = React.useCallback((blob) => {
|
|
819
973
|
if (objectUrlRef.current) {
|
|
@@ -825,19 +979,44 @@ const useAudioBlobLoader = (options) => {
|
|
|
825
979
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
826
980
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
827
981
|
onEnded: () => callbacksRef.current.onEnded(),
|
|
982
|
+
onPlay: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPlay) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
983
|
+
onPause: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPause) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
984
|
+
onStop: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onStop) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
985
|
+
onSeekBackward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekBackward) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
986
|
+
onSeekForward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekForward) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
987
|
+
onPreviousTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPreviousTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
988
|
+
onNextTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onNextTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
828
989
|
});
|
|
829
990
|
audioRef.current = audio;
|
|
830
991
|
setIsLoaded(true);
|
|
831
992
|
}, []);
|
|
993
|
+
/**
|
|
994
|
+
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
995
|
+
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
996
|
+
* must never block playback. Token keeps only the latest decode.
|
|
997
|
+
*/
|
|
998
|
+
const applyWaveformLevels = React.useCallback(async (blob) => {
|
|
999
|
+
if (waveformData) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
waveformTokenRef.current += 1;
|
|
1003
|
+
const token = waveformTokenRef.current;
|
|
1004
|
+
try {
|
|
1005
|
+
const levels = await decodeAudioLevels(blob);
|
|
1006
|
+
if (token === waveformTokenRef.current) {
|
|
1007
|
+
setWaveformLevels(levels);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1012
|
+
}
|
|
1013
|
+
}, [waveformData]);
|
|
832
1014
|
const mountFromBlob = React.useCallback(async (blob) => {
|
|
833
1015
|
if (audioRef.current) {
|
|
834
1016
|
return;
|
|
835
1017
|
}
|
|
836
1018
|
setIsLoading(true);
|
|
837
1019
|
try {
|
|
838
|
-
if (!waveformData) {
|
|
839
|
-
setWaveformLevels(await decodeAudioLevels(blob));
|
|
840
|
-
}
|
|
841
1020
|
createAudioFromBlob(blob);
|
|
842
1021
|
}
|
|
843
1022
|
catch (error) {
|
|
@@ -846,12 +1025,19 @@ const useAudioBlobLoader = (options) => {
|
|
|
846
1025
|
finally {
|
|
847
1026
|
setIsLoading(false);
|
|
848
1027
|
}
|
|
849
|
-
|
|
850
|
-
|
|
1028
|
+
void applyWaveformLevels(blob);
|
|
1029
|
+
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1030
|
+
const ensureAudioLoaded = React.useCallback(async (options) => {
|
|
851
1031
|
var _a;
|
|
852
|
-
if (audioRef.current
|
|
1032
|
+
if (audioRef.current) {
|
|
853
1033
|
return;
|
|
854
1034
|
}
|
|
1035
|
+
if (failedSrcRef.current === src) {
|
|
1036
|
+
if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
failedSrcRef.current = null;
|
|
1040
|
+
}
|
|
855
1041
|
if (preloadedBlob) {
|
|
856
1042
|
return mountFromBlob(preloadedBlob);
|
|
857
1043
|
}
|
|
@@ -867,14 +1053,8 @@ const useAudioBlobLoader = (options) => {
|
|
|
867
1053
|
if (controller.signal.aborted) {
|
|
868
1054
|
return;
|
|
869
1055
|
}
|
|
870
|
-
if (!waveformData) {
|
|
871
|
-
const levels = await decodeAudioLevels(blob);
|
|
872
|
-
if (controller.signal.aborted) {
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
setWaveformLevels(levels);
|
|
876
|
-
}
|
|
877
1056
|
createAudioFromBlob(blob);
|
|
1057
|
+
void applyWaveformLevels(blob);
|
|
878
1058
|
}
|
|
879
1059
|
catch (error) {
|
|
880
1060
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
@@ -888,13 +1068,16 @@ const useAudioBlobLoader = (options) => {
|
|
|
888
1068
|
setIsLoading(false);
|
|
889
1069
|
}
|
|
890
1070
|
}
|
|
891
|
-
}, [src,
|
|
1071
|
+
}, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
|
|
892
1072
|
/** Cleanup on src change or unmount */
|
|
893
1073
|
React.useEffect(() => {
|
|
894
1074
|
failedSrcRef.current = null;
|
|
1075
|
+
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1076
|
+
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
895
1077
|
return () => {
|
|
896
1078
|
var _a;
|
|
897
1079
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1080
|
+
waveformTokenRef.current += 1;
|
|
898
1081
|
if (audioRef.current) {
|
|
899
1082
|
cleanupAudio(audioRef.current);
|
|
900
1083
|
audioRef.current = null;
|
|
@@ -913,6 +1096,7 @@ const useAudioBlobLoader = (options) => {
|
|
|
913
1096
|
}, [preloadedBlob, mountFromBlob]);
|
|
914
1097
|
React.useEffect(() => {
|
|
915
1098
|
if (waveformData) {
|
|
1099
|
+
waveformTokenRef.current += 1;
|
|
916
1100
|
setWaveformLevels(waveformData);
|
|
917
1101
|
}
|
|
918
1102
|
}, [waveformData]);
|
|
@@ -955,9 +1139,13 @@ const audioPlaybackManager = {
|
|
|
955
1139
|
* @author: exode <hello@exode.ru>
|
|
956
1140
|
*/
|
|
957
1141
|
const useVoiceMessagePlayer = (options) => {
|
|
958
|
-
const { src, onEnded, waveformData, preloadedBlob, duration: externalDuration, } = options;
|
|
1142
|
+
const { src, onEnded, onPreviousTrack, onNextTrack, waveformData, preloadedBlob, duration: externalDuration, } = options;
|
|
959
1143
|
const onEndedRef = React.useRef(onEnded);
|
|
1144
|
+
const onPreviousTrackRef = React.useRef(onPreviousTrack);
|
|
1145
|
+
const onNextTrackRef = React.useRef(onNextTrack);
|
|
960
1146
|
onEndedRef.current = onEnded;
|
|
1147
|
+
onPreviousTrackRef.current = onPreviousTrack;
|
|
1148
|
+
onNextTrackRef.current = onNextTrack;
|
|
961
1149
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
|
962
1150
|
const [currentTime, setCurrentTime] = React.useState(0);
|
|
963
1151
|
const [duration, setDuration] = React.useState(externalDuration || 0);
|
|
@@ -991,6 +1179,14 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
991
1179
|
setCurrentTime(0);
|
|
992
1180
|
(_a = onEndedRef.current) === null || _a === void 0 ? void 0 : _a.call(onEndedRef);
|
|
993
1181
|
},
|
|
1182
|
+
onPlay: () => setIsPlaying(true),
|
|
1183
|
+
onPause: () => setIsPlaying(false),
|
|
1184
|
+
onStop: () => {
|
|
1185
|
+
setIsPlaying(false);
|
|
1186
|
+
setCurrentTime(0);
|
|
1187
|
+
},
|
|
1188
|
+
onPreviousTrack: () => { var _a; return (_a = onPreviousTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onPreviousTrackRef); },
|
|
1189
|
+
onNextTrack: () => { var _a; return (_a = onNextTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onNextTrackRef); },
|
|
994
1190
|
},
|
|
995
1191
|
});
|
|
996
1192
|
/** Pre-fetch audio without playing (for viewport preloading) */
|
|
@@ -1006,7 +1202,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1006
1202
|
return;
|
|
1007
1203
|
}
|
|
1008
1204
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1009
|
-
void ensureAudioLoaded().then(() => {
|
|
1205
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1010
1206
|
const audio = audioRef.current;
|
|
1011
1207
|
if (audio) {
|
|
1012
1208
|
audio.play()
|
|
@@ -1066,7 +1262,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1066
1262
|
.catch(() => setIsPlaying(false));
|
|
1067
1263
|
}
|
|
1068
1264
|
else {
|
|
1069
|
-
void ensureAudioLoaded().then(() => {
|
|
1265
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1070
1266
|
const loadedAudio = audioRef.current;
|
|
1071
1267
|
if (loadedAudio) {
|
|
1072
1268
|
audioPlaybackManager.stopOthers(stopThis);
|
|
@@ -2138,6 +2334,13 @@ class VoicePlaybackChainClass {
|
|
|
2138
2334
|
this.entries[idx + 1].play();
|
|
2139
2335
|
}
|
|
2140
2336
|
};
|
|
2337
|
+
/** Play the previous voice message before the given media ID */
|
|
2338
|
+
this.playPrevious = (currentId) => {
|
|
2339
|
+
const idx = this.entries.findIndex((e) => e.id === currentId);
|
|
2340
|
+
if (idx > 0) {
|
|
2341
|
+
this.entries[idx - 1].play();
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
2141
2344
|
}
|
|
2142
2345
|
}
|
|
2143
2346
|
const VoicePlaybackChain = new VoicePlaybackChainClass();
|
|
@@ -2245,12 +2448,24 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
|
|
|
2245
2448
|
VoicePlaybackChain.playNext(chainId);
|
|
2246
2449
|
}
|
|
2247
2450
|
}, [onEnded, chainId]);
|
|
2451
|
+
const handleNextTrack = React.useCallback(() => {
|
|
2452
|
+
if (chainId != null) {
|
|
2453
|
+
VoicePlaybackChain.playNext(chainId);
|
|
2454
|
+
}
|
|
2455
|
+
}, [chainId]);
|
|
2456
|
+
const handlePreviousTrack = React.useCallback(() => {
|
|
2457
|
+
if (chainId != null) {
|
|
2458
|
+
VoicePlaybackChain.playPrevious(chainId);
|
|
2459
|
+
}
|
|
2460
|
+
}, [chainId]);
|
|
2248
2461
|
const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, seekToTime, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
|
|
2249
2462
|
src,
|
|
2250
2463
|
duration,
|
|
2251
2464
|
waveformData,
|
|
2252
2465
|
preloadedBlob: props.preloadedBlob,
|
|
2253
2466
|
onEnded: handleEnded,
|
|
2467
|
+
onNextTrack: handleNextTrack,
|
|
2468
|
+
onPreviousTrack: handlePreviousTrack,
|
|
2254
2469
|
});
|
|
2255
2470
|
React.useImperativeHandle(ref, () => ({ play, prefetch, seekToTime }), [
|
|
2256
2471
|
play,
|