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