@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,2529 @@
1
+ import {
2
+ BlockRenderer,
3
+ LinearDocView
4
+ } from "./chunk-TQH6RTTV.js";
5
+ import {
6
+ useAudioSync,
7
+ useDocPlayback,
8
+ useMediaSchedule,
9
+ useViewportOrientation
10
+ } from "./chunk-NWZQGZIJ.js";
11
+ import {
12
+ useAutoSurface
13
+ } from "./chunk-TT6ENR6T.js";
14
+ import {
15
+ useMediaUrl
16
+ } from "./chunk-LR3AIGDD.js";
17
+
18
+ // src/MediaClipLayer.tsx
19
+ import { useEffect, useRef } from "react";
20
+ import { jsx } from "react/jsx-runtime";
21
+ var DRIFT = 0.25;
22
+ function MediaClipLayer({
23
+ schedule,
24
+ currentTime,
25
+ isPlaying,
26
+ basePath,
27
+ renderMode = false,
28
+ muted = false
29
+ }) {
30
+ const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
31
+ if (renderClips.length === 0) return null;
32
+ return /* @__PURE__ */ jsx("div", { className: "doc-player__media-clips", "aria-hidden": true, children: renderClips.map((clip) => /* @__PURE__ */ jsx(
33
+ MediaClipElement,
34
+ {
35
+ clip,
36
+ active: activeIds.has(clip.id),
37
+ currentTime,
38
+ isPlaying,
39
+ basePath,
40
+ renderMode,
41
+ muted
42
+ },
43
+ clip.id
44
+ )) });
45
+ }
46
+ function MediaClipElement({
47
+ clip,
48
+ active,
49
+ currentTime,
50
+ isPlaying,
51
+ basePath,
52
+ renderMode,
53
+ muted
54
+ }) {
55
+ const ref = useRef(null);
56
+ const src = useMediaUrl(clip.src, basePath);
57
+ useEffect(() => {
58
+ const el = ref.current;
59
+ if (!el) return;
60
+ if (!active) {
61
+ if (!el.paused) el.pause();
62
+ return;
63
+ }
64
+ const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
65
+ if (renderMode || Math.abs(el.currentTime - target) > DRIFT) {
66
+ try {
67
+ el.currentTime = target;
68
+ } catch {
69
+ }
70
+ }
71
+ if (isPlaying && !renderMode) {
72
+ const p = el.play();
73
+ if (p) p.catch(() => {
74
+ });
75
+ } else {
76
+ el.pause();
77
+ }
78
+ }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart, src]);
79
+ const isVideo = clip.kind === "video";
80
+ const common = {
81
+ ref,
82
+ src,
83
+ preload: "auto",
84
+ "data-clip-id": clip.id,
85
+ "data-abs-start": clip.absoluteStart,
86
+ "data-abs-end": clip.absoluteEnd,
87
+ "data-source-in": clip.sourceIn
88
+ };
89
+ if (isVideo) {
90
+ return /* @__PURE__ */ jsx(
91
+ "video",
92
+ {
93
+ ...common,
94
+ muted: true,
95
+ playsInline: true,
96
+ style: {
97
+ position: "absolute",
98
+ inset: 0,
99
+ width: "100%",
100
+ height: "100%",
101
+ objectFit: "cover",
102
+ zIndex: 0,
103
+ pointerEvents: "none"
104
+ }
105
+ }
106
+ );
107
+ }
108
+ return /* @__PURE__ */ jsx(
109
+ "audio",
110
+ {
111
+ ...common,
112
+ muted: renderMode || muted,
113
+ style: { position: "absolute", width: 0, height: 0 }
114
+ }
115
+ );
116
+ }
117
+
118
+ // src/SocialCaptionOverlay.tsx
119
+ import { useMemo } from "react";
120
+ import { resolveFontFamily } from "@bendyline/squisq/schemas";
121
+ import { jsx as jsx2 } from "react/jsx-runtime";
122
+ var TARGET_CHUNK_SIZE = 4;
123
+ var MIN_CHUNK_SIZE = 2;
124
+ var MAX_CHUNK_SIZE = 6;
125
+ function resolvePhraseTiming(phrase) {
126
+ if (phrase.words && phrase.words.length > 0) {
127
+ return phrase.words.map((w) => ({
128
+ text: w.text,
129
+ startTime: w.startTime,
130
+ endTime: w.endTime
131
+ }));
132
+ }
133
+ const rawWords = phrase.text.split(/\s+/).filter((w) => w.length > 0);
134
+ if (rawWords.length === 0) return [];
135
+ const duration = phrase.endTime - phrase.startTime;
136
+ const wordDuration = duration / rawWords.length;
137
+ return rawWords.map((text, i) => ({
138
+ text,
139
+ startTime: phrase.startTime + i * wordDuration,
140
+ endTime: phrase.startTime + (i + 1) * wordDuration
141
+ }));
142
+ }
143
+ function buildWordStream(captions) {
144
+ const allWords = [];
145
+ for (const phrase of captions.phrases) {
146
+ allWords.push(...resolvePhraseTiming(phrase));
147
+ }
148
+ if (allWords.length === 0) return { words: [], chunks: [] };
149
+ let chunkSize = TARGET_CHUNK_SIZE;
150
+ if (allWords.length > chunkSize) {
151
+ const numChunks = Math.ceil(allWords.length / chunkSize);
152
+ chunkSize = Math.ceil(allWords.length / numChunks);
153
+ chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(MIN_CHUNK_SIZE, chunkSize));
154
+ } else {
155
+ chunkSize = Math.max(MIN_CHUNK_SIZE, allWords.length);
156
+ }
157
+ const chunks = [];
158
+ for (let i = 0; i < allWords.length; i += chunkSize) {
159
+ const chunkWords = allWords.slice(i, i + chunkSize);
160
+ chunks.push({
161
+ words: chunkWords,
162
+ startTime: chunkWords[0].startTime,
163
+ endTime: chunkWords[chunkWords.length - 1].endTime
164
+ });
165
+ }
166
+ return { words: allWords, chunks };
167
+ }
168
+ function SocialCaptionOverlay({
169
+ captions,
170
+ currentTime,
171
+ enabled = true,
172
+ theme,
173
+ viewport
174
+ }) {
175
+ const { chunks } = useMemo(
176
+ () => captions ? buildWordStream(captions) : { words: [], chunks: [] },
177
+ [captions]
178
+ );
179
+ if (!enabled || chunks.length === 0) {
180
+ return /* @__PURE__ */ jsx2(
181
+ "div",
182
+ {
183
+ className: "social-caption-overlay",
184
+ style: {
185
+ position: "absolute",
186
+ bottom: "18%",
187
+ left: 0,
188
+ right: 0,
189
+ zIndex: 50,
190
+ pointerEvents: "none",
191
+ opacity: 0,
192
+ transition: "opacity 0.15s ease-in-out"
193
+ }
194
+ }
195
+ );
196
+ }
197
+ let activeChunk = null;
198
+ let activeWordIndex = -1;
199
+ for (const chunk of chunks) {
200
+ if (currentTime >= chunk.startTime && currentTime < chunk.endTime) {
201
+ activeChunk = chunk;
202
+ break;
203
+ }
204
+ }
205
+ if (!activeChunk) {
206
+ for (let i = 0; i < chunks.length - 1; i++) {
207
+ if (currentTime >= chunks[i].endTime && currentTime < chunks[i + 1].startTime) {
208
+ activeChunk = chunks[i];
209
+ activeWordIndex = activeChunk.words.length - 1;
210
+ break;
211
+ }
212
+ }
213
+ if (!activeChunk && chunks.length > 0 && currentTime >= chunks[chunks.length - 1].startTime) {
214
+ activeChunk = chunks[chunks.length - 1];
215
+ activeWordIndex = activeChunk.words.length - 1;
216
+ }
217
+ }
218
+ if (!activeChunk) return null;
219
+ if (activeWordIndex === -1) {
220
+ for (let i = 0; i < activeChunk.words.length; i++) {
221
+ const word = activeChunk.words[i];
222
+ if (currentTime >= word.startTime && currentTime < word.endTime) {
223
+ activeWordIndex = i;
224
+ break;
225
+ }
226
+ }
227
+ if (activeWordIndex === -1) {
228
+ for (let i = activeChunk.words.length - 1; i >= 0; i--) {
229
+ if (currentTime >= activeChunk.words[i].startTime) {
230
+ activeWordIndex = i;
231
+ break;
232
+ }
233
+ }
234
+ }
235
+ if (activeWordIndex === -1) activeWordIndex = 0;
236
+ }
237
+ const primaryColor = theme?.colors?.primary ?? "#5b9bd5";
238
+ const fontFamily = theme?.typography?.titleFont ? resolveFontFamily(theme.typography.titleFont, '"PT Serif", Georgia, serif') : '"PT Serif", Georgia, serif';
239
+ const viewportHeight = viewport?.height ?? 720;
240
+ const baseFontSize = Math.round(viewportHeight * 0.055);
241
+ const fontSize = Math.max(24, Math.min(72, baseFontSize));
242
+ return /* @__PURE__ */ jsx2(
243
+ "div",
244
+ {
245
+ className: "social-caption-overlay",
246
+ style: {
247
+ position: "absolute",
248
+ bottom: "18%",
249
+ left: 0,
250
+ right: 0,
251
+ zIndex: 50,
252
+ pointerEvents: "none",
253
+ textAlign: "center",
254
+ padding: "0 8%",
255
+ boxSizing: "border-box",
256
+ opacity: 1,
257
+ transition: "opacity 0.15s ease-in-out"
258
+ },
259
+ children: /* @__PURE__ */ jsx2(
260
+ "div",
261
+ {
262
+ style: {
263
+ display: "inline-block",
264
+ lineHeight: 1.3
265
+ },
266
+ children: activeChunk.words.map((word, i) => {
267
+ const isActive = i === activeWordIndex;
268
+ return /* @__PURE__ */ jsx2(
269
+ "span",
270
+ {
271
+ style: {
272
+ fontFamily,
273
+ fontSize: `${fontSize}px`,
274
+ fontWeight: isActive ? 800 : 600,
275
+ color: isActive ? primaryColor : "rgba(255, 255, 255, 0.9)",
276
+ textShadow: "0 2px 8px rgba(0,0,0,0.7), 0 0 20px rgba(0,0,0,0.4)",
277
+ marginRight: i < activeChunk.words.length - 1 ? "0.3em" : void 0,
278
+ transition: "color 0.1s ease, font-weight 0.1s ease",
279
+ textTransform: "uppercase"
280
+ },
281
+ children: word.text
282
+ },
283
+ `${word.startTime}-${i}`
284
+ );
285
+ })
286
+ }
287
+ )
288
+ }
289
+ );
290
+ }
291
+
292
+ // src/CaptionOverlay.tsx
293
+ import { getCaptionAtTime } from "@bendyline/squisq/schemas";
294
+ import { jsx as jsx3 } from "react/jsx-runtime";
295
+ function CaptionOverlay({
296
+ captions,
297
+ currentTime,
298
+ enabled = true,
299
+ fontSize = 16,
300
+ captionStyle = "standard",
301
+ theme,
302
+ viewport
303
+ }) {
304
+ if (captionStyle === "social") {
305
+ return /* @__PURE__ */ jsx3(
306
+ SocialCaptionOverlay,
307
+ {
308
+ captions,
309
+ currentTime,
310
+ enabled,
311
+ theme,
312
+ viewport
313
+ }
314
+ );
315
+ }
316
+ const phrase = enabled && captions ? getCaptionAtTime(captions, currentTime) : null;
317
+ const captionText = phrase?.text ?? null;
318
+ return /* @__PURE__ */ jsx3(
319
+ "div",
320
+ {
321
+ className: "caption-overlay",
322
+ style: {
323
+ position: "absolute",
324
+ top: "6px",
325
+ left: "50%",
326
+ transform: "translateX(-50%)",
327
+ zIndex: 50,
328
+ pointerEvents: "none",
329
+ maxWidth: "100%",
330
+ width: "100%",
331
+ textAlign: "center",
332
+ opacity: captionText ? 1 : 0,
333
+ transition: "opacity 0.15s ease-in-out",
334
+ padding: "0 4px",
335
+ boxSizing: "border-box"
336
+ },
337
+ children: captionText && /* @__PURE__ */ jsx3(
338
+ "div",
339
+ {
340
+ style: {
341
+ display: "inline-block",
342
+ padding: "3px 10px",
343
+ background: "rgba(0, 0, 0, 0.65)",
344
+ borderRadius: "4px",
345
+ backdropFilter: "blur(4px)"
346
+ },
347
+ children: /* @__PURE__ */ jsx3(
348
+ "span",
349
+ {
350
+ style: {
351
+ color: "#ffffff",
352
+ fontSize: `${fontSize}px`,
353
+ fontFamily: "'Hanken Grotesk', system-ui, sans-serif",
354
+ fontWeight: 500,
355
+ lineHeight: 1.25,
356
+ textShadow: "0 1px 3px rgba(0,0,0,0.5)",
357
+ whiteSpace: "pre-wrap",
358
+ wordWrap: "break-word"
359
+ },
360
+ children: captionText
361
+ }
362
+ )
363
+ }
364
+ )
365
+ }
366
+ );
367
+ }
368
+
369
+ // src/types.ts
370
+ function formatTime(seconds) {
371
+ const mins = Math.floor(seconds / 60);
372
+ const secs = Math.floor(seconds % 60);
373
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
374
+ }
375
+
376
+ // src/DocProgressBar.tsx
377
+ import { useRef as useRef2, useState, useCallback } from "react";
378
+ import { jsx as jsx4, jsxs } from "react/jsx-runtime";
379
+ function DocProgressBar({
380
+ state,
381
+ actions,
382
+ blockMarkers,
383
+ expandedBlocks,
384
+ getBlockTitle
385
+ }) {
386
+ const progressBarRef = useRef2(null);
387
+ const [hoverPosition, setHoverPosition] = useState(null);
388
+ const playProgress = state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
389
+ const handleProgressHover = useCallback((e) => {
390
+ const bar = progressBarRef.current;
391
+ if (!bar) return;
392
+ const rect = bar.getBoundingClientRect();
393
+ const x = e.clientX - rect.left;
394
+ const progress = Math.max(0, Math.min(1, x / rect.width));
395
+ setHoverPosition(progress);
396
+ }, []);
397
+ const handleProgressLeave = useCallback(() => {
398
+ setHoverPosition(null);
399
+ }, []);
400
+ const handleProgressKeyDown = useCallback(
401
+ (e) => {
402
+ let next = null;
403
+ switch (e.key) {
404
+ case "ArrowLeft":
405
+ case "ArrowDown":
406
+ next = state.currentTime - 5;
407
+ break;
408
+ case "ArrowRight":
409
+ case "ArrowUp":
410
+ next = state.currentTime + 5;
411
+ break;
412
+ case "Home":
413
+ next = 0;
414
+ break;
415
+ case "End":
416
+ next = state.totalDuration;
417
+ break;
418
+ }
419
+ if (next == null) return;
420
+ e.preventDefault();
421
+ actions.seekTo(Math.max(0, Math.min(state.totalDuration, next)));
422
+ },
423
+ [actions, state.currentTime, state.totalDuration]
424
+ );
425
+ const getBlockAtTimeLocal = useCallback(
426
+ (time) => {
427
+ for (let i = expandedBlocks.length - 1; i >= 0; i--) {
428
+ const blk = expandedBlocks[i];
429
+ if (time >= blk.startTime) {
430
+ return { block: blk, index: i };
431
+ }
432
+ }
433
+ return expandedBlocks.length > 0 ? { block: expandedBlocks[0], index: 0 } : null;
434
+ },
435
+ [expandedBlocks]
436
+ );
437
+ return /* @__PURE__ */ jsxs(
438
+ "div",
439
+ {
440
+ ref: progressBarRef,
441
+ role: "group",
442
+ "aria-label": "Playback timeline",
443
+ style: {
444
+ flex: 1,
445
+ height: "24px",
446
+ cursor: "pointer",
447
+ position: "relative",
448
+ display: "flex",
449
+ alignItems: "center"
450
+ },
451
+ onClick: (e) => {
452
+ const rect = e.currentTarget.getBoundingClientRect();
453
+ const x = e.clientX - rect.left;
454
+ const progress = x / rect.width;
455
+ actions.seekTo(progress * state.totalDuration);
456
+ },
457
+ onMouseMove: handleProgressHover,
458
+ onMouseLeave: handleProgressLeave,
459
+ children: [
460
+ /* @__PURE__ */ jsx4(
461
+ "div",
462
+ {
463
+ role: "slider",
464
+ tabIndex: 0,
465
+ "aria-label": "Playback position",
466
+ "aria-valuemin": 0,
467
+ "aria-valuemax": state.totalDuration,
468
+ "aria-valuenow": Math.max(0, Math.min(state.totalDuration, state.currentTime)),
469
+ "aria-valuetext": `${formatTime(state.currentTime)} of ${formatTime(state.totalDuration)}`,
470
+ onKeyDown: handleProgressKeyDown,
471
+ style: {
472
+ position: "absolute",
473
+ left: 0,
474
+ right: 0,
475
+ height: "6px",
476
+ background: "rgba(255,255,255,0.2)",
477
+ borderRadius: "3px"
478
+ }
479
+ }
480
+ ),
481
+ /* @__PURE__ */ jsx4(
482
+ "div",
483
+ {
484
+ "data-testid": "doc-progress-fill",
485
+ style: {
486
+ position: "absolute",
487
+ left: 0,
488
+ width: `${playProgress * 100}%`,
489
+ height: "6px",
490
+ background: "#5b9bd5",
491
+ borderRadius: "3px"
492
+ }
493
+ }
494
+ ),
495
+ blockMarkers.map((marker, i) => /* @__PURE__ */ jsx4(
496
+ "button",
497
+ {
498
+ type: "button",
499
+ style: {
500
+ position: "absolute",
501
+ left: `${marker.position}%`,
502
+ transform: "translateX(-50%)",
503
+ width: "10px",
504
+ height: "10px",
505
+ borderRadius: "50%",
506
+ background: marker.index === state.currentBlockIndex ? "#ffffff" : "rgba(255,255,255,0.5)",
507
+ border: "2px solid #5b9bd5",
508
+ padding: 0,
509
+ cursor: "pointer",
510
+ zIndex: 2,
511
+ transition: "transform 0.15s, background 0.15s"
512
+ },
513
+ title: marker.title,
514
+ "aria-label": `Seek to ${marker.title}`,
515
+ onClick: (e) => {
516
+ e.stopPropagation();
517
+ actions.seekTo(marker.block.startTime);
518
+ },
519
+ onMouseEnter: (e) => {
520
+ e.currentTarget.style.transform = "translateX(-50%) scale(1.3)";
521
+ },
522
+ onMouseLeave: (e) => {
523
+ e.currentTarget.style.transform = "translateX(-50%)";
524
+ }
525
+ },
526
+ `${marker.block.id}-${i}`
527
+ )),
528
+ hoverPosition !== null && /* @__PURE__ */ jsxs(
529
+ "div",
530
+ {
531
+ style: {
532
+ position: "absolute",
533
+ left: `${hoverPosition * 100}%`,
534
+ bottom: "100%",
535
+ transform: "translateX(-50%)",
536
+ marginBottom: "8px",
537
+ padding: "6px 10px",
538
+ background: "rgba(0,0,0,0.9)",
539
+ borderRadius: "4px",
540
+ whiteSpace: "nowrap",
541
+ pointerEvents: "none",
542
+ zIndex: 10
543
+ },
544
+ children: [
545
+ /* @__PURE__ */ jsx4("div", { style: { color: "white", fontSize: "12px", fontFamily: "monospace" }, children: formatTime(hoverPosition * state.totalDuration) }),
546
+ (() => {
547
+ const hoverTime = hoverPosition * state.totalDuration;
548
+ const slideInfo = getBlockAtTimeLocal(hoverTime);
549
+ if (slideInfo && getBlockTitle) {
550
+ return /* @__PURE__ */ jsx4("div", { style: { color: "rgba(255,255,255,0.7)", fontSize: "11px", marginTop: "2px" }, children: getBlockTitle(slideInfo.block) });
551
+ }
552
+ return null;
553
+ })()
554
+ ]
555
+ }
556
+ ),
557
+ hoverPosition !== null && /* @__PURE__ */ jsx4(
558
+ "div",
559
+ {
560
+ style: {
561
+ position: "absolute",
562
+ left: `${hoverPosition * 100}%`,
563
+ top: "50%",
564
+ transform: "translate(-50%, -50%)",
565
+ width: "2px",
566
+ height: "16px",
567
+ background: "rgba(255,255,255,0.6)",
568
+ pointerEvents: "none",
569
+ zIndex: 1
570
+ }
571
+ }
572
+ )
573
+ ]
574
+ }
575
+ );
576
+ }
577
+
578
+ // src/DocControlsOverlay.tsx
579
+ import { jsx as jsx5, jsxs as jsxs2 } from "react/jsx-runtime";
580
+ function DocControlsOverlay({
581
+ state,
582
+ actions,
583
+ blockMarkers,
584
+ expandedBlocks,
585
+ getBlockTitle
586
+ }) {
587
+ return /* @__PURE__ */ jsxs2(
588
+ "div",
589
+ {
590
+ className: "doc-player__controls",
591
+ style: {
592
+ position: "absolute",
593
+ bottom: 0,
594
+ left: 0,
595
+ right: 0,
596
+ padding: "12px 16px",
597
+ background: "linear-gradient(transparent, rgba(0,0,0,0.8))",
598
+ display: "flex",
599
+ alignItems: "center",
600
+ gap: "8px",
601
+ zIndex: 100
602
+ },
603
+ children: [
604
+ /* @__PURE__ */ jsx5(
605
+ "button",
606
+ {
607
+ onClick: actions.restart,
608
+ style: {
609
+ background: "none",
610
+ border: "none",
611
+ color: "rgba(255,255,255,0.7)",
612
+ cursor: "pointer",
613
+ padding: "8px",
614
+ fontSize: "14px",
615
+ display: "flex",
616
+ alignItems: "center"
617
+ },
618
+ title: "Restart",
619
+ "aria-label": "Restart from beginning",
620
+ children: /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx5("path", { d: "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }) })
621
+ }
622
+ ),
623
+ /* @__PURE__ */ jsx5(
624
+ "button",
625
+ {
626
+ onClick: actions.toggle,
627
+ style: {
628
+ background: "rgba(255,255,255,0.2)",
629
+ border: "none",
630
+ borderRadius: "50%",
631
+ color: "white",
632
+ cursor: "pointer",
633
+ padding: "10px",
634
+ fontSize: "16px",
635
+ display: "flex",
636
+ alignItems: "center",
637
+ justifyContent: "center",
638
+ width: "40px",
639
+ height: "40px"
640
+ },
641
+ "aria-label": state.isPlaying ? "Pause" : "Play",
642
+ children: state.isPlaying ? /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx5("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" }) }) : /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx5("path", { d: "M8 5v14l11-7z" }) })
643
+ }
644
+ ),
645
+ /* @__PURE__ */ jsxs2("span", { style: { color: "white", fontSize: "12px", minWidth: "80px", fontFamily: "monospace" }, children: [
646
+ formatTime(state.currentTime),
647
+ " / ",
648
+ formatTime(state.totalDuration)
649
+ ] }),
650
+ /* @__PURE__ */ jsx5(
651
+ DocProgressBar,
652
+ {
653
+ state,
654
+ actions,
655
+ blockMarkers,
656
+ expandedBlocks,
657
+ getBlockTitle
658
+ }
659
+ ),
660
+ /* @__PURE__ */ jsxs2("span", { style: { color: "rgba(255,255,255,0.5)", fontSize: "11px", whiteSpace: "nowrap" }, children: [
661
+ state.currentBlockIndex + 1,
662
+ "/",
663
+ state.totalBlocks
664
+ ] }),
665
+ state.hasCaptions && /* @__PURE__ */ jsxs2(
666
+ "button",
667
+ {
668
+ onClick: () => actions.cycleCaptionMode(),
669
+ style: {
670
+ background: state.captionMode !== "off" ? "rgba(255,255,255,0.2)" : "none",
671
+ border: "none",
672
+ color: state.captionMode !== "off" ? "white" : "rgba(255,255,255,0.5)",
673
+ cursor: "pointer",
674
+ padding: "8px",
675
+ fontSize: "12px",
676
+ display: "flex",
677
+ alignItems: "center",
678
+ gap: "4px",
679
+ borderRadius: "4px"
680
+ },
681
+ title: state.captionMode === "off" ? "Captions: Off (click for Standard)" : state.captionMode === "standard" ? "Captions: Standard (click for Social)" : "Captions: Social (click to turn off)",
682
+ "aria-label": "Cycle caption style",
683
+ children: [
684
+ /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx5("path", { d: "M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" }) }),
685
+ state.captionMode !== "off" && /* @__PURE__ */ jsx5("span", { style: { fontSize: "10px", textTransform: "uppercase", letterSpacing: "0.5px" }, children: state.captionMode === "standard" ? "CC" : "SM" })
686
+ ]
687
+ }
688
+ ),
689
+ actions.toggleFullscreen && /* @__PURE__ */ jsx5(
690
+ "button",
691
+ {
692
+ onClick: actions.toggleFullscreen,
693
+ style: {
694
+ background: state.isFullscreen ? "rgba(255,255,255,0.2)" : "none",
695
+ border: "none",
696
+ color: state.isFullscreen ? "white" : "rgba(255,255,255,0.5)",
697
+ cursor: "pointer",
698
+ padding: "8px",
699
+ display: "flex",
700
+ alignItems: "center",
701
+ borderRadius: "4px"
702
+ },
703
+ title: state.isFullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)",
704
+ "aria-label": state.isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
705
+ children: state.isFullscreen ? /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx5("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) }) : /* @__PURE__ */ jsx5("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx5("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })
706
+ }
707
+ )
708
+ ]
709
+ }
710
+ );
711
+ }
712
+
713
+ // src/DocControlsSlideshow.tsx
714
+ import { useCallback as useCallback2, useEffect as useEffect2, useId, useLayoutEffect, useRef as useRef3, useState as useState2 } from "react";
715
+ import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
716
+ function DocControlsSlideshow({
717
+ state,
718
+ slideNav,
719
+ slides = [],
720
+ pickerOpen,
721
+ onPickerOpenChange
722
+ }) {
723
+ const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState2(false);
724
+ const isPickerOpen = pickerOpen ?? uncontrolledPickerOpen;
725
+ const setPickerOpen = useCallback2(
726
+ (open) => {
727
+ if (pickerOpen === void 0) setUncontrolledPickerOpen(open);
728
+ onPickerOpenChange?.(open);
729
+ },
730
+ [pickerOpen, onPickerOpenChange]
731
+ );
732
+ const [pickerMaxHeight, setPickerMaxHeight] = useState2(280);
733
+ const controlsRef = useRef3(null);
734
+ const triggerRef = useRef3(null);
735
+ const menuRef = useRef3(null);
736
+ const menuId = useId();
737
+ const {
738
+ currentBlockIndex,
739
+ currentSlideLabel,
740
+ currentSlideNumber,
741
+ totalBlocks,
742
+ totalSlideNumber
743
+ } = state;
744
+ const isFirst = currentBlockIndex <= 0;
745
+ const isLast = currentBlockIndex >= totalBlocks - 1;
746
+ const counterText = totalBlocks > 0 ? currentSlideLabel ?? `${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}` : "\u2014";
747
+ useEffect2(() => {
748
+ if (!isPickerOpen) return;
749
+ const handlePointerDown = (event) => {
750
+ if (!controlsRef.current?.contains(event.target)) setPickerOpen(false);
751
+ };
752
+ const handleKeyDown = (event) => {
753
+ if (event.key !== "Escape") return;
754
+ event.preventDefault();
755
+ setPickerOpen(false);
756
+ triggerRef.current?.focus();
757
+ };
758
+ document.addEventListener("pointerdown", handlePointerDown);
759
+ document.addEventListener("keydown", handleKeyDown);
760
+ return () => {
761
+ document.removeEventListener("pointerdown", handlePointerDown);
762
+ document.removeEventListener("keydown", handleKeyDown);
763
+ };
764
+ }, [isPickerOpen, setPickerOpen]);
765
+ useLayoutEffect(() => {
766
+ if (!isPickerOpen) return;
767
+ const controls = controlsRef.current;
768
+ const player = controls?.closest(".doc-player") ?? controls?.parentElement;
769
+ if (!controls || !player) return;
770
+ const updateMaxHeight = () => {
771
+ const controlsBounds = controls.getBoundingClientRect();
772
+ const playerBounds = player.getBoundingClientRect();
773
+ const availableHeight = Math.floor(controlsBounds.top - playerBounds.top - 8 - 16);
774
+ setPickerMaxHeight(Math.max(72, availableHeight));
775
+ };
776
+ updateMaxHeight();
777
+ const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateMaxHeight);
778
+ resizeObserver?.observe(player);
779
+ resizeObserver?.observe(controls);
780
+ window.addEventListener("resize", updateMaxHeight);
781
+ return () => {
782
+ resizeObserver?.disconnect();
783
+ window.removeEventListener("resize", updateMaxHeight);
784
+ };
785
+ }, [isPickerOpen]);
786
+ useEffect2(() => {
787
+ if (!isPickerOpen) return;
788
+ menuRef.current?.querySelector('[aria-current="true"]')?.focus();
789
+ }, [isPickerOpen]);
790
+ const handleMenuKeyDown = (event) => {
791
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
792
+ const items = Array.from(
793
+ menuRef.current?.querySelectorAll('[role="menuitem"]') ?? []
794
+ );
795
+ if (items.length === 0) return;
796
+ event.preventDefault();
797
+ const focusedIndex = items.indexOf(document.activeElement);
798
+ let nextIndex = focusedIndex;
799
+ if (event.key === "Home") nextIndex = 0;
800
+ else if (event.key === "End") nextIndex = items.length - 1;
801
+ else if (event.key === "ArrowDown") nextIndex = (focusedIndex + 1) % items.length;
802
+ else nextIndex = (focusedIndex - 1 + items.length) % items.length;
803
+ items[nextIndex]?.focus();
804
+ };
805
+ return /* @__PURE__ */ jsxs3(
806
+ "div",
807
+ {
808
+ ref: controlsRef,
809
+ className: "doc-controls-slideshow",
810
+ "data-testid": "slideshow-controls",
811
+ style: {
812
+ position: "absolute",
813
+ bottom: "16px",
814
+ right: "16px",
815
+ display: "flex",
816
+ alignItems: "center",
817
+ gap: "2px",
818
+ background: "rgba(0, 0, 0, 0.65)",
819
+ borderRadius: "8px",
820
+ padding: "4px 6px",
821
+ zIndex: 100,
822
+ userSelect: "none",
823
+ backdropFilter: "blur(8px)",
824
+ WebkitBackdropFilter: "blur(8px)"
825
+ },
826
+ children: [
827
+ /* @__PURE__ */ jsx6(
828
+ "button",
829
+ {
830
+ onClick: (e) => {
831
+ e.stopPropagation();
832
+ slideNav.prevSlide();
833
+ },
834
+ disabled: isFirst,
835
+ "data-testid": "slide-prev",
836
+ "aria-label": "Previous slide",
837
+ title: "Previous slide",
838
+ style: {
839
+ background: "none",
840
+ border: "none",
841
+ color: isFirst ? "rgba(255,255,255,0.3)" : "rgba(255,255,255,0.9)",
842
+ cursor: isFirst ? "default" : "pointer",
843
+ padding: "6px 8px",
844
+ display: "flex",
845
+ alignItems: "center",
846
+ justifyContent: "center",
847
+ borderRadius: "4px",
848
+ transition: "background 0.15s"
849
+ },
850
+ onMouseEnter: (e) => {
851
+ if (!isFirst) e.currentTarget.style.background = "rgba(255,255,255,0.1)";
852
+ },
853
+ onMouseLeave: (e) => {
854
+ e.currentTarget.style.background = "none";
855
+ },
856
+ children: /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx6("path", { d: "M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" }) })
857
+ }
858
+ ),
859
+ /* @__PURE__ */ jsx6(
860
+ "button",
861
+ {
862
+ ref: triggerRef,
863
+ type: "button",
864
+ "data-testid": "slide-counter",
865
+ "aria-label": `Choose slide, current ${counterText}`,
866
+ "aria-haspopup": "menu",
867
+ "aria-expanded": isPickerOpen,
868
+ "aria-controls": isPickerOpen ? menuId : void 0,
869
+ title: "Choose slide",
870
+ disabled: slides.length === 0,
871
+ onClick: (event) => {
872
+ event.stopPropagation();
873
+ setPickerOpen(!isPickerOpen);
874
+ },
875
+ style: {
876
+ background: isPickerOpen ? "rgba(255,255,255,0.12)" : "none",
877
+ border: "none",
878
+ color: "rgba(255,255,255,0.9)",
879
+ cursor: slides.length > 0 ? "pointer" : "default",
880
+ fontSize: "13px",
881
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
882
+ fontVariantNumeric: "tabular-nums",
883
+ minWidth: "48px",
884
+ textAlign: "center",
885
+ padding: "6px 4px",
886
+ letterSpacing: "0.02em",
887
+ borderRadius: "4px",
888
+ transition: "background 0.15s"
889
+ },
890
+ children: counterText
891
+ }
892
+ ),
893
+ isPickerOpen && /* @__PURE__ */ jsx6(
894
+ "div",
895
+ {
896
+ ref: menuRef,
897
+ id: menuId,
898
+ role: "menu",
899
+ "aria-label": "Choose a slide",
900
+ "data-testid": "slide-picker",
901
+ onClick: (event) => event.stopPropagation(),
902
+ onKeyDown: handleMenuKeyDown,
903
+ style: {
904
+ position: "absolute",
905
+ right: 0,
906
+ bottom: "calc(100% + 8px)",
907
+ width: "min(280px, calc(100vw - 32px))",
908
+ maxHeight: `${pickerMaxHeight}px`,
909
+ overflowY: "auto",
910
+ padding: "6px",
911
+ background: "rgba(20, 20, 20, 0.94)",
912
+ border: "1px solid rgba(255,255,255,0.14)",
913
+ borderRadius: "8px",
914
+ boxShadow: "0 10px 30px rgba(0,0,0,0.38)",
915
+ backdropFilter: "blur(12px)",
916
+ WebkitBackdropFilter: "blur(12px)"
917
+ },
918
+ children: slides.map((slide, index) => {
919
+ const isCurrent = index === currentBlockIndex;
920
+ return /* @__PURE__ */ jsxs3(
921
+ "button",
922
+ {
923
+ type: "button",
924
+ role: "menuitem",
925
+ "aria-current": isCurrent ? "true" : void 0,
926
+ "data-testid": `slide-picker-item-${index}`,
927
+ onClick: () => {
928
+ slideNav.goToSlide(index);
929
+ setPickerOpen(false);
930
+ },
931
+ style: {
932
+ display: "grid",
933
+ gridTemplateColumns: "42px minmax(0, 1fr)",
934
+ alignItems: "center",
935
+ gap: "8px",
936
+ width: "100%",
937
+ padding: "8px 10px",
938
+ background: isCurrent ? "rgba(255,255,255,0.14)" : "transparent",
939
+ border: "none",
940
+ borderRadius: "5px",
941
+ color: "rgba(255,255,255,0.94)",
942
+ cursor: "pointer",
943
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
944
+ textAlign: "left"
945
+ },
946
+ onMouseEnter: (event) => {
947
+ event.currentTarget.style.background = "rgba(255,255,255,0.1)";
948
+ },
949
+ onMouseLeave: (event) => {
950
+ event.currentTarget.style.background = isCurrent ? "rgba(255,255,255,0.14)" : "transparent";
951
+ },
952
+ children: [
953
+ /* @__PURE__ */ jsx6(
954
+ "span",
955
+ {
956
+ style: {
957
+ color: isCurrent ? "#fff" : "rgba(255,255,255,0.58)",
958
+ fontSize: "12px",
959
+ fontVariantNumeric: "tabular-nums",
960
+ textAlign: "right"
961
+ },
962
+ children: slide.label
963
+ }
964
+ ),
965
+ /* @__PURE__ */ jsx6(
966
+ "span",
967
+ {
968
+ style: {
969
+ minWidth: 0,
970
+ overflow: "hidden",
971
+ color: isCurrent ? "#fff" : "rgba(255,255,255,0.82)",
972
+ fontSize: "13px",
973
+ lineHeight: 1.3,
974
+ textOverflow: "ellipsis",
975
+ whiteSpace: "nowrap"
976
+ },
977
+ children: slide.summary
978
+ }
979
+ )
980
+ ]
981
+ },
982
+ `${slide.id}-${index}`
983
+ );
984
+ })
985
+ }
986
+ ),
987
+ /* @__PURE__ */ jsx6(
988
+ "button",
989
+ {
990
+ onClick: (e) => {
991
+ e.stopPropagation();
992
+ slideNav.nextSlide();
993
+ },
994
+ disabled: isLast,
995
+ "data-testid": "slide-next",
996
+ "aria-label": "Next slide",
997
+ title: "Next slide",
998
+ style: {
999
+ background: "none",
1000
+ border: "none",
1001
+ color: isLast ? "rgba(255,255,255,0.3)" : "rgba(255,255,255,0.9)",
1002
+ cursor: isLast ? "default" : "pointer",
1003
+ padding: "6px 8px",
1004
+ display: "flex",
1005
+ alignItems: "center",
1006
+ justifyContent: "center",
1007
+ borderRadius: "4px",
1008
+ transition: "background 0.15s"
1009
+ },
1010
+ onMouseEnter: (e) => {
1011
+ if (!isLast) e.currentTarget.style.background = "rgba(255,255,255,0.1)";
1012
+ },
1013
+ onMouseLeave: (e) => {
1014
+ e.currentTarget.style.background = "none";
1015
+ },
1016
+ children: /* @__PURE__ */ jsx6("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx6("path", { d: "M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" }) })
1017
+ }
1018
+ )
1019
+ ]
1020
+ }
1021
+ );
1022
+ }
1023
+
1024
+ // src/DocPlayer.tsx
1025
+ import { Fragment, useId as useId2, useRef as useRef5, useState as useState4, useEffect as useEffect4, useCallback as useCallback4, useMemo as useMemo2 } from "react";
1026
+ import {
1027
+ isTemplateBlock as isTemplateBlock2,
1028
+ getCaptionAtTime as getCaptionAtTime2,
1029
+ resolveMediaSchedule,
1030
+ getDocPlaybackDuration
1031
+ } from "@bendyline/squisq/schemas";
1032
+ import { applySurface } from "@bendyline/squisq/schemas";
1033
+
1034
+ // src/hooks/useSlideSwipe.ts
1035
+ import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef4, useState as useState3 } from "react";
1036
+ var DISTANCE_RATIO = 0.3;
1037
+ var FLICK_VELOCITY = 0.5;
1038
+ var MIN_FLICK_DISTANCE = 12;
1039
+ var RUBBER_BAND = 0.35;
1040
+ var DEFAULT_SETTLE_MS = 260;
1041
+ function decideSwipe({
1042
+ dx,
1043
+ width,
1044
+ elapsedMs,
1045
+ canNext,
1046
+ canPrev
1047
+ }) {
1048
+ const distanceThreshold = width > 0 ? width * DISTANCE_RATIO : Infinity;
1049
+ const velocity = elapsedMs > 0 ? Math.abs(dx) / elapsedMs : 0;
1050
+ const passesDistance = Math.abs(dx) >= distanceThreshold;
1051
+ const passesFlick = velocity >= FLICK_VELOCITY && Math.abs(dx) >= MIN_FLICK_DISTANCE;
1052
+ if (!passesDistance && !passesFlick) return "snap";
1053
+ if (dx < 0) return canNext ? "next" : "snap";
1054
+ if (dx > 0) return canPrev ? "prev" : "snap";
1055
+ return "snap";
1056
+ }
1057
+ function useSlideSwipe(opts) {
1058
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
1059
+ const [offsetPx, setOffsetPx] = useState3(0);
1060
+ const [phase, setPhase] = useState3("idle");
1061
+ const optsRef = useRef4(opts);
1062
+ optsRef.current = opts;
1063
+ const dragRef = useRef4(null);
1064
+ const phaseRef = useRef4("idle");
1065
+ phaseRef.current = phase;
1066
+ const settleTimer = useRef4(null);
1067
+ const settleRaf = useRef4(null);
1068
+ const clearPending = useCallback3(() => {
1069
+ if (settleTimer.current != null) {
1070
+ clearTimeout(settleTimer.current);
1071
+ settleTimer.current = null;
1072
+ }
1073
+ if (settleRaf.current != null) {
1074
+ cancelAnimationFrame(settleRaf.current);
1075
+ settleRaf.current = null;
1076
+ }
1077
+ }, []);
1078
+ const onPointerDown = useCallback3((e) => {
1079
+ const o = optsRef.current;
1080
+ if (!o.enabled) return;
1081
+ if (phaseRef.current === "settling") return;
1082
+ if (e.pointerType === "mouse" && e.button !== 0) return;
1083
+ const target = e.target;
1084
+ if (target.closest?.("button, a, input, textarea, select, [data-no-swipe]")) return;
1085
+ dragRef.current = {
1086
+ pointerId: e.pointerId,
1087
+ startX: e.clientX,
1088
+ startTime: performance.now(),
1089
+ target
1090
+ };
1091
+ try {
1092
+ target.setPointerCapture?.(e.pointerId);
1093
+ } catch {
1094
+ }
1095
+ setPhase("dragging");
1096
+ setOffsetPx(0);
1097
+ }, []);
1098
+ useEffect3(() => {
1099
+ function currentWidth() {
1100
+ return optsRef.current.containerRef.current?.getBoundingClientRect().width ?? 0;
1101
+ }
1102
+ function endDrag(drag) {
1103
+ dragRef.current = null;
1104
+ try {
1105
+ drag.target.releasePointerCapture?.(drag.pointerId);
1106
+ } catch {
1107
+ }
1108
+ }
1109
+ function settleTo(target, onArrive) {
1110
+ setPhase("settling");
1111
+ settleRaf.current = requestAnimationFrame(() => {
1112
+ settleRaf.current = null;
1113
+ setOffsetPx(target);
1114
+ settleTimer.current = setTimeout(() => {
1115
+ settleTimer.current = null;
1116
+ onArrive?.();
1117
+ setOffsetPx(0);
1118
+ setPhase("idle");
1119
+ }, settleMs);
1120
+ });
1121
+ }
1122
+ function onMove(e) {
1123
+ const drag = dragRef.current;
1124
+ if (!drag || e.pointerId !== drag.pointerId) return;
1125
+ const o = optsRef.current;
1126
+ const raw = e.clientX - drag.startX;
1127
+ const blocked = raw > 0 && !o.canGoPrev || raw < 0 && !o.canGoNext;
1128
+ setOffsetPx(blocked ? raw * RUBBER_BAND : raw);
1129
+ }
1130
+ function onUp(e) {
1131
+ const drag = dragRef.current;
1132
+ if (!drag || e.pointerId !== drag.pointerId) return;
1133
+ endDrag(drag);
1134
+ const o = optsRef.current;
1135
+ const rawDx = e.clientX - drag.startX;
1136
+ const width = currentWidth();
1137
+ const elapsedMs = performance.now() - drag.startTime;
1138
+ const decision = decideSwipe({
1139
+ dx: rawDx,
1140
+ width,
1141
+ elapsedMs,
1142
+ canNext: o.canGoNext,
1143
+ canPrev: o.canGoPrev
1144
+ });
1145
+ if (decision === "snap") {
1146
+ settleTo(0);
1147
+ return;
1148
+ }
1149
+ const distance = Math.max(width, Math.abs(rawDx));
1150
+ const target = decision === "next" ? -distance : distance;
1151
+ settleTo(target, decision === "next" ? o.onNext : o.onPrev);
1152
+ }
1153
+ function onCancel(e) {
1154
+ const drag = dragRef.current;
1155
+ if (!drag || e.pointerId !== drag.pointerId) return;
1156
+ endDrag(drag);
1157
+ settleTo(0);
1158
+ }
1159
+ window.addEventListener("pointermove", onMove);
1160
+ window.addEventListener("pointerup", onUp);
1161
+ window.addEventListener("pointercancel", onCancel);
1162
+ return () => {
1163
+ window.removeEventListener("pointermove", onMove);
1164
+ window.removeEventListener("pointerup", onUp);
1165
+ window.removeEventListener("pointercancel", onCancel);
1166
+ };
1167
+ }, [settleMs]);
1168
+ useEffect3(() => {
1169
+ if (!opts.enabled) {
1170
+ dragRef.current = null;
1171
+ clearPending();
1172
+ setPhase("idle");
1173
+ setOffsetPx(0);
1174
+ }
1175
+ }, [opts.enabled, clearPending]);
1176
+ useEffect3(() => clearPending, [clearPending]);
1177
+ return { offsetPx, phase, onPointerDown };
1178
+ }
1179
+
1180
+ // src/DocPlayer.tsx
1181
+ import {
1182
+ expandCoverBlock,
1183
+ createTemplateContext,
1184
+ markdownToDoc,
1185
+ DEFAULT_THEME,
1186
+ VIEWPORT_PRESETS
1187
+ } from "@bendyline/squisq/doc";
1188
+ import { parseMarkdown } from "@bendyline/squisq/markdown";
1189
+
1190
+ // src/docPlayer/segmentTitles.ts
1191
+ import { isTemplateBlock } from "@bendyline/squisq/schemas";
1192
+ var SMALL_WORDS = /* @__PURE__ */ new Set([
1193
+ "a",
1194
+ "an",
1195
+ "the",
1196
+ "and",
1197
+ "but",
1198
+ "or",
1199
+ "for",
1200
+ "nor",
1201
+ "on",
1202
+ "at",
1203
+ "to",
1204
+ "in",
1205
+ "of",
1206
+ "by",
1207
+ "is"
1208
+ ]);
1209
+ function buildSegmentTitleMap(doc) {
1210
+ const map = /* @__PURE__ */ new Map();
1211
+ for (const block of doc.blocks) {
1212
+ if (isTemplateBlock(block) && block.template === "sectionHeader" && "title" in block) {
1213
+ const segmentIndex = block.audioSegment;
1214
+ if (!map.has(segmentIndex)) map.set(segmentIndex, block.title);
1215
+ }
1216
+ }
1217
+ for (let index = 0; index < doc.audio.segments.length; index++) {
1218
+ if (map.has(index)) continue;
1219
+ const name = doc.audio.segments[index].name;
1220
+ if (name.includes("intro")) {
1221
+ map.set(index, "Introduction");
1222
+ } else if (name.includes("flight-context")) {
1223
+ map.set(index, "Flight Context");
1224
+ } else {
1225
+ const title = name.split("-").map(
1226
+ (word, wordIndex) => wordIndex === 0 || !SMALL_WORDS.has(word) ? word.charAt(0).toUpperCase() + word.slice(1) : word
1227
+ ).join(" ");
1228
+ map.set(index, title);
1229
+ }
1230
+ }
1231
+ return map;
1232
+ }
1233
+
1234
+ // src/DocPlayer.tsx
1235
+ import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
1236
+ function isDevEnvironment() {
1237
+ try {
1238
+ return typeof process !== "undefined" && process.env.NODE_ENV !== "production";
1239
+ } catch {
1240
+ return false;
1241
+ }
1242
+ }
1243
+ var warnedMissingStyles = false;
1244
+ function DocPlayer(props) {
1245
+ const { doc, markdown } = props;
1246
+ const markdownDoc = useMemo2(
1247
+ () => !doc && markdown !== void 0 ? markdownToDoc(parseMarkdown(markdown)) : void 0,
1248
+ [doc, markdown]
1249
+ );
1250
+ const resolvedDoc = doc ?? markdownDoc;
1251
+ if (!resolvedDoc) {
1252
+ return /* @__PURE__ */ jsx7("div", { className: "doc-player doc-player--empty" });
1253
+ }
1254
+ return /* @__PURE__ */ jsx7(DocPlayerContent, { ...props, doc: resolvedDoc });
1255
+ }
1256
+ function DocPlayerContent({
1257
+ doc,
1258
+ basePath = ".",
1259
+ renderMode = false,
1260
+ animationsEnabled = true,
1261
+ autoPlay = false,
1262
+ onEnded,
1263
+ onTimeUpdate,
1264
+ audioController: externalAudioController,
1265
+ audioMode = "media",
1266
+ showControls = true,
1267
+ showScrubber = false,
1268
+ muted = false,
1269
+ captionsEnabled: captionsEnabledProp,
1270
+ onCaptionsToggle,
1271
+ onPlaybackStateChange,
1272
+ onControlsReady,
1273
+ onRenderAPIReady,
1274
+ isFullscreen = false,
1275
+ onFullscreenToggle,
1276
+ onBlockMarkers,
1277
+ forceViewport,
1278
+ displayMode = "video",
1279
+ showCoverSlide = true,
1280
+ coverVisible,
1281
+ theme,
1282
+ surface,
1283
+ captionStyle = "standard",
1284
+ enableSwipe = true,
1285
+ globalKeyboardShortcuts = false
1286
+ }) {
1287
+ const isSlideshowMode = displayMode === "slideshow";
1288
+ const isLinearMode = displayMode === "linear";
1289
+ const audioRef = useRef5(null);
1290
+ const containerRef = useRef5(null);
1291
+ const playerId = `squisq-player-${useId2().replace(/:/g, "")}`;
1292
+ const [tapFeedback, setTapFeedback] = useState4(null);
1293
+ const tapFeedbackTimer = useRef5();
1294
+ const { viewport, orientation } = useViewportOrientation();
1295
+ const activeViewport = forceViewport || (renderMode ? VIEWPORT_PRESETS.landscape : viewport);
1296
+ const isDebugMode = useMemo2(() => {
1297
+ if (typeof window === "undefined") return false;
1298
+ const params = new URLSearchParams(window.location.search);
1299
+ return params.get("debug") === "true";
1300
+ }, []);
1301
+ const internalAudio = useAudioSync(
1302
+ audioRef,
1303
+ doc.audio,
1304
+ basePath,
1305
+ !externalAudioController,
1306
+ audioMode
1307
+ );
1308
+ const audio = externalAudioController || internalAudio;
1309
+ useEffect4(() => {
1310
+ if (warnedMissingStyles || !isDevEnvironment()) return;
1311
+ const el = containerRef.current;
1312
+ if (!el || typeof getComputedStyle !== "function") return;
1313
+ const value = getComputedStyle(el).getPropertyValue("--squisq-styles-loaded");
1314
+ if (!value.trim()) {
1315
+ warnedMissingStyles = true;
1316
+ console.warn(
1317
+ '[squisq] @bendyline/squisq-react/styles is not loaded \u2014 import "@bendyline/squisq-react/styles"'
1318
+ );
1319
+ }
1320
+ }, []);
1321
+ const {
1322
+ currentTime,
1323
+ isPlaying,
1324
+ currentSegment,
1325
+ totalDuration,
1326
+ isEnded,
1327
+ isReady: isAudioReady,
1328
+ isAvailable,
1329
+ unavailableMessage,
1330
+ play,
1331
+ pause,
1332
+ toggle,
1333
+ seekTo,
1334
+ skipToSegment: _skipToSegment,
1335
+ restart
1336
+ } = audio;
1337
+ const mediaSchedule = useMemo2(() => resolveMediaSchedule(doc), [doc]);
1338
+ const currentTimeRef = useRef5(currentTime);
1339
+ currentTimeRef.current = currentTime;
1340
+ const totalDurationRef = useRef5(totalDuration);
1341
+ totalDurationRef.current = totalDuration;
1342
+ const expandedBlocksLenRef = useRef5(0);
1343
+ const handleContainerClick = useCallback4(
1344
+ (e) => {
1345
+ if (renderMode || isLinearMode) return;
1346
+ const target = e.target;
1347
+ if (isSlideshowMode) {
1348
+ if (!target.closest('button, a, input, textarea, select, [contenteditable="true"]')) {
1349
+ containerRef.current?.focus({ preventScroll: true });
1350
+ }
1351
+ return;
1352
+ }
1353
+ if (target.closest(
1354
+ 'button, a, input, textarea, select, [contenteditable="true"], .doc-player__controls, .doc-player__scrubber, .doc-controls-sidebar, .doc-controls-slideshow'
1355
+ ))
1356
+ return;
1357
+ containerRef.current?.focus({ preventScroll: true });
1358
+ toggle();
1359
+ const nextState = isPlaying ? "pause" : "play";
1360
+ setTapFeedback(nextState);
1361
+ clearTimeout(tapFeedbackTimer.current);
1362
+ tapFeedbackTimer.current = setTimeout(() => setTapFeedback(null), 600);
1363
+ },
1364
+ [renderMode, toggle, isPlaying, isSlideshowMode, isLinearMode]
1365
+ );
1366
+ const autoSurface = useAutoSurface(surface === "auto");
1367
+ const resolvedSurface = surface === "auto" ? autoSurface : surface;
1368
+ const effectiveTheme = useMemo2(() => {
1369
+ const base = theme ?? DEFAULT_THEME;
1370
+ return resolvedSurface ? applySurface(base, resolvedSurface) : base;
1371
+ }, [theme, resolvedSurface]);
1372
+ const {
1373
+ currentBlock,
1374
+ currentBlockIndex,
1375
+ previousBlock,
1376
+ isEntering,
1377
+ isExiting,
1378
+ blockTime,
1379
+ blockProgress: _blockProgress,
1380
+ docProgress,
1381
+ nextBlock: _nextBlock,
1382
+ prevBlock: _prevBlock,
1383
+ blocks: expandedBlocks,
1384
+ suppressOutgoingForNextBlock
1385
+ } = useDocPlayback(doc, currentTime, {
1386
+ viewport: activeViewport,
1387
+ theme: effectiveTheme,
1388
+ onSeek: seekTo
1389
+ });
1390
+ const coverBlock = useMemo2(() => {
1391
+ const startBlockConfig = doc.startBlock;
1392
+ if (!showCoverSlide) return null;
1393
+ if (!startBlockConfig) return null;
1394
+ const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
1395
+ const layers = expandCoverBlock(startBlockConfig, context);
1396
+ return {
1397
+ id: "cover-block",
1398
+ startTime: -1,
1399
+ // Not part of timeline
1400
+ duration: 0,
1401
+ // Static
1402
+ audioSegment: -1,
1403
+ layers
1404
+ };
1405
+ }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
1406
+ const hasManagedCover = !!coverBlock;
1407
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState4(false);
1408
+ const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState4(false);
1409
+ const slideshowCoverInitKeyRef = useRef5("");
1410
+ useEffect4(() => {
1411
+ slideshowCoverInitKeyRef.current = "";
1412
+ }, [doc]);
1413
+ useEffect4(() => {
1414
+ const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
1415
+ if (slideshowCoverInitKeyRef.current === initKey) return;
1416
+ slideshowCoverInitKeyRef.current = initKey;
1417
+ if (isSlideshowMode && hasManagedCover && !renderMode) {
1418
+ setSlideshowCoverVisible(true);
1419
+ pause();
1420
+ } else {
1421
+ setSlideshowCoverVisible(false);
1422
+ }
1423
+ }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
1424
+ const [coverForced, setCoverForced] = useState4(false);
1425
+ const [coverGraceActive, setCoverGraceActive] = useState4(false);
1426
+ const coverGraceTimer = useRef5();
1427
+ const coverWasShowing = useRef5(false);
1428
+ const hasPlayedOnce = useRef5(false);
1429
+ useEffect4(() => {
1430
+ hasPlayedOnce.current = false;
1431
+ coverWasShowing.current = false;
1432
+ clearTimeout(coverGraceTimer.current);
1433
+ setCoverGraceActive(false);
1434
+ setCoverForced(false);
1435
+ }, [doc]);
1436
+ const atRest = !!(coverBlock && !isSlideshowMode && !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1437
+ if (atRest) coverWasShowing.current = true;
1438
+ useEffect4(() => {
1439
+ if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
1440
+ coverWasShowing.current = false;
1441
+ hasPlayedOnce.current = true;
1442
+ setCoverGraceActive(true);
1443
+ coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3e3);
1444
+ }
1445
+ }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
1446
+ useEffect4(() => () => clearTimeout(coverGraceTimer.current), []);
1447
+ useEffect4(() => () => clearTimeout(tapFeedbackTimer.current), []);
1448
+ const showVideoCoverBlock = !isSlideshowMode && !isLinearMode && !!coverBlock && (coverForced || coverGraceActive || !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1449
+ const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
1450
+ const showSlideshowCover = !!(isSlideshowMode && !isLinearMode && !renderMode && coverBlock && effectiveSlideshowCoverVisible);
1451
+ const showCoverBlock = coverVisible === void 0 ? showVideoCoverBlock || showSlideshowCover : !!coverBlock && coverVisible;
1452
+ const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
1453
+ const slideshowSlideIndex = slideshowHasCover ? effectiveSlideshowCoverVisible ? 0 : currentBlockIndex + 1 : currentBlockIndex;
1454
+ const slideshowTotalSlides = slideshowHasCover ? expandedBlocks.length + 1 : expandedBlocks.length;
1455
+ const hasAutoPlayed = useRef5(false);
1456
+ useEffect4(() => {
1457
+ hasAutoPlayed.current = false;
1458
+ }, [doc]);
1459
+ useEffect4(() => {
1460
+ if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
1461
+ hasAutoPlayed.current = true;
1462
+ play();
1463
+ }
1464
+ }, [isAudioReady, autoPlay, play]);
1465
+ useEffect4(() => {
1466
+ onTimeUpdate?.(currentTime);
1467
+ }, [currentTime, onTimeUpdate]);
1468
+ useEffect4(() => {
1469
+ if (isEnded) {
1470
+ onEnded?.();
1471
+ }
1472
+ }, [isEnded, onEnded]);
1473
+ const liveRenderAPIRef = useRef5(null);
1474
+ const stableRenderAPIRef = useRef5(null);
1475
+ if (!stableRenderAPIRef.current) {
1476
+ const current = () => {
1477
+ const api = liveRenderAPIRef.current;
1478
+ if (!api) throw new Error("Squisq render API is not currently available.");
1479
+ return api;
1480
+ };
1481
+ stableRenderAPIRef.current = {
1482
+ seekTo: (time) => current().seekTo(time),
1483
+ getDuration: () => current().getDuration(),
1484
+ getBlocks: () => current().getBlocks(),
1485
+ getAudioSegments: () => current().getAudioSegments(),
1486
+ getCaptions: () => current().getCaptions(),
1487
+ getChapters: () => current().getChapters(),
1488
+ showCover: () => current().showCover(),
1489
+ hideCover: () => current().hideCover(),
1490
+ hasCoverBlock: () => current().hasCoverBlock()
1491
+ };
1492
+ }
1493
+ const stableRenderAPI = stableRenderAPIRef.current;
1494
+ useEffect4(() => {
1495
+ if (!renderMode && !isDebugMode) {
1496
+ liveRenderAPIRef.current = null;
1497
+ return;
1498
+ }
1499
+ const root = containerRef.current;
1500
+ if (!root) {
1501
+ liveRenderAPIRef.current = null;
1502
+ return;
1503
+ }
1504
+ const renderSeekTo = (time) => {
1505
+ seekTo(time);
1506
+ return new Promise((resolve) => {
1507
+ requestAnimationFrame(() => {
1508
+ let blockStartTime = 0;
1509
+ for (let i = expandedBlocks.length - 1; i >= 0; i--) {
1510
+ if (time >= expandedBlocks[i].startTime) {
1511
+ blockStartTime = expandedBlocks[i].startTime;
1512
+ break;
1513
+ }
1514
+ }
1515
+ const elapsedMs = (time - blockStartTime) * 1e3;
1516
+ (root.getAnimations?.() ?? []).forEach((anim) => {
1517
+ const target = anim.effect?.target;
1518
+ if (!target) return;
1519
+ if (target.closest(".doc-player__block--active")) {
1520
+ anim.currentTime = Math.max(0, elapsedMs);
1521
+ } else if (target.closest(".doc-player__block--previous")) {
1522
+ anim.currentTime = Math.max(0, elapsedMs);
1523
+ }
1524
+ });
1525
+ const blockElapsed = time - blockStartTime;
1526
+ const videoSeekPromises = [];
1527
+ const activeBlockEl = root.querySelector(".doc-player__block--active");
1528
+ if (activeBlockEl) {
1529
+ const videos = activeBlockEl.querySelectorAll("video[data-clip-start]");
1530
+ videos.forEach((el) => {
1531
+ const video = el;
1532
+ const clipStart = parseFloat(video.dataset.clipStart || "0");
1533
+ const clipEnd = parseFloat(video.dataset.clipEnd || "0");
1534
+ const startAt = parseFloat(video.dataset.startAt || "0");
1535
+ const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
1536
+ video.pause();
1537
+ video.currentTime = targetTime;
1538
+ videoSeekPromises.push(
1539
+ new Promise((r) => {
1540
+ if (Math.abs(video.currentTime - targetTime) < 0.1) {
1541
+ r();
1542
+ } else {
1543
+ video.addEventListener("seeked", () => r(), { once: true });
1544
+ setTimeout(r, 200);
1545
+ }
1546
+ })
1547
+ );
1548
+ });
1549
+ }
1550
+ root.querySelectorAll("video[data-clip-id]").forEach((el) => {
1551
+ const video = el;
1552
+ const absStart = parseFloat(video.dataset.absStart || "0");
1553
+ const absEnd = parseFloat(video.dataset.absEnd || "0");
1554
+ const sourceIn = parseFloat(video.dataset.sourceIn || "0");
1555
+ video.pause();
1556
+ if (time < absStart || time >= absEnd) return;
1557
+ const targetTime = sourceIn + (time - absStart);
1558
+ video.currentTime = targetTime;
1559
+ videoSeekPromises.push(
1560
+ new Promise((r) => {
1561
+ if (Math.abs(video.currentTime - targetTime) < 0.1) {
1562
+ r();
1563
+ } else {
1564
+ video.addEventListener("seeked", () => r(), { once: true });
1565
+ setTimeout(r, 200);
1566
+ }
1567
+ })
1568
+ );
1569
+ });
1570
+ Promise.all(videoSeekPromises).then(() => {
1571
+ requestAnimationFrame(() => resolve());
1572
+ });
1573
+ });
1574
+ });
1575
+ };
1576
+ const getDuration = () => {
1577
+ const mediaDuration = getDocPlaybackDuration(doc);
1578
+ if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
1579
+ return mediaDuration;
1580
+ };
1581
+ const getBlocks = () => expandedBlocks.map((s) => ({
1582
+ id: s.id,
1583
+ template: s.template ?? "raw",
1584
+ startTime: s.startTime,
1585
+ duration: s.duration
1586
+ }));
1587
+ const getAudioSegments = () => doc.audio.segments.map((seg) => ({
1588
+ src: seg.src,
1589
+ name: seg.name,
1590
+ duration: seg.duration,
1591
+ startTime: seg.startTime
1592
+ }));
1593
+ const getCaptions = () => doc.captions?.phrases?.map((p) => ({
1594
+ text: p.text,
1595
+ startTime: p.startTime,
1596
+ endTime: p.endTime
1597
+ })) || [];
1598
+ const getChapters = () => {
1599
+ const titleMap = buildSegmentTitleMap(doc);
1600
+ return doc.audio.segments.map((seg, i) => ({
1601
+ title: titleMap.get(i) || seg.name,
1602
+ startTime: seg.startTime,
1603
+ duration: seg.duration
1604
+ }));
1605
+ };
1606
+ const showCover = () => {
1607
+ setCoverForced(true);
1608
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
1609
+ };
1610
+ const hideCover = () => {
1611
+ setCoverForced(false);
1612
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
1613
+ };
1614
+ const hasCoverBlock = () => !!coverBlock;
1615
+ const api = {
1616
+ seekTo: renderSeekTo,
1617
+ getDuration,
1618
+ getBlocks,
1619
+ getAudioSegments,
1620
+ getCaptions,
1621
+ getChapters,
1622
+ showCover,
1623
+ hideCover,
1624
+ hasCoverBlock
1625
+ };
1626
+ liveRenderAPIRef.current = api;
1627
+ return () => {
1628
+ if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
1629
+ };
1630
+ }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock, doc]);
1631
+ useEffect4(() => {
1632
+ if (!renderMode && !isDebugMode || !containerRef.current) {
1633
+ onRenderAPIReady?.(null);
1634
+ return;
1635
+ }
1636
+ onRenderAPIReady?.(stableRenderAPI);
1637
+ return () => onRenderAPIReady?.(null);
1638
+ }, [renderMode, isDebugMode, onRenderAPIReady, stableRenderAPI]);
1639
+ const defaultMode = captionsEnabledProp === false ? "off" : captionStyle || "standard";
1640
+ const [captionMode, setCaptionMode] = useState4(defaultMode);
1641
+ useEffect4(() => {
1642
+ setCaptionMode(defaultMode);
1643
+ }, [defaultMode]);
1644
+ const captionsEnabled = captionMode !== "off";
1645
+ const activeCaptionStyle = captionMode === "social" ? "social" : "standard";
1646
+ const setCaptionsEnabled = useCallback4(
1647
+ (enabled) => {
1648
+ setCaptionMode(enabled ? captionStyle || "standard" : "off");
1649
+ onCaptionsToggle?.(enabled);
1650
+ },
1651
+ [onCaptionsToggle, captionStyle]
1652
+ );
1653
+ const cycleCaptionMode = useCallback4(() => {
1654
+ setCaptionMode((prev) => {
1655
+ const next = prev === "off" ? "standard" : prev === "standard" ? "social" : "off";
1656
+ onCaptionsToggle?.(next !== "off");
1657
+ return next;
1658
+ });
1659
+ }, [onCaptionsToggle]);
1660
+ const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
1661
+ const segmentTitleMap = useMemo2(() => buildSegmentTitleMap(doc), [doc]);
1662
+ const playbackState = useMemo2(
1663
+ () => ({
1664
+ isPlaying,
1665
+ currentTime,
1666
+ totalDuration,
1667
+ isCoverVisible: showCoverBlock,
1668
+ currentBlockIndex: slideshowSlideIndex,
1669
+ totalBlocks: slideshowTotalSlides,
1670
+ docProgress,
1671
+ hasCaptions: !!hasCaptions,
1672
+ captionsEnabled,
1673
+ captionMode,
1674
+ isFullscreen,
1675
+ currentSegmentIndex: currentSegment,
1676
+ currentSegmentName: segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
1677
+ currentBlock: showSlideshowCover ? coverBlock : currentBlock ?? null,
1678
+ currentSlideLabel: showSlideshowCover ? "Cover" : void 0,
1679
+ currentSlideNumber: slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : void 0,
1680
+ totalSlideNumber: slideshowHasCover ? expandedBlocks.length : void 0
1681
+ }),
1682
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
1683
+ [
1684
+ isPlaying,
1685
+ currentTime,
1686
+ totalDuration,
1687
+ showCoverBlock,
1688
+ slideshowSlideIndex,
1689
+ slideshowTotalSlides,
1690
+ docProgress,
1691
+ hasCaptions,
1692
+ captionsEnabled,
1693
+ captionMode,
1694
+ isFullscreen,
1695
+ currentSegment,
1696
+ segmentTitleMap,
1697
+ currentBlock,
1698
+ currentBlockIndex,
1699
+ showSlideshowCover,
1700
+ coverBlock,
1701
+ slideshowHasCover,
1702
+ expandedBlocks.length
1703
+ ]
1704
+ );
1705
+ const playbackActions = useMemo2(
1706
+ () => ({
1707
+ toggle,
1708
+ restart,
1709
+ seekTo,
1710
+ setCaptionsEnabled,
1711
+ cycleCaptionMode,
1712
+ toggleFullscreen: onFullscreenToggle
1713
+ }),
1714
+ [toggle, restart, seekTo, setCaptionsEnabled, cycleCaptionMode, onFullscreenToggle]
1715
+ );
1716
+ const slideNavActions = useMemo2(
1717
+ () => ({
1718
+ nextSlide: () => {
1719
+ if (slideshowHasCover && slideshowCoverVisible) {
1720
+ const target = expandedBlocks[0];
1721
+ if (target) {
1722
+ setSlideshowCoverVisible(false);
1723
+ seekTo(target.startTime);
1724
+ pause();
1725
+ }
1726
+ return;
1727
+ }
1728
+ if (currentBlockIndex < expandedBlocks.length - 1) {
1729
+ const target = expandedBlocks[currentBlockIndex + 1];
1730
+ if (target) {
1731
+ setSlideshowCoverVisible(false);
1732
+ seekTo(target.startTime);
1733
+ pause();
1734
+ }
1735
+ }
1736
+ },
1737
+ prevSlide: () => {
1738
+ if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
1739
+ setSlideshowCoverVisible(true);
1740
+ seekTo(0);
1741
+ pause();
1742
+ return;
1743
+ }
1744
+ if (currentBlockIndex > 0) {
1745
+ const target = expandedBlocks[currentBlockIndex - 1];
1746
+ if (target) {
1747
+ setSlideshowCoverVisible(false);
1748
+ seekTo(target.startTime);
1749
+ pause();
1750
+ }
1751
+ }
1752
+ },
1753
+ goToSlide: (index) => {
1754
+ if (slideshowHasCover) {
1755
+ if (index === 0) {
1756
+ setSlideshowCoverVisible(true);
1757
+ seekTo(0);
1758
+ pause();
1759
+ return;
1760
+ }
1761
+ const target = expandedBlocks[index - 1];
1762
+ if (target) {
1763
+ setSlideshowCoverVisible(false);
1764
+ seekTo(target.startTime);
1765
+ pause();
1766
+ }
1767
+ return;
1768
+ }
1769
+ if (index >= 0 && index < expandedBlocks.length) {
1770
+ const target = expandedBlocks[index];
1771
+ if (target) {
1772
+ seekTo(target.startTime);
1773
+ pause();
1774
+ }
1775
+ }
1776
+ }
1777
+ }),
1778
+ [currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible]
1779
+ );
1780
+ const swipeEnabled = isSlideshowMode && !isLinearMode && !renderMode && enableSwipe;
1781
+ const armContextFreeSwipeEntry = useCallback4(
1782
+ (destinationSlideIndex) => {
1783
+ const destinationBlockIndex = destinationSlideIndex - (slideshowHasCover ? 1 : 0);
1784
+ const destinationBlock = expandedBlocks[destinationBlockIndex];
1785
+ if (destinationBlock) suppressOutgoingForNextBlock(destinationBlock.id);
1786
+ },
1787
+ [expandedBlocks, slideshowHasCover, suppressOutgoingForNextBlock]
1788
+ );
1789
+ const handleSwipeNext = useCallback4(() => {
1790
+ armContextFreeSwipeEntry(slideshowSlideIndex + 1);
1791
+ slideNavActions.nextSlide();
1792
+ }, [armContextFreeSwipeEntry, slideshowSlideIndex, slideNavActions]);
1793
+ const handleSwipePrev = useCallback4(() => {
1794
+ armContextFreeSwipeEntry(slideshowSlideIndex - 1);
1795
+ slideNavActions.prevSlide();
1796
+ }, [armContextFreeSwipeEntry, slideshowSlideIndex, slideNavActions]);
1797
+ const swipe = useSlideSwipe({
1798
+ enabled: swipeEnabled,
1799
+ containerRef,
1800
+ canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
1801
+ canGoPrev: slideshowSlideIndex > 0,
1802
+ onNext: handleSwipeNext,
1803
+ onPrev: handleSwipePrev
1804
+ });
1805
+ useEffect4(() => {
1806
+ onPlaybackStateChange?.(playbackState);
1807
+ }, [playbackState, onPlaybackStateChange]);
1808
+ useEffect4(() => {
1809
+ onControlsReady?.({ play, pause, ...playbackActions });
1810
+ }, [play, pause, playbackActions, onControlsReady]);
1811
+ const getBlockTitle = useCallback4((block) => {
1812
+ const docBlock = block;
1813
+ if (isTemplateBlock2(docBlock)) {
1814
+ const props = docBlock;
1815
+ if (typeof props.title === "string") return props.title;
1816
+ if (typeof props.stat === "string") return props.stat;
1817
+ if (typeof props.quote === "string") {
1818
+ const firstLine = props.quote.split("\n")[0];
1819
+ if (firstLine.length <= 30) return firstLine;
1820
+ return firstLine.slice(0, 27) + "...";
1821
+ }
1822
+ if (typeof props.date === "string") return props.date;
1823
+ if (typeof props.fact === "string") return props.fact;
1824
+ }
1825
+ if (block.layers && Array.isArray(block.layers)) {
1826
+ const textLayer = block.layers.find((l) => l.type === "text");
1827
+ if (textLayer?.content?.text) {
1828
+ const firstLine = textLayer.content.text.split("\n")[0];
1829
+ if (firstLine.length <= 30) return firstLine;
1830
+ return firstLine.slice(0, 27) + "...";
1831
+ }
1832
+ }
1833
+ return block.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1834
+ }, []);
1835
+ const slideshowPickerItems = useMemo2(() => {
1836
+ const blockItems = expandedBlocks.map((block, index) => ({
1837
+ id: block.id,
1838
+ label: String(index + 1),
1839
+ summary: getBlockTitle(block)
1840
+ }));
1841
+ if (!slideshowHasCover || !coverBlock) return blockItems;
1842
+ return [
1843
+ {
1844
+ id: "__cover__",
1845
+ label: "Cover",
1846
+ summary: getBlockTitle(coverBlock)
1847
+ },
1848
+ ...blockItems
1849
+ ];
1850
+ }, [coverBlock, expandedBlocks, getBlockTitle, slideshowHasCover]);
1851
+ const blockMarkers = useMemo2(() => {
1852
+ if (!totalDuration || !expandedBlocks.length) return [];
1853
+ let prevSegment = -1;
1854
+ return expandedBlocks.map((block, index) => {
1855
+ const isSectionStart = block.audioSegment !== prevSegment;
1856
+ prevSegment = block.audioSegment;
1857
+ return {
1858
+ block,
1859
+ index,
1860
+ position: block.startTime / totalDuration * 100,
1861
+ title: getBlockTitle(block),
1862
+ isSectionStart
1863
+ };
1864
+ });
1865
+ }, [expandedBlocks, totalDuration, getBlockTitle]);
1866
+ useEffect4(() => {
1867
+ if (blockMarkers.length > 0) {
1868
+ onBlockMarkers?.(blockMarkers);
1869
+ }
1870
+ }, [blockMarkers, onBlockMarkers]);
1871
+ expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
1872
+ const handleKeyboardShortcut = useCallback4(
1873
+ (e, global) => {
1874
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
1875
+ const target = e.target instanceof Element ? e.target : null;
1876
+ const isEditableTarget = !!target?.closest(
1877
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="slider"], [role="spinbutton"], .monaco-editor'
1878
+ );
1879
+ const isOpenInteractionTarget = !!target?.closest(
1880
+ '[role="menu"], [role="dialog"], [aria-modal="true"]'
1881
+ );
1882
+ const isSlideshowToolbarTarget = isSlideshowMode && !!target?.closest(".doc-controls-slideshow") && !target.closest('[role="menu"]');
1883
+ if (isEditableTarget || global && isOpenInteractionTarget || !global && !!target?.closest(
1884
+ 'input, textarea, select, button, a, [contenteditable]:not([contenteditable="false"]), [role="textbox"]'
1885
+ ) && !isSlideshowToolbarTarget) {
1886
+ return;
1887
+ }
1888
+ if (isLinearMode) return;
1889
+ if (e.key === "f" || e.key === "F") {
1890
+ if (!onFullscreenToggle) return;
1891
+ e.preventDefault();
1892
+ onFullscreenToggle();
1893
+ return;
1894
+ }
1895
+ if (isSlideshowMode) {
1896
+ switch (e.key) {
1897
+ case "ArrowRight":
1898
+ case "ArrowDown":
1899
+ case " ":
1900
+ e.preventDefault();
1901
+ slideNavActions.nextSlide();
1902
+ break;
1903
+ case "ArrowLeft":
1904
+ e.preventDefault();
1905
+ slideNavActions.prevSlide();
1906
+ break;
1907
+ case "ArrowUp":
1908
+ e.preventDefault();
1909
+ setIsSlideshowPickerOpen(true);
1910
+ break;
1911
+ case "Home":
1912
+ e.preventDefault();
1913
+ slideNavActions.goToSlide(0);
1914
+ break;
1915
+ case "End":
1916
+ e.preventDefault();
1917
+ slideNavActions.goToSlide(expandedBlocksLenRef.current - 1);
1918
+ break;
1919
+ }
1920
+ } else {
1921
+ switch (e.key) {
1922
+ case " ":
1923
+ e.preventDefault();
1924
+ toggle();
1925
+ break;
1926
+ case "ArrowRight":
1927
+ e.preventDefault();
1928
+ seekTo(Math.min(currentTimeRef.current + 10, totalDurationRef.current));
1929
+ break;
1930
+ case "ArrowLeft":
1931
+ e.preventDefault();
1932
+ seekTo(Math.max(currentTimeRef.current - 10, 0));
1933
+ break;
1934
+ }
1935
+ }
1936
+ },
1937
+ [isSlideshowMode, isLinearMode, toggle, seekTo, slideNavActions, onFullscreenToggle]
1938
+ );
1939
+ const handleKeyDown = useCallback4(
1940
+ (e) => handleKeyboardShortcut(e, false),
1941
+ [handleKeyboardShortcut]
1942
+ );
1943
+ useEffect4(() => {
1944
+ if (!globalKeyboardShortcuts || renderMode || isLinearMode) return;
1945
+ const handleDocumentKeyDown = (event) => {
1946
+ handleKeyboardShortcut(event, true);
1947
+ };
1948
+ document.addEventListener("keydown", handleDocumentKeyDown);
1949
+ return () => document.removeEventListener("keydown", handleDocumentKeyDown);
1950
+ }, [globalKeyboardShortcuts, handleKeyboardShortcut, isLinearMode, renderMode]);
1951
+ if (isLinearMode) {
1952
+ return /* @__PURE__ */ jsx7(
1953
+ "div",
1954
+ {
1955
+ ref: containerRef,
1956
+ "data-player-id": playerId,
1957
+ className: "doc-player doc-player--linear",
1958
+ style: {
1959
+ position: "relative",
1960
+ width: "100%",
1961
+ height: "100%",
1962
+ overflow: "hidden"
1963
+ },
1964
+ children: /* @__PURE__ */ jsx7(
1965
+ LinearDocView,
1966
+ {
1967
+ doc,
1968
+ basePath,
1969
+ viewport: activeViewport,
1970
+ theme,
1971
+ surface,
1972
+ animationsEnabled
1973
+ }
1974
+ )
1975
+ }
1976
+ );
1977
+ }
1978
+ return /* @__PURE__ */ jsxs4(
1979
+ "div",
1980
+ {
1981
+ ref: containerRef,
1982
+ "data-player-id": playerId,
1983
+ tabIndex: renderMode ? -1 : 0,
1984
+ "aria-label": "Document player",
1985
+ onKeyDown: renderMode ? void 0 : handleKeyDown,
1986
+ className: `doc-player${swipeEnabled ? " doc-player--swipe" : ""}${swipe.phase === "dragging" ? " doc-player--grabbing" : ""}`,
1987
+ onClick: handleContainerClick,
1988
+ onPointerDown: swipe.onPointerDown,
1989
+ style: {
1990
+ position: "relative",
1991
+ width: "100%",
1992
+ aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
1993
+ margin: "0 auto",
1994
+ overflow: "hidden",
1995
+ // Swipe uses the grab/grabbing cursor via CSS classes; let vertical page
1996
+ // scroll through on touch while we own horizontal drags.
1997
+ cursor: renderMode || swipeEnabled ? void 0 : "pointer",
1998
+ touchAction: swipeEnabled ? "pan-y" : void 0
1999
+ },
2000
+ children: [
2001
+ /* @__PURE__ */ jsx7("audio", { ref: audioRef, preload: "auto", muted }),
2002
+ /* @__PURE__ */ jsx7(
2003
+ MediaClipLayer,
2004
+ {
2005
+ schedule: mediaSchedule,
2006
+ currentTime,
2007
+ isPlaying,
2008
+ basePath,
2009
+ renderMode,
2010
+ muted
2011
+ }
2012
+ ),
2013
+ /* @__PURE__ */ jsxs4("div", { className: "doc-player__viewport", children: [
2014
+ showCoverBlock && coverBlock && /* @__PURE__ */ jsx7("div", { className: "doc-player__block doc-player__block--cover", children: /* @__PURE__ */ jsx7(
2015
+ BlockRenderer,
2016
+ {
2017
+ block: coverBlock,
2018
+ blockTime: 0,
2019
+ basePath,
2020
+ isEntering: false,
2021
+ viewport: activeViewport,
2022
+ animationsEnabled,
2023
+ theme: effectiveTheme
2024
+ }
2025
+ ) }),
2026
+ animationsEnabled && !showCoverBlock && previousBlock && isExiting && // Keyed by block id so each block is its own DOM subtree: React never
2027
+ // reconciles one block's layers onto another's (templates reuse layer
2028
+ // ids like `title`/`background`), which would otherwise reuse stale
2029
+ // DOM / skip entrance animations mid-transition.
2030
+ /* @__PURE__ */ jsx7("div", { className: "doc-player__block doc-player__block--previous", children: /* @__PURE__ */ jsx7(
2031
+ BlockRenderer,
2032
+ {
2033
+ block: previousBlock,
2034
+ blockTime,
2035
+ basePath,
2036
+ isExiting: true,
2037
+ transition: currentBlock?.transition,
2038
+ viewport: activeViewport,
2039
+ animationsEnabled,
2040
+ theme: effectiveTheme
2041
+ }
2042
+ ) }, previousBlock.id),
2043
+ !showCoverBlock && currentBlock && /* @__PURE__ */ jsx7(
2044
+ "div",
2045
+ {
2046
+ className: `doc-player__block doc-player__block--active${swipe.phase !== "idle" ? ` doc-player__block--${swipe.phase}` : ""}`,
2047
+ style: swipe.phase !== "idle" ? { transform: `translateX(${swipe.offsetPx}px)` } : void 0,
2048
+ children: /* @__PURE__ */ jsx7(
2049
+ BlockRenderer,
2050
+ {
2051
+ block: currentBlock,
2052
+ blockTime,
2053
+ basePath,
2054
+ isEntering: animationsEnabled && isEntering,
2055
+ viewport: activeViewport,
2056
+ isPlaying,
2057
+ animationsEnabled,
2058
+ theme: effectiveTheme
2059
+ }
2060
+ )
2061
+ },
2062
+ currentBlock.id
2063
+ ),
2064
+ hasCaptions && (renderMode ? captionsEnabled : true) && /* @__PURE__ */ jsx7(
2065
+ CaptionOverlay,
2066
+ {
2067
+ captions: doc.captions,
2068
+ currentTime,
2069
+ enabled: captionsEnabled && (renderMode || isPlaying || currentTime > 0),
2070
+ fontSize: 16,
2071
+ captionStyle: activeCaptionStyle,
2072
+ theme: effectiveTheme,
2073
+ viewport: activeViewport
2074
+ }
2075
+ ),
2076
+ isDebugMode && /* @__PURE__ */ jsxs4(
2077
+ "div",
2078
+ {
2079
+ className: "doc-player__debug",
2080
+ style: {
2081
+ position: "absolute",
2082
+ top: "8px",
2083
+ right: "8px",
2084
+ padding: "8px 12px",
2085
+ background: "rgba(0, 0, 0, 0.85)",
2086
+ borderRadius: "6px",
2087
+ color: "#00ff00",
2088
+ fontFamily: "monospace",
2089
+ fontSize: "11px",
2090
+ lineHeight: "1.5",
2091
+ zIndex: 200,
2092
+ maxWidth: "280px",
2093
+ pointerEvents: "none",
2094
+ textAlign: "left"
2095
+ },
2096
+ children: [
2097
+ /* @__PURE__ */ jsx7("div", { style: { color: "#ffcc00", fontWeight: "bold", marginBottom: "4px" }, children: "DEBUG MODE" }),
2098
+ /* @__PURE__ */ jsxs4("div", { children: [
2099
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "template:" }),
2100
+ " ",
2101
+ /* @__PURE__ */ jsx7("span", { style: { color: "#ff6b6b" }, children: currentBlock?.template ?? "raw" })
2102
+ ] }),
2103
+ /* @__PURE__ */ jsxs4("div", { children: [
2104
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "block:" }),
2105
+ " ",
2106
+ currentBlockIndex + 1,
2107
+ "/",
2108
+ expandedBlocks.length,
2109
+ " ",
2110
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2111
+ "(",
2112
+ currentBlock?.id || "none",
2113
+ ")"
2114
+ ] })
2115
+ ] }),
2116
+ /* @__PURE__ */ jsxs4("div", { children: [
2117
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "time:" }),
2118
+ " ",
2119
+ currentTime.toFixed(2),
2120
+ "s /",
2121
+ " ",
2122
+ totalDuration.toFixed(1),
2123
+ "s",
2124
+ " ",
2125
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2126
+ "(progress: ",
2127
+ (docProgress * 100).toFixed(1),
2128
+ "%, scriptDur: ",
2129
+ doc.duration.toFixed(1),
2130
+ ")"
2131
+ ] })
2132
+ ] }),
2133
+ /* @__PURE__ */ jsxs4("div", { children: [
2134
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "blockTime:" }),
2135
+ " ",
2136
+ blockTime.toFixed(2),
2137
+ "s /",
2138
+ " ",
2139
+ (currentBlock?.duration || 0).toFixed(1),
2140
+ "s"
2141
+ ] }),
2142
+ /* @__PURE__ */ jsxs4("div", { children: [
2143
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "segment:" }),
2144
+ " ",
2145
+ currentSegment,
2146
+ "/",
2147
+ doc.audio.segments.length - 1,
2148
+ " ",
2149
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2150
+ "(",
2151
+ doc.audio.segments[currentSegment]?.name || "none",
2152
+ ")"
2153
+ ] })
2154
+ ] }),
2155
+ /* @__PURE__ */ jsxs4("div", { children: [
2156
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "viewport:" }),
2157
+ " ",
2158
+ activeViewport.name || `${activeViewport.width}x${activeViewport.height}`,
2159
+ " ",
2160
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2161
+ "(",
2162
+ orientation,
2163
+ ")"
2164
+ ] })
2165
+ ] }),
2166
+ /* @__PURE__ */ jsxs4("div", { children: [
2167
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "playing:" }),
2168
+ " ",
2169
+ /* @__PURE__ */ jsx7("span", { style: { color: isPlaying ? "#4ade80" : "#f87171" }, children: isPlaying ? "yes" : "no" }),
2170
+ showCoverBlock && /* @__PURE__ */ jsx7("span", { style: { color: "#60a5fa" }, children: " (cover)" })
2171
+ ] }),
2172
+ hasCaptions && (() => {
2173
+ const debugPhrase = getCaptionAtTime2(doc.captions, currentTime);
2174
+ const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
2175
+ return /* @__PURE__ */ jsxs4(Fragment, { children: [
2176
+ /* @__PURE__ */ jsxs4("div", { children: [
2177
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "captions:" }),
2178
+ " ",
2179
+ doc.captions?.phrases.length || 0,
2180
+ " phrases",
2181
+ " ",
2182
+ /* @__PURE__ */ jsxs4("span", { style: { color: captionsEnabled ? "#4ade80" : "#666" }, children: [
2183
+ "(",
2184
+ captionsEnabled ? "on" : "off",
2185
+ ")"
2186
+ ] })
2187
+ ] }),
2188
+ /* @__PURE__ */ jsxs4("div", { children: [
2189
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "cc.enabled:" }),
2190
+ " ",
2191
+ /* @__PURE__ */ jsx7("span", { style: { color: debugEnabled ? "#4ade80" : "#f87171" }, children: String(debugEnabled) }),
2192
+ " ",
2193
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2194
+ "(playing=",
2195
+ String(isPlaying),
2196
+ " t>0=",
2197
+ String(currentTime > 0),
2198
+ ")"
2199
+ ] })
2200
+ ] }),
2201
+ /* @__PURE__ */ jsxs4("div", { children: [
2202
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "cc.phrase:" }),
2203
+ " ",
2204
+ /* @__PURE__ */ jsx7("span", { style: { color: debugPhrase ? "#4ade80" : "#f87171" }, children: debugPhrase ? `"${debugPhrase.text.slice(0, 30)}..."` : "null" })
2205
+ ] }),
2206
+ debugPhrase && /* @__PURE__ */ jsxs4("div", { children: [
2207
+ /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "cc.range:" }),
2208
+ " ",
2209
+ /* @__PURE__ */ jsxs4("span", { style: { color: "#60a5fa" }, children: [
2210
+ debugPhrase.startTime.toFixed(2),
2211
+ "-",
2212
+ debugPhrase.endTime.toFixed(2)
2213
+ ] })
2214
+ ] })
2215
+ ] });
2216
+ })()
2217
+ ]
2218
+ }
2219
+ )
2220
+ ] }),
2221
+ !isAvailable && unavailableMessage && /* @__PURE__ */ jsxs4(
2222
+ "div",
2223
+ {
2224
+ className: "doc-player__unavailable",
2225
+ style: {
2226
+ position: "absolute",
2227
+ top: 0,
2228
+ left: 0,
2229
+ right: 0,
2230
+ bottom: 0,
2231
+ display: "flex",
2232
+ alignItems: "center",
2233
+ justifyContent: "center",
2234
+ flexDirection: "column",
2235
+ gap: "16px",
2236
+ background: "rgba(0, 0, 0, 0.7)",
2237
+ color: "rgba(255, 255, 255, 0.9)",
2238
+ fontSize: "14px",
2239
+ zIndex: 50
2240
+ },
2241
+ children: [
2242
+ /* @__PURE__ */ jsx7("span", { style: { fontSize: "32px" }, children: "\u{1F50A}" }),
2243
+ /* @__PURE__ */ jsx7("span", { children: unavailableMessage })
2244
+ ]
2245
+ }
2246
+ ),
2247
+ !renderMode && !isSlideshowMode && showControls && /* @__PURE__ */ jsx7(
2248
+ DocControlsOverlay,
2249
+ {
2250
+ state: playbackState,
2251
+ actions: playbackActions,
2252
+ blockMarkers,
2253
+ expandedBlocks,
2254
+ getBlockTitle
2255
+ }
2256
+ ),
2257
+ !renderMode && !isSlideshowMode && !showControls && showScrubber && /* @__PURE__ */ jsx7(
2258
+ "div",
2259
+ {
2260
+ className: "doc-player__scrubber",
2261
+ style: {
2262
+ position: "absolute",
2263
+ bottom: 0,
2264
+ left: 0,
2265
+ right: 0,
2266
+ padding: "12px 16px 8px",
2267
+ background: "linear-gradient(transparent, rgba(0,0,0,0.6))",
2268
+ display: "flex",
2269
+ alignItems: "center",
2270
+ zIndex: 100
2271
+ },
2272
+ children: /* @__PURE__ */ jsx7(
2273
+ DocProgressBar,
2274
+ {
2275
+ state: playbackState,
2276
+ actions: playbackActions,
2277
+ blockMarkers,
2278
+ expandedBlocks,
2279
+ getBlockTitle
2280
+ }
2281
+ )
2282
+ }
2283
+ ),
2284
+ !renderMode && isSlideshowMode && showControls && /* @__PURE__ */ jsx7(
2285
+ DocControlsSlideshow,
2286
+ {
2287
+ state: playbackState,
2288
+ slideNav: slideNavActions,
2289
+ slides: slideshowPickerItems,
2290
+ pickerOpen: isSlideshowPickerOpen,
2291
+ onPickerOpenChange: setIsSlideshowPickerOpen
2292
+ }
2293
+ ),
2294
+ !isSlideshowMode && tapFeedback && /* @__PURE__ */ jsx7("div", { className: "doc-player__tap-feedback", children: /* @__PURE__ */ jsx7("svg", { viewBox: "0 0 24 24", fill: "white", width: "48", height: "48", children: tapFeedback === "pause" ? /* @__PURE__ */ jsx7("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" }) : /* @__PURE__ */ jsx7("path", { d: "M8 5v14l11-7z" }) }) }, tapFeedback)
2295
+ ]
2296
+ }
2297
+ );
2298
+ }
2299
+
2300
+ // src/DocControlsBottom.tsx
2301
+ import { jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
2302
+ function DocControlsBottom({
2303
+ state,
2304
+ actions,
2305
+ blockMarkers,
2306
+ expandedBlocks,
2307
+ getBlockTitle
2308
+ }) {
2309
+ return /* @__PURE__ */ jsxs5("div", { className: "doc-controls-bottom", children: [
2310
+ /* @__PURE__ */ jsx8(
2311
+ "button",
2312
+ {
2313
+ className: "bottom-ctrl-btn",
2314
+ onClick: actions.restart,
2315
+ title: "Restart",
2316
+ "aria-label": "Restart from beginning",
2317
+ children: /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx8("path", { d: "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }) })
2318
+ }
2319
+ ),
2320
+ /* @__PURE__ */ jsx8(
2321
+ "button",
2322
+ {
2323
+ className: "bottom-ctrl-btn bottom-play-btn",
2324
+ onClick: actions.toggle,
2325
+ "aria-label": state.isPlaying ? "Pause" : "Play",
2326
+ children: state.isPlaying ? /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx8("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" }) }) : /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx8("path", { d: "M8 5v14l11-7z" }) })
2327
+ }
2328
+ ),
2329
+ /* @__PURE__ */ jsxs5("span", { className: "bottom-time", children: [
2330
+ formatTime(state.currentTime),
2331
+ " / ",
2332
+ formatTime(state.totalDuration)
2333
+ ] }),
2334
+ /* @__PURE__ */ jsx8(
2335
+ DocProgressBar,
2336
+ {
2337
+ state,
2338
+ actions,
2339
+ blockMarkers,
2340
+ expandedBlocks,
2341
+ getBlockTitle
2342
+ }
2343
+ ),
2344
+ /* @__PURE__ */ jsxs5("span", { className: "bottom-segment", children: [
2345
+ state.currentBlockIndex + 1,
2346
+ "/",
2347
+ state.totalBlocks
2348
+ ] }),
2349
+ state.hasCaptions && /* @__PURE__ */ jsx8(
2350
+ "button",
2351
+ {
2352
+ className: `bottom-ctrl-btn ${state.captionMode !== "off" ? "bottom-ctrl-btn--active" : ""}`,
2353
+ onClick: () => actions.cycleCaptionMode(),
2354
+ title: state.captionMode === "off" ? "Captions: Off" : state.captionMode === "standard" ? "Captions: Standard" : "Captions: Social",
2355
+ "aria-label": "Cycle caption style",
2356
+ children: /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx8("path", { d: "M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" }) })
2357
+ }
2358
+ ),
2359
+ actions.toggleFullscreen && /* @__PURE__ */ jsx8(
2360
+ "button",
2361
+ {
2362
+ className: `bottom-ctrl-btn ${state.isFullscreen ? "bottom-ctrl-btn--active" : ""}`,
2363
+ onClick: actions.toggleFullscreen,
2364
+ title: state.isFullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)",
2365
+ "aria-label": state.isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
2366
+ children: state.isFullscreen ? /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx8("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) }) : /* @__PURE__ */ jsx8("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "18", height: "18", children: /* @__PURE__ */ jsx8("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })
2367
+ }
2368
+ )
2369
+ ] });
2370
+ }
2371
+
2372
+ // src/DocControlsSidebar.tsx
2373
+ import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
2374
+ function DocControlsSidebar({ state, actions }) {
2375
+ return /* @__PURE__ */ jsxs6("div", { className: "doc-controls-sidebar", children: [
2376
+ /* @__PURE__ */ jsx9(
2377
+ "button",
2378
+ {
2379
+ className: "sidebar-ctrl-btn",
2380
+ onClick: actions.restart,
2381
+ title: "Restart",
2382
+ "aria-label": "Restart from beginning",
2383
+ children: /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx9("path", { d: "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" }) })
2384
+ }
2385
+ ),
2386
+ /* @__PURE__ */ jsx9(
2387
+ "button",
2388
+ {
2389
+ className: "sidebar-ctrl-btn sidebar-play-btn",
2390
+ onClick: actions.toggle,
2391
+ "aria-label": state.isPlaying ? "Pause" : "Play",
2392
+ children: state.isPlaying ? /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "22", height: "22", children: /* @__PURE__ */ jsx9("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" }) }) : /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "22", height: "22", children: /* @__PURE__ */ jsx9("path", { d: "M8 5v14l11-7z" }) })
2393
+ }
2394
+ ),
2395
+ /* @__PURE__ */ jsxs6("div", { className: "sidebar-time", children: [
2396
+ /* @__PURE__ */ jsx9("div", { children: formatTime(state.currentTime) }),
2397
+ /* @__PURE__ */ jsx9("div", { className: "sidebar-time-total", children: formatTime(state.totalDuration) })
2398
+ ] }),
2399
+ /* @__PURE__ */ jsxs6("div", { className: "sidebar-segment", children: [
2400
+ state.currentBlockIndex + 1,
2401
+ "/",
2402
+ state.totalBlocks
2403
+ ] }),
2404
+ state.hasCaptions && /* @__PURE__ */ jsx9(
2405
+ "button",
2406
+ {
2407
+ className: `sidebar-ctrl-btn ${state.captionMode !== "off" ? "sidebar-ctrl-btn--active" : ""}`,
2408
+ onClick: () => actions.cycleCaptionMode(),
2409
+ title: state.captionMode === "off" ? "Captions: Off" : state.captionMode === "standard" ? "Captions: Standard" : "Captions: Social",
2410
+ "aria-label": "Cycle caption style",
2411
+ children: /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx9("path", { d: "M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" }) })
2412
+ }
2413
+ ),
2414
+ actions.toggleFullscreen && /* @__PURE__ */ jsx9(
2415
+ "button",
2416
+ {
2417
+ className: `sidebar-ctrl-btn ${state.isFullscreen ? "sidebar-ctrl-btn--active" : ""}`,
2418
+ onClick: actions.toggleFullscreen,
2419
+ title: state.isFullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)",
2420
+ "aria-label": state.isFullscreen ? "Exit fullscreen" : "Enter fullscreen",
2421
+ children: state.isFullscreen ? /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx9("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) }) : /* @__PURE__ */ jsx9("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: "20", height: "20", children: /* @__PURE__ */ jsx9("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })
2422
+ }
2423
+ )
2424
+ ] });
2425
+ }
2426
+
2427
+ // src/DocPlayerWithSidebar.tsx
2428
+ import { useRef as useRef6, useState as useState5, useCallback as useCallback5, useEffect as useEffect5 } from "react";
2429
+ import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
2430
+ var DEFAULT_STATE = {
2431
+ isPlaying: false,
2432
+ currentTime: 0,
2433
+ totalDuration: 0,
2434
+ currentBlockIndex: 0,
2435
+ totalBlocks: 0,
2436
+ docProgress: 0,
2437
+ hasCaptions: false,
2438
+ captionsEnabled: false,
2439
+ captionMode: "off",
2440
+ currentSegmentIndex: 0,
2441
+ currentSegmentName: null,
2442
+ currentBlock: null
2443
+ };
2444
+ function DocPlayerWithSidebar({
2445
+ doc,
2446
+ basePath,
2447
+ autoPlay = false,
2448
+ onEnded,
2449
+ onTimeUpdate,
2450
+ audioController,
2451
+ animationsEnabled = true,
2452
+ muted,
2453
+ captionsEnabled,
2454
+ isFullscreen,
2455
+ onFullscreenToggle,
2456
+ forceViewport,
2457
+ onPlayingChange,
2458
+ theme
2459
+ }) {
2460
+ const stateRef = useRef6(DEFAULT_STATE);
2461
+ const actionsRef = useRef6(null);
2462
+ const wasPlayingRef = useRef6(false);
2463
+ const [, setTick] = useState5(0);
2464
+ const handleStateChange = useCallback5(
2465
+ (state) => {
2466
+ stateRef.current = state;
2467
+ if (onPlayingChange && state.isPlaying !== wasPlayingRef.current) {
2468
+ wasPlayingRef.current = state.isPlaying;
2469
+ onPlayingChange(state.isPlaying);
2470
+ }
2471
+ if (!state.isPlaying) setTick((t) => t + 1);
2472
+ },
2473
+ [onPlayingChange]
2474
+ );
2475
+ const handleControlsReady = useCallback5(
2476
+ (controls) => {
2477
+ const isFirst = !actionsRef.current;
2478
+ actionsRef.current = controls;
2479
+ if (isFirst) setTick((t) => t + 1);
2480
+ },
2481
+ []
2482
+ );
2483
+ useEffect5(() => {
2484
+ const interval = setInterval(() => {
2485
+ if (!stateRef.current.isPlaying) return;
2486
+ setTick((t) => t + 1);
2487
+ }, 250);
2488
+ return () => clearInterval(interval);
2489
+ }, []);
2490
+ return /* @__PURE__ */ jsxs7("div", { className: "doc-player-sidebar-layout", children: [
2491
+ /* @__PURE__ */ jsx10("div", { className: "doc-player-sidebar-layout__video", children: /* @__PURE__ */ jsx10(
2492
+ DocPlayer,
2493
+ {
2494
+ doc,
2495
+ theme,
2496
+ basePath,
2497
+ autoPlay,
2498
+ onEnded,
2499
+ onTimeUpdate,
2500
+ audioController,
2501
+ animationsEnabled,
2502
+ muted,
2503
+ captionsEnabled,
2504
+ showControls: isFullscreen,
2505
+ showScrubber: !isFullscreen,
2506
+ onPlaybackStateChange: handleStateChange,
2507
+ onControlsReady: handleControlsReady,
2508
+ isFullscreen,
2509
+ onFullscreenToggle,
2510
+ forceViewport
2511
+ }
2512
+ ) }),
2513
+ actionsRef.current && /* @__PURE__ */ jsx10(DocControlsSidebar, { state: stateRef.current, actions: actionsRef.current })
2514
+ ] });
2515
+ }
2516
+
2517
+ export {
2518
+ MediaClipLayer,
2519
+ SocialCaptionOverlay,
2520
+ CaptionOverlay,
2521
+ formatTime,
2522
+ DocProgressBar,
2523
+ DocControlsOverlay,
2524
+ DocControlsSlideshow,
2525
+ DocPlayer,
2526
+ DocControlsBottom,
2527
+ DocControlsSidebar,
2528
+ DocPlayerWithSidebar
2529
+ };