@exode-team/react-recorder 1.1.10 → 1.1.12
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/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/hooks/useAudioBlobLoader.d.ts +5 -1
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +143 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +143 -40
- 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/mpeg-frame.d.ts +12 -0
- package/dist/platform/mpeg-frame.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/constants.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @author: exode <hello@exode.ru>
|
|
5
5
|
*/
|
|
6
6
|
export declare const AUDIO_LEVEL_BARS = 40;
|
|
7
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
8
|
+
export declare const WAVEFORM_SAMPLE_RATE = 44100;
|
|
7
9
|
export declare const createInitialLevels: () => number[];
|
|
8
10
|
export declare const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
9
11
|
export declare const TIMELINE_BAR_WIDTH = 3;
|
package/dist/constants.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,eAAO,MAAM,mBAAmB,QAAO,MAAM,EAAuD,CAAC;AAErG,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,kBAAkB,IAAI,CAAC;AACpC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,eAAO,MAAM,uBAAuB,KAAK,CAAC"}
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAO,MAAM,gBAAgB,KAAK,CAAC;AAEnC,qFAAqF;AACrF,eAAO,MAAM,oBAAoB,QAAQ,CAAC;AAE1C,eAAO,MAAM,mBAAmB,QAAO,MAAM,EAAuD,CAAC;AAErG,eAAO,MAAM,wBAAwB,MAAM,CAAC;AAC5C,eAAO,MAAM,kBAAkB,IAAI,CAAC;AACpC,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,eAAO,MAAM,uBAAuB,KAAK,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"}
|
package/dist/index.cjs
CHANGED
|
@@ -184,6 +184,8 @@ const revokeObjectURL = (url) => {
|
|
|
184
184
|
* @author: exode <hello@exode.ru>
|
|
185
185
|
*/
|
|
186
186
|
const AUDIO_LEVEL_BARS = 40;
|
|
187
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
188
|
+
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
187
189
|
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
188
190
|
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
189
191
|
const TIMELINE_BAR_WIDTH = 3;
|
|
@@ -191,6 +193,64 @@ const TIMELINE_BAR_GAP = 2;
|
|
|
191
193
|
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
192
194
|
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
193
195
|
|
|
196
|
+
/**
|
|
197
|
+
* MPEG frame scanner
|
|
198
|
+
*
|
|
199
|
+
* @author: exode <hello@exode.ru>
|
|
200
|
+
*/
|
|
201
|
+
const MPEG_LAYER_III = 1;
|
|
202
|
+
const MPEG_SAMPLE_RATES = [44100, 48000, 32000];
|
|
203
|
+
const MPEG_BITRATES_V1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
|
204
|
+
const MPEG_BITRATES_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
|
|
205
|
+
/** Length of the Layer III frame at the given offset, or 0 when it is not one */
|
|
206
|
+
const readFrameLength = (bytes, offset) => {
|
|
207
|
+
if (offset + 3 >= bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
/** Version ids: 0 = MPEG 2.5, 1 = reserved, 2 = MPEG 2, 3 = MPEG 1 */
|
|
211
|
+
const version = (bytes[offset + 1] >> 3) & 0x03;
|
|
212
|
+
const layer = (bytes[offset + 1] >> 1) & 0x03;
|
|
213
|
+
const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
|
|
214
|
+
const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
|
|
215
|
+
const isReserved = version === 1
|
|
216
|
+
|| layer !== MPEG_LAYER_III
|
|
217
|
+
|| sampleRateIndex === 3
|
|
218
|
+
|| bitrateIndex === 0
|
|
219
|
+
|| bitrateIndex === 0x0f;
|
|
220
|
+
if (isReserved) {
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
const isVersion1 = version === 3;
|
|
224
|
+
const padding = (bytes[offset + 2] >> 1) & 0x01;
|
|
225
|
+
const bitrate = (isVersion1 ? MPEG_BITRATES_V1 : MPEG_BITRATES_V2)[bitrateIndex] * 1000;
|
|
226
|
+
const sampleRate = MPEG_SAMPLE_RATES[sampleRateIndex] / (isVersion1 ? 1 : version === 2 ? 2 : 4);
|
|
227
|
+
return Math.floor((isVersion1 ? 144 : 72) * bitrate / sampleRate) + padding;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Some mp3 (e.g. converted from wma) keep junk between the ID3 tag and the
|
|
231
|
+
* first MPEG frame: media elements skip it, the WebAudio decoder does not.
|
|
232
|
+
* Returns the offset of the first real frame, or 0 when there is none.
|
|
233
|
+
*/
|
|
234
|
+
const findFirstFrameOffset = (bytes) => {
|
|
235
|
+
let start = 0;
|
|
236
|
+
/** ID3v2 header: "ID3" + version + flags + 4 syncsafe size bytes */
|
|
237
|
+
if (bytes.length >= 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
|
|
238
|
+
start = 10 + ((bytes[6] << 21) | (bytes[7] << 14) | (bytes[8] << 7) | bytes[9]);
|
|
239
|
+
/** ID3v2.4 may append a 10 byte footer copy of the header */
|
|
240
|
+
if (bytes[5] & 0x10) {
|
|
241
|
+
start += 10;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (let i = start; i < bytes.length - 3; i += 1) {
|
|
245
|
+
const length = readFrameLength(bytes, i);
|
|
246
|
+
/** Junk can open with a valid looking header, so require the next frame to follow */
|
|
247
|
+
if (length && readFrameLength(bytes, i + length)) {
|
|
248
|
+
return i;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return 0;
|
|
252
|
+
};
|
|
253
|
+
|
|
194
254
|
/**
|
|
195
255
|
* Audio analyzer platform adapter
|
|
196
256
|
*
|
|
@@ -217,34 +277,48 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
|
|
|
217
277
|
}
|
|
218
278
|
return levels;
|
|
219
279
|
};
|
|
220
|
-
|
|
221
|
-
|
|
280
|
+
/** decodeAudioData detaches the buffer, so the retry re-reads the blob */
|
|
281
|
+
const decodeSkippingJunk = async (context, blob) => {
|
|
222
282
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
231
|
-
for (let i = 0; i < barCount; i += 1) {
|
|
232
|
-
const segmentStart = i * segmentLength;
|
|
233
|
-
let sum = 0;
|
|
234
|
-
for (let j = 0; j < segmentLength; j += 1) {
|
|
235
|
-
const sample = channelData[segmentStart + j];
|
|
236
|
-
if (sample !== undefined) {
|
|
237
|
-
sum += Math.abs(sample);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
const average = sum / segmentLength;
|
|
241
|
-
levels.push(Math.min(100, average * 200));
|
|
283
|
+
return await context.decodeAudioData(await blob.arrayBuffer());
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
287
|
+
const offset = findFirstFrameOffset(bytes);
|
|
288
|
+
if (!offset) {
|
|
289
|
+
throw error;
|
|
242
290
|
}
|
|
243
|
-
return
|
|
291
|
+
return context.decodeAudioData(bytes.buffer.slice(offset));
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
295
|
+
/**
|
|
296
|
+
* Offline context stays out of the audio session. A realtime AudioContext
|
|
297
|
+
* registers as a WebAudio source, which pins WebKit to the AmbientSound
|
|
298
|
+
* category and leaves media playback silent inside iOS apps — including
|
|
299
|
+
* iPad apps running on macOS.
|
|
300
|
+
*/
|
|
301
|
+
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
302
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
303
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
304
|
+
if (!channelData.length) {
|
|
305
|
+
return Array.from({ length: barCount }, () => 0);
|
|
244
306
|
}
|
|
245
|
-
|
|
246
|
-
|
|
307
|
+
const levels = [];
|
|
308
|
+
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
309
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
310
|
+
const segmentStart = i * segmentLength;
|
|
311
|
+
let sum = 0;
|
|
312
|
+
for (let j = 0; j < segmentLength; j += 1) {
|
|
313
|
+
const sample = channelData[segmentStart + j];
|
|
314
|
+
if (sample !== undefined) {
|
|
315
|
+
sum += Math.abs(sample);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const average = sum / segmentLength;
|
|
319
|
+
levels.push(Math.min(100, average * 200));
|
|
247
320
|
}
|
|
321
|
+
return levels;
|
|
248
322
|
};
|
|
249
323
|
|
|
250
324
|
/**
|
|
@@ -258,6 +332,9 @@ const fetchAudioBlob = async (url, signal) => {
|
|
|
258
332
|
mode: 'cors',
|
|
259
333
|
credentials: 'omit',
|
|
260
334
|
});
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
throw new Error(`Failed to fetch audio: ${response.status}`);
|
|
337
|
+
}
|
|
261
338
|
return response.blob();
|
|
262
339
|
};
|
|
263
340
|
|
|
@@ -890,7 +967,10 @@ const useAudioBlobLoader = (options) => {
|
|
|
890
967
|
const objectUrlRef = React.useRef(null);
|
|
891
968
|
const abortControllerRef = React.useRef(null);
|
|
892
969
|
const failedSrcRef = React.useRef(null);
|
|
970
|
+
const waveformTokenRef = React.useRef(0);
|
|
971
|
+
const waveformDataRef = React.useRef(waveformData);
|
|
893
972
|
const callbacksRef = React.useRef(playbackCallbacks);
|
|
973
|
+
waveformDataRef.current = waveformData;
|
|
894
974
|
callbacksRef.current = playbackCallbacks;
|
|
895
975
|
const createAudioFromBlob = React.useCallback((blob) => {
|
|
896
976
|
if (objectUrlRef.current) {
|
|
@@ -913,15 +993,33 @@ const useAudioBlobLoader = (options) => {
|
|
|
913
993
|
audioRef.current = audio;
|
|
914
994
|
setIsLoaded(true);
|
|
915
995
|
}, []);
|
|
996
|
+
/**
|
|
997
|
+
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
998
|
+
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
999
|
+
* must never block playback. Token keeps only the latest decode.
|
|
1000
|
+
*/
|
|
1001
|
+
const applyWaveformLevels = React.useCallback(async (blob) => {
|
|
1002
|
+
if (waveformData) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
waveformTokenRef.current += 1;
|
|
1006
|
+
const token = waveformTokenRef.current;
|
|
1007
|
+
try {
|
|
1008
|
+
const levels = await decodeAudioLevels(blob);
|
|
1009
|
+
if (token === waveformTokenRef.current) {
|
|
1010
|
+
setWaveformLevels(levels);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1015
|
+
}
|
|
1016
|
+
}, [waveformData]);
|
|
916
1017
|
const mountFromBlob = React.useCallback(async (blob) => {
|
|
917
1018
|
if (audioRef.current) {
|
|
918
1019
|
return;
|
|
919
1020
|
}
|
|
920
1021
|
setIsLoading(true);
|
|
921
1022
|
try {
|
|
922
|
-
if (!waveformData) {
|
|
923
|
-
setWaveformLevels(await decodeAudioLevels(blob));
|
|
924
|
-
}
|
|
925
1023
|
createAudioFromBlob(blob);
|
|
926
1024
|
}
|
|
927
1025
|
catch (error) {
|
|
@@ -930,12 +1028,19 @@ const useAudioBlobLoader = (options) => {
|
|
|
930
1028
|
finally {
|
|
931
1029
|
setIsLoading(false);
|
|
932
1030
|
}
|
|
933
|
-
|
|
934
|
-
|
|
1031
|
+
void applyWaveformLevels(blob);
|
|
1032
|
+
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1033
|
+
const ensureAudioLoaded = React.useCallback(async (options) => {
|
|
935
1034
|
var _a;
|
|
936
|
-
if (audioRef.current
|
|
1035
|
+
if (audioRef.current) {
|
|
937
1036
|
return;
|
|
938
1037
|
}
|
|
1038
|
+
if (failedSrcRef.current === src) {
|
|
1039
|
+
if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
failedSrcRef.current = null;
|
|
1043
|
+
}
|
|
939
1044
|
if (preloadedBlob) {
|
|
940
1045
|
return mountFromBlob(preloadedBlob);
|
|
941
1046
|
}
|
|
@@ -951,14 +1056,8 @@ const useAudioBlobLoader = (options) => {
|
|
|
951
1056
|
if (controller.signal.aborted) {
|
|
952
1057
|
return;
|
|
953
1058
|
}
|
|
954
|
-
if (!waveformData) {
|
|
955
|
-
const levels = await decodeAudioLevels(blob);
|
|
956
|
-
if (controller.signal.aborted) {
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
setWaveformLevels(levels);
|
|
960
|
-
}
|
|
961
1059
|
createAudioFromBlob(blob);
|
|
1060
|
+
void applyWaveformLevels(blob);
|
|
962
1061
|
}
|
|
963
1062
|
catch (error) {
|
|
964
1063
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
@@ -972,13 +1071,16 @@ const useAudioBlobLoader = (options) => {
|
|
|
972
1071
|
setIsLoading(false);
|
|
973
1072
|
}
|
|
974
1073
|
}
|
|
975
|
-
}, [src,
|
|
1074
|
+
}, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
|
|
976
1075
|
/** Cleanup on src change or unmount */
|
|
977
1076
|
React.useEffect(() => {
|
|
978
1077
|
failedSrcRef.current = null;
|
|
1078
|
+
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1079
|
+
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
979
1080
|
return () => {
|
|
980
1081
|
var _a;
|
|
981
1082
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1083
|
+
waveformTokenRef.current += 1;
|
|
982
1084
|
if (audioRef.current) {
|
|
983
1085
|
cleanupAudio(audioRef.current);
|
|
984
1086
|
audioRef.current = null;
|
|
@@ -997,6 +1099,7 @@ const useAudioBlobLoader = (options) => {
|
|
|
997
1099
|
}, [preloadedBlob, mountFromBlob]);
|
|
998
1100
|
React.useEffect(() => {
|
|
999
1101
|
if (waveformData) {
|
|
1102
|
+
waveformTokenRef.current += 1;
|
|
1000
1103
|
setWaveformLevels(waveformData);
|
|
1001
1104
|
}
|
|
1002
1105
|
}, [waveformData]);
|
|
@@ -1102,7 +1205,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1102
1205
|
return;
|
|
1103
1206
|
}
|
|
1104
1207
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1105
|
-
void ensureAudioLoaded().then(() => {
|
|
1208
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1106
1209
|
const audio = audioRef.current;
|
|
1107
1210
|
if (audio) {
|
|
1108
1211
|
audio.play()
|
|
@@ -1162,7 +1265,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1162
1265
|
.catch(() => setIsPlaying(false));
|
|
1163
1266
|
}
|
|
1164
1267
|
else {
|
|
1165
|
-
void ensureAudioLoaded().then(() => {
|
|
1268
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1166
1269
|
const loadedAudio = audioRef.current;
|
|
1167
1270
|
if (loadedAudio) {
|
|
1168
1271
|
audioPlaybackManager.stopOthers(stopThis);
|