@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
package/dist/index.js
CHANGED
|
@@ -76,6 +76,61 @@ const stopStream = (stream) => {
|
|
|
76
76
|
*
|
|
77
77
|
* @author: exode <hello@exode.ru>
|
|
78
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
|
+
};
|
|
79
134
|
const createAudioPlayback = (url, callbacks) => {
|
|
80
135
|
const audio = new Audio(url);
|
|
81
136
|
audio.onloadedmetadata = () => {
|
|
@@ -87,6 +142,21 @@ const createAudioPlayback = (url, callbacks) => {
|
|
|
87
142
|
audio.onended = () => {
|
|
88
143
|
callbacks.onEnded();
|
|
89
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
|
+
};
|
|
90
160
|
return { audio, url };
|
|
91
161
|
};
|
|
92
162
|
const cleanupAudio = (audio) => {
|
|
@@ -95,6 +165,9 @@ const cleanupAudio = (audio) => {
|
|
|
95
165
|
audio.onended = null;
|
|
96
166
|
audio.ontimeupdate = null;
|
|
97
167
|
audio.onloadedmetadata = null;
|
|
168
|
+
audio.onplay = null;
|
|
169
|
+
audio.onpause = null;
|
|
170
|
+
clearMediaSession();
|
|
98
171
|
};
|
|
99
172
|
const createObjectURL = (blob) => {
|
|
100
173
|
return URL.createObjectURL(blob);
|
|
@@ -116,6 +189,64 @@ const TIMELINE_BAR_GAP = 2;
|
|
|
116
189
|
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
117
190
|
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
118
191
|
|
|
192
|
+
/**
|
|
193
|
+
* MPEG frame scanner
|
|
194
|
+
*
|
|
195
|
+
* @author: exode <hello@exode.ru>
|
|
196
|
+
*/
|
|
197
|
+
const MPEG_LAYER_III = 1;
|
|
198
|
+
const MPEG_SAMPLE_RATES = [44100, 48000, 32000];
|
|
199
|
+
const MPEG_BITRATES_V1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
|
200
|
+
const MPEG_BITRATES_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
|
|
201
|
+
/** Length of the Layer III frame at the given offset, or 0 when it is not one */
|
|
202
|
+
const readFrameLength = (bytes, offset) => {
|
|
203
|
+
if (offset + 3 >= bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
/** Version ids: 0 = MPEG 2.5, 1 = reserved, 2 = MPEG 2, 3 = MPEG 1 */
|
|
207
|
+
const version = (bytes[offset + 1] >> 3) & 0x03;
|
|
208
|
+
const layer = (bytes[offset + 1] >> 1) & 0x03;
|
|
209
|
+
const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
|
|
210
|
+
const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
|
|
211
|
+
const isReserved = version === 1
|
|
212
|
+
|| layer !== MPEG_LAYER_III
|
|
213
|
+
|| sampleRateIndex === 3
|
|
214
|
+
|| bitrateIndex === 0
|
|
215
|
+
|| bitrateIndex === 0x0f;
|
|
216
|
+
if (isReserved) {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
const isVersion1 = version === 3;
|
|
220
|
+
const padding = (bytes[offset + 2] >> 1) & 0x01;
|
|
221
|
+
const bitrate = (isVersion1 ? MPEG_BITRATES_V1 : MPEG_BITRATES_V2)[bitrateIndex] * 1000;
|
|
222
|
+
const sampleRate = MPEG_SAMPLE_RATES[sampleRateIndex] / (isVersion1 ? 1 : version === 2 ? 2 : 4);
|
|
223
|
+
return Math.floor((isVersion1 ? 144 : 72) * bitrate / sampleRate) + padding;
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Some mp3 (e.g. converted from wma) keep junk between the ID3 tag and the
|
|
227
|
+
* first MPEG frame: media elements skip it, the WebAudio decoder does not.
|
|
228
|
+
* Returns the offset of the first real frame, or 0 when there is none.
|
|
229
|
+
*/
|
|
230
|
+
const findFirstFrameOffset = (bytes) => {
|
|
231
|
+
let start = 0;
|
|
232
|
+
/** ID3v2 header: "ID3" + version + flags + 4 syncsafe size bytes */
|
|
233
|
+
if (bytes.length >= 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
|
|
234
|
+
start = 10 + ((bytes[6] << 21) | (bytes[7] << 14) | (bytes[8] << 7) | bytes[9]);
|
|
235
|
+
/** ID3v2.4 may append a 10 byte footer copy of the header */
|
|
236
|
+
if (bytes[5] & 0x10) {
|
|
237
|
+
start += 10;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let i = start; i < bytes.length - 3; i += 1) {
|
|
241
|
+
const length = readFrameLength(bytes, i);
|
|
242
|
+
/** Junk can open with a valid looking header, so require the next frame to follow */
|
|
243
|
+
if (length && readFrameLength(bytes, i + length)) {
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
};
|
|
249
|
+
|
|
119
250
|
/**
|
|
120
251
|
* Audio analyzer platform adapter
|
|
121
252
|
*
|
|
@@ -142,11 +273,24 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
|
|
|
142
273
|
}
|
|
143
274
|
return levels;
|
|
144
275
|
};
|
|
276
|
+
/** decodeAudioData detaches the buffer, so the retry re-reads the blob */
|
|
277
|
+
const decodeSkippingJunk = async (context, blob) => {
|
|
278
|
+
try {
|
|
279
|
+
return await context.decodeAudioData(await blob.arrayBuffer());
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
283
|
+
const offset = findFirstFrameOffset(bytes);
|
|
284
|
+
if (!offset) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return context.decodeAudioData(bytes.buffer.slice(offset));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
145
290
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
146
291
|
const context = new AudioContext();
|
|
147
292
|
try {
|
|
148
|
-
const
|
|
149
|
-
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
|
293
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
150
294
|
const channelData = audioBuffer.getChannelData(0);
|
|
151
295
|
if (!channelData.length) {
|
|
152
296
|
return Array.from({ length: barCount }, () => 0);
|
|
@@ -183,6 +327,9 @@ const fetchAudioBlob = async (url, signal) => {
|
|
|
183
327
|
mode: 'cors',
|
|
184
328
|
credentials: 'omit',
|
|
185
329
|
});
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(`Failed to fetch audio: ${response.status}`);
|
|
332
|
+
}
|
|
186
333
|
return response.blob();
|
|
187
334
|
};
|
|
188
335
|
|
|
@@ -364,6 +511,8 @@ const useAudioPlayback = () => {
|
|
|
364
511
|
setIsPlaying(false);
|
|
365
512
|
setPlaybackTime(0);
|
|
366
513
|
},
|
|
514
|
+
onPlay: () => setIsPlaying(true),
|
|
515
|
+
onPause: () => setIsPlaying(false),
|
|
367
516
|
});
|
|
368
517
|
audioRef.current = audio;
|
|
369
518
|
setPlaybackTime(0);
|
|
@@ -383,6 +532,8 @@ const useAudioPlayback = () => {
|
|
|
383
532
|
setIsPlaying(false);
|
|
384
533
|
setPlaybackTime(0);
|
|
385
534
|
},
|
|
535
|
+
onPlay: () => setIsPlaying(true),
|
|
536
|
+
onPause: () => setIsPlaying(false),
|
|
386
537
|
});
|
|
387
538
|
audioRef.current = audio;
|
|
388
539
|
}
|
|
@@ -811,7 +962,10 @@ const useAudioBlobLoader = (options) => {
|
|
|
811
962
|
const objectUrlRef = useRef(null);
|
|
812
963
|
const abortControllerRef = useRef(null);
|
|
813
964
|
const failedSrcRef = useRef(null);
|
|
965
|
+
const waveformTokenRef = useRef(0);
|
|
966
|
+
const waveformDataRef = useRef(waveformData);
|
|
814
967
|
const callbacksRef = useRef(playbackCallbacks);
|
|
968
|
+
waveformDataRef.current = waveformData;
|
|
815
969
|
callbacksRef.current = playbackCallbacks;
|
|
816
970
|
const createAudioFromBlob = useCallback((blob) => {
|
|
817
971
|
if (objectUrlRef.current) {
|
|
@@ -823,19 +977,44 @@ const useAudioBlobLoader = (options) => {
|
|
|
823
977
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
824
978
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
825
979
|
onEnded: () => callbacksRef.current.onEnded(),
|
|
980
|
+
onPlay: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPlay) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
981
|
+
onPause: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPause) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
982
|
+
onStop: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onStop) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
983
|
+
onSeekBackward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekBackward) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
984
|
+
onSeekForward: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onSeekForward) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
985
|
+
onPreviousTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onPreviousTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
986
|
+
onNextTrack: () => { var _a, _b; return (_b = (_a = callbacksRef.current).onNextTrack) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
826
987
|
});
|
|
827
988
|
audioRef.current = audio;
|
|
828
989
|
setIsLoaded(true);
|
|
829
990
|
}, []);
|
|
991
|
+
/**
|
|
992
|
+
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
993
|
+
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
994
|
+
* must never block playback. Token keeps only the latest decode.
|
|
995
|
+
*/
|
|
996
|
+
const applyWaveformLevels = useCallback(async (blob) => {
|
|
997
|
+
if (waveformData) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
waveformTokenRef.current += 1;
|
|
1001
|
+
const token = waveformTokenRef.current;
|
|
1002
|
+
try {
|
|
1003
|
+
const levels = await decodeAudioLevels(blob);
|
|
1004
|
+
if (token === waveformTokenRef.current) {
|
|
1005
|
+
setWaveformLevels(levels);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1010
|
+
}
|
|
1011
|
+
}, [waveformData]);
|
|
830
1012
|
const mountFromBlob = useCallback(async (blob) => {
|
|
831
1013
|
if (audioRef.current) {
|
|
832
1014
|
return;
|
|
833
1015
|
}
|
|
834
1016
|
setIsLoading(true);
|
|
835
1017
|
try {
|
|
836
|
-
if (!waveformData) {
|
|
837
|
-
setWaveformLevels(await decodeAudioLevels(blob));
|
|
838
|
-
}
|
|
839
1018
|
createAudioFromBlob(blob);
|
|
840
1019
|
}
|
|
841
1020
|
catch (error) {
|
|
@@ -844,12 +1023,19 @@ const useAudioBlobLoader = (options) => {
|
|
|
844
1023
|
finally {
|
|
845
1024
|
setIsLoading(false);
|
|
846
1025
|
}
|
|
847
|
-
|
|
848
|
-
|
|
1026
|
+
void applyWaveformLevels(blob);
|
|
1027
|
+
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1028
|
+
const ensureAudioLoaded = useCallback(async (options) => {
|
|
849
1029
|
var _a;
|
|
850
|
-
if (audioRef.current
|
|
1030
|
+
if (audioRef.current) {
|
|
851
1031
|
return;
|
|
852
1032
|
}
|
|
1033
|
+
if (failedSrcRef.current === src) {
|
|
1034
|
+
if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
failedSrcRef.current = null;
|
|
1038
|
+
}
|
|
853
1039
|
if (preloadedBlob) {
|
|
854
1040
|
return mountFromBlob(preloadedBlob);
|
|
855
1041
|
}
|
|
@@ -865,14 +1051,8 @@ const useAudioBlobLoader = (options) => {
|
|
|
865
1051
|
if (controller.signal.aborted) {
|
|
866
1052
|
return;
|
|
867
1053
|
}
|
|
868
|
-
if (!waveformData) {
|
|
869
|
-
const levels = await decodeAudioLevels(blob);
|
|
870
|
-
if (controller.signal.aborted) {
|
|
871
|
-
return;
|
|
872
|
-
}
|
|
873
|
-
setWaveformLevels(levels);
|
|
874
|
-
}
|
|
875
1054
|
createAudioFromBlob(blob);
|
|
1055
|
+
void applyWaveformLevels(blob);
|
|
876
1056
|
}
|
|
877
1057
|
catch (error) {
|
|
878
1058
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
@@ -886,13 +1066,16 @@ const useAudioBlobLoader = (options) => {
|
|
|
886
1066
|
setIsLoading(false);
|
|
887
1067
|
}
|
|
888
1068
|
}
|
|
889
|
-
}, [src,
|
|
1069
|
+
}, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
|
|
890
1070
|
/** Cleanup on src change or unmount */
|
|
891
1071
|
useEffect(() => {
|
|
892
1072
|
failedSrcRef.current = null;
|
|
1073
|
+
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1074
|
+
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
893
1075
|
return () => {
|
|
894
1076
|
var _a;
|
|
895
1077
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1078
|
+
waveformTokenRef.current += 1;
|
|
896
1079
|
if (audioRef.current) {
|
|
897
1080
|
cleanupAudio(audioRef.current);
|
|
898
1081
|
audioRef.current = null;
|
|
@@ -911,6 +1094,7 @@ const useAudioBlobLoader = (options) => {
|
|
|
911
1094
|
}, [preloadedBlob, mountFromBlob]);
|
|
912
1095
|
useEffect(() => {
|
|
913
1096
|
if (waveformData) {
|
|
1097
|
+
waveformTokenRef.current += 1;
|
|
914
1098
|
setWaveformLevels(waveformData);
|
|
915
1099
|
}
|
|
916
1100
|
}, [waveformData]);
|
|
@@ -953,9 +1137,13 @@ const audioPlaybackManager = {
|
|
|
953
1137
|
* @author: exode <hello@exode.ru>
|
|
954
1138
|
*/
|
|
955
1139
|
const useVoiceMessagePlayer = (options) => {
|
|
956
|
-
const { src, onEnded, waveformData, preloadedBlob, duration: externalDuration, } = options;
|
|
1140
|
+
const { src, onEnded, onPreviousTrack, onNextTrack, waveformData, preloadedBlob, duration: externalDuration, } = options;
|
|
957
1141
|
const onEndedRef = useRef(onEnded);
|
|
1142
|
+
const onPreviousTrackRef = useRef(onPreviousTrack);
|
|
1143
|
+
const onNextTrackRef = useRef(onNextTrack);
|
|
958
1144
|
onEndedRef.current = onEnded;
|
|
1145
|
+
onPreviousTrackRef.current = onPreviousTrack;
|
|
1146
|
+
onNextTrackRef.current = onNextTrack;
|
|
959
1147
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
960
1148
|
const [currentTime, setCurrentTime] = useState(0);
|
|
961
1149
|
const [duration, setDuration] = useState(externalDuration || 0);
|
|
@@ -989,6 +1177,14 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
989
1177
|
setCurrentTime(0);
|
|
990
1178
|
(_a = onEndedRef.current) === null || _a === void 0 ? void 0 : _a.call(onEndedRef);
|
|
991
1179
|
},
|
|
1180
|
+
onPlay: () => setIsPlaying(true),
|
|
1181
|
+
onPause: () => setIsPlaying(false),
|
|
1182
|
+
onStop: () => {
|
|
1183
|
+
setIsPlaying(false);
|
|
1184
|
+
setCurrentTime(0);
|
|
1185
|
+
},
|
|
1186
|
+
onPreviousTrack: () => { var _a; return (_a = onPreviousTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onPreviousTrackRef); },
|
|
1187
|
+
onNextTrack: () => { var _a; return (_a = onNextTrackRef.current) === null || _a === void 0 ? void 0 : _a.call(onNextTrackRef); },
|
|
992
1188
|
},
|
|
993
1189
|
});
|
|
994
1190
|
/** Pre-fetch audio without playing (for viewport preloading) */
|
|
@@ -1004,7 +1200,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1004
1200
|
return;
|
|
1005
1201
|
}
|
|
1006
1202
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1007
|
-
void ensureAudioLoaded().then(() => {
|
|
1203
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1008
1204
|
const audio = audioRef.current;
|
|
1009
1205
|
if (audio) {
|
|
1010
1206
|
audio.play()
|
|
@@ -1064,7 +1260,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1064
1260
|
.catch(() => setIsPlaying(false));
|
|
1065
1261
|
}
|
|
1066
1262
|
else {
|
|
1067
|
-
void ensureAudioLoaded().then(() => {
|
|
1263
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1068
1264
|
const loadedAudio = audioRef.current;
|
|
1069
1265
|
if (loadedAudio) {
|
|
1070
1266
|
audioPlaybackManager.stopOthers(stopThis);
|
|
@@ -2136,6 +2332,13 @@ class VoicePlaybackChainClass {
|
|
|
2136
2332
|
this.entries[idx + 1].play();
|
|
2137
2333
|
}
|
|
2138
2334
|
};
|
|
2335
|
+
/** Play the previous voice message before the given media ID */
|
|
2336
|
+
this.playPrevious = (currentId) => {
|
|
2337
|
+
const idx = this.entries.findIndex((e) => e.id === currentId);
|
|
2338
|
+
if (idx > 0) {
|
|
2339
|
+
this.entries[idx - 1].play();
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2139
2342
|
}
|
|
2140
2343
|
}
|
|
2141
2344
|
const VoicePlaybackChain = new VoicePlaybackChainClass();
|
|
@@ -2243,12 +2446,24 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
|
|
|
2243
2446
|
VoicePlaybackChain.playNext(chainId);
|
|
2244
2447
|
}
|
|
2245
2448
|
}, [onEnded, chainId]);
|
|
2449
|
+
const handleNextTrack = useCallback(() => {
|
|
2450
|
+
if (chainId != null) {
|
|
2451
|
+
VoicePlaybackChain.playNext(chainId);
|
|
2452
|
+
}
|
|
2453
|
+
}, [chainId]);
|
|
2454
|
+
const handlePreviousTrack = useCallback(() => {
|
|
2455
|
+
if (chainId != null) {
|
|
2456
|
+
VoicePlaybackChain.playPrevious(chainId);
|
|
2457
|
+
}
|
|
2458
|
+
}, [chainId]);
|
|
2246
2459
|
const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, seekToTime, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
|
|
2247
2460
|
src,
|
|
2248
2461
|
duration,
|
|
2249
2462
|
waveformData,
|
|
2250
2463
|
preloadedBlob: props.preloadedBlob,
|
|
2251
2464
|
onEnded: handleEnded,
|
|
2465
|
+
onNextTrack: handleNextTrack,
|
|
2466
|
+
onPreviousTrack: handlePreviousTrack,
|
|
2252
2467
|
});
|
|
2253
2468
|
useImperativeHandle(ref, () => ({ play, prefetch, seekToTime }), [
|
|
2254
2469
|
play,
|