@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.
@@ -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,KAuNtB,CAAC;AAGH,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
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,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBAoIhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
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,sBA2H5B,CAAC;AAGF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
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,2BAqMtE,CAAC;AAGF,OAAO,EAAE,qBAAqB,EAAE,CAAC"}
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 arrayBuffer = await blob.arrayBuffer();
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
- }, [waveformData, createAudioFromBlob]);
850
- const ensureAudioLoaded = React.useCallback(async () => {
1028
+ void applyWaveformLevels(blob);
1029
+ }, [applyWaveformLevels, createAudioFromBlob]);
1030
+ const ensureAudioLoaded = React.useCallback(async (options) => {
851
1031
  var _a;
852
- if (audioRef.current || failedSrcRef.current === src) {
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, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
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,