@bendyline/squisq-react 2.2.0 → 2.3.1

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.
@@ -0,0 +1,675 @@
1
+ import {
2
+ useResourcePolicy
3
+ } from "./chunk-LR3AIGDD.js";
4
+
5
+ // src/hooks/useMediaSchedule.ts
6
+ import { useMemo } from "react";
7
+ function useMediaSchedule(schedule, currentTime) {
8
+ const activeIds = useMemo(() => {
9
+ const ids = /* @__PURE__ */ new Set();
10
+ for (const c of schedule) {
11
+ if (currentTime >= c.absoluteStart && currentTime < c.absoluteEnd) ids.add(c.id);
12
+ }
13
+ return ids;
14
+ }, [schedule, currentTime]);
15
+ return { renderClips: schedule, activeIds };
16
+ }
17
+
18
+ // src/hooks/useAudioSync.ts
19
+ import { useState, useEffect, useRef, useCallback } from "react";
20
+ import { fetchResourceBytes, isResourceUrlAllowed } from "@bendyline/squisq/markdown";
21
+
22
+ // src/hooks/AudioController.ts
23
+ function calculateSegmentTiming(segments) {
24
+ if (!segments?.length) {
25
+ return { segmentStarts: [], totalDuration: 0 };
26
+ }
27
+ let time = 0;
28
+ const segmentStarts = segments.map((seg) => {
29
+ const start = time;
30
+ time += seg.duration;
31
+ return start;
32
+ });
33
+ return { segmentStarts, totalDuration: time };
34
+ }
35
+ function findSegmentAtTime(time, segments, segmentStarts) {
36
+ if (!segments?.length) {
37
+ return { segmentIndex: 0, segmentStart: 0 };
38
+ }
39
+ let segmentIndex = 0;
40
+ let segmentStart = 0;
41
+ for (let i = 0; i < segments.length; i++) {
42
+ const segEnd = segmentStarts[i] + segments[i].duration;
43
+ if (time < segEnd) {
44
+ segmentIndex = i;
45
+ segmentStart = segmentStarts[i];
46
+ break;
47
+ }
48
+ if (i === segments.length - 1) {
49
+ segmentIndex = i;
50
+ segmentStart = segmentStarts[i];
51
+ }
52
+ }
53
+ return { segmentIndex, segmentStart };
54
+ }
55
+
56
+ // src/hooks/useAudioSync.ts
57
+ var SEEK_COMPLETION_TIMEOUT_MS = 15e3;
58
+ var UNLOADABLE_MESSAGE = "Audio could not be loaded. Check that the media file is available.";
59
+ function resolveAudioUrl(src, basePath) {
60
+ if (!src || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(src)) return src;
61
+ if (!basePath) return src;
62
+ return `${basePath.replace(/\/$/, "")}/${src.replace(/^\//, "")}`;
63
+ }
64
+ function useAudioSync(audioRef, audioTrack, basePath = "", enabled = true, mode = "media") {
65
+ const resourcePolicy = useResourcePolicy();
66
+ const [currentTime, setCurrentTime] = useState(0);
67
+ const [isPlaying, setIsPlaying] = useState(false);
68
+ const [currentSegment, setCurrentSegment] = useState(0);
69
+ const [isEnded, setIsEnded] = useState(false);
70
+ const [isAudioReady, setIsAudioReady] = useState(false);
71
+ const [totalDuration, setTotalDuration] = useState(0);
72
+ const [isAvailable, setIsAvailable] = useState(true);
73
+ const [unavailableMessage, setUnavailableMessage] = useState();
74
+ const segmentStarts = useRef([]);
75
+ const pendingSeekTime = useRef(null);
76
+ const pendingSeekCompletion = useRef(null);
77
+ const shouldPlayAfterLoad = useRef(false);
78
+ const blobUrls = useRef(/* @__PURE__ */ new Map());
79
+ const loadingPromises = useRef(/* @__PURE__ */ new Map());
80
+ const abortControllers = useRef(/* @__PURE__ */ new Set());
81
+ const loadGeneration = useRef(0);
82
+ const activeSegmentSrc = useRef(void 0);
83
+ activeSegmentSrc.current = audioTrack?.segments[currentSegment]?.src;
84
+ const boundSource = useRef(null);
85
+ const currentSegmentRef = useRef(0);
86
+ currentSegmentRef.current = currentSegment;
87
+ const fallbackMode = useRef(false);
88
+ useEffect(() => {
89
+ loadGeneration.current += 1;
90
+ pendingSeekTime.current = null;
91
+ pendingSeekCompletion.current?.();
92
+ pendingSeekCompletion.current = null;
93
+ shouldPlayAfterLoad.current = false;
94
+ fallbackMode.current = false;
95
+ boundSource.current = null;
96
+ setCurrentTime(0);
97
+ setCurrentSegment(0);
98
+ setIsPlaying(false);
99
+ setIsEnded(false);
100
+ setIsAudioReady(false);
101
+ setIsAvailable(true);
102
+ setUnavailableMessage(void 0);
103
+ if (!enabled || !audioTrack?.segments) {
104
+ segmentStarts.current = [];
105
+ setTotalDuration(0);
106
+ return;
107
+ }
108
+ const timing = calculateSegmentTiming(audioTrack.segments);
109
+ segmentStarts.current = timing.segmentStarts;
110
+ setTotalDuration(timing.totalDuration);
111
+ if (mode === "synthetic") setIsAudioReady(true);
112
+ }, [audioTrack, enabled, mode]);
113
+ const preloadAudio = useCallback(
114
+ async (src) => {
115
+ const audioUrl = resolveAudioUrl(src, basePath);
116
+ if (!isResourceUrlAllowed(audioUrl, resourcePolicy)) return "";
117
+ if (blobUrls.current.has(src)) {
118
+ const cached = blobUrls.current.get(src);
119
+ blobUrls.current.delete(src);
120
+ blobUrls.current.set(src, cached);
121
+ return cached;
122
+ }
123
+ if (loadingPromises.current.has(src)) {
124
+ return loadingPromises.current.get(src);
125
+ }
126
+ const controller = new AbortController();
127
+ abortControllers.current.add(controller);
128
+ const generation = loadGeneration.current;
129
+ const loadPromise = (async () => {
130
+ try {
131
+ const resource = await fetchResourceBytes(audioUrl, {
132
+ policy: resourcePolicy,
133
+ signal: controller.signal,
134
+ contentTypePrefixes: ["audio/", "video/", "application/octet-stream"]
135
+ });
136
+ const blob = new Blob([resource.bytes.slice().buffer], {
137
+ type: resource.contentType || "application/octet-stream"
138
+ });
139
+ const blobUrl = URL.createObjectURL(blob);
140
+ if (controller.signal.aborted || generation !== loadGeneration.current) {
141
+ URL.revokeObjectURL(blobUrl);
142
+ return audioUrl;
143
+ }
144
+ blobUrls.current.set(src, blobUrl);
145
+ while (blobUrls.current.size > 2) {
146
+ const oldest = [...blobUrls.current.entries()].find(([key]) => key !== activeSegmentSrc.current) ?? blobUrls.current.entries().next().value;
147
+ if (!oldest) break;
148
+ blobUrls.current.delete(oldest[0]);
149
+ URL.revokeObjectURL(oldest[1]);
150
+ }
151
+ return blobUrl;
152
+ } catch {
153
+ return "";
154
+ }
155
+ })();
156
+ loadingPromises.current.set(src, loadPromise);
157
+ void loadPromise.then(() => {
158
+ abortControllers.current.delete(controller);
159
+ if (loadingPromises.current.get(src) === loadPromise) {
160
+ loadingPromises.current.delete(src);
161
+ }
162
+ });
163
+ return loadPromise;
164
+ },
165
+ [basePath, resourcePolicy]
166
+ );
167
+ useEffect(() => {
168
+ if (!enabled || !audioTrack?.segments) return;
169
+ const currentBlobUrls = blobUrls.current;
170
+ const currentAbortControllers = abortControllers.current;
171
+ const currentLoadingPromises = loadingPromises.current;
172
+ return () => {
173
+ loadGeneration.current += 1;
174
+ currentAbortControllers.forEach((controller) => controller.abort());
175
+ currentAbortControllers.clear();
176
+ currentLoadingPromises.clear();
177
+ currentBlobUrls.forEach((url) => {
178
+ URL.revokeObjectURL(url);
179
+ });
180
+ currentBlobUrls.clear();
181
+ };
182
+ }, [audioTrack, preloadAudio, enabled]);
183
+ useEffect(() => {
184
+ if (!enabled || mode === "synthetic") return;
185
+ const segment = audioTrack?.segments[currentSegment];
186
+ if (segment) void preloadAudio(segment.src);
187
+ }, [audioTrack, currentSegment, enabled, mode, preloadAudio]);
188
+ useEffect(() => {
189
+ if (!enabled || mode === "synthetic") return;
190
+ const audio = audioRef.current;
191
+ if (!audio) return;
192
+ const handleTimeUpdate = () => {
193
+ if (fallbackMode.current) return;
194
+ const segmentStart = segmentStarts.current[currentSegment] || 0;
195
+ const overallTime = segmentStart + audio.currentTime;
196
+ setCurrentTime(overallTime);
197
+ };
198
+ const handlePlay = () => {
199
+ setIsPlaying(true);
200
+ };
201
+ const handlePause = () => setIsPlaying(false);
202
+ const handleError = () => {
203
+ setIsAudioReady(true);
204
+ setIsPlaying(false);
205
+ setIsAvailable(false);
206
+ setUnavailableMessage(UNLOADABLE_MESSAGE);
207
+ shouldPlayAfterLoad.current = false;
208
+ if (pendingSeekTime.current !== null) {
209
+ setCurrentTime(pendingSeekTime.current);
210
+ pendingSeekTime.current = null;
211
+ }
212
+ pendingSeekCompletion.current?.();
213
+ pendingSeekCompletion.current = null;
214
+ };
215
+ const handleEnded = () => {
216
+ if (audioTrack && currentSegment < audioTrack.segments.length - 1) {
217
+ shouldPlayAfterLoad.current = true;
218
+ setCurrentSegment((prev) => prev + 1);
219
+ } else {
220
+ setIsEnded(true);
221
+ setIsPlaying(false);
222
+ }
223
+ };
224
+ audio.addEventListener("timeupdate", handleTimeUpdate);
225
+ audio.addEventListener("play", handlePlay);
226
+ audio.addEventListener("pause", handlePause);
227
+ audio.addEventListener("ended", handleEnded);
228
+ audio.addEventListener("error", handleError);
229
+ return () => {
230
+ audio.removeEventListener("timeupdate", handleTimeUpdate);
231
+ audio.removeEventListener("play", handlePlay);
232
+ audio.removeEventListener("pause", handlePause);
233
+ audio.removeEventListener("ended", handleEnded);
234
+ audio.removeEventListener("error", handleError);
235
+ };
236
+ }, [audioRef, currentSegment, audioTrack, enabled, mode]);
237
+ useEffect(() => {
238
+ if (!enabled || mode === "synthetic") return;
239
+ const audio = audioRef.current;
240
+ if (!audio || !audioTrack?.segments) return;
241
+ const segment = audioTrack.segments[currentSegment];
242
+ if (!segment) return;
243
+ const applyPendingSeek = () => {
244
+ if (pendingSeekTime.current !== null) {
245
+ const segmentStart = segmentStarts.current[currentSegment] || 0;
246
+ const segmentTime = pendingSeekTime.current - segmentStart;
247
+ audio.currentTime = Math.max(0, segmentTime);
248
+ setCurrentTime(pendingSeekTime.current);
249
+ pendingSeekTime.current = null;
250
+ pendingSeekCompletion.current?.();
251
+ pendingSeekCompletion.current = null;
252
+ }
253
+ if (shouldPlayAfterLoad.current) {
254
+ audio.play().catch((error) => {
255
+ setIsPlaying(false);
256
+ if (!(error instanceof Error && error.name === "NotAllowedError")) {
257
+ setIsAvailable(false);
258
+ setUnavailableMessage(
259
+ "Audio playback failed. Check that the media file is supported and available."
260
+ );
261
+ }
262
+ });
263
+ shouldPlayAfterLoad.current = false;
264
+ }
265
+ };
266
+ const currentSrc = audio.src;
267
+ const bound = boundSource.current;
268
+ const isSameSource = !!currentSrc && bound?.key === segment.src && bound.url === currentSrc;
269
+ let cancelled = false;
270
+ let handleCanPlay = null;
271
+ if (!isSameSource) {
272
+ const loadAndPlay = async () => {
273
+ const blobUrl = await preloadAudio(segment.src);
274
+ if (cancelled) return;
275
+ if (!blobUrl) {
276
+ audio.removeAttribute("src");
277
+ audio.load();
278
+ boundSource.current = null;
279
+ shouldPlayAfterLoad.current = false;
280
+ setIsAudioReady(true);
281
+ setIsPlaying(false);
282
+ setIsAvailable(false);
283
+ setUnavailableMessage(UNLOADABLE_MESSAGE);
284
+ if (pendingSeekTime.current !== null) {
285
+ setCurrentTime(pendingSeekTime.current);
286
+ pendingSeekTime.current = null;
287
+ }
288
+ pendingSeekCompletion.current?.();
289
+ pendingSeekCompletion.current = null;
290
+ return;
291
+ }
292
+ handleCanPlay = () => {
293
+ if (cancelled) return;
294
+ setIsAudioReady(true);
295
+ applyPendingSeek();
296
+ if (handleCanPlay) audio.removeEventListener("canplay", handleCanPlay);
297
+ };
298
+ audio.addEventListener("canplay", handleCanPlay);
299
+ audio.src = blobUrl;
300
+ boundSource.current = { key: segment.src, url: audio.src };
301
+ audio.load();
302
+ await Promise.resolve();
303
+ if (audio.readyState >= 3) {
304
+ if (handleCanPlay) audio.removeEventListener("canplay", handleCanPlay);
305
+ setIsAudioReady(true);
306
+ applyPendingSeek();
307
+ }
308
+ };
309
+ void loadAndPlay();
310
+ } else {
311
+ applyPendingSeek();
312
+ }
313
+ return () => {
314
+ cancelled = true;
315
+ if (handleCanPlay) audio.removeEventListener("canplay", handleCanPlay);
316
+ };
317
+ }, [audioRef, currentSegment, audioTrack, preloadAudio, enabled, mode]);
318
+ const seekTo = useCallback(
319
+ async (time) => {
320
+ const audio = audioRef.current;
321
+ if (!audioTrack?.segments) return;
322
+ const clampedTime = totalDuration > 0 ? Math.max(0, Math.min(time, totalDuration)) : Math.max(0, time);
323
+ const starts = segmentStarts.current.length === audioTrack.segments.length ? segmentStarts.current : calculateSegmentTiming(audioTrack.segments).segmentStarts;
324
+ const { segmentIndex, segmentStart } = findSegmentAtTime(
325
+ clampedTime,
326
+ audioTrack.segments,
327
+ starts
328
+ );
329
+ const wasPlaying = mode === "synthetic" ? isPlaying : !audio?.paused;
330
+ setIsEnded(false);
331
+ if (!audio && mode === "media") {
332
+ setCurrentSegment(segmentIndex);
333
+ setCurrentTime(clampedTime);
334
+ return;
335
+ }
336
+ if (segmentIndex !== currentSegmentRef.current) {
337
+ pendingSeekTime.current = clampedTime;
338
+ shouldPlayAfterLoad.current = wasPlaying;
339
+ const completion = new Promise((resolve) => {
340
+ pendingSeekCompletion.current?.();
341
+ let settled = false;
342
+ const settle = () => {
343
+ if (settled) return;
344
+ settled = true;
345
+ clearTimeout(timer);
346
+ if (pendingSeekCompletion.current === settle) pendingSeekCompletion.current = null;
347
+ resolve();
348
+ };
349
+ const timer = setTimeout(settle, SEEK_COMPLETION_TIMEOUT_MS);
350
+ pendingSeekCompletion.current = settle;
351
+ });
352
+ setCurrentSegment(segmentIndex);
353
+ if (mode === "synthetic") {
354
+ setCurrentTime(clampedTime);
355
+ pendingSeekTime.current = null;
356
+ pendingSeekCompletion.current?.();
357
+ pendingSeekCompletion.current = null;
358
+ }
359
+ await completion;
360
+ } else {
361
+ const segmentTime = clampedTime - segmentStart;
362
+ if (audio && mode === "media") audio.currentTime = Math.max(0, segmentTime);
363
+ setCurrentTime(clampedTime);
364
+ }
365
+ },
366
+ [audioRef, audioTrack, isPlaying, mode, totalDuration]
367
+ );
368
+ const play = useCallback(async () => {
369
+ if (mode === "synthetic") {
370
+ fallbackMode.current = true;
371
+ setIsPlaying(true);
372
+ return;
373
+ }
374
+ const audio = audioRef.current;
375
+ if (audio) {
376
+ if (isEnded) {
377
+ await seekTo(0);
378
+ }
379
+ try {
380
+ await audio.play();
381
+ fallbackMode.current = false;
382
+ setIsAvailable(true);
383
+ setUnavailableMessage(void 0);
384
+ } catch (error) {
385
+ fallbackMode.current = false;
386
+ setIsPlaying(false);
387
+ const name = error instanceof Error ? error.name : "";
388
+ if (name !== "NotAllowedError") {
389
+ setIsAvailable(false);
390
+ setUnavailableMessage(
391
+ "Audio playback failed. Check that the media file is supported and available."
392
+ );
393
+ }
394
+ }
395
+ }
396
+ }, [audioRef, isEnded, mode, seekTo]);
397
+ const pause = useCallback(() => {
398
+ const audio = audioRef.current;
399
+ if (audio) {
400
+ audio.pause();
401
+ }
402
+ setIsPlaying(false);
403
+ }, [audioRef]);
404
+ const toggle = useCallback(() => {
405
+ const audio = audioRef.current;
406
+ if (!audio && mode === "media") return;
407
+ if (!isPlaying) {
408
+ play();
409
+ } else {
410
+ pause();
411
+ }
412
+ }, [audioRef, isPlaying, mode, play, pause]);
413
+ const skipToSegment = useCallback(
414
+ (index) => {
415
+ if (!audioTrack?.segments || index < 0 || index >= audioTrack.segments.length) {
416
+ return;
417
+ }
418
+ setCurrentSegment(index);
419
+ setIsEnded(false);
420
+ },
421
+ [audioTrack]
422
+ );
423
+ const restart = useCallback(async () => {
424
+ await seekTo(0);
425
+ await play();
426
+ }, [seekTo, play]);
427
+ useEffect(() => {
428
+ if (!isPlaying || !fallbackMode.current || !totalDuration) return;
429
+ let lastTime = performance.now();
430
+ let raf;
431
+ const tick = (now) => {
432
+ if (!fallbackMode.current) return;
433
+ const dt = (now - lastTime) / 1e3;
434
+ lastTime = now;
435
+ setCurrentTime((prev) => {
436
+ const next = prev + dt;
437
+ if (next >= totalDuration) {
438
+ fallbackMode.current = false;
439
+ setIsEnded(true);
440
+ setIsPlaying(false);
441
+ return totalDuration;
442
+ }
443
+ return next;
444
+ });
445
+ raf = requestAnimationFrame(tick);
446
+ };
447
+ raf = requestAnimationFrame(tick);
448
+ return () => cancelAnimationFrame(raf);
449
+ }, [isPlaying, totalDuration]);
450
+ return {
451
+ // State
452
+ currentTime,
453
+ isPlaying,
454
+ currentSegment,
455
+ totalDuration,
456
+ isEnded,
457
+ isReady: isAudioReady,
458
+ isAvailable,
459
+ unavailableMessage,
460
+ // Actions
461
+ play,
462
+ pause: async () => pause(),
463
+ toggle: async () => toggle(),
464
+ seekTo,
465
+ skipToSegment: async (index) => skipToSegment(index),
466
+ restart
467
+ };
468
+ }
469
+
470
+ // src/hooks/useDocPlayback.ts
471
+ import { useMemo as useMemo2, useCallback as useCallback2, useRef as useRef2 } from "react";
472
+ import {
473
+ DEFAULT_THEME,
474
+ getBlockAtTime,
475
+ resolveBlockTransition,
476
+ resolveTransitionDuration
477
+ } from "@bendyline/squisq/schemas";
478
+ import {
479
+ expandDocBlocks,
480
+ flattenRenderableBlocks,
481
+ isTemplateBlock,
482
+ resolvePersistentLayers,
483
+ VIEWPORT_PRESETS
484
+ } from "@bendyline/squisq/doc";
485
+ function useDocPlayback(script, currentTime, options = {}) {
486
+ const { viewport = VIEWPORT_PRESETS.landscape, theme, onSeek } = options;
487
+ const blocks = useMemo2(() => {
488
+ if (!script?.blocks) {
489
+ return [];
490
+ }
491
+ const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
492
+ const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
493
+ const hasTemplates = flatBlocks.some(isTemplateBlock);
494
+ const resolvedTheme = theme ?? DEFAULT_THEME;
495
+ const persistentLayers = resolvePersistentLayers(
496
+ { persistentLayers: script.persistentLayers },
497
+ resolvedTheme
498
+ );
499
+ if (hasTemplates) {
500
+ const audioSegments = script.audio?.segments?.map((seg) => ({
501
+ startTime: seg.startTime,
502
+ duration: seg.duration
503
+ }));
504
+ const expanded = expandDocBlocks(flatBlocks, {
505
+ audioSegments,
506
+ viewport,
507
+ persistentLayers,
508
+ theme,
509
+ // Custom (user-defined) templates inlined into the doc's
510
+ // frontmatter — see CustomTemplates.ts. Merged onto the
511
+ // built-in registry so blocks annotated with `{[myhero]}`
512
+ // resolve through the user's design.
513
+ customTemplates: script.customTemplates
514
+ });
515
+ return expanded;
516
+ }
517
+ return flatBlocks.map((block, index) => {
518
+ const transition = resolveBlockTransition(block, resolvedTheme, index);
519
+ return transition !== block.transition ? { ...block, transition } : block;
520
+ });
521
+ }, [
522
+ script?.blocks,
523
+ script?.audio?.segments,
524
+ script?.persistentLayers,
525
+ script?.customTemplates,
526
+ viewport,
527
+ theme
528
+ ]);
529
+ const currentBlock = useMemo2(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
530
+ const currentBlockIndex = useMemo2(
531
+ () => currentBlock ? blocks.indexOf(currentBlock) : -1,
532
+ [blocks, currentBlock]
533
+ );
534
+ const blockTime = useMemo2(() => {
535
+ if (!currentBlock) return 0;
536
+ return Math.max(0, currentTime - currentBlock.startTime);
537
+ }, [currentBlock, currentTime]);
538
+ const blockProgress = useMemo2(() => {
539
+ if (!currentBlock || currentBlock.duration === 0) return 0;
540
+ return Math.min(1, blockTime / currentBlock.duration);
541
+ }, [currentBlock, blockTime]);
542
+ const docProgress = useMemo2(() => {
543
+ if (!script || script.duration === 0) return 0;
544
+ return Math.min(1, currentTime / script.duration);
545
+ }, [script, currentTime]);
546
+ const outgoingBlockRef = useRef2(null);
547
+ const activeBlockIdRef = useRef2(null);
548
+ const lastRenderedBlockRef = useRef2(null);
549
+ const suppressOutgoingTargetRef = useRef2(null);
550
+ const suppressOutgoingForNextBlock = useCallback2((blockId) => {
551
+ if (activeBlockIdRef.current === blockId) {
552
+ outgoingBlockRef.current = null;
553
+ suppressOutgoingTargetRef.current = null;
554
+ return;
555
+ }
556
+ suppressOutgoingTargetRef.current = blockId;
557
+ }, []);
558
+ if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
559
+ const suppressOutgoing = suppressOutgoingTargetRef.current === currentBlock.id;
560
+ outgoingBlockRef.current = suppressOutgoing ? null : lastRenderedBlockRef.current;
561
+ suppressOutgoingTargetRef.current = null;
562
+ activeBlockIdRef.current = currentBlock.id;
563
+ }
564
+ lastRenderedBlockRef.current = currentBlock;
565
+ const transitionDuration = currentBlock?.transition ? resolveTransitionDuration(currentBlock.transition) : 0;
566
+ const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
567
+ const outgoingBlock = outgoingBlockRef.current;
568
+ const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
569
+ const previousBlock = isExiting ? outgoingBlock : null;
570
+ const goToBlock = useCallback2(
571
+ (index) => {
572
+ if (!script || index < 0 || index >= blocks.length) return;
573
+ const targetBlock = blocks[index];
574
+ if (targetBlock) {
575
+ onSeek?.(targetBlock.startTime);
576
+ }
577
+ },
578
+ [script, blocks, onSeek]
579
+ );
580
+ const nextBlock = useCallback2(() => {
581
+ if (currentBlockIndex < blocks.length - 1) {
582
+ return goToBlock(currentBlockIndex + 1);
583
+ }
584
+ }, [currentBlockIndex, blocks.length, goToBlock]);
585
+ const prevBlock = useCallback2(() => {
586
+ if (currentBlockIndex > 0) {
587
+ return goToBlock(currentBlockIndex - 1);
588
+ }
589
+ }, [currentBlockIndex, goToBlock]);
590
+ return {
591
+ currentBlock,
592
+ currentBlockIndex,
593
+ previousBlock,
594
+ isEntering,
595
+ isExiting,
596
+ blockTime,
597
+ blockProgress,
598
+ docProgress,
599
+ nextBlock,
600
+ prevBlock,
601
+ goToBlock,
602
+ suppressOutgoingForNextBlock,
603
+ /** Expanded blocks (templates converted to full blocks with layers) */
604
+ blocks
605
+ };
606
+ }
607
+
608
+ // src/hooks/useViewportOrientation.ts
609
+ import { useState as useState2, useEffect as useEffect2, useMemo as useMemo3 } from "react";
610
+ import {
611
+ VIEWPORT_PRESETS as VIEWPORT_PRESETS2
612
+ } from "@bendyline/squisq/doc";
613
+ function getOrientationFromWindow(width, height) {
614
+ const ratio = width / height;
615
+ if (ratio > 1.2) {
616
+ return "landscape";
617
+ } else if (ratio < 0.83) {
618
+ return "portrait";
619
+ } else {
620
+ return "square";
621
+ }
622
+ }
623
+ function getViewportForOrientation(orientation) {
624
+ switch (orientation) {
625
+ case "portrait":
626
+ return VIEWPORT_PRESETS2.portrait;
627
+ case "square":
628
+ return VIEWPORT_PRESETS2.square;
629
+ case "landscape":
630
+ default:
631
+ return VIEWPORT_PRESETS2.landscape;
632
+ }
633
+ }
634
+ function useViewportOrientation() {
635
+ const [windowSize, setWindowSize] = useState2(() => ({
636
+ width: typeof window !== "undefined" ? window.innerWidth : 1920,
637
+ height: typeof window !== "undefined" ? window.innerHeight : 1080
638
+ }));
639
+ useEffect2(() => {
640
+ if (typeof window === "undefined") return;
641
+ const handleResize = () => {
642
+ setWindowSize({
643
+ width: window.innerWidth,
644
+ height: window.innerHeight
645
+ });
646
+ };
647
+ let timeoutId;
648
+ const debouncedResize = () => {
649
+ clearTimeout(timeoutId);
650
+ timeoutId = setTimeout(handleResize, 100);
651
+ };
652
+ window.addEventListener("resize", debouncedResize);
653
+ return () => {
654
+ window.removeEventListener("resize", debouncedResize);
655
+ clearTimeout(timeoutId);
656
+ };
657
+ }, []);
658
+ const orientation = useMemo3(
659
+ () => getOrientationFromWindow(windowSize.width, windowSize.height),
660
+ [windowSize.width, windowSize.height]
661
+ );
662
+ const viewport = useMemo3(() => getViewportForOrientation(orientation), [orientation]);
663
+ return {
664
+ viewport,
665
+ orientation,
666
+ windowSize
667
+ };
668
+ }
669
+
670
+ export {
671
+ useMediaSchedule,
672
+ useAudioSync,
673
+ useDocPlayback,
674
+ useViewportOrientation
675
+ };