@bendyline/squisq-react 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
package/src/DocPlayer.tsx DELETED
@@ -1,1528 +0,0 @@
1
- /**
2
- * DocPlayer Component
3
- *
4
- * Main component for playing visual stories. Combines audio playback
5
- * with synchronized SVG block animations. Supports both interactive
6
- * browser playback and headless rendering for video export.
7
- *
8
- * Features:
9
- * - Audio synchronization with multiple MP3 segments
10
- * - Block transitions (fade, dissolve, slide)
11
- * - Playback controls (play/pause, seek, next/prev)
12
- * - Progress display
13
- * - Instance-scoped render API for deterministic video capture
14
- * - Pluggable audio controller for different environments (browser, EFB)
15
- * - Multiple control layouts: overlay (default), sidebar, bottom
16
- *
17
- * Related Files:
18
- * - DocControlsOverlay.tsx -- Default overlay controls
19
- * - DocProgressBar.tsx -- Extracted progress bar
20
- * - DocControlsSidebar.tsx -- Vertical sidebar controls
21
- * - DocPlayerWithSidebar.tsx -- Wrapper for sidebar layout
22
- * - types.ts -- Shared control types
23
- */
24
-
25
- import { Fragment, useId, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
- import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
27
- import {
28
- isTemplateBlock,
29
- getCaptionAtTime,
30
- resolveMediaSchedule,
31
- getDocPlaybackDuration,
32
- } from '@bendyline/squisq/schemas';
33
- import { MediaClipLayer } from './MediaClipLayer';
34
- import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
35
- import { applySurface } from '@bendyline/squisq/schemas';
36
- import { BlockRenderer } from './BlockRenderer';
37
- import { CaptionOverlay } from './CaptionOverlay';
38
- import { useAutoSurface } from './hooks/useAutoSurface';
39
- import { useAudioSync } from './hooks/useAudioSync';
40
- import { useDocPlayback } from './hooks/useDocPlayback';
41
- import { useViewportOrientation } from './hooks/useViewportOrientation';
42
- import { useSlideSwipe } from './hooks/useSlideSwipe';
43
- import type { AudioController } from './hooks/AudioController';
44
- import {
45
- expandCoverBlock,
46
- createTemplateContext,
47
- markdownToDoc,
48
- DEFAULT_THEME,
49
- VIEWPORT_PRESETS,
50
- type ViewportConfig,
51
- } from '@bendyline/squisq/doc';
52
- import { parseMarkdown } from '@bendyline/squisq/markdown';
53
- import { DocControlsOverlay } from './DocControlsOverlay';
54
- import { DocControlsSlideshow } from './DocControlsSlideshow';
55
- import { DocProgressBar } from './DocProgressBar';
56
- import { LinearDocView } from './LinearDocView';
57
- import type {
58
- PlaybackState,
59
- PlaybackActions,
60
- BlockMarker,
61
- DisplayMode,
62
- CaptionStyle,
63
- CaptionMode,
64
- SlideNavActions,
65
- SquisqRenderAPI,
66
- } from './types';
67
-
68
- const SMALL_WORDS = new Set([
69
- 'a',
70
- 'an',
71
- 'the',
72
- 'and',
73
- 'but',
74
- 'or',
75
- 'for',
76
- 'nor',
77
- 'on',
78
- 'at',
79
- 'to',
80
- 'in',
81
- 'of',
82
- 'by',
83
- 'is',
84
- ]);
85
-
86
- /**
87
- * Build a map of audio segment index -> display-friendly title.
88
- * Uses sectionHeader blocks to find real titles, with fallbacks
89
- * for "intro" and slug-based names.
90
- */
91
- function buildSegmentTitleMap(doc: Doc): Map<number, string> {
92
- const map = new Map<number, string>();
93
-
94
- // Scan blocks for sectionHeader templates which carry the real title
95
- for (const block of doc.blocks as DocBlock[]) {
96
- if (isTemplateBlock(block) && block.template === 'sectionHeader' && 'title' in block) {
97
- const segIdx = block.audioSegment;
98
- if (!map.has(segIdx)) {
99
- map.set(segIdx, (block as { title: string }).title);
100
- }
101
- }
102
- }
103
-
104
- // Fill in any segments that weren't covered by sectionHeader blocks
105
- for (let i = 0; i < doc.audio.segments.length; i++) {
106
- if (!map.has(i)) {
107
- const name = doc.audio.segments[i].name;
108
- if (name === 'intro' || name.includes('intro')) {
109
- map.set(i, 'Introduction');
110
- } else if (name === 'flight-context' || name.includes('flight-context')) {
111
- map.set(i, 'Flight Context');
112
- } else {
113
- // Title-case the slug: "hands-on-history" -> "Hands on History"
114
- const words = name.split('-');
115
- const titled = words
116
- .map((w, idx) =>
117
- idx === 0 || !SMALL_WORDS.has(w) ? w.charAt(0).toUpperCase() + w.slice(1) : w,
118
- )
119
- .join(' ');
120
- map.set(i, titled);
121
- }
122
- }
123
- }
124
-
125
- return map;
126
- }
127
-
128
- export interface DocPlayerProps {
129
- /**
130
- * The Doc to play. Wins over `markdown` when both are provided.
131
- * When neither `doc` nor `markdown` is given, the player renders a
132
- * minimal themed empty state instead of crashing.
133
- */
134
- doc?: Doc;
135
- /**
136
- * Markdown source to play. When `doc` is absent, the markdown is parsed
137
- * and converted to a Doc via `markdownToDoc(parseMarkdown(markdown))`.
138
- * Ignored when `doc` is provided.
139
- */
140
- markdown?: string;
141
- /** Base path for resolving media URLs (default: `'.'`) */
142
- basePath?: string;
143
- /** Render mode for video capture (hides controls and creates a render API). */
144
- renderMode?: boolean;
145
- /**
146
- * Whether to render slide transitions and per-layer animations (default: true).
147
- * Set to false for static slide changes while preserving timeline and media
148
- * playback.
149
- */
150
- animationsEnabled?: boolean;
151
- /**
152
- * Receives this player's instance-scoped render API, and `null` on cleanup.
153
- * The API is created in render mode and `?debug=true` mode only.
154
- */
155
- onRenderAPIReady?: (api: SquisqRenderAPI | null) => void;
156
- /** Auto-play when loaded */
157
- autoPlay?: boolean;
158
- /** Callback when playback ends */
159
- onEnded?: () => void;
160
- /** Callback for time updates */
161
- onTimeUpdate?: (time: number) => void;
162
- /** Optional audio controller (if not provided, uses default HTML5 audio) */
163
- audioController?: AudioController;
164
- /** Show built-in controls (default: true). Set to false for custom controls. */
165
- showControls?: boolean;
166
- /** Show only the progress bar/scrubber at bottom (no other controls).
167
- * Only takes effect when showControls is false. Allows external controls
168
- * while keeping the scrubber in-video. */
169
- showScrubber?: boolean;
170
- /** Mute audio (default: false) */
171
- muted?: boolean;
172
- /** Enable captions (default: true) */
173
- captionsEnabled?: boolean;
174
- /** Callback when captions enabled state is toggled */
175
- onCaptionsToggle?: (enabled: boolean) => void;
176
- /** Callback for playback state changes (for external controls) */
177
- onPlaybackStateChange?: (state: PlaybackState) => void;
178
- /** Callback when playback controls are ready (for external controls) */
179
- onControlsReady?: (
180
- controls: PlaybackActions & {
181
- play: () => void;
182
- pause: () => void;
183
- },
184
- ) => void;
185
- /** Whether the player is currently in fullscreen mode */
186
- isFullscreen?: boolean;
187
- /** Callback to toggle fullscreen mode */
188
- onFullscreenToggle?: () => void;
189
- /** Callback when block markers are computed (for external progress bars) */
190
- onBlockMarkers?: (markers: BlockMarker[]) => void;
191
- /** Force a specific viewport preset, bypassing window-based orientation detection.
192
- * Used when the player is rendered in a constrained container (e.g., map overlay panel)
193
- * whose shape differs from the window's. */
194
- forceViewport?: ViewportConfig;
195
- /** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
196
- theme?: Theme;
197
- /**
198
- * Optional surface scheme (light / dark paper) overlaid on top of the
199
- * theme's colors. Passed through to the underlying LinearDocView when
200
- * `displayMode === 'linear'`; otherwise overlaid onto the theme that
201
- * renders the player's SVG blocks.
202
- */
203
- surface?: SurfaceScheme | 'auto';
204
- /**
205
- * Display mode for the player.
206
- * - `'video'` (default) — Traditional video playback with play/pause, scrub bar, auto-advance.
207
- * - `'slideshow'` — PowerPoint-style with prev/next buttons. Blocks are static slides
208
- * that only change on user click. No auto-advance, no scrub bar.
209
- * - `'linear'` — Long-scrolling document view. Renders markdown as readable HTML with
210
- * template-annotated sections as inline SVG cards. No audio, no timeline.
211
- */
212
- displayMode?: DisplayMode;
213
- /**
214
- * Whether to synthesize and show the managed cover slide from
215
- * `doc.startBlock`. Defaults to true for existing documents.
216
- */
217
- showCoverSlide?: boolean;
218
- /**
219
- * Optional controlled cover visibility. Intended for synchronized audience
220
- * mirrors that follow another DocPlayer's visual cursor. When omitted, the
221
- * player owns its normal cover lifecycle.
222
- */
223
- coverVisible?: boolean;
224
- /** Caption display style (default: 'standard').
225
- * 'social' shows large centered words with the active word highlighted. */
226
- captionStyle?: CaptionStyle;
227
- /**
228
- * Enable drag-to-swipe slide navigation in slideshow mode (default: true).
229
- * When enabled, press-and-drag on a slide advances/rewinds on release past a
230
- * threshold (or a quick flick), and snaps back otherwise. Only applies when
231
- * `displayMode === 'slideshow'` and not in render/headless mode.
232
- */
233
- enableSwipe?: boolean;
234
- /**
235
- * Listen for playback/navigation shortcuts at the document level instead of
236
- * requiring this player to hold focus. Intended for a primary preview or
237
- * standalone presentation; leave disabled when several players share a page.
238
- */
239
- globalKeyboardShortcuts?: boolean;
240
- }
241
-
242
- // Dev-only, browser-safe environment probe. Bundlers substitute the
243
- // `process.env.NODE_ENV` expression; bare browsers without a bundler have
244
- // no `process` at all and are treated as production (no warning noise).
245
- function isDevEnvironment(): boolean {
246
- try {
247
- return typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
248
- } catch {
249
- return false;
250
- }
251
- }
252
-
253
- // One-shot flag for the missing-stylesheet warning (module-level so the
254
- // warning fires at most once per page, not once per player instance).
255
- let warnedMissingStyles = false;
256
-
257
- /**
258
- * Front-door component: resolves the `doc` / `markdown` props into a Doc
259
- * and renders a themed empty state when neither is provided. The playback
260
- * machinery lives in `DocPlayerContent` so its hook order never changes
261
- * when a doc appears or disappears.
262
- */
263
- export function DocPlayer(props: DocPlayerProps) {
264
- const { doc, markdown } = props;
265
-
266
- // Parse markdown into a Doc only when no explicit doc is supplied.
267
- const markdownDoc = useMemo(
268
- () => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
269
- [doc, markdown],
270
- );
271
-
272
- const resolvedDoc = doc ?? markdownDoc;
273
-
274
- if (!resolvedDoc) {
275
- return <div className="doc-player doc-player--empty" />;
276
- }
277
-
278
- return <DocPlayerContent {...props} doc={resolvedDoc} />;
279
- }
280
-
281
- interface DocPlayerContentProps extends DocPlayerProps {
282
- doc: Doc;
283
- }
284
-
285
- function DocPlayerContent({
286
- doc,
287
- basePath = '.',
288
- renderMode = false,
289
- animationsEnabled = true,
290
- autoPlay = false,
291
- onEnded,
292
- onTimeUpdate,
293
- audioController: externalAudioController,
294
- showControls = true,
295
- showScrubber = false,
296
- muted = false,
297
- captionsEnabled: captionsEnabledProp,
298
- onCaptionsToggle,
299
- onPlaybackStateChange,
300
- onControlsReady,
301
- onRenderAPIReady,
302
- isFullscreen = false,
303
- onFullscreenToggle,
304
- onBlockMarkers,
305
- forceViewport,
306
- displayMode = 'video',
307
- showCoverSlide = true,
308
- coverVisible,
309
- theme,
310
- surface,
311
- captionStyle = 'standard',
312
- enableSwipe = true,
313
- globalKeyboardShortcuts = false,
314
- }: DocPlayerContentProps) {
315
- const isSlideshowMode = displayMode === 'slideshow';
316
- const isLinearMode = displayMode === 'linear';
317
- const audioRef = useRef<HTMLAudioElement>(null);
318
- const containerRef = useRef<HTMLDivElement>(null);
319
- const playerId = `squisq-player-${useId().replace(/:/g, '')}`;
320
-
321
- // Tap-to-toggle play/pause feedback animation
322
- const [tapFeedback, setTapFeedback] = useState<'play' | 'pause' | null>(null);
323
- const tapFeedbackTimer = useRef<ReturnType<typeof setTimeout>>();
324
-
325
- // Detect viewport orientation for responsive docs
326
- // forceViewport takes precedence (used by render mode with explicit viewport and constrained panels)
327
- // In render mode without forceViewport, default to landscape for backward compatibility
328
- const { viewport, orientation } = useViewportOrientation();
329
- const activeViewport = forceViewport || (renderMode ? VIEWPORT_PRESETS.landscape : viewport);
330
-
331
- // Check for debug mode via URL parameter
332
- const isDebugMode = useMemo(() => {
333
- if (typeof window === 'undefined') return false;
334
- const params = new URLSearchParams(window.location.search);
335
- return params.get('debug') === 'true';
336
- }, []);
337
-
338
- // Use internal HTML5 audio sync if no external controller is given
339
- const internalAudio = useAudioSync(audioRef, doc.audio, basePath, !externalAudioController);
340
-
341
- // Use external controller if provided, otherwise fall back to internal
342
- const audio = externalAudioController || internalAudio;
343
-
344
- // Dev-only sentinel: warn once when the package stylesheet isn't loaded.
345
- // The stylesheet sets `--squisq-styles-loaded: 1` on `.doc-player`; if the
346
- // mounted container computes an empty value, the CSS never made it in.
347
- useEffect(() => {
348
- if (warnedMissingStyles || !isDevEnvironment()) return;
349
- const el = containerRef.current;
350
- if (!el || typeof getComputedStyle !== 'function') return;
351
- const value = getComputedStyle(el).getPropertyValue('--squisq-styles-loaded');
352
- if (!value.trim()) {
353
- warnedMissingStyles = true;
354
- console.warn(
355
- '[squisq] @bendyline/squisq-react/styles is not loaded — import "@bendyline/squisq-react/styles"',
356
- );
357
- }
358
- }, []);
359
-
360
- // Destructure for convenience
361
- const {
362
- currentTime,
363
- isPlaying,
364
- currentSegment,
365
- totalDuration,
366
- isEnded,
367
- isReady: isAudioReady,
368
- isAvailable,
369
- unavailableMessage,
370
- play,
371
- pause,
372
- toggle,
373
- seekTo,
374
- skipToSegment: _skipToSegment,
375
- restart,
376
- } = audio;
377
-
378
- // Timed media clips (block.media + doc.documentMedia) resolved to absolute
379
- // doc-timeline coordinates. Empty for documents without the media model, so
380
- // <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
381
- const mediaSchedule = useMemo(() => resolveMediaSchedule(doc), [doc]);
382
-
383
- // Refs for frequently-changing values used in the keyboard handler,
384
- // so the handler callback doesn't need to be recreated every frame.
385
- const currentTimeRef = useRef(currentTime);
386
- currentTimeRef.current = currentTime;
387
- const totalDurationRef = useRef(totalDuration);
388
- totalDurationRef.current = totalDuration;
389
- const expandedBlocksLenRef = useRef(0);
390
-
391
- // Tap the player surface to toggle play/pause (disabled in slideshow and linear mode)
392
- const handleContainerClick = useCallback(
393
- (e: React.MouseEvent) => {
394
- if (renderMode || isLinearMode) return;
395
- const target = e.target as HTMLElement;
396
- if (isSlideshowMode) {
397
- // The keyboard shortcuts are intentionally scoped to the focused player.
398
- // A presentation surface is not naturally focusable on click, so focus it
399
- // explicitly while preserving native focus for its controls.
400
- if (!target.closest('button, a, input, textarea, select, [contenteditable="true"]')) {
401
- containerRef.current?.focus({ preventScroll: true });
402
- }
403
- return;
404
- }
405
- // Don't toggle if user clicked a control element
406
- if (
407
- target.closest(
408
- 'button, a, input, textarea, select, [contenteditable="true"], .doc-player__controls, .doc-player__scrubber, .doc-controls-sidebar, .doc-controls-slideshow',
409
- )
410
- )
411
- return;
412
- containerRef.current?.focus({ preventScroll: true });
413
- toggle();
414
- // Show visual feedback (show the state we're transitioning TO)
415
- const nextState = isPlaying ? 'pause' : 'play';
416
- setTapFeedback(nextState);
417
- clearTimeout(tapFeedbackTimer.current);
418
- tapFeedbackTimer.current = setTimeout(() => setTapFeedback(null), 600);
419
- },
420
- [renderMode, toggle, isPlaying, isSlideshowMode, isLinearMode],
421
- );
422
-
423
- // Resolve surface (light/dark paper) and apply it to the theme before
424
- // handing off to downstream renderers. Orthogonal to the editorial theme.
425
- const autoSurface = useAutoSurface(surface === 'auto');
426
- const resolvedSurface = surface === 'auto' ? autoSurface : surface;
427
- const effectiveTheme = useMemo(() => {
428
- const base = theme ?? DEFAULT_THEME;
429
- return resolvedSurface ? applySurface(base, resolvedSurface) : base;
430
- }, [theme, resolvedSurface]);
431
-
432
- // Doc playback hook - pass viewport for responsive template expansion
433
- const {
434
- currentBlock,
435
- currentBlockIndex,
436
- previousBlock,
437
- isEntering,
438
- isExiting,
439
- blockTime,
440
- blockProgress: _blockProgress,
441
- docProgress,
442
- nextBlock: _nextBlock,
443
- prevBlock: _prevBlock,
444
- blocks: expandedBlocks,
445
- suppressOutgoingForNextBlock,
446
- } = useDocPlayback(doc, currentTime, {
447
- viewport: activeViewport,
448
- theme: effectiveTheme,
449
- onSeek: seekTo,
450
- });
451
-
452
- // Expand cover block (startBlock) if present - uses active viewport
453
- const coverBlock = useMemo((): Block | null => {
454
- const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
455
- if (!showCoverSlide) return null;
456
- if (!startBlockConfig) return null;
457
-
458
- const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
459
- const layers = expandCoverBlock(startBlockConfig, context);
460
-
461
- return {
462
- id: 'cover-block',
463
- startTime: -1, // Not part of timeline
464
- duration: 0, // Static
465
- audioSegment: -1,
466
- layers,
467
- };
468
- }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
469
-
470
- // Slideshow mode treats the managed cover as a static slide before block 1.
471
- // It has no timeline startTime, so keep its visibility separate from audio.
472
- const hasManagedCover = !!coverBlock;
473
- const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
474
- const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState(false);
475
- const slideshowCoverInitKeyRef = useRef('');
476
- useEffect(() => {
477
- slideshowCoverInitKeyRef.current = '';
478
- }, [doc]);
479
- useEffect(() => {
480
- const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
481
- if (slideshowCoverInitKeyRef.current === initKey) return;
482
- slideshowCoverInitKeyRef.current = initKey;
483
- if (isSlideshowMode && hasManagedCover && !renderMode) {
484
- setSlideshowCoverVisible(true);
485
- pause();
486
- } else {
487
- setSlideshowCoverVisible(false);
488
- }
489
- }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
490
-
491
- // Render-mode cover block control: allows Playwright to force-show the cover block
492
- const [coverForced, setCoverForced] = useState(false);
493
-
494
- // Grace period: keep cover block visible for 3s after first play press
495
- const [coverGraceActive, setCoverGraceActive] = useState(false);
496
- const coverGraceTimer = useRef<ReturnType<typeof setTimeout>>();
497
- const coverWasShowing = useRef(false);
498
- // Track whether playback has ever been initiated — prevents the cover block
499
- // from re-appearing when paused at currentTime === 0 (e.g., no audio source).
500
- const hasPlayedOnce = useRef(false);
501
-
502
- useEffect(() => {
503
- hasPlayedOnce.current = false;
504
- coverWasShowing.current = false;
505
- clearTimeout(coverGraceTimer.current);
506
- setCoverGraceActive(false);
507
- setCoverForced(false);
508
- }, [doc]);
509
-
510
- // Track when cover is showing at rest (before play)
511
- const atRest = !!(
512
- coverBlock &&
513
- !isSlideshowMode &&
514
- !isPlaying &&
515
- currentTime === 0 &&
516
- !hasPlayedOnce.current &&
517
- !renderMode &&
518
- !autoPlay
519
- );
520
- if (atRest) coverWasShowing.current = true;
521
-
522
- useEffect(() => {
523
- if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
524
- coverWasShowing.current = false;
525
- hasPlayedOnce.current = true;
526
- setCoverGraceActive(true);
527
- // Intentionally no cleanup: if coverBlock's memoized reference changes
528
- // mid-grace (e.g., due to a preview re-render), clearing the timer would
529
- // leave coverGraceActive stuck at true because the effect body won't
530
- // re-run (coverWasShowing.current is now false).
531
- coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
532
- }
533
- }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
534
-
535
- // Always clear the grace timer on unmount
536
- useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
537
-
538
- // Determine if we should show the cover block
539
- // Show cover when: has cover block, not playing, at time 0, not in render mode
540
- // OR during the grace period after first play, OR when coverForced (render mode)
541
- const showVideoCoverBlock =
542
- !isSlideshowMode &&
543
- !isLinearMode &&
544
- !!coverBlock &&
545
- (coverForced ||
546
- coverGraceActive ||
547
- (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
548
- const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
549
- const showSlideshowCover = !!(
550
- isSlideshowMode &&
551
- !isLinearMode &&
552
- !renderMode &&
553
- coverBlock &&
554
- effectiveSlideshowCoverVisible
555
- );
556
- const showCoverBlock =
557
- coverVisible === undefined
558
- ? showVideoCoverBlock || showSlideshowCover
559
- : !!coverBlock && coverVisible;
560
-
561
- const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
562
- const slideshowSlideIndex = slideshowHasCover
563
- ? effectiveSlideshowCoverVisible
564
- ? 0
565
- : currentBlockIndex + 1
566
- : currentBlockIndex;
567
- const slideshowTotalSlides = slideshowHasCover
568
- ? expandedBlocks.length + 1
569
- : expandedBlocks.length;
570
-
571
- // Auto-play if enabled (wait for audio to be ready)
572
- // Use a ref to track if we've already auto-played to avoid repeating on every render
573
- const hasAutoPlayed = useRef(false);
574
- useEffect(() => {
575
- hasAutoPlayed.current = false;
576
- }, [doc]);
577
- useEffect(() => {
578
- if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
579
- hasAutoPlayed.current = true;
580
- play();
581
- }
582
- }, [isAudioReady, autoPlay, play]);
583
-
584
- // Callback for time updates
585
- useEffect(() => {
586
- onTimeUpdate?.(currentTime);
587
- }, [currentTime, onTimeUpdate]);
588
-
589
- // Callback for ended
590
- useEffect(() => {
591
- if (isEnded) {
592
- onEnded?.();
593
- }
594
- }, [isEnded, onEnded]);
595
-
596
- // Consumers keep this API object for the lifetime of the mounted player
597
- // (the standalone handle promise and Playwright both do). The implementation
598
- // behind it may change as audio segments, documents, or viewports change, so
599
- // every method dispatches through the latest implementation ref.
600
- const liveRenderAPIRef = useRef<SquisqRenderAPI | null>(null);
601
- const stableRenderAPIRef = useRef<SquisqRenderAPI | null>(null);
602
- if (!stableRenderAPIRef.current) {
603
- const current = (): SquisqRenderAPI => {
604
- const api = liveRenderAPIRef.current;
605
- if (!api) throw new Error('Squisq render API is not currently available.');
606
- return api;
607
- };
608
- stableRenderAPIRef.current = {
609
- seekTo: (time) => current().seekTo(time),
610
- getDuration: () => current().getDuration(),
611
- getBlocks: () => current().getBlocks(),
612
- getAudioSegments: () => current().getAudioSegments(),
613
- getCaptions: () => current().getCaptions(),
614
- getChapters: () => current().getChapters(),
615
- showCover: () => current().showCover(),
616
- hideCover: () => current().hideCover(),
617
- hasCoverBlock: () => current().hasCoverBlock(),
618
- };
619
- }
620
- const stableRenderAPI = stableRenderAPIRef.current;
621
-
622
- // Refresh the implementation behind the stable instance API.
623
- useEffect(() => {
624
- if (!renderMode && !isDebugMode) {
625
- liveRenderAPIRef.current = null;
626
- return;
627
- }
628
- const root = containerRef.current;
629
- if (!root) {
630
- liveRenderAPIRef.current = null;
631
- return;
632
- }
633
- const renderSeekTo = (time: number) => {
634
- seekTo(time);
635
- // After React renders the correct block, advance CSS animations
636
- // (Ken Burns, transitions) to match the doc timeline position.
637
- // Without this, animations restart from zero on each seekTo because
638
- // they run on the browser's real clock, not doc time.
639
- return new Promise<void>((resolve) => {
640
- requestAnimationFrame(() => {
641
- // Find the current block's start time
642
- let blockStartTime = 0;
643
- for (let i = expandedBlocks.length - 1; i >= 0; i--) {
644
- if (time >= expandedBlocks[i].startTime) {
645
- blockStartTime = expandedBlocks[i].startTime;
646
- break;
647
- }
648
- }
649
- const elapsedMs = (time - blockStartTime) * 1000;
650
-
651
- // Set all CSS animations to the correct timeline position
652
- (root.getAnimations?.() ?? []).forEach((anim) => {
653
- const target = (anim.effect as KeyframeEffect)?.target as Element | null;
654
- if (!target) return;
655
-
656
- // Animations on the active block: use current block elapsed time
657
- if (target.closest('.doc-player__block--active')) {
658
- anim.currentTime = Math.max(0, elapsedMs);
659
- }
660
- // Animations on the exiting block (during crossfade): use current
661
- // block elapsed for transition animations, keep Ken Burns at their
662
- // natural position based on when that block started
663
- // eslint-disable-next-line sonarjs/no-duplicated-branches
664
- else if (target.closest('.doc-player__block--previous')) {
665
- anim.currentTime = Math.max(0, elapsedMs);
666
- }
667
- });
668
-
669
- // Seek <video> elements in the active block to the correct clip position.
670
- // Each <video> carries data-clip-start/data-clip-end attributes set by
671
- // VideoLayer.tsx; we calculate targetTime = clipStart + blockElapsed.
672
- const blockElapsed = time - blockStartTime;
673
- const videoSeekPromises: Promise<void>[] = [];
674
- const activeBlockEl = root.querySelector('.doc-player__block--active');
675
- if (activeBlockEl) {
676
- const videos = activeBlockEl.querySelectorAll('video[data-clip-start]');
677
- videos.forEach((el) => {
678
- const video = el as HTMLVideoElement;
679
- const clipStart = parseFloat(video.dataset.clipStart || '0');
680
- const clipEnd = parseFloat(video.dataset.clipEnd || '0');
681
- // Honor the per-clip startAt offset: before it, hold at the
682
- // in-point; after, advance by (blockElapsed - startAt).
683
- const startAt = parseFloat(video.dataset.startAt || '0');
684
- const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
685
-
686
- video.pause();
687
- video.currentTime = targetTime;
688
-
689
- videoSeekPromises.push(
690
- new Promise<void>((r) => {
691
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
692
- r();
693
- } else {
694
- video.addEventListener('seeked', () => r(), { once: true });
695
- setTimeout(r, 200); // Fallback if seeked never fires
696
- }
697
- }),
698
- );
699
- });
700
- }
701
-
702
- // Seek player-level scheduled videos (document-spanning clips
703
- // rendered by MediaClipLayer, outside any single block). Each
704
- // carries data-abs-start/data-abs-end/data-source-in.
705
- root.querySelectorAll('video[data-clip-id]').forEach((el) => {
706
- const video = el as HTMLVideoElement;
707
- const absStart = parseFloat(video.dataset.absStart || '0');
708
- const absEnd = parseFloat(video.dataset.absEnd || '0');
709
- const sourceIn = parseFloat(video.dataset.sourceIn || '0');
710
- video.pause();
711
- if (time < absStart || time >= absEnd) return;
712
- const targetTime = sourceIn + (time - absStart);
713
- video.currentTime = targetTime;
714
- videoSeekPromises.push(
715
- new Promise<void>((r) => {
716
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
717
- r();
718
- } else {
719
- video.addEventListener('seeked', () => r(), { once: true });
720
- setTimeout(r, 200);
721
- }
722
- }),
723
- );
724
- });
725
-
726
- // Wait for video seeks + one more frame for the browser to render
727
- Promise.all(videoSeekPromises).then(() => {
728
- requestAnimationFrame(() => resolve());
729
- });
730
- });
731
- });
732
- };
733
- const getDuration = () => {
734
- // The larger of the audio/block timeline and any media that spills
735
- // past the last block (block-clip spillover or document-spanning
736
- // media), so frame capture covers the full tail.
737
- const mediaDuration = getDocPlaybackDuration(doc);
738
- if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
739
- return mediaDuration;
740
- };
741
- // Expose block metadata for testing -- allows tests to find specific templates
742
- const getBlocks = () =>
743
- expandedBlocks.map((s: Block) => ({
744
- id: s.id,
745
- template: (s as DocBlock).template ?? 'raw',
746
- startTime: s.startTime,
747
- duration: s.duration,
748
- }));
749
- // Audio segment info for video production -- returns the actual files in composition order
750
- const getAudioSegments = () =>
751
- doc.audio.segments.map((seg) => ({
752
- src: seg.src,
753
- name: seg.name,
754
- duration: seg.duration,
755
- startTime: seg.startTime,
756
- }));
757
- // Caption phrases for SRT/subtitle export
758
- const getCaptions = () =>
759
- doc.captions?.phrases?.map((p) => ({
760
- text: p.text,
761
- startTime: p.startTime,
762
- endTime: p.endTime,
763
- })) || [];
764
- // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
765
- const getChapters = () => {
766
- const titleMap = buildSegmentTitleMap(doc);
767
- return doc.audio.segments.map((seg, i) => ({
768
- title: titleMap.get(i) || seg.name,
769
- startTime: seg.startTime,
770
- duration: seg.duration,
771
- }));
772
- };
773
- // Cover block control for video pre-roll -- force-show or hide the cover block
774
- const showCover = () => {
775
- setCoverForced(true);
776
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
777
- };
778
- const hideCover = () => {
779
- setCoverForced(false);
780
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
781
- };
782
- const hasCoverBlock = () => !!coverBlock;
783
-
784
- const api: SquisqRenderAPI = {
785
- seekTo: renderSeekTo,
786
- getDuration,
787
- getBlocks,
788
- getAudioSegments,
789
- getCaptions,
790
- getChapters,
791
- showCover,
792
- hideCover,
793
- hasCoverBlock,
794
- };
795
- liveRenderAPIRef.current = api;
796
-
797
- return () => {
798
- if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
799
- };
800
- }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock, doc]);
801
-
802
- // Publish/clean up only when the host callback or API availability changes;
803
- // ordinary playback state changes update the implementation ref above
804
- // without replacing the object consumers already hold.
805
- useEffect(() => {
806
- if ((!renderMode && !isDebugMode) || !containerRef.current) {
807
- onRenderAPIReady?.(null);
808
- return;
809
- }
810
- onRenderAPIReady?.(stableRenderAPI);
811
- return () => onRenderAPIReady?.(null);
812
- }, [renderMode, isDebugMode, onRenderAPIReady, stableRenderAPI]);
813
-
814
- // Caption mode state: cycles through off → standard → social → off
815
- // The captionStyle prop sets the default active style; captionsEnabledProp
816
- // can override the initial on/off state.
817
- const defaultMode: CaptionMode =
818
- captionsEnabledProp === false ? 'off' : captionStyle || 'standard';
819
- const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultMode);
820
-
821
- // Keep the internal caption mode in sync when the controlling props change
822
- // — e.g. the editor's preview toolbar drives caption style / on-off. Keyed
823
- // on the derived `defaultMode` string, so it only fires on a real prop
824
- // change and never disturbs the in-player CC toggle for consumers (the
825
- // standalone player, video export) that set these props once at mount.
826
- useEffect(() => {
827
- setCaptionMode(defaultMode);
828
- }, [defaultMode]);
829
-
830
- // Derive captionsEnabled and active style from the mode
831
- const captionsEnabled = captionMode !== 'off';
832
- const activeCaptionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
833
-
834
- const setCaptionsEnabled = useCallback(
835
- (enabled: boolean) => {
836
- // When re-enabling, restore the prop-specified style rather than
837
- // always defaulting to 'standard'
838
- setCaptionMode(enabled ? captionStyle || 'standard' : 'off');
839
- onCaptionsToggle?.(enabled);
840
- },
841
- [onCaptionsToggle, captionStyle],
842
- );
843
-
844
- const cycleCaptionMode = useCallback(() => {
845
- setCaptionMode((prev) => {
846
- const next: CaptionMode =
847
- prev === 'off' ? 'standard' : prev === 'standard' ? 'social' : 'off';
848
- onCaptionsToggle?.(next !== 'off');
849
- return next;
850
- });
851
- }, [onCaptionsToggle]);
852
-
853
- const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
854
-
855
- // Map segment indices to human-readable titles (from sectionHeader blocks)
856
- const segmentTitleMap = useMemo(() => buildSegmentTitleMap(doc), [doc]);
857
-
858
- // Build shared playback state for extracted controls
859
- const playbackState: PlaybackState = useMemo(
860
- () => ({
861
- isPlaying,
862
- currentTime,
863
- totalDuration,
864
- isCoverVisible: showCoverBlock,
865
- currentBlockIndex: slideshowSlideIndex,
866
- totalBlocks: slideshowTotalSlides,
867
- docProgress,
868
- hasCaptions: !!hasCaptions,
869
- captionsEnabled,
870
- captionMode,
871
- isFullscreen,
872
- currentSegmentIndex: currentSegment,
873
- currentSegmentName:
874
- segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
875
- currentBlock: showSlideshowCover ? coverBlock : (currentBlock ?? null),
876
- currentSlideLabel: showSlideshowCover ? 'Cover' : undefined,
877
- currentSlideNumber:
878
- slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : undefined,
879
- totalSlideNumber: slideshowHasCover ? expandedBlocks.length : undefined,
880
- }),
881
- // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
882
- [
883
- isPlaying,
884
- currentTime,
885
- totalDuration,
886
- showCoverBlock,
887
- slideshowSlideIndex,
888
- slideshowTotalSlides,
889
- docProgress,
890
- hasCaptions,
891
- captionsEnabled,
892
- captionMode,
893
- isFullscreen,
894
- currentSegment,
895
- segmentTitleMap,
896
- currentBlock,
897
- currentBlockIndex,
898
- showSlideshowCover,
899
- coverBlock,
900
- slideshowHasCover,
901
- expandedBlocks.length,
902
- ],
903
- );
904
-
905
- // Build shared playback actions for extracted controls
906
- const playbackActions: PlaybackActions = useMemo(
907
- () => ({
908
- toggle,
909
- restart,
910
- seekTo,
911
- setCaptionsEnabled,
912
- cycleCaptionMode,
913
- toggleFullscreen: onFullscreenToggle,
914
- }),
915
- [toggle, restart, seekTo, setCaptionsEnabled, cycleCaptionMode, onFullscreenToggle],
916
- );
917
-
918
- // Slide navigation actions for slideshow mode
919
- // These seek to the target block's startTime and keep the player paused.
920
- const slideNavActions: SlideNavActions = useMemo(
921
- () => ({
922
- nextSlide: () => {
923
- if (slideshowHasCover && slideshowCoverVisible) {
924
- const target = expandedBlocks[0];
925
- if (target) {
926
- setSlideshowCoverVisible(false);
927
- seekTo(target.startTime);
928
- pause();
929
- }
930
- return;
931
- }
932
- if (currentBlockIndex < expandedBlocks.length - 1) {
933
- const target = expandedBlocks[currentBlockIndex + 1];
934
- if (target) {
935
- setSlideshowCoverVisible(false);
936
- seekTo(target.startTime);
937
- pause();
938
- }
939
- }
940
- },
941
- prevSlide: () => {
942
- if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
943
- setSlideshowCoverVisible(true);
944
- seekTo(0);
945
- pause();
946
- return;
947
- }
948
- if (currentBlockIndex > 0) {
949
- const target = expandedBlocks[currentBlockIndex - 1];
950
- if (target) {
951
- setSlideshowCoverVisible(false);
952
- seekTo(target.startTime);
953
- pause();
954
- }
955
- }
956
- },
957
- goToSlide: (index: number) => {
958
- if (slideshowHasCover) {
959
- if (index === 0) {
960
- setSlideshowCoverVisible(true);
961
- seekTo(0);
962
- pause();
963
- return;
964
- }
965
- const target = expandedBlocks[index - 1];
966
- if (target) {
967
- setSlideshowCoverVisible(false);
968
- seekTo(target.startTime);
969
- pause();
970
- }
971
- return;
972
- }
973
- if (index >= 0 && index < expandedBlocks.length) {
974
- const target = expandedBlocks[index];
975
- if (target) {
976
- seekTo(target.startTime);
977
- pause();
978
- }
979
- }
980
- },
981
- }),
982
- [currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible],
983
- );
984
-
985
- // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
986
- // interactive (not headless), and not overridden off via `enableSwipe`.
987
- const swipeEnabled = isSlideshowMode && !isLinearMode && !renderMode && enableSwipe;
988
- const armContextFreeSwipeEntry = useCallback(
989
- (destinationSlideIndex: number) => {
990
- const destinationBlockIndex = destinationSlideIndex - (slideshowHasCover ? 1 : 0);
991
- const destinationBlock = expandedBlocks[destinationBlockIndex];
992
- if (destinationBlock) suppressOutgoingForNextBlock(destinationBlock.id);
993
- },
994
- [expandedBlocks, slideshowHasCover, suppressOutgoingForNextBlock],
995
- );
996
- const handleSwipeNext = useCallback(() => {
997
- armContextFreeSwipeEntry(slideshowSlideIndex + 1);
998
- slideNavActions.nextSlide();
999
- }, [armContextFreeSwipeEntry, slideshowSlideIndex, slideNavActions]);
1000
- const handleSwipePrev = useCallback(() => {
1001
- armContextFreeSwipeEntry(slideshowSlideIndex - 1);
1002
- slideNavActions.prevSlide();
1003
- }, [armContextFreeSwipeEntry, slideshowSlideIndex, slideNavActions]);
1004
- const swipe = useSlideSwipe({
1005
- enabled: swipeEnabled,
1006
- containerRef,
1007
- canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
1008
- canGoPrev: slideshowSlideIndex > 0,
1009
- onNext: handleSwipeNext,
1010
- onPrev: handleSwipePrev,
1011
- });
1012
-
1013
- // Callback for playback state changes (for external controls)
1014
- useEffect(() => {
1015
- onPlaybackStateChange?.(playbackState);
1016
- }, [playbackState, onPlaybackStateChange]);
1017
-
1018
- // Callback when controls are ready (for external controls)
1019
- // Fires every time playbackActions change so the external sidebar always holds
1020
- // fresh function references (toggle closes over isPlaying, so it changes often).
1021
- useEffect(() => {
1022
- onControlsReady?.({ play, pause, ...playbackActions });
1023
- }, [play, pause, playbackActions, onControlsReady]);
1024
-
1025
- // Extract display title from a block (handles both template and expanded blocks)
1026
- const getBlockTitle = useCallback((block: Block): string => {
1027
- // For template blocks, extract title from template-specific properties first
1028
- const docBlock = block as DocBlock;
1029
- if (isTemplateBlock(docBlock)) {
1030
- const props = docBlock as unknown as Record<string, unknown>;
1031
- if (typeof props.title === 'string') return props.title;
1032
- if (typeof props.stat === 'string') return props.stat;
1033
- if (typeof props.quote === 'string') {
1034
- const firstLine = props.quote.split('\n')[0];
1035
- if (firstLine.length <= 30) return firstLine;
1036
- return firstLine.slice(0, 27) + '...';
1037
- }
1038
- if (typeof props.date === 'string') return props.date;
1039
- if (typeof props.fact === 'string') return props.fact;
1040
- }
1041
-
1042
- // For expanded blocks with layers, try to find text content
1043
- if (block.layers && Array.isArray(block.layers)) {
1044
- const textLayer = block.layers.find((l): l is TextLayer => l.type === 'text');
1045
- if (textLayer?.content?.text) {
1046
- // Get first line of text, truncate if too long
1047
- const firstLine = textLayer.content.text.split('\n')[0];
1048
- if (firstLine.length <= 30) return firstLine;
1049
- return firstLine.slice(0, 27) + '...';
1050
- }
1051
- }
1052
-
1053
- // Fallback to formatted id
1054
- return block.id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
1055
- }, []);
1056
-
1057
- const slideshowPickerItems = useMemo(() => {
1058
- const blockItems = expandedBlocks.map((block, index) => ({
1059
- id: block.id,
1060
- label: String(index + 1),
1061
- summary: getBlockTitle(block),
1062
- }));
1063
-
1064
- if (!slideshowHasCover || !coverBlock) return blockItems;
1065
- return [
1066
- {
1067
- id: '__cover__',
1068
- label: 'Cover',
1069
- summary: getBlockTitle(coverBlock),
1070
- },
1071
- ...blockItems,
1072
- ];
1073
- }, [coverBlock, expandedBlocks, getBlockTitle, slideshowHasCover]);
1074
-
1075
- // Compute block markers for progress bar (using expanded blocks)
1076
- const blockMarkers = useMemo(() => {
1077
- if (!totalDuration || !expandedBlocks.length) return [];
1078
- let prevSegment = -1;
1079
- return expandedBlocks.map((block, index) => {
1080
- const isSectionStart = block.audioSegment !== prevSegment;
1081
- prevSegment = block.audioSegment;
1082
- return {
1083
- block,
1084
- index,
1085
- position: (block.startTime / totalDuration) * 100,
1086
- title: getBlockTitle(block),
1087
- isSectionStart,
1088
- };
1089
- });
1090
- }, [expandedBlocks, totalDuration, getBlockTitle]);
1091
-
1092
- // Notify parent when block markers are computed
1093
- useEffect(() => {
1094
- if (blockMarkers.length > 0) {
1095
- onBlockMarkers?.(blockMarkers);
1096
- }
1097
- }, [blockMarkers, onBlockMarkers]);
1098
-
1099
- // Keep expandedBlocks length in a ref so keyboard handler stays stable
1100
- expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
1101
-
1102
- // Handle keyboard controls — uses refs for frequently-changing values
1103
- // (currentTime, totalDuration, expandedBlocks.length) to avoid
1104
- // re-registering the event listener on every animation frame.
1105
- const handleKeyboardShortcut = useCallback(
1106
- (e: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>, global: boolean) => {
1107
- if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
1108
-
1109
- const target = e.target instanceof Element ? e.target : null;
1110
- const isEditableTarget = !!target?.closest(
1111
- 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="slider"], [role="spinbutton"], .monaco-editor',
1112
- );
1113
- const isOpenInteractionTarget = !!target?.closest(
1114
- '[role="menu"], [role="dialog"], [aria-modal="true"]',
1115
- );
1116
- const isSlideshowToolbarTarget =
1117
- isSlideshowMode &&
1118
- !!target?.closest('.doc-controls-slideshow') &&
1119
- !target.closest('[role="menu"]');
1120
- if (
1121
- isEditableTarget ||
1122
- (global && isOpenInteractionTarget) ||
1123
- (!global &&
1124
- !!target?.closest(
1125
- 'input, textarea, select, button, a, [contenteditable]:not([contenteditable="false"]), [role="textbox"]',
1126
- ) &&
1127
- !isSlideshowToolbarTarget)
1128
- ) {
1129
- return;
1130
- }
1131
-
1132
- // Linear mode: no keyboard shortcuts (native scrolling handles it)
1133
- if (isLinearMode) return;
1134
-
1135
- if (isSlideshowMode) {
1136
- // Slideshow mode: arrow keys navigate slides
1137
- switch (e.key) {
1138
- case 'ArrowRight':
1139
- case 'ArrowDown':
1140
- case ' ':
1141
- e.preventDefault();
1142
- slideNavActions.nextSlide();
1143
- break;
1144
- case 'ArrowLeft':
1145
- e.preventDefault();
1146
- slideNavActions.prevSlide();
1147
- break;
1148
- case 'ArrowUp':
1149
- e.preventDefault();
1150
- setIsSlideshowPickerOpen(true);
1151
- break;
1152
- case 'Home':
1153
- e.preventDefault();
1154
- slideNavActions.goToSlide(0);
1155
- break;
1156
- case 'End':
1157
- e.preventDefault();
1158
- slideNavActions.goToSlide(expandedBlocksLenRef.current - 1);
1159
- break;
1160
- }
1161
- } else {
1162
- // Video mode: standard playback controls
1163
- switch (e.key) {
1164
- case ' ':
1165
- e.preventDefault();
1166
- toggle();
1167
- break;
1168
- case 'ArrowRight':
1169
- e.preventDefault();
1170
- seekTo(Math.min(currentTimeRef.current + 10, totalDurationRef.current));
1171
- break;
1172
- case 'ArrowLeft':
1173
- e.preventDefault();
1174
- seekTo(Math.max(currentTimeRef.current - 10, 0));
1175
- break;
1176
- }
1177
- }
1178
- },
1179
- [isSlideshowMode, isLinearMode, toggle, seekTo, slideNavActions],
1180
- );
1181
-
1182
- const handleKeyDown = useCallback(
1183
- (e: React.KeyboardEvent<HTMLDivElement>) => handleKeyboardShortcut(e, false),
1184
- [handleKeyboardShortcut],
1185
- );
1186
-
1187
- useEffect(() => {
1188
- if (!globalKeyboardShortcuts || renderMode || isLinearMode) return;
1189
- const handleDocumentKeyDown = (event: KeyboardEvent) => {
1190
- handleKeyboardShortcut(event, true);
1191
- };
1192
- document.addEventListener('keydown', handleDocumentKeyDown);
1193
- return () => document.removeEventListener('keydown', handleDocumentKeyDown);
1194
- }, [globalKeyboardShortcuts, handleKeyboardShortcut, isLinearMode, renderMode]);
1195
-
1196
- // ── Linear mode: render as scrollable document ──────────────────
1197
- if (isLinearMode) {
1198
- return (
1199
- <div
1200
- ref={containerRef}
1201
- data-player-id={playerId}
1202
- className="doc-player doc-player--linear"
1203
- style={{
1204
- position: 'relative',
1205
- width: '100%',
1206
- height: '100%',
1207
- overflow: 'hidden',
1208
- }}
1209
- >
1210
- <LinearDocView
1211
- doc={doc}
1212
- basePath={basePath}
1213
- viewport={activeViewport}
1214
- theme={theme}
1215
- surface={surface}
1216
- animationsEnabled={animationsEnabled}
1217
- />
1218
- </div>
1219
- );
1220
- }
1221
-
1222
- return (
1223
- <div
1224
- ref={containerRef}
1225
- data-player-id={playerId}
1226
- tabIndex={renderMode ? -1 : 0}
1227
- aria-label="Document player"
1228
- onKeyDown={renderMode ? undefined : handleKeyDown}
1229
- className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
1230
- swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
1231
- }`}
1232
- onClick={handleContainerClick}
1233
- onPointerDown={swipe.onPointerDown}
1234
- style={{
1235
- position: 'relative',
1236
- width: '100%',
1237
- aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
1238
- margin: '0 auto',
1239
- overflow: 'hidden',
1240
- // Swipe uses the grab/grabbing cursor via CSS classes; let vertical page
1241
- // scroll through on touch while we own horizontal drags.
1242
- cursor: renderMode || swipeEnabled ? undefined : 'pointer',
1243
- touchAction: swipeEnabled ? 'pan-y' : undefined,
1244
- }}
1245
- >
1246
- {/* Hidden audio element */}
1247
- <audio ref={audioRef} preload="auto" muted={muted} />
1248
-
1249
- {/* Timed media clips (per-block + document-spanning audio/video). */}
1250
- <MediaClipLayer
1251
- schedule={mediaSchedule}
1252
- currentTime={currentTime}
1253
- isPlaying={isPlaying}
1254
- basePath={basePath}
1255
- renderMode={renderMode}
1256
- muted={muted}
1257
- />
1258
-
1259
- {/* Block viewport */}
1260
- <div className="doc-player__viewport">
1261
- {/* Cover block (shown at rest before playback) */}
1262
- {showCoverBlock && coverBlock && (
1263
- <div className="doc-player__block doc-player__block--cover">
1264
- <BlockRenderer
1265
- block={coverBlock}
1266
- blockTime={0}
1267
- basePath={basePath}
1268
- isEntering={false}
1269
- viewport={activeViewport}
1270
- animationsEnabled={animationsEnabled}
1271
- />
1272
- </div>
1273
- )}
1274
-
1275
- {/* Previous block (during transition) */}
1276
- {animationsEnabled && !showCoverBlock && previousBlock && isExiting && (
1277
- // Keyed by block id so each block is its own DOM subtree: React never
1278
- // reconciles one block's layers onto another's (templates reuse layer
1279
- // ids like `title`/`background`), which would otherwise reuse stale
1280
- // DOM / skip entrance animations mid-transition.
1281
- <div key={previousBlock.id} className="doc-player__block doc-player__block--previous">
1282
- <BlockRenderer
1283
- block={previousBlock}
1284
- blockTime={blockTime}
1285
- basePath={basePath}
1286
- isExiting={true}
1287
- transition={currentBlock?.transition}
1288
- viewport={activeViewport}
1289
- animationsEnabled={animationsEnabled}
1290
- />
1291
- </div>
1292
- )}
1293
-
1294
- {/* Current block */}
1295
- {!showCoverBlock && currentBlock && (
1296
- <div
1297
- key={currentBlock.id}
1298
- className={`doc-player__block doc-player__block--active${
1299
- swipe.phase !== 'idle' ? ` doc-player__block--${swipe.phase}` : ''
1300
- }`}
1301
- style={
1302
- swipe.phase !== 'idle' ? { transform: `translateX(${swipe.offsetPx}px)` } : undefined
1303
- }
1304
- >
1305
- <BlockRenderer
1306
- block={currentBlock}
1307
- blockTime={blockTime}
1308
- basePath={basePath}
1309
- isEntering={animationsEnabled && isEntering}
1310
- viewport={activeViewport}
1311
- isPlaying={isPlaying}
1312
- animationsEnabled={animationsEnabled}
1313
- />
1314
- </div>
1315
- )}
1316
-
1317
- {/* Caption overlay -- shown during playback and in render mode when captions are enabled */}
1318
- {hasCaptions && (renderMode ? captionsEnabled : true) && (
1319
- <CaptionOverlay
1320
- captions={doc.captions}
1321
- currentTime={currentTime}
1322
- enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
1323
- fontSize={16}
1324
- captionStyle={activeCaptionStyle}
1325
- theme={effectiveTheme}
1326
- viewport={activeViewport}
1327
- />
1328
- )}
1329
-
1330
- {/* Debug overlay (when ?debug=true) */}
1331
- {isDebugMode && (
1332
- <div
1333
- className="doc-player__debug"
1334
- style={{
1335
- position: 'absolute',
1336
- top: '8px',
1337
- right: '8px',
1338
- padding: '8px 12px',
1339
- background: 'rgba(0, 0, 0, 0.85)',
1340
- borderRadius: '6px',
1341
- color: '#00ff00',
1342
- fontFamily: 'monospace',
1343
- fontSize: '11px',
1344
- lineHeight: '1.5',
1345
- zIndex: 200,
1346
- maxWidth: '280px',
1347
- pointerEvents: 'none',
1348
- textAlign: 'left',
1349
- }}
1350
- >
1351
- <div style={{ color: '#ffcc00', fontWeight: 'bold', marginBottom: '4px' }}>
1352
- DEBUG MODE
1353
- </div>
1354
- <div>
1355
- <span style={{ color: '#888' }}>template:</span>{' '}
1356
- <span style={{ color: '#ff6b6b' }}>
1357
- {(currentBlock as DocBlock | null)?.template ?? 'raw'}
1358
- </span>
1359
- </div>
1360
- <div>
1361
- <span style={{ color: '#888' }}>block:</span> {currentBlockIndex + 1}/
1362
- {expandedBlocks.length}{' '}
1363
- <span style={{ color: '#666' }}>({currentBlock?.id || 'none'})</span>
1364
- </div>
1365
- <div>
1366
- <span style={{ color: '#888' }}>time:</span> {currentTime.toFixed(2)}s /{' '}
1367
- {totalDuration.toFixed(1)}s{' '}
1368
- <span style={{ color: '#666' }}>
1369
- (progress: {(docProgress * 100).toFixed(1)}%, scriptDur: {doc.duration.toFixed(1)})
1370
- </span>
1371
- </div>
1372
- <div>
1373
- <span style={{ color: '#888' }}>blockTime:</span> {blockTime.toFixed(2)}s /{' '}
1374
- {(currentBlock?.duration || 0).toFixed(1)}s
1375
- </div>
1376
- <div>
1377
- <span style={{ color: '#888' }}>segment:</span> {currentSegment}/
1378
- {doc.audio.segments.length - 1}{' '}
1379
- <span style={{ color: '#666' }}>
1380
- ({doc.audio.segments[currentSegment]?.name || 'none'})
1381
- </span>
1382
- </div>
1383
- <div>
1384
- <span style={{ color: '#888' }}>viewport:</span>{' '}
1385
- {activeViewport.name || `${activeViewport.width}x${activeViewport.height}`}{' '}
1386
- <span style={{ color: '#666' }}>({orientation})</span>
1387
- </div>
1388
- <div>
1389
- <span style={{ color: '#888' }}>playing:</span>{' '}
1390
- <span style={{ color: isPlaying ? '#4ade80' : '#f87171' }}>
1391
- {isPlaying ? 'yes' : 'no'}
1392
- </span>
1393
- {showCoverBlock && <span style={{ color: '#60a5fa' }}> (cover)</span>}
1394
- </div>
1395
- {hasCaptions &&
1396
- (() => {
1397
- const debugPhrase = getCaptionAtTime(doc.captions!, currentTime);
1398
- const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
1399
- return (
1400
- <Fragment>
1401
- <div>
1402
- <span style={{ color: '#888' }}>captions:</span>{' '}
1403
- {doc.captions?.phrases.length || 0} phrases{' '}
1404
- <span style={{ color: captionsEnabled ? '#4ade80' : '#666' }}>
1405
- ({captionsEnabled ? 'on' : 'off'})
1406
- </span>
1407
- </div>
1408
- <div>
1409
- <span style={{ color: '#888' }}>cc.enabled:</span>{' '}
1410
- <span style={{ color: debugEnabled ? '#4ade80' : '#f87171' }}>
1411
- {String(debugEnabled)}
1412
- </span>{' '}
1413
- <span style={{ color: '#666' }}>
1414
- (playing={String(isPlaying)} t&gt;0={String(currentTime > 0)})
1415
- </span>
1416
- </div>
1417
- <div>
1418
- <span style={{ color: '#888' }}>cc.phrase:</span>{' '}
1419
- <span style={{ color: debugPhrase ? '#4ade80' : '#f87171' }}>
1420
- {debugPhrase ? `"${debugPhrase.text.slice(0, 30)}..."` : 'null'}
1421
- </span>
1422
- </div>
1423
- {debugPhrase && (
1424
- <div>
1425
- <span style={{ color: '#888' }}>cc.range:</span>{' '}
1426
- <span style={{ color: '#60a5fa' }}>
1427
- {debugPhrase.startTime.toFixed(2)}-{debugPhrase.endTime.toFixed(2)}
1428
- </span>
1429
- </div>
1430
- )}
1431
- </Fragment>
1432
- );
1433
- })()}
1434
- </div>
1435
- )}
1436
- </div>
1437
-
1438
- {/* Audio unavailable overlay */}
1439
- {!isAvailable && unavailableMessage && (
1440
- <div
1441
- className="doc-player__unavailable"
1442
- style={{
1443
- position: 'absolute',
1444
- top: 0,
1445
- left: 0,
1446
- right: 0,
1447
- bottom: 0,
1448
- display: 'flex',
1449
- alignItems: 'center',
1450
- justifyContent: 'center',
1451
- flexDirection: 'column',
1452
- gap: '16px',
1453
- background: 'rgba(0, 0, 0, 0.7)',
1454
- color: 'rgba(255, 255, 255, 0.9)',
1455
- fontSize: '14px',
1456
- zIndex: 50,
1457
- }}
1458
- >
1459
- <span style={{ fontSize: '32px' }}>&#x1F50A;</span>
1460
- <span>{unavailableMessage}</span>
1461
- </div>
1462
- )}
1463
-
1464
- {/* Full overlay controls (default video layout) */}
1465
- {!renderMode && !isSlideshowMode && showControls && (
1466
- <DocControlsOverlay
1467
- state={playbackState}
1468
- actions={playbackActions}
1469
- blockMarkers={blockMarkers}
1470
- expandedBlocks={expandedBlocks}
1471
- getBlockTitle={getBlockTitle}
1472
- />
1473
- )}
1474
-
1475
- {/* Scrubber-only mode (for sidebar/bottom layouts where other controls are external) */}
1476
- {!renderMode && !isSlideshowMode && !showControls && showScrubber && (
1477
- <div
1478
- className="doc-player__scrubber"
1479
- style={{
1480
- position: 'absolute',
1481
- bottom: 0,
1482
- left: 0,
1483
- right: 0,
1484
- padding: '12px 16px 8px',
1485
- background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
1486
- display: 'flex',
1487
- alignItems: 'center',
1488
- zIndex: 100,
1489
- }}
1490
- >
1491
- <DocProgressBar
1492
- state={playbackState}
1493
- actions={playbackActions}
1494
- blockMarkers={blockMarkers}
1495
- expandedBlocks={expandedBlocks}
1496
- getBlockTitle={getBlockTitle}
1497
- />
1498
- </div>
1499
- )}
1500
-
1501
- {/* Slideshow controls (prev / counter / next) */}
1502
- {!renderMode && isSlideshowMode && showControls && (
1503
- <DocControlsSlideshow
1504
- state={playbackState}
1505
- slideNav={slideNavActions}
1506
- slides={slideshowPickerItems}
1507
- pickerOpen={isSlideshowPickerOpen}
1508
- onPickerOpenChange={setIsSlideshowPickerOpen}
1509
- />
1510
- )}
1511
-
1512
- {/* Tap feedback animation -- shows play/pause icon briefly on tap (video mode only) */}
1513
- {!isSlideshowMode && tapFeedback && (
1514
- <div className="doc-player__tap-feedback" key={tapFeedback}>
1515
- <svg viewBox="0 0 24 24" fill="white" width="48" height="48">
1516
- {tapFeedback === 'pause' ? (
1517
- <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
1518
- ) : (
1519
- <path d="M8 5v14l11-7z" />
1520
- )}
1521
- </svg>
1522
- </div>
1523
- )}
1524
- </div>
1525
- );
1526
- }
1527
-
1528
- export default DocPlayer;