@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. 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
- }