@bendyline/squisq-react 2.0.1 → 2.2.0
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/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
|
@@ -1,456 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useAudioSync Hook
|
|
3
|
-
*
|
|
4
|
-
* Synchronizes playback state with an audio element. Provides current
|
|
5
|
-
* playback time, playing state, and methods to control audio playback.
|
|
6
|
-
*
|
|
7
|
-
* Handles multiple audio segments (MP3 files) by tracking which segment
|
|
8
|
-
* is currently playing and calculating the overall timeline position.
|
|
9
|
-
*
|
|
10
|
-
* This is the HTML5 Audio implementation of the AudioController interface.
|
|
11
|
-
* Hosts that drive audio through an external player (e.g. a native shell)
|
|
12
|
-
* can supply their own AudioController to DocPlayer instead of this hook.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
16
|
-
import type { RefObject } from 'react';
|
|
17
|
-
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
18
|
-
import type { AudioController } from './AudioController';
|
|
19
|
-
|
|
20
|
-
function resolveAudioUrl(src: string, basePath: string): string {
|
|
21
|
-
// Preserve absolute/protocol-relative/data/blob URLs. Prefixing an absolute
|
|
22
|
-
// URL with the common default base path (`.`) produces `./https://...`.
|
|
23
|
-
if (!src || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(src)) return src;
|
|
24
|
-
if (!basePath) return src;
|
|
25
|
-
return `${basePath.replace(/\/$/, '')}/${src.replace(/^\//, '')}`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function useAudioSync(
|
|
29
|
-
audioRef: RefObject<HTMLAudioElement>,
|
|
30
|
-
audioTrack: AudioTrack | undefined,
|
|
31
|
-
basePath: string = '',
|
|
32
|
-
enabled: boolean = true,
|
|
33
|
-
): AudioController {
|
|
34
|
-
const [currentTime, setCurrentTime] = useState(0);
|
|
35
|
-
const [isPlaying, setIsPlaying] = useState(false);
|
|
36
|
-
const [currentSegment, setCurrentSegment] = useState(0);
|
|
37
|
-
const [isEnded, setIsEnded] = useState(false);
|
|
38
|
-
const [isAudioReady, setIsAudioReady] = useState(false);
|
|
39
|
-
const [totalDuration, setTotalDuration] = useState(0);
|
|
40
|
-
|
|
41
|
-
// Calculate segment start times
|
|
42
|
-
const segmentStarts = useRef<number[]>([]);
|
|
43
|
-
|
|
44
|
-
// Pending seek time (used when switching segments)
|
|
45
|
-
const pendingSeekTime = useRef<number | null>(null);
|
|
46
|
-
const shouldPlayAfterLoad = useRef(false);
|
|
47
|
-
|
|
48
|
-
// Preloaded audio blob URLs (for seeking without range request support)
|
|
49
|
-
const blobUrls = useRef<Map<string, string>>(new Map());
|
|
50
|
-
const loadingPromises = useRef<Map<string, Promise<string>>>(new Map());
|
|
51
|
-
const abortControllers = useRef<Set<AbortController>>(new Set());
|
|
52
|
-
const loadGeneration = useRef(0);
|
|
53
|
-
|
|
54
|
-
// Fallback timer: when audio.play() is blocked (e.g., autoplay policy),
|
|
55
|
-
// advance currentTime synthetically so blocks still progress without audio.
|
|
56
|
-
const fallbackMode = useRef(false);
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
loadGeneration.current += 1;
|
|
60
|
-
pendingSeekTime.current = null;
|
|
61
|
-
shouldPlayAfterLoad.current = false;
|
|
62
|
-
fallbackMode.current = false;
|
|
63
|
-
setCurrentTime(0);
|
|
64
|
-
setCurrentSegment(0);
|
|
65
|
-
setIsPlaying(false);
|
|
66
|
-
setIsEnded(false);
|
|
67
|
-
setIsAudioReady(false);
|
|
68
|
-
|
|
69
|
-
if (!enabled || !audioTrack?.segments) {
|
|
70
|
-
segmentStarts.current = [];
|
|
71
|
-
setTotalDuration(0);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
let time = 0;
|
|
76
|
-
segmentStarts.current = audioTrack.segments.map((seg) => {
|
|
77
|
-
const start = time;
|
|
78
|
-
time += seg.duration;
|
|
79
|
-
return start;
|
|
80
|
-
});
|
|
81
|
-
setTotalDuration(time);
|
|
82
|
-
}, [audioTrack, enabled]);
|
|
83
|
-
|
|
84
|
-
// Preload audio file as blob (enables seeking without range request support)
|
|
85
|
-
const preloadAudio = useCallback(
|
|
86
|
-
async (src: string): Promise<string> => {
|
|
87
|
-
const audioUrl = resolveAudioUrl(src, basePath);
|
|
88
|
-
|
|
89
|
-
// Return cached blob URL if available
|
|
90
|
-
if (blobUrls.current.has(src)) {
|
|
91
|
-
return blobUrls.current.get(src)!;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Return existing loading promise if in progress
|
|
95
|
-
if (loadingPromises.current.has(src)) {
|
|
96
|
-
return loadingPromises.current.get(src)!;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Start loading
|
|
100
|
-
const controller = new AbortController();
|
|
101
|
-
abortControllers.current.add(controller);
|
|
102
|
-
const generation = loadGeneration.current;
|
|
103
|
-
const loadPromise = (async () => {
|
|
104
|
-
try {
|
|
105
|
-
const response = await fetch(audioUrl, { signal: controller.signal });
|
|
106
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
107
|
-
const blob = await response.blob();
|
|
108
|
-
const blobUrl = URL.createObjectURL(blob);
|
|
109
|
-
if (controller.signal.aborted || generation !== loadGeneration.current) {
|
|
110
|
-
URL.revokeObjectURL(blobUrl);
|
|
111
|
-
return audioUrl;
|
|
112
|
-
}
|
|
113
|
-
blobUrls.current.set(src, blobUrl);
|
|
114
|
-
return blobUrl;
|
|
115
|
-
} catch {
|
|
116
|
-
// Fall back to direct URL if blob loading fails
|
|
117
|
-
return audioUrl;
|
|
118
|
-
} finally {
|
|
119
|
-
abortControllers.current.delete(controller);
|
|
120
|
-
loadingPromises.current.delete(src);
|
|
121
|
-
}
|
|
122
|
-
})();
|
|
123
|
-
|
|
124
|
-
loadingPromises.current.set(src, loadPromise);
|
|
125
|
-
return loadPromise;
|
|
126
|
-
},
|
|
127
|
-
[basePath],
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// Preload all audio segments on mount
|
|
131
|
-
useEffect(() => {
|
|
132
|
-
if (!enabled || !audioTrack?.segments) return;
|
|
133
|
-
|
|
134
|
-
// Preload all segments in parallel
|
|
135
|
-
audioTrack.segments.forEach((segment) => {
|
|
136
|
-
preloadAudio(segment.src);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
// Cleanup blob URLs on unmount
|
|
140
|
-
const currentBlobUrls = blobUrls.current;
|
|
141
|
-
const currentAbortControllers = abortControllers.current;
|
|
142
|
-
const currentLoadingPromises = loadingPromises.current;
|
|
143
|
-
return () => {
|
|
144
|
-
loadGeneration.current += 1;
|
|
145
|
-
currentAbortControllers.forEach((controller) => controller.abort());
|
|
146
|
-
currentAbortControllers.clear();
|
|
147
|
-
currentLoadingPromises.clear();
|
|
148
|
-
currentBlobUrls.forEach((url) => {
|
|
149
|
-
URL.revokeObjectURL(url);
|
|
150
|
-
});
|
|
151
|
-
currentBlobUrls.clear();
|
|
152
|
-
};
|
|
153
|
-
}, [audioTrack, preloadAudio, enabled]);
|
|
154
|
-
|
|
155
|
-
// Handle audio time updates
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
if (!enabled) return;
|
|
158
|
-
const audio = audioRef.current;
|
|
159
|
-
if (!audio) return;
|
|
160
|
-
|
|
161
|
-
const handleTimeUpdate = () => {
|
|
162
|
-
// In fallback mode the <audio> element is NOT the clock — there's no real
|
|
163
|
-
// source (e.g. the editor preview's synthetic, empty-src track), so the
|
|
164
|
-
// synthetic timer and `seekTo` own `currentTime`. A `timeupdate` fired as
|
|
165
|
-
// a side effect of programmatically setting `audio.currentTime` (during a
|
|
166
|
-
// seek) would otherwise clobber the just-seeked position with the empty
|
|
167
|
-
// element's unreliable `currentTime`, snapping the scrubber back.
|
|
168
|
-
if (fallbackMode.current) return;
|
|
169
|
-
// Calculate overall timeline position
|
|
170
|
-
const segmentStart = segmentStarts.current[currentSegment] || 0;
|
|
171
|
-
const overallTime = segmentStart + audio.currentTime;
|
|
172
|
-
setCurrentTime(overallTime);
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const handlePlay = () => {
|
|
176
|
-
// Don't clear `fallbackMode` here. Whether the <audio> element is really
|
|
177
|
-
// the clock is decided authoritatively by the play() promise: it only
|
|
178
|
-
// resolves (clearing fallback, see `play`) when a real source actually
|
|
179
|
-
// plays. The 'play' event, by contrast, can fire spuriously on the
|
|
180
|
-
// source-less preview element — and clearing fallback there makes the
|
|
181
|
-
// synthetic timer's tick guard bail on its next frame, freezing the
|
|
182
|
-
// clock and the scrubber after a seek/resume.
|
|
183
|
-
setIsPlaying(true);
|
|
184
|
-
};
|
|
185
|
-
const handlePause = () => setIsPlaying(false);
|
|
186
|
-
const handleError = () => {
|
|
187
|
-
// Audio source failed to load (e.g., 404 in CI or missing files).
|
|
188
|
-
// Set ready so the UI can still render controls and progress.
|
|
189
|
-
setIsAudioReady(true);
|
|
190
|
-
};
|
|
191
|
-
const handleEnded = () => {
|
|
192
|
-
// Move to next segment or end
|
|
193
|
-
if (audioTrack && currentSegment < audioTrack.segments.length - 1) {
|
|
194
|
-
// Auto-advance to next segment
|
|
195
|
-
// Set shouldPlayAfterLoad so the next segment auto-plays after loading
|
|
196
|
-
shouldPlayAfterLoad.current = true;
|
|
197
|
-
setCurrentSegment((prev) => prev + 1);
|
|
198
|
-
} else {
|
|
199
|
-
setIsEnded(true);
|
|
200
|
-
setIsPlaying(false);
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
audio.addEventListener('timeupdate', handleTimeUpdate);
|
|
205
|
-
audio.addEventListener('play', handlePlay);
|
|
206
|
-
audio.addEventListener('pause', handlePause);
|
|
207
|
-
audio.addEventListener('ended', handleEnded);
|
|
208
|
-
audio.addEventListener('error', handleError);
|
|
209
|
-
|
|
210
|
-
return () => {
|
|
211
|
-
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
|
212
|
-
audio.removeEventListener('play', handlePlay);
|
|
213
|
-
audio.removeEventListener('pause', handlePause);
|
|
214
|
-
audio.removeEventListener('ended', handleEnded);
|
|
215
|
-
audio.removeEventListener('error', handleError);
|
|
216
|
-
};
|
|
217
|
-
}, [audioRef, currentSegment, audioTrack, enabled]);
|
|
218
|
-
|
|
219
|
-
// Load new segment when currentSegment changes
|
|
220
|
-
useEffect(() => {
|
|
221
|
-
if (!enabled) return;
|
|
222
|
-
const audio = audioRef.current;
|
|
223
|
-
if (!audio || !audioTrack?.segments) return;
|
|
224
|
-
|
|
225
|
-
const segment = audioTrack.segments[currentSegment];
|
|
226
|
-
if (!segment) return;
|
|
227
|
-
|
|
228
|
-
// Apply pending seek and play state
|
|
229
|
-
const applyPendingSeek = () => {
|
|
230
|
-
if (pendingSeekTime.current !== null) {
|
|
231
|
-
const segmentStart = segmentStarts.current[currentSegment] || 0;
|
|
232
|
-
const segmentTime = pendingSeekTime.current - segmentStart;
|
|
233
|
-
audio.currentTime = Math.max(0, segmentTime);
|
|
234
|
-
setCurrentTime(pendingSeekTime.current);
|
|
235
|
-
pendingSeekTime.current = null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (shouldPlayAfterLoad.current) {
|
|
239
|
-
audio.play().catch(() => {});
|
|
240
|
-
shouldPlayAfterLoad.current = false;
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
// Check if we're already on this source (avoid unnecessary reload)
|
|
245
|
-
// For blob URLs, check by segment src key
|
|
246
|
-
const currentSrc = audio.src;
|
|
247
|
-
const cachedBlobUrl = blobUrls.current.get(segment.src);
|
|
248
|
-
const isSameSource =
|
|
249
|
-
currentSrc && (currentSrc === cachedBlobUrl || currentSrc.endsWith(segment.src));
|
|
250
|
-
|
|
251
|
-
let cancelled = false;
|
|
252
|
-
let handleCanPlay: (() => void) | null = null;
|
|
253
|
-
|
|
254
|
-
if (!isSameSource) {
|
|
255
|
-
// Need to load new source - use preloaded blob URL
|
|
256
|
-
const loadAndPlay = async () => {
|
|
257
|
-
const blobUrl = await preloadAudio(segment.src);
|
|
258
|
-
if (cancelled) return;
|
|
259
|
-
|
|
260
|
-
handleCanPlay = () => {
|
|
261
|
-
if (cancelled) return;
|
|
262
|
-
setIsAudioReady(true);
|
|
263
|
-
applyPendingSeek();
|
|
264
|
-
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
audio.addEventListener('canplay', handleCanPlay);
|
|
268
|
-
audio.src = blobUrl;
|
|
269
|
-
audio.load();
|
|
270
|
-
|
|
271
|
-
// If audio is already ready (blob is instant), canplay might not fire
|
|
272
|
-
// Check after a microtask to see if it's ready
|
|
273
|
-
await Promise.resolve();
|
|
274
|
-
if (audio.readyState >= 3) {
|
|
275
|
-
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
276
|
-
setIsAudioReady(true);
|
|
277
|
-
applyPendingSeek();
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
void loadAndPlay();
|
|
282
|
-
} else {
|
|
283
|
-
// Same source - apply seek directly
|
|
284
|
-
applyPendingSeek();
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return () => {
|
|
288
|
-
cancelled = true;
|
|
289
|
-
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
290
|
-
};
|
|
291
|
-
}, [audioRef, currentSegment, audioTrack, preloadAudio, enabled]);
|
|
292
|
-
|
|
293
|
-
const play = useCallback(() => {
|
|
294
|
-
const audio = audioRef.current;
|
|
295
|
-
if (audio) {
|
|
296
|
-
if (isEnded) {
|
|
297
|
-
// Restart from beginning
|
|
298
|
-
setCurrentSegment(0);
|
|
299
|
-
setIsEnded(false);
|
|
300
|
-
}
|
|
301
|
-
audio
|
|
302
|
-
.play()
|
|
303
|
-
.then(() => {
|
|
304
|
-
fallbackMode.current = false;
|
|
305
|
-
})
|
|
306
|
-
.catch(() => {
|
|
307
|
-
// Audio playback failed (e.g., autoplay policy or 404).
|
|
308
|
-
// Enable fallback timer so blocks progress without audio.
|
|
309
|
-
fallbackMode.current = true;
|
|
310
|
-
setIsPlaying(true);
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}, [audioRef, isEnded]);
|
|
314
|
-
|
|
315
|
-
const pause = useCallback(() => {
|
|
316
|
-
const audio = audioRef.current;
|
|
317
|
-
if (audio) {
|
|
318
|
-
audio.pause();
|
|
319
|
-
}
|
|
320
|
-
// Also set state directly for cases where audio isn't loaded
|
|
321
|
-
// (pause event won't fire if audio has no valid source)
|
|
322
|
-
setIsPlaying(false);
|
|
323
|
-
}, [audioRef]);
|
|
324
|
-
|
|
325
|
-
const toggle = useCallback(() => {
|
|
326
|
-
const audio = audioRef.current;
|
|
327
|
-
if (!audio) return;
|
|
328
|
-
|
|
329
|
-
// Use component state instead of audio.paused to handle cases where
|
|
330
|
-
// audio source isn't loaded (audio.paused is always true without a source)
|
|
331
|
-
if (!isPlaying) {
|
|
332
|
-
play();
|
|
333
|
-
} else {
|
|
334
|
-
pause();
|
|
335
|
-
}
|
|
336
|
-
}, [audioRef, isPlaying, play, pause]);
|
|
337
|
-
|
|
338
|
-
const seekTo = useCallback(
|
|
339
|
-
(time: number) => {
|
|
340
|
-
const audio = audioRef.current;
|
|
341
|
-
if (!audio || !audioTrack?.segments) return;
|
|
342
|
-
|
|
343
|
-
// Clamp time to valid range.
|
|
344
|
-
// When totalDuration is 0 (no audio segments), don't clamp — allow
|
|
345
|
-
// seeking by block timing alone (used in render mode / preview).
|
|
346
|
-
const clampedTime =
|
|
347
|
-
totalDuration > 0 ? Math.max(0, Math.min(time, totalDuration)) : Math.max(0, time);
|
|
348
|
-
|
|
349
|
-
// Find which segment this time falls into
|
|
350
|
-
let segmentIndex = 0;
|
|
351
|
-
let segmentStart = 0;
|
|
352
|
-
for (let i = 0; i < audioTrack.segments.length; i++) {
|
|
353
|
-
const segEnd = segmentStart + audioTrack.segments[i].duration;
|
|
354
|
-
if (clampedTime < segEnd) {
|
|
355
|
-
segmentIndex = i;
|
|
356
|
-
break;
|
|
357
|
-
}
|
|
358
|
-
segmentStart = segEnd;
|
|
359
|
-
// Handle edge case: time exactly at end goes to last segment
|
|
360
|
-
if (i === audioTrack.segments.length - 1) {
|
|
361
|
-
segmentIndex = i;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const wasPlaying = !audio.paused;
|
|
366
|
-
setIsEnded(false);
|
|
367
|
-
|
|
368
|
-
// Check if we need to switch segments
|
|
369
|
-
if (segmentIndex !== currentSegment) {
|
|
370
|
-
// Store pending seek time - will be applied after segment loads
|
|
371
|
-
pendingSeekTime.current = clampedTime;
|
|
372
|
-
shouldPlayAfterLoad.current = wasPlaying;
|
|
373
|
-
setCurrentSegment(segmentIndex);
|
|
374
|
-
} else {
|
|
375
|
-
// Same segment - seek directly
|
|
376
|
-
const segmentTime = clampedTime - segmentStart;
|
|
377
|
-
audio.currentTime = Math.max(0, segmentTime);
|
|
378
|
-
setCurrentTime(clampedTime);
|
|
379
|
-
}
|
|
380
|
-
},
|
|
381
|
-
[audioRef, audioTrack, currentSegment, totalDuration],
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
const skipToSegment = useCallback(
|
|
385
|
-
(index: number) => {
|
|
386
|
-
if (!audioTrack?.segments || index < 0 || index >= audioTrack.segments.length) {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
setCurrentSegment(index);
|
|
390
|
-
setIsEnded(false);
|
|
391
|
-
},
|
|
392
|
-
[audioTrack],
|
|
393
|
-
);
|
|
394
|
-
|
|
395
|
-
// Restart from beginning
|
|
396
|
-
const restart = useCallback(async () => {
|
|
397
|
-
seekTo(0);
|
|
398
|
-
// Small delay to ensure seek completes before playing
|
|
399
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
400
|
-
play();
|
|
401
|
-
}, [seekTo, play]);
|
|
402
|
-
|
|
403
|
-
// Fallback timer: advance currentTime synthetically when audio.play() was blocked
|
|
404
|
-
// (e.g., autoplay policy or missing audio). Blocks progress at real-time pace
|
|
405
|
-
// without sound. Stops when audio actually starts playing or playback is paused.
|
|
406
|
-
useEffect(() => {
|
|
407
|
-
if (!isPlaying || !fallbackMode.current || !totalDuration) return;
|
|
408
|
-
|
|
409
|
-
let lastTime = performance.now();
|
|
410
|
-
let raf: number;
|
|
411
|
-
|
|
412
|
-
const tick = (now: number) => {
|
|
413
|
-
// Audio started playing for real — stop the fallback
|
|
414
|
-
if (!fallbackMode.current) return;
|
|
415
|
-
|
|
416
|
-
const dt = (now - lastTime) / 1000;
|
|
417
|
-
lastTime = now;
|
|
418
|
-
|
|
419
|
-
setCurrentTime((prev) => {
|
|
420
|
-
const next = prev + dt;
|
|
421
|
-
if (next >= totalDuration) {
|
|
422
|
-
fallbackMode.current = false;
|
|
423
|
-
setIsEnded(true);
|
|
424
|
-
setIsPlaying(false);
|
|
425
|
-
return totalDuration;
|
|
426
|
-
}
|
|
427
|
-
return next;
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
raf = requestAnimationFrame(tick);
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
raf = requestAnimationFrame(tick);
|
|
434
|
-
return () => cancelAnimationFrame(raf);
|
|
435
|
-
}, [isPlaying, totalDuration]);
|
|
436
|
-
|
|
437
|
-
return {
|
|
438
|
-
// State
|
|
439
|
-
currentTime,
|
|
440
|
-
isPlaying,
|
|
441
|
-
currentSegment,
|
|
442
|
-
totalDuration,
|
|
443
|
-
isEnded,
|
|
444
|
-
isReady: isAudioReady,
|
|
445
|
-
isAvailable: true, // HTML5 audio is always available in browsers
|
|
446
|
-
// Actions
|
|
447
|
-
play: async () => play(),
|
|
448
|
-
pause: async () => pause(),
|
|
449
|
-
toggle: async () => toggle(),
|
|
450
|
-
seekTo: async (time: number) => seekTo(time),
|
|
451
|
-
skipToSegment: async (index: number) => skipToSegment(index),
|
|
452
|
-
restart,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
export default useAudioSync;
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
|
2
|
-
import { DARK_SURFACE, LIGHT_SURFACE, type SurfaceScheme } from '@bendyline/squisq/schemas';
|
|
3
|
-
|
|
4
|
-
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
|
5
|
-
const getServerSnapshot = () => LIGHT_SURFACE;
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Live-track `prefers-color-scheme` and return a stable SurfaceScheme.
|
|
9
|
-
* `enabled: false` short-circuits to LIGHT_SURFACE (callers pass `false`
|
|
10
|
-
* when a static surface was provided so the hook never observes the
|
|
11
|
-
* media query). The `MediaQueryList` and the `subscribe`/`getSnapshot`
|
|
12
|
-
* callbacks are memoized so `useSyncExternalStore` doesn't resubscribe on
|
|
13
|
-
* every parent render.
|
|
14
|
-
*/
|
|
15
|
-
export function useAutoSurface(enabled: boolean): SurfaceScheme {
|
|
16
|
-
const mql = useMemo(
|
|
17
|
-
() => (enabled && typeof window !== 'undefined' ? window.matchMedia(DARK_QUERY) : null),
|
|
18
|
-
[enabled],
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
const subscribe = useCallback(
|
|
22
|
-
(cb: () => void) => {
|
|
23
|
-
if (!mql) return () => {};
|
|
24
|
-
mql.addEventListener('change', cb);
|
|
25
|
-
return () => mql.removeEventListener('change', cb);
|
|
26
|
-
},
|
|
27
|
-
[mql],
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
const getSnapshot = useCallback(() => (mql?.matches ? DARK_SURFACE : LIGHT_SURFACE), [mql]);
|
|
31
|
-
|
|
32
|
-
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
33
|
-
}
|