@bendyline/squisq-react 0.1.3 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/BlockRenderer.d.ts +37 -0
  2. package/dist/BlockRenderer.d.ts.map +1 -0
  3. package/dist/BlockRenderer.js +51 -0
  4. package/dist/BlockRenderer.js.map +1 -0
  5. package/dist/CaptionOverlay.d.ts +24 -0
  6. package/dist/CaptionOverlay.d.ts.map +1 -0
  7. package/dist/CaptionOverlay.js +40 -0
  8. package/dist/CaptionOverlay.js.map +1 -0
  9. package/dist/DocControlsBottom.d.ts +28 -0
  10. package/dist/DocControlsBottom.d.ts.map +1 -0
  11. package/dist/DocControlsBottom.js +23 -0
  12. package/dist/DocControlsBottom.js.map +1 -0
  13. package/dist/DocControlsOverlay.d.ts +28 -0
  14. package/dist/DocControlsOverlay.d.ts.map +1 -0
  15. package/dist/DocControlsOverlay.js +75 -0
  16. package/dist/DocControlsOverlay.js.map +1 -0
  17. package/dist/DocControlsSidebar.d.ts +27 -0
  18. package/dist/DocControlsSidebar.d.ts.map +1 -0
  19. package/dist/DocControlsSidebar.js +6 -0
  20. package/dist/DocControlsSidebar.js.map +1 -0
  21. package/dist/DocControlsSlideshow.d.ts +20 -0
  22. package/dist/DocControlsSlideshow.d.ts.map +1 -0
  23. package/dist/DocControlsSlideshow.js +69 -0
  24. package/dist/DocControlsSlideshow.js.map +1 -0
  25. package/dist/DocPlayer.d.ts +87 -0
  26. package/dist/DocPlayer.d.ts.map +1 -0
  27. package/dist/DocPlayer.js +604 -0
  28. package/dist/DocPlayer.js.map +1 -0
  29. package/dist/DocPlayerWithSidebar.d.ts +41 -0
  30. package/dist/DocPlayerWithSidebar.d.ts.map +1 -0
  31. package/dist/DocPlayerWithSidebar.js +68 -0
  32. package/dist/DocPlayerWithSidebar.js.map +1 -0
  33. package/dist/DocProgressBar.d.ts +31 -0
  34. package/dist/DocProgressBar.d.ts.map +1 -0
  35. package/dist/DocProgressBar.js +122 -0
  36. package/dist/DocProgressBar.js.map +1 -0
  37. package/dist/LinearDocView.d.ts +47 -0
  38. package/dist/LinearDocView.d.ts.map +1 -0
  39. package/dist/LinearDocView.js +184 -0
  40. package/dist/LinearDocView.js.map +1 -0
  41. package/dist/MarkdownRenderer.d.ts +32 -0
  42. package/dist/MarkdownRenderer.d.ts.map +1 -0
  43. package/dist/MarkdownRenderer.js +133 -0
  44. package/dist/MarkdownRenderer.js.map +1 -0
  45. package/dist/__tests__/BlockRenderer.test.d.ts +2 -0
  46. package/dist/__tests__/BlockRenderer.test.d.ts.map +1 -0
  47. package/dist/__tests__/BlockRenderer.test.js +82 -0
  48. package/dist/__tests__/BlockRenderer.test.js.map +1 -0
  49. package/dist/__tests__/DocControlsSlideshow.test.d.ts +2 -0
  50. package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +1 -0
  51. package/dist/__tests__/DocControlsSlideshow.test.js +88 -0
  52. package/dist/__tests__/DocControlsSlideshow.test.js.map +1 -0
  53. package/dist/__tests__/LinearDocView.test.d.ts +2 -0
  54. package/dist/__tests__/LinearDocView.test.d.ts.map +1 -0
  55. package/dist/__tests__/LinearDocView.test.js +160 -0
  56. package/dist/__tests__/LinearDocView.test.js.map +1 -0
  57. package/dist/__tests__/MarkdownRenderer.test.d.ts +2 -0
  58. package/dist/__tests__/MarkdownRenderer.test.d.ts.map +1 -0
  59. package/dist/__tests__/MarkdownRenderer.test.js +208 -0
  60. package/dist/__tests__/MarkdownRenderer.test.js.map +1 -0
  61. package/dist/__tests__/exports.test.d.ts +2 -0
  62. package/dist/__tests__/exports.test.d.ts.map +1 -0
  63. package/dist/__tests__/exports.test.js +48 -0
  64. package/dist/__tests__/exports.test.js.map +1 -0
  65. package/dist/hooks/AudioProvider.d.ts +71 -0
  66. package/dist/hooks/AudioProvider.d.ts.map +1 -0
  67. package/dist/hooks/AudioProvider.js +52 -0
  68. package/dist/hooks/AudioProvider.js.map +1 -0
  69. package/dist/hooks/MediaContext.d.ts +35 -0
  70. package/dist/hooks/MediaContext.d.ts.map +1 -0
  71. package/dist/hooks/MediaContext.js +66 -0
  72. package/dist/hooks/MediaContext.js.map +1 -0
  73. package/dist/hooks/index.d.ts +6 -0
  74. package/dist/hooks/index.d.ts.map +1 -0
  75. package/dist/hooks/index.js +5 -0
  76. package/dist/hooks/index.js.map +1 -0
  77. package/dist/hooks/useAudioSync.d.ts +18 -0
  78. package/dist/hooks/useAudioSync.d.ts.map +1 -0
  79. package/dist/hooks/useAudioSync.js +338 -0
  80. package/dist/hooks/useAudioSync.js.map +1 -0
  81. package/dist/hooks/useDocPlayback.d.ts +47 -0
  82. package/dist/hooks/useDocPlayback.d.ts.map +1 -0
  83. package/dist/hooks/useDocPlayback.js +176 -0
  84. package/dist/hooks/useDocPlayback.js.map +1 -0
  85. package/dist/hooks/useViewportOrientation.d.ts +31 -0
  86. package/dist/hooks/useViewportOrientation.d.ts.map +1 -0
  87. package/dist/hooks/useViewportOrientation.js +92 -0
  88. package/dist/hooks/useViewportOrientation.js.map +1 -0
  89. package/dist/index.d.ts +25 -563
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +26 -3179
  92. package/dist/index.js.map +1 -1
  93. package/dist/layers/ImageLayer.d.ts +23 -0
  94. package/dist/layers/ImageLayer.d.ts.map +1 -0
  95. package/dist/layers/ImageLayer.js +104 -0
  96. package/dist/layers/ImageLayer.js.map +1 -0
  97. package/dist/layers/MapLayer.d.ts +29 -0
  98. package/dist/layers/MapLayer.d.ts.map +1 -0
  99. package/dist/layers/MapLayer.js +97 -0
  100. package/dist/layers/MapLayer.js.map +1 -0
  101. package/dist/layers/ShapeLayer.d.ts +20 -0
  102. package/dist/layers/ShapeLayer.d.ts.map +1 -0
  103. package/dist/layers/ShapeLayer.js +35 -0
  104. package/dist/layers/ShapeLayer.js.map +1 -0
  105. package/dist/layers/TextLayer.d.ts +23 -0
  106. package/dist/layers/TextLayer.d.ts.map +1 -0
  107. package/dist/layers/TextLayer.js +126 -0
  108. package/dist/layers/TextLayer.js.map +1 -0
  109. package/dist/layers/VideoLayer.d.ts +40 -0
  110. package/dist/layers/VideoLayer.d.ts.map +1 -0
  111. package/dist/layers/VideoLayer.js +105 -0
  112. package/dist/layers/VideoLayer.js.map +1 -0
  113. package/dist/layers/index.d.ts +6 -0
  114. package/dist/layers/index.d.ts.map +1 -0
  115. package/dist/layers/index.js +6 -0
  116. package/dist/layers/index.js.map +1 -0
  117. package/dist/squisq-player.global.js +1 -1
  118. package/dist/standalone-entry.d.ts +61 -0
  119. package/dist/standalone-entry.d.ts.map +1 -0
  120. package/dist/standalone-entry.js +169 -0
  121. package/dist/standalone-entry.js.map +1 -0
  122. package/dist/standalone-source.js +1 -1
  123. package/dist/types.d.ts +131 -0
  124. package/dist/types.d.ts.map +1 -0
  125. package/dist/types.js +21 -0
  126. package/dist/types.js.map +1 -0
  127. package/dist/utils/animationUtils.d.ts +8 -0
  128. package/dist/utils/animationUtils.d.ts.map +1 -0
  129. package/dist/utils/animationUtils.js +8 -0
  130. package/dist/utils/animationUtils.js.map +1 -0
  131. package/dist/utils/layerUtils.d.ts +17 -0
  132. package/dist/utils/layerUtils.d.ts.map +1 -0
  133. package/dist/utils/layerUtils.js +37 -0
  134. package/dist/utils/layerUtils.js.map +1 -0
  135. package/dist/utils/mapTileUtils.d.ts +105 -0
  136. package/dist/utils/mapTileUtils.d.ts.map +1 -0
  137. package/dist/utils/mapTileUtils.js +268 -0
  138. package/dist/utils/mapTileUtils.js.map +1 -0
  139. package/package.json +3 -4
@@ -0,0 +1,604 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * DocPlayer Component
4
+ *
5
+ * Main component for playing visual stories. Combines audio playback
6
+ * with synchronized SVG block animations. Supports both interactive
7
+ * browser playback and headless rendering for video export.
8
+ *
9
+ * Features:
10
+ * - Audio synchronization with multiple MP3 segments
11
+ * - Block transitions (fade, dissolve, slide)
12
+ * - Playback controls (play/pause, seek, next/prev)
13
+ * - Progress display
14
+ * - Render mode for video capture (via window.seekTo)
15
+ * - Pluggable audio provider for different environments (browser, EFB)
16
+ * - Multiple control layouts: overlay (default), sidebar, bottom
17
+ *
18
+ * Related Files:
19
+ * - DocControlsOverlay.tsx -- Default overlay controls
20
+ * - DocProgressBar.tsx -- Extracted progress bar
21
+ * - DocControlsSidebar.tsx -- Vertical sidebar controls
22
+ * - DocPlayerWithSidebar.tsx -- Wrapper for sidebar layout
23
+ * - types.ts -- Shared control types
24
+ */
25
+ import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
+ import { isTemplateBlock, getCaptionAtTime } from '@bendyline/squisq/schemas';
27
+ import { BlockRenderer } from './BlockRenderer';
28
+ import { CaptionOverlay } from './CaptionOverlay';
29
+ import { useAudioSync } from './hooks/useAudioSync';
30
+ import { useDocPlayback } from './hooks/useDocPlayback';
31
+ import { useViewportOrientation } from './hooks/useViewportOrientation';
32
+ import { expandCoverBlock, createTemplateContext, DEFAULT_THEME, VIEWPORT_PRESETS, } from '@bendyline/squisq/doc';
33
+ import { DocControlsOverlay } from './DocControlsOverlay';
34
+ import { DocControlsSlideshow } from './DocControlsSlideshow';
35
+ import { DocProgressBar } from './DocProgressBar';
36
+ import { LinearDocView } from './LinearDocView';
37
+ const SMALL_WORDS = new Set([
38
+ 'a',
39
+ 'an',
40
+ 'the',
41
+ 'and',
42
+ 'but',
43
+ 'or',
44
+ 'for',
45
+ 'nor',
46
+ 'on',
47
+ 'at',
48
+ 'to',
49
+ 'in',
50
+ 'of',
51
+ 'by',
52
+ 'is',
53
+ ]);
54
+ /**
55
+ * Build a map of audio segment index -> display-friendly title.
56
+ * Uses sectionHeader blocks to find real titles, with fallbacks
57
+ * for "intro" and slug-based names.
58
+ */
59
+ function buildSegmentTitleMap(script) {
60
+ const map = new Map();
61
+ // Scan blocks for sectionHeader templates which carry the real title
62
+ for (const block of script.blocks) {
63
+ if (isTemplateBlock(block) && block.template === 'sectionHeader' && 'title' in block) {
64
+ const segIdx = block.audioSegment;
65
+ if (!map.has(segIdx)) {
66
+ map.set(segIdx, block.title);
67
+ }
68
+ }
69
+ }
70
+ // Fill in any segments that weren't covered by sectionHeader blocks
71
+ for (let i = 0; i < script.audio.segments.length; i++) {
72
+ if (!map.has(i)) {
73
+ const name = script.audio.segments[i].name;
74
+ if (name === 'intro' || name.includes('intro')) {
75
+ map.set(i, 'Introduction');
76
+ }
77
+ else if (name === 'flight-context' || name.includes('flight-context')) {
78
+ map.set(i, 'Flight Context');
79
+ }
80
+ else {
81
+ // Title-case the slug: "hands-on-history" -> "Hands on History"
82
+ const words = name.split('-');
83
+ const titled = words
84
+ .map((w, idx) => idx === 0 || !SMALL_WORDS.has(w) ? w.charAt(0).toUpperCase() + w.slice(1) : w)
85
+ .join(' ');
86
+ map.set(i, titled);
87
+ }
88
+ }
89
+ }
90
+ return map;
91
+ }
92
+ export function DocPlayer({ script, basePath, renderMode = false, autoPlay = false, onEnded, onTimeUpdate, audioProvider: externalAudioProvider, showControls = true, showScrubber = false, muted = false, captionsEnabled: captionsEnabledProp, onCaptionsToggle, onPlaybackStateChange, onControlsReady, isFullscreen = false, onFullscreenToggle, onBlockMarkers, forceViewport, displayMode = 'video', theme, }) {
93
+ const isSlideshowMode = displayMode === 'slideshow';
94
+ const isLinearMode = displayMode === 'linear';
95
+ const audioRef = useRef(null);
96
+ const containerRef = useRef(null);
97
+ // Tap-to-toggle play/pause feedback animation
98
+ const [tapFeedback, setTapFeedback] = useState(null);
99
+ const tapFeedbackTimer = useRef();
100
+ // Detect viewport orientation for responsive docs
101
+ // forceViewport takes precedence (used by render mode with explicit viewport and constrained panels)
102
+ // In render mode without forceViewport, default to landscape for backward compatibility
103
+ const { viewport, orientation } = useViewportOrientation();
104
+ const activeViewport = forceViewport || (renderMode ? VIEWPORT_PRESETS.landscape : viewport);
105
+ // Check for debug mode via URL parameter
106
+ const isDebugMode = useMemo(() => {
107
+ if (typeof window === 'undefined')
108
+ return false;
109
+ const params = new URLSearchParams(window.location.search);
110
+ return params.get('debug') === 'true';
111
+ }, []);
112
+ // Use internal HTML5 audio sync if no external provider is given
113
+ const internalAudio = useAudioSync(audioRef, script.audio, basePath);
114
+ // Use external provider if provided, otherwise fall back to internal
115
+ const audio = externalAudioProvider || internalAudio;
116
+ // Destructure for convenience
117
+ const { currentTime, isPlaying, currentSegment, totalDuration, isEnded, isReady: isAudioReady, isAvailable, unavailableMessage, play, pause, toggle, seekTo, skipToSegment: _skipToSegment, restart, } = audio;
118
+ // Refs for frequently-changing values used in the keyboard handler,
119
+ // so the handler callback doesn't need to be recreated every frame.
120
+ const currentTimeRef = useRef(currentTime);
121
+ currentTimeRef.current = currentTime;
122
+ const totalDurationRef = useRef(totalDuration);
123
+ totalDurationRef.current = totalDuration;
124
+ const expandedBlocksLenRef = useRef(0);
125
+ // Tap the player surface to toggle play/pause (disabled in slideshow and linear mode)
126
+ const handleContainerClick = useCallback((e) => {
127
+ if (renderMode || isSlideshowMode || isLinearMode)
128
+ return;
129
+ const target = e.target;
130
+ // Don't toggle if user clicked a control element
131
+ if (target.closest('button, a, input, .doc-player__controls, .doc-player__scrubber, .doc-controls-sidebar, .doc-controls-slideshow'))
132
+ return;
133
+ toggle();
134
+ // Show visual feedback (show the state we're transitioning TO)
135
+ const nextState = isPlaying ? 'play' : 'pause';
136
+ setTapFeedback(nextState);
137
+ clearTimeout(tapFeedbackTimer.current);
138
+ tapFeedbackTimer.current = setTimeout(() => setTapFeedback(null), 600);
139
+ }, [renderMode, toggle, isPlaying, isSlideshowMode, isLinearMode]);
140
+ // Doc playback hook - pass viewport for responsive template expansion
141
+ const { currentBlock, currentBlockIndex, previousBlock, isEntering, isExiting, blockTime, blockProgress: _blockProgress, docProgress, nextBlock: _nextBlock, prevBlock: _prevBlock, blocks: expandedBlocks, } = useDocPlayback(script, currentTime, activeViewport, renderMode, theme);
142
+ // Expand cover block (startBlock) if present - uses active viewport
143
+ const coverBlock = useMemo(() => {
144
+ const startBlockConfig = script.startBlock;
145
+ if (!startBlockConfig)
146
+ return null;
147
+ const context = createTemplateContext(theme ?? DEFAULT_THEME, 0, 1, activeViewport);
148
+ const layers = expandCoverBlock(startBlockConfig, context);
149
+ return {
150
+ id: 'cover-block',
151
+ startTime: -1, // Not part of timeline
152
+ duration: 0, // Static
153
+ audioSegment: -1,
154
+ layers,
155
+ };
156
+ }, [script.startBlock, activeViewport, theme]);
157
+ // Render-mode cover block control: allows Playwright to force-show the cover block
158
+ const [coverForced, setCoverForced] = useState(false);
159
+ // Grace period: keep cover block visible for 3s after first play press
160
+ const [coverGraceActive, setCoverGraceActive] = useState(false);
161
+ const coverGraceTimer = useRef();
162
+ const coverWasShowing = useRef(false);
163
+ // Track when cover is showing at rest (before play)
164
+ const atRest = !!(coverBlock && !isPlaying && currentTime === 0 && !renderMode && !autoPlay);
165
+ if (atRest)
166
+ coverWasShowing.current = true;
167
+ useEffect(() => {
168
+ if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
169
+ coverWasShowing.current = false;
170
+ setCoverGraceActive(true);
171
+ coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
172
+ return () => clearTimeout(coverGraceTimer.current);
173
+ }
174
+ }, [isPlaying, coverBlock, renderMode]);
175
+ // Determine if we should show the cover block
176
+ // Show cover when: has cover block, not playing, at time 0, not in render mode
177
+ // OR during the grace period after first play, OR when coverForced (render mode)
178
+ // Cover block is suppressed in slideshow and linear mode — start directly on content
179
+ const showCoverBlock = !isSlideshowMode &&
180
+ !isLinearMode &&
181
+ coverBlock &&
182
+ (coverForced ||
183
+ coverGraceActive ||
184
+ (!isPlaying && currentTime === 0 && !renderMode && !autoPlay));
185
+ // Auto-play if enabled (wait for audio to be ready)
186
+ // Use a ref to track if we've already auto-played to avoid repeating on every render
187
+ const hasAutoPlayed = useRef(false);
188
+ useEffect(() => {
189
+ if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
190
+ hasAutoPlayed.current = true;
191
+ play();
192
+ }
193
+ }, [isAudioReady, autoPlay, play]);
194
+ // Callback for time updates
195
+ useEffect(() => {
196
+ onTimeUpdate?.(currentTime);
197
+ }, [currentTime, onTimeUpdate]);
198
+ // Callback for ended
199
+ useEffect(() => {
200
+ if (isEnded) {
201
+ onEnded?.();
202
+ }
203
+ }, [isEnded, onEnded]);
204
+ // Expose seekTo globally for render mode (Playwright) and debug mode (testing)
205
+ useEffect(() => {
206
+ if ((renderMode || isDebugMode) && typeof window !== 'undefined') {
207
+ const w = window;
208
+ w.seekTo = (time) => {
209
+ seekTo(time);
210
+ // After React renders the correct block, advance CSS animations
211
+ // (Ken Burns, transitions) to match the doc timeline position.
212
+ // Without this, animations restart from zero on each seekTo because
213
+ // they run on the browser's real clock, not doc time.
214
+ return new Promise((resolve) => {
215
+ requestAnimationFrame(() => {
216
+ // Find the current block's start time
217
+ let blockStartTime = 0;
218
+ for (let i = expandedBlocks.length - 1; i >= 0; i--) {
219
+ if (time >= expandedBlocks[i].startTime) {
220
+ blockStartTime = expandedBlocks[i].startTime;
221
+ break;
222
+ }
223
+ }
224
+ const elapsedMs = (time - blockStartTime) * 1000;
225
+ // Set all CSS animations to the correct timeline position
226
+ document.getAnimations().forEach((anim) => {
227
+ const target = anim.effect?.target;
228
+ if (!target)
229
+ return;
230
+ // Animations on the active block: use current block elapsed time
231
+ if (target.closest('.doc-player__block--active')) {
232
+ anim.currentTime = Math.max(0, elapsedMs);
233
+ }
234
+ // Animations on the exiting block (during crossfade): use current
235
+ // block elapsed for transition animations, keep Ken Burns at their
236
+ // natural position based on when that block started
237
+ // eslint-disable-next-line sonarjs/no-duplicated-branches
238
+ else if (target.closest('.doc-player__block--previous')) {
239
+ anim.currentTime = Math.max(0, elapsedMs);
240
+ }
241
+ });
242
+ // Seek <video> elements in the active block to the correct clip position.
243
+ // Each <video> carries data-clip-start/data-clip-end attributes set by
244
+ // VideoLayer.tsx; we calculate targetTime = clipStart + blockElapsed.
245
+ const blockElapsed = time - blockStartTime;
246
+ const videoSeekPromises = [];
247
+ const activeBlockEl = document.querySelector('.doc-player__block--active');
248
+ if (activeBlockEl) {
249
+ const videos = activeBlockEl.querySelectorAll('video[data-clip-start]');
250
+ videos.forEach((el) => {
251
+ const video = el;
252
+ const clipStart = parseFloat(video.dataset.clipStart || '0');
253
+ const clipEnd = parseFloat(video.dataset.clipEnd || '0');
254
+ const targetTime = Math.min(clipStart + Math.max(0, blockElapsed), clipEnd);
255
+ video.pause();
256
+ video.currentTime = targetTime;
257
+ videoSeekPromises.push(new Promise((r) => {
258
+ if (Math.abs(video.currentTime - targetTime) < 0.1) {
259
+ r();
260
+ }
261
+ else {
262
+ video.addEventListener('seeked', () => r(), { once: true });
263
+ setTimeout(r, 200); // Fallback if seeked never fires
264
+ }
265
+ }));
266
+ });
267
+ }
268
+ // Wait for video seeks + one more frame for the browser to render
269
+ Promise.all(videoSeekPromises).then(() => {
270
+ requestAnimationFrame(() => resolve());
271
+ });
272
+ });
273
+ });
274
+ };
275
+ w.getDuration = () => totalDuration;
276
+ // Expose block metadata for testing -- allows tests to find specific templates
277
+ w.getBlocks = () => expandedBlocks.map((s) => ({
278
+ id: s.id,
279
+ template: s.template ?? 'raw',
280
+ startTime: s.startTime,
281
+ duration: s.duration,
282
+ }));
283
+ // Audio segment info for video production -- returns the actual files in composition order
284
+ w.getAudioSegments = () => script.audio.segments.map((seg) => ({
285
+ src: seg.src,
286
+ name: seg.name,
287
+ duration: seg.duration,
288
+ startTime: seg.startTime,
289
+ }));
290
+ // Caption phrases for SRT/subtitle export
291
+ w.getCaptions = () => script.captions?.phrases?.map((p) => ({
292
+ text: p.text,
293
+ startTime: p.startTime,
294
+ endTime: p.endTime,
295
+ })) || [];
296
+ // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
297
+ w.getChapters = () => {
298
+ const titleMap = buildSegmentTitleMap(script);
299
+ return script.audio.segments.map((seg, i) => ({
300
+ title: titleMap.get(i) || seg.name,
301
+ startTime: seg.startTime,
302
+ duration: seg.duration,
303
+ }));
304
+ };
305
+ // Cover block control for video pre-roll -- force-show or hide the cover block
306
+ w.showCover = () => {
307
+ setCoverForced(true);
308
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
309
+ };
310
+ w.hideCover = () => {
311
+ setCoverForced(false);
312
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()));
313
+ };
314
+ w.hasCoverBlock = () => !!coverBlock;
315
+ }
316
+ return () => {
317
+ if (typeof window !== 'undefined') {
318
+ const w = window;
319
+ delete w.seekTo;
320
+ delete w.getDuration;
321
+ delete w.getBlocks;
322
+ delete w.getAudioSegments;
323
+ delete w.getCaptions;
324
+ delete w.getChapters;
325
+ delete w.showCover;
326
+ delete w.hideCover;
327
+ delete w.hasCoverBlock;
328
+ }
329
+ };
330
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- script is a stable prop; re-registering on every script change is unnecessary
331
+ }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
332
+ // Captions state: use prop if provided, otherwise default to true
333
+ const captionsEnabled = captionsEnabledProp !== undefined ? captionsEnabledProp : true;
334
+ const setCaptionsEnabled = useCallback((enabled) => {
335
+ onCaptionsToggle?.(enabled);
336
+ }, [onCaptionsToggle]);
337
+ const hasCaptions = script.captions && script.captions.phrases.length > 0;
338
+ // Map segment indices to human-readable titles (from sectionHeader blocks)
339
+ const segmentTitleMap = useMemo(() => buildSegmentTitleMap(script), [script]);
340
+ // Build shared playback state for extracted controls
341
+ const playbackState = useMemo(() => ({
342
+ isPlaying,
343
+ currentTime,
344
+ totalDuration,
345
+ currentBlockIndex,
346
+ totalBlocks: expandedBlocks.length,
347
+ docProgress,
348
+ hasCaptions: !!hasCaptions,
349
+ captionsEnabled,
350
+ isFullscreen,
351
+ currentSegmentIndex: currentSegment,
352
+ currentSegmentName: segmentTitleMap.get(currentSegment) ?? script.audio.segments[currentSegment]?.name ?? null,
353
+ currentBlock: currentBlock ?? null,
354
+ }),
355
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- script.audio.segments is stable within a given script
356
+ [
357
+ isPlaying,
358
+ currentTime,
359
+ totalDuration,
360
+ currentBlockIndex,
361
+ expandedBlocks.length,
362
+ docProgress,
363
+ hasCaptions,
364
+ captionsEnabled,
365
+ isFullscreen,
366
+ currentSegment,
367
+ segmentTitleMap,
368
+ currentBlock,
369
+ ]);
370
+ // Build shared playback actions for extracted controls
371
+ const playbackActions = useMemo(() => ({
372
+ toggle,
373
+ restart,
374
+ seekTo,
375
+ setCaptionsEnabled,
376
+ toggleFullscreen: onFullscreenToggle,
377
+ }), [toggle, restart, seekTo, setCaptionsEnabled, onFullscreenToggle]);
378
+ // Slide navigation actions for slideshow mode
379
+ // These seek to the target block's startTime and keep the player paused.
380
+ const slideNavActions = useMemo(() => ({
381
+ nextSlide: () => {
382
+ if (currentBlockIndex < expandedBlocks.length - 1) {
383
+ const target = expandedBlocks[currentBlockIndex + 1];
384
+ if (target) {
385
+ seekTo(target.startTime);
386
+ pause();
387
+ }
388
+ }
389
+ },
390
+ prevSlide: () => {
391
+ if (currentBlockIndex > 0) {
392
+ const target = expandedBlocks[currentBlockIndex - 1];
393
+ if (target) {
394
+ seekTo(target.startTime);
395
+ pause();
396
+ }
397
+ }
398
+ },
399
+ goToSlide: (index) => {
400
+ if (index >= 0 && index < expandedBlocks.length) {
401
+ const target = expandedBlocks[index];
402
+ if (target) {
403
+ seekTo(target.startTime);
404
+ pause();
405
+ }
406
+ }
407
+ },
408
+ }), [currentBlockIndex, expandedBlocks, seekTo, pause]);
409
+ // Callback for playback state changes (for external controls)
410
+ useEffect(() => {
411
+ onPlaybackStateChange?.(playbackState);
412
+ }, [playbackState, onPlaybackStateChange]);
413
+ // Callback when controls are ready (for external controls)
414
+ // Fires every time playbackActions change so the external sidebar always holds
415
+ // fresh function references (toggle closes over isPlaying, so it changes often).
416
+ useEffect(() => {
417
+ onControlsReady?.({ play, pause, ...playbackActions });
418
+ }, [play, pause, playbackActions, onControlsReady]);
419
+ // Extract display title from a block (handles both template and expanded blocks)
420
+ const getBlockTitle = useCallback((block) => {
421
+ // For template blocks, extract title from template-specific properties first
422
+ const docBlock = block;
423
+ if (isTemplateBlock(docBlock)) {
424
+ const props = docBlock;
425
+ if (typeof props.title === 'string')
426
+ return props.title;
427
+ if (typeof props.stat === 'string')
428
+ return props.stat;
429
+ if (typeof props.quote === 'string') {
430
+ const firstLine = props.quote.split('\n')[0];
431
+ if (firstLine.length <= 30)
432
+ return firstLine;
433
+ return firstLine.slice(0, 27) + '...';
434
+ }
435
+ if (typeof props.date === 'string')
436
+ return props.date;
437
+ if (typeof props.fact === 'string')
438
+ return props.fact;
439
+ }
440
+ // For expanded blocks with layers, try to find text content
441
+ if (block.layers && Array.isArray(block.layers)) {
442
+ const textLayer = block.layers.find((l) => l.type === 'text');
443
+ if (textLayer?.content?.text) {
444
+ // Get first line of text, truncate if too long
445
+ const firstLine = textLayer.content.text.split('\n')[0];
446
+ if (firstLine.length <= 30)
447
+ return firstLine;
448
+ return firstLine.slice(0, 27) + '...';
449
+ }
450
+ }
451
+ // Fallback to formatted id
452
+ return block.id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
453
+ }, []);
454
+ // Compute block markers for progress bar (using expanded blocks)
455
+ const blockMarkers = useMemo(() => {
456
+ if (!totalDuration || !expandedBlocks.length)
457
+ return [];
458
+ let prevSegment = -1;
459
+ return expandedBlocks.map((block, index) => {
460
+ const isSectionStart = block.audioSegment !== prevSegment;
461
+ prevSegment = block.audioSegment;
462
+ return {
463
+ block,
464
+ index,
465
+ position: (block.startTime / totalDuration) * 100,
466
+ title: getBlockTitle(block),
467
+ isSectionStart,
468
+ };
469
+ });
470
+ }, [expandedBlocks, totalDuration, getBlockTitle]);
471
+ // Notify parent when block markers are computed
472
+ useEffect(() => {
473
+ if (blockMarkers.length > 0) {
474
+ onBlockMarkers?.(blockMarkers);
475
+ }
476
+ }, [blockMarkers, onBlockMarkers]);
477
+ // Keep expandedBlocks length in a ref so keyboard handler stays stable
478
+ expandedBlocksLenRef.current = expandedBlocks.length;
479
+ // Handle keyboard controls — uses refs for frequently-changing values
480
+ // (currentTime, totalDuration, expandedBlocks.length) to avoid
481
+ // re-registering the event listener on every animation frame.
482
+ const handleKeyDown = useCallback((e) => {
483
+ // Don't capture keyboard events when focus is on an input/textarea
484
+ const activeEl = document.activeElement;
485
+ if (activeEl &&
486
+ (activeEl.tagName === 'INPUT' ||
487
+ activeEl.tagName === 'TEXTAREA' ||
488
+ activeEl.tagName === 'SELECT')) {
489
+ return;
490
+ }
491
+ // Linear mode: no keyboard shortcuts (native scrolling handles it)
492
+ if (isLinearMode)
493
+ return;
494
+ if (isSlideshowMode) {
495
+ // Slideshow mode: arrow keys navigate slides
496
+ switch (e.key) {
497
+ case 'ArrowRight':
498
+ case 'ArrowDown':
499
+ case ' ':
500
+ e.preventDefault();
501
+ slideNavActions.nextSlide();
502
+ break;
503
+ case 'ArrowLeft':
504
+ case 'ArrowUp':
505
+ e.preventDefault();
506
+ slideNavActions.prevSlide();
507
+ break;
508
+ case 'Home':
509
+ e.preventDefault();
510
+ slideNavActions.goToSlide(0);
511
+ break;
512
+ case 'End':
513
+ e.preventDefault();
514
+ slideNavActions.goToSlide(expandedBlocksLenRef.current - 1);
515
+ break;
516
+ }
517
+ }
518
+ else {
519
+ // Video mode: standard playback controls
520
+ switch (e.key) {
521
+ case ' ':
522
+ e.preventDefault();
523
+ toggle();
524
+ break;
525
+ case 'ArrowRight':
526
+ seekTo(Math.min(currentTimeRef.current + 10, totalDurationRef.current));
527
+ break;
528
+ case 'ArrowLeft':
529
+ seekTo(Math.max(currentTimeRef.current - 10, 0));
530
+ break;
531
+ }
532
+ }
533
+ }, [isSlideshowMode, isLinearMode, toggle, seekTo, slideNavActions]);
534
+ useEffect(() => {
535
+ if (renderMode)
536
+ return; // No keyboard in render mode
537
+ window.addEventListener('keydown', handleKeyDown);
538
+ return () => window.removeEventListener('keydown', handleKeyDown);
539
+ }, [handleKeyDown, renderMode]);
540
+ // ── Linear mode: render as scrollable document ──────────────────
541
+ if (isLinearMode) {
542
+ return (_jsx("div", { ref: containerRef, className: "doc-player doc-player--linear", style: {
543
+ position: 'relative',
544
+ width: '100%',
545
+ height: '100%',
546
+ overflow: 'hidden',
547
+ }, children: _jsx(LinearDocView, { doc: script, basePath: basePath, viewport: activeViewport }) }));
548
+ }
549
+ return (_jsxs("div", { ref: containerRef, className: "doc-player", onClick: handleContainerClick, style: {
550
+ position: 'relative',
551
+ width: '100%',
552
+ aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
553
+ margin: '0 auto',
554
+ overflow: 'hidden',
555
+ cursor: renderMode ? undefined : 'pointer',
556
+ }, children: [_jsx("audio", { ref: audioRef, preload: "auto", muted: muted }), _jsxs("div", { className: "doc-player__viewport", children: [showCoverBlock && coverBlock && (_jsx("div", { className: "doc-player__block doc-player__block--cover", children: _jsx(BlockRenderer, { block: coverBlock, blockTime: 0, basePath: basePath, isEntering: false, viewport: activeViewport }) })), !showCoverBlock && previousBlock && isExiting && (_jsx("div", { className: "doc-player__block doc-player__block--previous", children: _jsx(BlockRenderer, { block: previousBlock, blockTime: blockTime, basePath: basePath, isExiting: true, viewport: activeViewport }) })), !showCoverBlock && currentBlock && (_jsx("div", { className: "doc-player__block doc-player__block--active", children: _jsx(BlockRenderer, { block: currentBlock, blockTime: blockTime, basePath: basePath, isEntering: isEntering, viewport: activeViewport, isPlaying: isPlaying }) })), hasCaptions && !renderMode && (_jsx(CaptionOverlay, { captions: script.captions, currentTime: currentTime, enabled: captionsEnabled && (isPlaying || currentTime > 0), fontSize: 16 })), isDebugMode && (_jsxs("div", { className: "doc-player__debug", style: {
557
+ position: 'absolute',
558
+ top: '8px',
559
+ right: '8px',
560
+ padding: '8px 12px',
561
+ background: 'rgba(0, 0, 0, 0.85)',
562
+ borderRadius: '6px',
563
+ color: '#00ff00',
564
+ fontFamily: 'monospace',
565
+ fontSize: '11px',
566
+ lineHeight: '1.5',
567
+ zIndex: 200,
568
+ maxWidth: '280px',
569
+ pointerEvents: 'none',
570
+ textAlign: 'left',
571
+ }, children: [_jsx("div", { style: { color: '#ffcc00', fontWeight: 'bold', marginBottom: '4px' }, children: "DEBUG MODE" }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "template:" }), ' ', _jsx("span", { style: { color: '#ff6b6b' }, children: currentBlock?.template ?? 'raw' })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "block:" }), " ", currentBlockIndex + 1, "/", expandedBlocks.length, ' ', _jsxs("span", { style: { color: '#666' }, children: ["(", currentBlock?.id || 'none', ")"] })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "time:" }), " ", currentTime.toFixed(2), "s /", ' ', totalDuration.toFixed(1), "s"] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "blockTime:" }), " ", blockTime.toFixed(2), "s /", ' ', (currentBlock?.duration || 0).toFixed(1), "s"] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "segment:" }), " ", currentSegment, "/", script.audio.segments.length - 1, ' ', _jsxs("span", { style: { color: '#666' }, children: ["(", script.audio.segments[currentSegment]?.name || 'none', ")"] })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "viewport:" }), ' ', activeViewport.name || `${activeViewport.width}x${activeViewport.height}`, ' ', _jsxs("span", { style: { color: '#666' }, children: ["(", orientation, ")"] })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "playing:" }), ' ', _jsx("span", { style: { color: isPlaying ? '#4ade80' : '#f87171' }, children: isPlaying ? 'yes' : 'no' }), showCoverBlock && _jsx("span", { style: { color: '#60a5fa' }, children: " (cover)" })] }), hasCaptions &&
572
+ (() => {
573
+ const debugPhrase = getCaptionAtTime(script.captions, currentTime);
574
+ const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
575
+ return (_jsxs(Fragment, { children: [_jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "captions:" }), ' ', script.captions?.phrases.length || 0, " phrases", ' ', _jsxs("span", { style: { color: captionsEnabled ? '#4ade80' : '#666' }, children: ["(", captionsEnabled ? 'on' : 'off', ")"] })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "cc.enabled:" }), ' ', _jsx("span", { style: { color: debugEnabled ? '#4ade80' : '#f87171' }, children: String(debugEnabled) }), ' ', _jsxs("span", { style: { color: '#666' }, children: ["(playing=", String(isPlaying), " t>0=", String(currentTime > 0), ")"] })] }), _jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "cc.phrase:" }), ' ', _jsx("span", { style: { color: debugPhrase ? '#4ade80' : '#f87171' }, children: debugPhrase ? `"${debugPhrase.text.slice(0, 30)}..."` : 'null' })] }), debugPhrase && (_jsxs("div", { children: [_jsx("span", { style: { color: '#888' }, children: "cc.range:" }), ' ', _jsxs("span", { style: { color: '#60a5fa' }, children: [debugPhrase.startTime.toFixed(2), "-", debugPhrase.endTime.toFixed(2)] })] }))] }));
576
+ })()] }))] }), !isAvailable && unavailableMessage && (_jsxs("div", { className: "doc-player__unavailable", style: {
577
+ position: 'absolute',
578
+ top: 0,
579
+ left: 0,
580
+ right: 0,
581
+ bottom: 0,
582
+ display: 'flex',
583
+ alignItems: 'center',
584
+ justifyContent: 'center',
585
+ flexDirection: 'column',
586
+ gap: '16px',
587
+ background: 'rgba(0, 0, 0, 0.7)',
588
+ color: 'rgba(255, 255, 255, 0.9)',
589
+ fontSize: '14px',
590
+ zIndex: 50,
591
+ }, children: [_jsx("span", { style: { fontSize: '32px' }, children: "\uD83D\uDD0A" }), _jsx("span", { children: unavailableMessage })] })), !renderMode && !isSlideshowMode && showControls && (_jsx(DocControlsOverlay, { state: playbackState, actions: playbackActions, blockMarkers: blockMarkers, expandedBlocks: expandedBlocks, getBlockTitle: getBlockTitle })), !renderMode && !isSlideshowMode && !showControls && showScrubber && (_jsx("div", { className: "doc-player__scrubber", style: {
592
+ position: 'absolute',
593
+ bottom: 0,
594
+ left: 0,
595
+ right: 0,
596
+ padding: '12px 16px 8px',
597
+ background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
598
+ display: 'flex',
599
+ alignItems: 'center',
600
+ zIndex: 100,
601
+ }, children: _jsx(DocProgressBar, { state: playbackState, actions: playbackActions, blockMarkers: blockMarkers, expandedBlocks: expandedBlocks, getBlockTitle: getBlockTitle }) })), !renderMode && isSlideshowMode && (_jsx(DocControlsSlideshow, { state: playbackState, slideNav: slideNavActions })), !isSlideshowMode && tapFeedback && (_jsx("div", { className: "doc-player__tap-feedback", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "white", width: "48", height: "48", children: tapFeedback === 'pause' ? (_jsx("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" })) : (_jsx("path", { d: "M8 5v14l11-7z" })) }) }, Date.now()))] }));
602
+ }
603
+ export default DocPlayer;
604
+ //# sourceMappingURL=DocPlayer.js.map