@exode-team/react-recorder 1.1.11 → 1.1.14
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/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/hooks/useAudioBlobLoader.d.ts +1 -5
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +128 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +128 -78
- package/dist/index.js.map +1 -1
- package/dist/platform/audio-analyzer-adapter.d.ts.map +1 -1
- package/dist/platform/audio-playback-adapter.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/pseudo-waveform.d.ts +12 -0
- package/dist/utils/pseudo-waveform.d.ts.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,7 +146,7 @@ const Messages = () => {
|
|
|
146
146
|
| `duration` | `number` | — | — | Длительность в секундах (отображается до загрузки аудио). |
|
|
147
147
|
| `autoPreload` | `boolean` | — | `true` | Автозагрузка аудио при попадании в viewport (IntersectionObserver). |
|
|
148
148
|
| `waveformData` | `number[]` | — | — | Предварительно вычисленные уровни waveform. |
|
|
149
|
-
| `preloadedBlob` | `Blob \| null` | — | — |
|
|
149
|
+
| `preloadedBlob` | `Blob \| null` | — | — | Локальная запись. Играет из него, только пока нет `src`; при заданном `src` идёт на него, а blob переиспользуется для waveform. |
|
|
150
150
|
| `onEnded` | `() => void` | — | — | Колбэк окончания воспроизведения. |
|
|
151
151
|
| `className` | `string` | — | — | CSS-класс контейнера. |
|
|
152
152
|
| `onClick` | `(e: React.MouseEvent) => void`| — | — | Обработчик клика по контейнеру. |
|
|
@@ -154,7 +154,7 @@ const Messages = () => {
|
|
|
154
154
|
**Ref API (`VoiceMessagePlayerRef`):**
|
|
155
155
|
|
|
156
156
|
- `play()` — программный запуск воспроизведения.
|
|
157
|
-
- `prefetch()` —
|
|
157
|
+
- `prefetch()` — подготовка аудио без воспроизведения (waveform считается фоном и play не задерживает).
|
|
158
158
|
|
|
159
159
|
#### `VoicePlaybackChain`
|
|
160
160
|
|
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,16 +11,12 @@ 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
|
-
}
|
|
18
14
|
interface UseAudioBlobLoaderResult {
|
|
19
15
|
audioRef: React.MutableRefObject<HTMLAudioElement | null>;
|
|
20
16
|
isLoading: boolean;
|
|
21
17
|
isLoaded: boolean;
|
|
22
18
|
waveformLevels: number[];
|
|
23
|
-
ensureAudioLoaded: (
|
|
19
|
+
ensureAudioLoaded: () => Promise<void>;
|
|
24
20
|
}
|
|
25
21
|
declare const useAudioBlobLoader: (options: UseAudioBlobLoaderOptions) => UseAudioBlobLoaderResult;
|
|
26
22
|
export { useAudioBlobLoader };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,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,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBAyLhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,56 @@ const formatTime = (seconds) => {
|
|
|
30
30
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* AudioRecorder constants
|
|
35
|
+
*
|
|
36
|
+
* @author: exode <hello@exode.ru>
|
|
37
|
+
*/
|
|
38
|
+
const AUDIO_LEVEL_BARS = 40;
|
|
39
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
40
|
+
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
41
|
+
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
42
|
+
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
43
|
+
const TIMELINE_BAR_WIDTH = 3;
|
|
44
|
+
const TIMELINE_BAR_GAP = 2;
|
|
45
|
+
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
46
|
+
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pseudo waveform utilities
|
|
50
|
+
*
|
|
51
|
+
* @author: exode <hello@exode.ru>
|
|
52
|
+
*/
|
|
53
|
+
/** Deterministic generator: the same source always draws the same waveform */
|
|
54
|
+
const seededRandom = (seed) => {
|
|
55
|
+
let state = Math.abs(Math.floor(seed)) % 2147483647 || 1;
|
|
56
|
+
return () => (state = state * 16807 % 2147483647) / 2147483647;
|
|
57
|
+
};
|
|
58
|
+
const hashString = (value) => {
|
|
59
|
+
let hash = 0;
|
|
60
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
61
|
+
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
|
62
|
+
}
|
|
63
|
+
return hash;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Decorative levels for runtimes that cannot decode audio at all — WebAudio
|
|
67
|
+
* rejects every mp3 inside iPad apps running on macOS. Purely cosmetic: it
|
|
68
|
+
* stands in for a flat line and says nothing about the recording itself.
|
|
69
|
+
*/
|
|
70
|
+
const createPseudoLevels = (seed, barCount = AUDIO_LEVEL_BARS) => {
|
|
71
|
+
const random = seededRandom(hashString(seed));
|
|
72
|
+
const levels = [];
|
|
73
|
+
let level = 45 + random() * 25;
|
|
74
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
75
|
+
/** Smooth drift instead of pure noise, so it reads as speech rather than static */
|
|
76
|
+
level = clampValue(level + (random() - 0.5) * 42, 14);
|
|
77
|
+
/** Slight fade at both ends — that is how a real recording usually looks */
|
|
78
|
+
levels.push(level * (0.72 + 0.28 * Math.sin((i / (barCount - 1)) * Math.PI)));
|
|
79
|
+
}
|
|
80
|
+
return levels;
|
|
81
|
+
};
|
|
82
|
+
|
|
33
83
|
/**
|
|
34
84
|
* Microphone platform adapter
|
|
35
85
|
*
|
|
@@ -135,6 +185,12 @@ const clearMediaSession = () => {
|
|
|
135
185
|
};
|
|
136
186
|
const createAudioPlayback = (url, callbacks) => {
|
|
137
187
|
const audio = new Audio(url);
|
|
188
|
+
/**
|
|
189
|
+
* A detached element is not reliably rendered by every WebKit runtime
|
|
190
|
+
* (notably iPad apps running on macOS), so keep it in the document.
|
|
191
|
+
*/
|
|
192
|
+
audio.style.display = 'none';
|
|
193
|
+
document.body.appendChild(audio);
|
|
138
194
|
audio.onloadedmetadata = () => {
|
|
139
195
|
callbacks.onLoadedMetadata(audio.duration);
|
|
140
196
|
};
|
|
@@ -163,6 +219,7 @@ const createAudioPlayback = (url, callbacks) => {
|
|
|
163
219
|
};
|
|
164
220
|
const cleanupAudio = (audio) => {
|
|
165
221
|
audio.pause();
|
|
222
|
+
audio.remove();
|
|
166
223
|
audio.src = '';
|
|
167
224
|
audio.onended = null;
|
|
168
225
|
audio.ontimeupdate = null;
|
|
@@ -178,19 +235,6 @@ const revokeObjectURL = (url) => {
|
|
|
178
235
|
URL.revokeObjectURL(url);
|
|
179
236
|
};
|
|
180
237
|
|
|
181
|
-
/**
|
|
182
|
-
* AudioRecorder constants
|
|
183
|
-
*
|
|
184
|
-
* @author: exode <hello@exode.ru>
|
|
185
|
-
*/
|
|
186
|
-
const AUDIO_LEVEL_BARS = 40;
|
|
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
238
|
/**
|
|
195
239
|
* MPEG frame scanner
|
|
196
240
|
*
|
|
@@ -290,32 +334,32 @@ const decodeSkippingJunk = async (context, blob) => {
|
|
|
290
334
|
}
|
|
291
335
|
};
|
|
292
336
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Offline rendering keeps the audio hardware untouched. Runtimes that
|
|
339
|
+
* cannot decode at all (iPad apps on macOS reject every mp3) fall through
|
|
340
|
+
* to the caller, which draws a placeholder waveform instead.
|
|
341
|
+
*/
|
|
342
|
+
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
343
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
344
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
345
|
+
if (!channelData.length) {
|
|
346
|
+
return Array.from({ length: barCount }, () => 0);
|
|
347
|
+
}
|
|
348
|
+
const levels = [];
|
|
349
|
+
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
350
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
351
|
+
const segmentStart = i * segmentLength;
|
|
352
|
+
let sum = 0;
|
|
353
|
+
for (let j = 0; j < segmentLength; j += 1) {
|
|
354
|
+
const sample = channelData[segmentStart + j];
|
|
355
|
+
if (sample !== undefined) {
|
|
356
|
+
sum += Math.abs(sample);
|
|
310
357
|
}
|
|
311
|
-
const average = sum / segmentLength;
|
|
312
|
-
levels.push(Math.min(100, average * 200));
|
|
313
358
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
finally {
|
|
317
|
-
await context.close();
|
|
359
|
+
const average = sum / segmentLength;
|
|
360
|
+
levels.push(Math.min(100, average * 200));
|
|
318
361
|
}
|
|
362
|
+
return levels;
|
|
319
363
|
};
|
|
320
364
|
|
|
321
365
|
/**
|
|
@@ -963,18 +1007,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
963
1007
|
const audioRef = React.useRef(null);
|
|
964
1008
|
const objectUrlRef = React.useRef(null);
|
|
965
1009
|
const abortControllerRef = React.useRef(null);
|
|
966
|
-
const failedSrcRef = React.useRef(null);
|
|
967
1010
|
const waveformTokenRef = React.useRef(0);
|
|
968
1011
|
const waveformDataRef = React.useRef(waveformData);
|
|
969
1012
|
const callbacksRef = React.useRef(playbackCallbacks);
|
|
970
1013
|
waveformDataRef.current = waveformData;
|
|
971
1014
|
callbacksRef.current = playbackCallbacks;
|
|
972
|
-
const
|
|
973
|
-
if (objectUrlRef.current) {
|
|
974
|
-
revokeObjectURL(objectUrlRef.current);
|
|
975
|
-
}
|
|
976
|
-
const url = createObjectURL(blob);
|
|
977
|
-
objectUrlRef.current = url;
|
|
1015
|
+
const createAudioFromUrl = React.useCallback((url) => {
|
|
978
1016
|
const { audio } = createAudioPlayback(url, {
|
|
979
1017
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
980
1018
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
@@ -990,13 +1028,22 @@ const useAudioBlobLoader = (options) => {
|
|
|
990
1028
|
audioRef.current = audio;
|
|
991
1029
|
setIsLoaded(true);
|
|
992
1030
|
}, []);
|
|
1031
|
+
/** Local recordings have no url of their own, so they still go through a blob */
|
|
1032
|
+
const createAudioFromBlob = React.useCallback((blob) => {
|
|
1033
|
+
if (objectUrlRef.current) {
|
|
1034
|
+
revokeObjectURL(objectUrlRef.current);
|
|
1035
|
+
}
|
|
1036
|
+
objectUrlRef.current = createObjectURL(blob);
|
|
1037
|
+
createAudioFromUrl(objectUrlRef.current);
|
|
1038
|
+
}, [createAudioFromUrl]);
|
|
993
1039
|
/**
|
|
994
1040
|
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
995
1041
|
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
996
1042
|
* must never block playback. Token keeps only the latest decode.
|
|
997
1043
|
*/
|
|
998
|
-
const applyWaveformLevels = React.useCallback(async (blob) => {
|
|
999
|
-
|
|
1044
|
+
const applyWaveformLevels = React.useCallback(async (blob, seed) => {
|
|
1045
|
+
/** Read through the ref: levels may arrive while the bytes are still loading */
|
|
1046
|
+
if (waveformDataRef.current) {
|
|
1000
1047
|
return;
|
|
1001
1048
|
}
|
|
1002
1049
|
waveformTokenRef.current += 1;
|
|
@@ -1009,8 +1056,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1009
1056
|
}
|
|
1010
1057
|
catch (error) {
|
|
1011
1058
|
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1059
|
+
/** Some runtimes decode nothing at all — draw a placeholder instead of a flat line */
|
|
1060
|
+
if (token === waveformTokenRef.current) {
|
|
1061
|
+
setWaveformLevels(createPseudoLevels(seed));
|
|
1062
|
+
}
|
|
1012
1063
|
}
|
|
1013
|
-
}, [
|
|
1064
|
+
}, []);
|
|
1014
1065
|
const mountFromBlob = React.useCallback(async (blob) => {
|
|
1015
1066
|
if (audioRef.current) {
|
|
1016
1067
|
return;
|
|
@@ -1025,53 +1076,51 @@ const useAudioBlobLoader = (options) => {
|
|
|
1025
1076
|
finally {
|
|
1026
1077
|
setIsLoading(false);
|
|
1027
1078
|
}
|
|
1028
|
-
void applyWaveformLevels(blob);
|
|
1079
|
+
void applyWaveformLevels(blob, `${blob.size}`);
|
|
1029
1080
|
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1030
|
-
|
|
1081
|
+
/** Waveform bytes are cosmetic, so nothing here may delay playback */
|
|
1082
|
+
const loadWaveformSource = React.useCallback(async (url, cached) => {
|
|
1031
1083
|
var _a;
|
|
1032
|
-
if (
|
|
1084
|
+
if (waveformDataRef.current) {
|
|
1033
1085
|
return;
|
|
1034
1086
|
}
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
failedSrcRef.current = null;
|
|
1040
|
-
}
|
|
1041
|
-
if (preloadedBlob) {
|
|
1042
|
-
return mountFromBlob(preloadedBlob);
|
|
1043
|
-
}
|
|
1044
|
-
if (!src) {
|
|
1045
|
-
return;
|
|
1087
|
+
if (cached) {
|
|
1088
|
+
return applyWaveformLevels(cached, url);
|
|
1046
1089
|
}
|
|
1047
1090
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1048
1091
|
const controller = new AbortController();
|
|
1049
1092
|
abortControllerRef.current = controller;
|
|
1050
|
-
setIsLoading(true);
|
|
1051
1093
|
try {
|
|
1052
|
-
const blob = await fetchAudioBlob(
|
|
1053
|
-
if (controller.signal.aborted) {
|
|
1054
|
-
|
|
1094
|
+
const blob = await fetchAudioBlob(url, controller.signal);
|
|
1095
|
+
if (!controller.signal.aborted) {
|
|
1096
|
+
await applyWaveformLevels(blob, url);
|
|
1055
1097
|
}
|
|
1056
|
-
createAudioFromBlob(blob);
|
|
1057
|
-
void applyWaveformLevels(blob);
|
|
1058
1098
|
}
|
|
1059
1099
|
catch (error) {
|
|
1060
1100
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
1061
1101
|
return;
|
|
1062
1102
|
}
|
|
1063
|
-
|
|
1064
|
-
console.warn('[voice-player] Failed to load audio', error);
|
|
1103
|
+
console.warn('[voice-player] Failed to fetch waveform source', error);
|
|
1065
1104
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1105
|
+
}, [applyWaveformLevels]);
|
|
1106
|
+
const ensureAudioLoaded = React.useCallback(async () => {
|
|
1107
|
+
if (audioRef.current) {
|
|
1108
|
+
return;
|
|
1070
1109
|
}
|
|
1071
|
-
|
|
1110
|
+
/** Only a recording that has not been uploaded yet has no url of its own */
|
|
1111
|
+
if (!src) {
|
|
1112
|
+
return preloadedBlob ? mountFromBlob(preloadedBlob) : undefined;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Remote files play straight from their url: it streams with range
|
|
1116
|
+
* requests, keeps the user gesture (no await before play) and avoids
|
|
1117
|
+
* blob sources, which some WebKit runtimes refuse to render.
|
|
1118
|
+
*/
|
|
1119
|
+
createAudioFromUrl(src);
|
|
1120
|
+
void loadWaveformSource(src, preloadedBlob);
|
|
1121
|
+
}, [src, preloadedBlob, mountFromBlob, loadWaveformSource, createAudioFromUrl]);
|
|
1072
1122
|
/** Cleanup on src change or unmount */
|
|
1073
1123
|
React.useEffect(() => {
|
|
1074
|
-
failedSrcRef.current = null;
|
|
1075
1124
|
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1076
1125
|
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
1077
1126
|
return () => {
|
|
@@ -1089,11 +1138,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1089
1138
|
setIsLoaded(false);
|
|
1090
1139
|
};
|
|
1091
1140
|
}, [src]);
|
|
1141
|
+
/** A cached recording mounts on its own only while it has no url yet */
|
|
1092
1142
|
React.useEffect(() => {
|
|
1093
|
-
if (preloadedBlob && !audioRef.current) {
|
|
1143
|
+
if (preloadedBlob && !src && !audioRef.current) {
|
|
1094
1144
|
void mountFromBlob(preloadedBlob);
|
|
1095
1145
|
}
|
|
1096
|
-
}, [preloadedBlob, mountFromBlob]);
|
|
1146
|
+
}, [src, preloadedBlob, mountFromBlob]);
|
|
1097
1147
|
React.useEffect(() => {
|
|
1098
1148
|
if (waveformData) {
|
|
1099
1149
|
waveformTokenRef.current += 1;
|
|
@@ -1202,7 +1252,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1202
1252
|
return;
|
|
1203
1253
|
}
|
|
1204
1254
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1205
|
-
void ensureAudioLoaded(
|
|
1255
|
+
void ensureAudioLoaded().then(() => {
|
|
1206
1256
|
const audio = audioRef.current;
|
|
1207
1257
|
if (audio) {
|
|
1208
1258
|
audio.play()
|
|
@@ -1262,7 +1312,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1262
1312
|
.catch(() => setIsPlaying(false));
|
|
1263
1313
|
}
|
|
1264
1314
|
else {
|
|
1265
|
-
void ensureAudioLoaded(
|
|
1315
|
+
void ensureAudioLoaded().then(() => {
|
|
1266
1316
|
const loadedAudio = audioRef.current;
|
|
1267
1317
|
if (loadedAudio) {
|
|
1268
1318
|
audioPlaybackManager.stopOthers(stopThis);
|