@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/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 arrayBuffer = await blob.arrayBuffer();
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
- }, [waveformData, createAudioFromBlob]);
848
- const ensureAudioLoaded = useCallback(async () => {
1026
+ void applyWaveformLevels(blob);
1027
+ }, [applyWaveformLevels, createAudioFromBlob]);
1028
+ const ensureAudioLoaded = useCallback(async (options) => {
849
1029
  var _a;
850
- if (audioRef.current || failedSrcRef.current === src) {
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, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
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,