@wavv/ui 2.4.15-alpha.2 → 2.4.15-alpha.4

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.
@@ -46,6 +46,8 @@ type AudioProps = {
46
46
  playedColor?: string;
47
47
  /** Color for unplayed portion of waveform */
48
48
  unplayedColor?: string;
49
+ /** Force prefetch into blob for remote URLs to fix seeking (use when server range support is unreliable) */
50
+ forceBlobForSeeking?: boolean;
49
51
  };
50
52
  ref?: React.Ref<AudioRef>;
51
53
  onPlay?: (event: ChangeEvent<HTMLAudioElement>) => void;
@@ -12,11 +12,18 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
12
12
  const [totalDuration, setTotalDuration] = useState(0);
13
13
  const [displayCurrent, setDisplayCurrent] = useState('');
14
14
  const [displayDuration, setDisplayDuration] = useState('0:00');
15
+ const [playbackUrl, setPlaybackUrl] = useState(src);
15
16
  const audioRef = useRef(null);
17
+ const objectUrlRef = useRef(null);
16
18
  const isSeekingRef = useRef(false);
17
19
  const seekedTimeoutRef = useRef(null);
20
+ const lastRequestedSeekSecondsRef = useRef(null);
21
+ const seekVerifyRetryCountRef = useRef(0);
22
+ const seekVerifyTimeoutRef = useRef(null);
18
23
  const END_EPSILON_SECONDS = 0.75;
19
- const { height = 30, showHoverTime = false, playedColor, unplayedColor } = 'object' == typeof waveform ? waveform : {};
24
+ const SEEK_TOLERANCE_SECONDS = 0.5;
25
+ const MAX_SEEK_VERIFY_RETRIES = 3;
26
+ const { height = 30, showHoverTime = false, playedColor, unplayedColor, forceBlobForSeeking = false } = 'object' == typeof waveform ? waveform : {};
20
27
  const play = ()=>{
21
28
  const { current: audio } = audioRef;
22
29
  if (audio) audio.play().catch(()=>{
@@ -40,6 +47,7 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
40
47
  if (isPlaying) pause();
41
48
  else {
42
49
  if (isNearEnd(progress)) {
50
+ lastRequestedSeekSecondsRef.current = null;
43
51
  setProgress(0);
44
52
  setDisplayCurrent('0:00');
45
53
  const { current: audio } = audioRef;
@@ -68,6 +76,7 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
68
76
  if (onPause) onPause(event);
69
77
  };
70
78
  const handleAudioStop = (event)=>{
79
+ lastRequestedSeekSecondsRef.current = null;
71
80
  setIsPlaying(false);
72
81
  setIsCleared(true);
73
82
  setProgress(0);
@@ -93,9 +102,54 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
93
102
  }, [
94
103
  isPlaying
95
104
  ]);
105
+ useEffect(()=>{
106
+ setPlaybackUrl(src);
107
+ if (objectUrlRef.current) {
108
+ try {
109
+ URL.revokeObjectURL(objectUrlRef.current);
110
+ } catch (_e) {}
111
+ objectUrlRef.current = null;
112
+ }
113
+ if (!waveform || !src || !src.startsWith('http')) return;
114
+ let cancelled = false;
115
+ const buildBlobUrl = async ()=>{
116
+ try {
117
+ const resp = await fetch(src, {
118
+ cache: 'force-cache'
119
+ });
120
+ const buf = await resp.arrayBuffer();
121
+ if (cancelled) return;
122
+ const useBlob = forceBlobForSeeking || 'bytes' !== resp.headers.get('accept-ranges');
123
+ if (useBlob) {
124
+ try {
125
+ if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
126
+ } catch (_e) {}
127
+ const blob = new Blob([
128
+ buf
129
+ ], {
130
+ type: resp.headers.get('content-type') || 'audio/mpeg'
131
+ });
132
+ const objectUrl = URL.createObjectURL(blob);
133
+ objectUrlRef.current = objectUrl;
134
+ if (!cancelled) setPlaybackUrl(objectUrl);
135
+ }
136
+ } catch (_e) {}
137
+ };
138
+ buildBlobUrl();
139
+ return ()=>{
140
+ cancelled = true;
141
+ try {
142
+ if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
143
+ } catch (_e) {}
144
+ };
145
+ }, [
146
+ src,
147
+ waveform,
148
+ forceBlobForSeeking
149
+ ]);
96
150
  useEffect(()=>{
97
151
  const { current: audio } = audioRef;
98
- if (audio && src) {
152
+ if (audio && playbackUrl) {
99
153
  audio.load();
100
154
  const checkDuration = setTimeout(()=>{
101
155
  if (audio.duration && Number.isFinite(audio.duration) && audio.duration > 0 && audio.duration !== 1 / 0) {
@@ -109,11 +163,12 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
109
163
  return ()=>clearTimeout(checkDuration);
110
164
  }
111
165
  }, [
112
- src,
166
+ playbackUrl,
113
167
  fallbackDuration
114
168
  ]);
115
169
  useEffect(()=>()=>{
116
170
  if (seekedTimeoutRef.current) clearTimeout(seekedTimeoutRef.current);
171
+ if (seekVerifyTimeoutRef.current) clearTimeout(seekVerifyTimeoutRef.current);
117
172
  }, []);
118
173
  const formatTime = (time)=>{
119
174
  if (!Number.isFinite(time) || time < 0) return '0:00';
@@ -121,17 +176,41 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
121
176
  const secs = Math.floor(time % 60);
122
177
  return `${mins}:${secs.toString().padStart(2, '0')}`;
123
178
  };
179
+ const performSeek = (targetSeconds)=>{
180
+ const { current: audio } = audioRef;
181
+ if (!audio || !Number.isFinite(targetSeconds)) return false;
182
+ try {
183
+ audio.currentTime = targetSeconds;
184
+ return true;
185
+ } catch (_e) {
186
+ return false;
187
+ }
188
+ };
124
189
  const updateProgress = ()=>{
125
190
  if (isSeekingRef.current) return;
126
191
  const { current: audio } = audioRef;
127
- if (audio) {
128
- const { currentTime } = audio;
129
- if (Number.isFinite(currentTime) && currentTime >= 0) {
130
- const current = formatTime(currentTime);
131
- setProgress(currentTime);
132
- setDisplayCurrent(current);
192
+ if (!audio) return;
193
+ const currentTime = audio.currentTime;
194
+ if (!Number.isFinite(currentTime) || currentTime < 0) return;
195
+ const requestedSeek = lastRequestedSeekSecondsRef.current;
196
+ if ('number' == typeof requestedSeek) {
197
+ const drift = Math.abs(currentTime - requestedSeek);
198
+ if (drift > SEEK_TOLERANCE_SECONDS) {
199
+ if (seekVerifyRetryCountRef.current < MAX_SEEK_VERIFY_RETRIES) {
200
+ seekVerifyRetryCountRef.current += 1;
201
+ performSeek(requestedSeek);
202
+ return;
203
+ }
204
+ lastRequestedSeekSecondsRef.current = null;
205
+ seekVerifyRetryCountRef.current = 0;
206
+ return;
133
207
  }
208
+ lastRequestedSeekSecondsRef.current = null;
209
+ seekVerifyRetryCountRef.current = 0;
134
210
  }
211
+ const current = formatTime(currentTime);
212
+ setProgress(currentTime);
213
+ setDisplayCurrent(current);
135
214
  };
136
215
  const handleLoad = ()=>{
137
216
  const { current: audio } = audioRef;
@@ -169,6 +248,8 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
169
248
  const maxSeekTarget = Math.max(0, totalDuration - END_EPSILON_SECONDS);
170
249
  const clamped = Math.min(Math.max(sliderPosition, 0), maxSeekTarget);
171
250
  isSeekingRef.current = true;
251
+ lastRequestedSeekSecondsRef.current = clamped;
252
+ seekVerifyRetryCountRef.current = 0;
172
253
  if (seekedTimeoutRef.current) clearTimeout(seekedTimeoutRef.current);
173
254
  seekedTimeoutRef.current = setTimeout(()=>{
174
255
  isSeekingRef.current = false;
@@ -176,7 +257,15 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
176
257
  }, 300);
177
258
  setProgress(clamped);
178
259
  setDisplayCurrent(formatTime(clamped));
179
- audioRef.current.currentTime = clamped;
260
+ performSeek(clamped);
261
+ if (seekVerifyTimeoutRef.current) clearTimeout(seekVerifyTimeoutRef.current);
262
+ seekVerifyTimeoutRef.current = window.setTimeout(()=>{
263
+ seekVerifyTimeoutRef.current = null;
264
+ const { current: audio } = audioRef;
265
+ if (!audio || null === lastRequestedSeekSecondsRef.current) return;
266
+ const ct = audio.currentTime ?? 0;
267
+ if (Math.abs(ct - clamped) > SEEK_TOLERANCE_SECONDS) performSeek(clamped);
268
+ }, 150);
180
269
  };
181
270
  const hideSlider = isCleared && hideSliderOnStop;
182
271
  const hideTime = isCleared && hideTimeOnStop;
@@ -207,7 +296,7 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
207
296
  alignData: infoPosition,
208
297
  children: [
209
298
  !hideSlider && (waveform ? /*#__PURE__*/ jsx(Waveform, {
210
- src: src,
299
+ src: playbackUrl,
211
300
  value: progress,
212
301
  maxValue: totalDuration,
213
302
  onChange: handleControlGrab,
@@ -239,7 +328,7 @@ const Audio = ({ src, title, width, infoPosition, centerAlignContent, small, tin
239
328
  'center' === infoPosition && timeData,
240
329
  /*#__PURE__*/ jsx("audio", {
241
330
  ref: audioRef,
242
- src: src,
331
+ src: playbackUrl,
243
332
  onPlay: handleAudioPlay,
244
333
  onPause: handleAudioPause,
245
334
  onEmptied: handleAudioStop,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavv/ui",
3
- "version": "2.4.15-alpha.2",
3
+ "version": "2.4.15-alpha.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {