@bendyline/squisq-react 1.3.2 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +57 -23
  2. package/dist/index.d.ts +131 -26
  3. package/dist/index.js +1475 -755
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +49 -13
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +2263 -0
  11. package/package.json +9 -5
  12. package/src/BlockRenderer.tsx +15 -7
  13. package/src/DocPlayer.tsx +222 -55
  14. package/src/DocPlayerWithSidebar.tsx +21 -9
  15. package/src/DocProgressBar.tsx +21 -3
  16. package/src/LinearDocView.tsx +69 -206
  17. package/src/MarkdownRenderer.tsx +182 -41
  18. package/src/MediaClipLayer.tsx +135 -0
  19. package/src/__tests__/DocPlayer.test.tsx +81 -0
  20. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
  21. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  22. package/src/__tests__/LinearDocView.test.tsx +53 -1
  23. package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
  24. package/src/__tests__/PathLayer.test.tsx +73 -0
  25. package/src/__tests__/fillStyle.test.tsx +112 -0
  26. package/src/__tests__/transitionStyles.test.ts +125 -0
  27. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  28. package/src/__tests__/useJsonViewTokens.test.ts +41 -0
  29. package/src/__tests__/useSlideSwipe.test.ts +81 -0
  30. package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
  31. package/src/hooks/index.ts +7 -2
  32. package/src/hooks/useAudioSync.ts +19 -5
  33. package/src/hooks/useDocPlayback.ts +81 -100
  34. package/src/hooks/useMediaSchedule.ts +39 -0
  35. package/src/hooks/useSlideSwipe.ts +265 -0
  36. package/src/index.ts +8 -1
  37. package/src/jsonView/useJsonViewTokens.ts +6 -31
  38. package/src/layers/ImageLayer.tsx +11 -1
  39. package/src/layers/PathLayer.tsx +146 -0
  40. package/src/layers/ShapeLayer.tsx +27 -5
  41. package/src/layers/TextLayer.tsx +395 -22
  42. package/src/layers/VideoLayer.tsx +16 -9
  43. package/src/layers/index.ts +1 -0
  44. package/src/standalone-entry.tsx +1 -1
  45. package/src/styles/doc-animations.css +1936 -35
  46. package/src/utils/fillStyle.tsx +148 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -34,7 +34,7 @@
34
34
  "types": "./dist/index.d.ts",
35
35
  "import": "./dist/index.js"
36
36
  },
37
- "./styles": "./src/styles/index.css",
37
+ "./styles": "./dist/styles/index.css",
38
38
  "./standalone": "./dist/squisq-player.global.js",
39
39
  "./standalone-source": {
40
40
  "types": "./src/standalone-source.d.ts",
@@ -42,7 +42,7 @@
42
42
  }
43
43
  },
44
44
  "scripts": {
45
- "build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
45
+ "build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs && node ../../scripts/build-styles.mjs",
46
46
  "build:esm": "tsup",
47
47
  "build:standalone": "tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
48
48
  "dev": "npm run build:standalone && concurrently -n js,dts -c blue,gray -r \"tsup --watch --no-dts --no-clean\" \"tsup --watch --dts-only --no-clean\"",
@@ -53,17 +53,21 @@
53
53
  "react-dom": "^18.0.0 || ^19.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@bendyline/squisq": "1.4.1"
56
+ "@bendyline/squisq": "1.5.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/react": "18.3.28",
60
60
  "preact": "10.29.0",
61
61
  "react": "18.3.1",
62
62
  "react-dom": "18.3.1",
63
+ "@testing-library/dom": "10.4.1",
63
64
  "@testing-library/react": "16.3.2",
64
65
  "@testing-library/jest-dom": "6.9.1",
65
66
  "jsdom": "25.0.1",
66
67
  "tsup": "8.5.1",
67
68
  "typescript": "5.9.3"
68
- }
69
+ },
70
+ "sideEffects": [
71
+ "**/*.css"
72
+ ]
69
73
  }
@@ -6,10 +6,12 @@
6
6
  * Handles positioning, animations, and transitions.
7
7
  */
8
8
 
9
- import type { Block, Layer } from '@bendyline/squisq/schemas';
9
+ import type { Block, Layer, Transition } from '@bendyline/squisq/schemas';
10
+ import { resolveTransitionDuration } from '@bendyline/squisq/schemas';
10
11
  import { ImageLayer } from './layers/ImageLayer';
11
12
  import { TextLayer } from './layers/TextLayer';
12
13
  import { ShapeLayer } from './layers/ShapeLayer';
14
+ import { PathLayer } from './layers/PathLayer';
13
15
  import { MapLayer } from './layers/MapLayer';
14
16
  import { VideoLayer } from './layers/VideoLayer';
15
17
  import { TableLayer } from './layers/TableLayer';
@@ -39,6 +41,8 @@ interface BlockRendererProps {
39
41
  isEntering?: boolean;
40
42
  /** Whether this block is exiting (for transition) */
41
43
  isExiting?: boolean;
44
+ /** Transition to apply. Defaults to block.transition. */
45
+ transition?: Transition;
42
46
  /** Viewport dimensions (defaults to 1920x1080 landscape) */
43
47
  viewport?: ViewportDimensions;
44
48
  /** Whether the doc is currently playing (controls video playback) */
@@ -51,18 +55,20 @@ export function BlockRenderer({
51
55
  basePath,
52
56
  isEntering = false,
53
57
  isExiting = false,
58
+ transition,
54
59
  viewport = VIEWPORT,
55
60
  isPlaying,
56
61
  }: BlockRendererProps) {
57
62
  // Build transition class and inline style for dynamic duration
58
63
  let transitionClass = '';
59
64
  const transitionStyle: Record<string, string> = {};
60
- if (block.transition && isEntering) {
61
- transitionClass = getTransitionClass(block.transition.type, true);
62
- transitionStyle['--transition-duration'] = `${block.transition.duration}s`;
63
- } else if (block.transition && isExiting) {
64
- transitionClass = getTransitionClass(block.transition.type, false);
65
- transitionStyle['--transition-duration'] = `${block.transition.duration}s`;
65
+ const activeTransition = transition ?? block.transition;
66
+ if (activeTransition && isEntering) {
67
+ transitionClass = getTransitionClass(activeTransition.type, true, activeTransition.direction);
68
+ transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
69
+ } else if (activeTransition && isExiting) {
70
+ transitionClass = getTransitionClass(activeTransition.type, false, activeTransition.direction);
71
+ transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
66
72
  }
67
73
 
68
74
  // Unique clip path ID per block to avoid conflicts when multiple blocks render simultaneously
@@ -124,6 +130,8 @@ function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: Laye
124
130
  return <TextLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
125
131
  case 'shape':
126
132
  return <ShapeLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
133
+ case 'path':
134
+ return <PathLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
127
135
  case 'map':
128
136
  return (
129
137
  <MapLayer layer={layer} basePath={basePath} viewport={viewport} blockTime={blockTime} />
package/src/DocPlayer.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  * - Playback controls (play/pause, seek, next/prev)
12
12
  * - Progress display
13
13
  * - Render mode for video capture (via window.seekTo)
14
- * - Pluggable audio provider for different environments (browser, EFB)
14
+ * - Pluggable audio controller for different environments (browser, EFB)
15
15
  * - Multiple control layouts: overlay (default), sidebar, bottom
16
16
  *
17
17
  * Related Files:
@@ -24,7 +24,13 @@
24
24
 
25
25
  import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
26
  import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
27
- import { isTemplateBlock, getCaptionAtTime } from '@bendyline/squisq/schemas';
27
+ import {
28
+ isTemplateBlock,
29
+ getCaptionAtTime,
30
+ resolveMediaSchedule,
31
+ getDocPlaybackDuration,
32
+ } from '@bendyline/squisq/schemas';
33
+ import { MediaClipLayer } from './MediaClipLayer';
28
34
  import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
29
35
  import { applySurface } from '@bendyline/squisq/schemas';
30
36
  import { BlockRenderer } from './BlockRenderer';
@@ -33,14 +39,17 @@ import { useAutoSurface } from './hooks/useAutoSurface';
33
39
  import { useAudioSync } from './hooks/useAudioSync';
34
40
  import { useDocPlayback } from './hooks/useDocPlayback';
35
41
  import { useViewportOrientation } from './hooks/useViewportOrientation';
36
- import type { AudioProvider } from './hooks/AudioProvider';
42
+ import { useSlideSwipe } from './hooks/useSlideSwipe';
43
+ import type { AudioController } from './hooks/AudioController';
37
44
  import {
38
45
  expandCoverBlock,
39
46
  createTemplateContext,
47
+ markdownToDoc,
40
48
  DEFAULT_THEME,
41
49
  VIEWPORT_PRESETS,
42
50
  type ViewportConfig,
43
51
  } from '@bendyline/squisq/doc';
52
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
44
53
  import { DocControlsOverlay } from './DocControlsOverlay';
45
54
  import { DocControlsSlideshow } from './DocControlsSlideshow';
46
55
  import { DocProgressBar } from './DocProgressBar';
@@ -79,11 +88,11 @@ const SMALL_WORDS = new Set([
79
88
  * Uses sectionHeader blocks to find real titles, with fallbacks
80
89
  * for "intro" and slug-based names.
81
90
  */
82
- function buildSegmentTitleMap(script: Doc): Map<number, string> {
91
+ function buildSegmentTitleMap(doc: Doc): Map<number, string> {
83
92
  const map = new Map<number, string>();
84
93
 
85
94
  // Scan blocks for sectionHeader templates which carry the real title
86
- for (const block of script.blocks as DocBlock[]) {
95
+ for (const block of doc.blocks as DocBlock[]) {
87
96
  if (isTemplateBlock(block) && block.template === 'sectionHeader' && 'title' in block) {
88
97
  const segIdx = block.audioSegment;
89
98
  if (!map.has(segIdx)) {
@@ -93,9 +102,9 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
93
102
  }
94
103
 
95
104
  // Fill in any segments that weren't covered by sectionHeader blocks
96
- for (let i = 0; i < script.audio.segments.length; i++) {
105
+ for (let i = 0; i < doc.audio.segments.length; i++) {
97
106
  if (!map.has(i)) {
98
- const name = script.audio.segments[i].name;
107
+ const name = doc.audio.segments[i].name;
99
108
  if (name === 'intro' || name.includes('intro')) {
100
109
  map.set(i, 'Introduction');
101
110
  } else if (name === 'flight-context' || name.includes('flight-context')) {
@@ -117,10 +126,20 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
117
126
  }
118
127
 
119
128
  interface DocPlayerProps {
120
- /** Doc script to play */
121
- script: Doc;
122
- /** Base path for resolving media URLs */
123
- basePath: string;
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;
124
143
  /** Render mode for video capture (hides controls, exposes seekTo) */
125
144
  renderMode?: boolean;
126
145
  /** Auto-play when loaded */
@@ -129,8 +148,8 @@ interface DocPlayerProps {
129
148
  onEnded?: () => void;
130
149
  /** Callback for time updates */
131
150
  onTimeUpdate?: (time: number) => void;
132
- /** Optional audio provider (if not provided, uses default HTML5 audio) */
133
- audioProvider?: AudioProvider;
151
+ /** Optional audio controller (if not provided, uses default HTML5 audio) */
152
+ audioController?: AudioController;
134
153
  /** Show built-in controls (default: true). Set to false for custom controls. */
135
154
  showControls?: boolean;
136
155
  /** Show only the progress bar/scrubber at bottom (no other controls).
@@ -183,16 +202,66 @@ interface DocPlayerProps {
183
202
  /** Caption display style (default: 'standard').
184
203
  * 'social' shows large centered words with the active word highlighted. */
185
204
  captionStyle?: CaptionStyle;
205
+ /**
206
+ * Enable drag-to-swipe slide navigation in slideshow mode (default: true).
207
+ * When enabled, press-and-drag on a slide advances/rewinds on release past a
208
+ * threshold (or a quick flick), and snaps back otherwise. Only applies when
209
+ * `displayMode === 'slideshow'` and not in render/headless mode.
210
+ */
211
+ enableSwipe?: boolean;
212
+ }
213
+
214
+ // Dev-only, browser-safe environment probe. Bundlers substitute the
215
+ // `process.env.NODE_ENV` expression; bare browsers without a bundler have
216
+ // no `process` at all and are treated as production (no warning noise).
217
+ function isDevEnvironment(): boolean {
218
+ try {
219
+ return typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
220
+ } catch {
221
+ return false;
222
+ }
186
223
  }
187
224
 
188
- export function DocPlayer({
189
- script,
190
- basePath,
225
+ // One-shot flag for the missing-stylesheet warning (module-level so the
226
+ // warning fires at most once per page, not once per player instance).
227
+ let warnedMissingStyles = false;
228
+
229
+ /**
230
+ * Front-door component: resolves the `doc` / `markdown` props into a Doc
231
+ * and renders a themed empty state when neither is provided. The playback
232
+ * machinery lives in `DocPlayerContent` so its hook order never changes
233
+ * when a doc appears or disappears.
234
+ */
235
+ export function DocPlayer(props: DocPlayerProps) {
236
+ const { doc, markdown } = props;
237
+
238
+ // Parse markdown into a Doc only when no explicit doc is supplied.
239
+ const markdownDoc = useMemo(
240
+ () => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
241
+ [doc, markdown],
242
+ );
243
+
244
+ const resolvedDoc = doc ?? markdownDoc;
245
+
246
+ if (!resolvedDoc) {
247
+ return <div className="doc-player doc-player--empty" />;
248
+ }
249
+
250
+ return <DocPlayerContent {...props} doc={resolvedDoc} />;
251
+ }
252
+
253
+ interface DocPlayerContentProps extends DocPlayerProps {
254
+ doc: Doc;
255
+ }
256
+
257
+ function DocPlayerContent({
258
+ doc,
259
+ basePath = '.',
191
260
  renderMode = false,
192
261
  autoPlay = false,
193
262
  onEnded,
194
263
  onTimeUpdate,
195
- audioProvider: externalAudioProvider,
264
+ audioController: externalAudioController,
196
265
  showControls = true,
197
266
  showScrubber = false,
198
267
  muted = false,
@@ -208,7 +277,8 @@ export function DocPlayer({
208
277
  theme,
209
278
  surface,
210
279
  captionStyle = 'standard',
211
- }: DocPlayerProps) {
280
+ enableSwipe = true,
281
+ }: DocPlayerContentProps) {
212
282
  const isSlideshowMode = displayMode === 'slideshow';
213
283
  const isLinearMode = displayMode === 'linear';
214
284
  const audioRef = useRef<HTMLAudioElement>(null);
@@ -231,11 +301,27 @@ export function DocPlayer({
231
301
  return params.get('debug') === 'true';
232
302
  }, []);
233
303
 
234
- // Use internal HTML5 audio sync if no external provider is given
235
- const internalAudio = useAudioSync(audioRef, script.audio, basePath);
304
+ // Use internal HTML5 audio sync if no external controller is given
305
+ const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
236
306
 
237
- // Use external provider if provided, otherwise fall back to internal
238
- const audio = externalAudioProvider || internalAudio;
307
+ // Use external controller if provided, otherwise fall back to internal
308
+ const audio = externalAudioController || internalAudio;
309
+
310
+ // Dev-only sentinel: warn once when the package stylesheet isn't loaded.
311
+ // The stylesheet sets `--squisq-styles-loaded: 1` on `.doc-player`; if the
312
+ // mounted container computes an empty value, the CSS never made it in.
313
+ useEffect(() => {
314
+ if (warnedMissingStyles || !isDevEnvironment()) return;
315
+ const el = containerRef.current;
316
+ if (!el || typeof getComputedStyle !== 'function') return;
317
+ const value = getComputedStyle(el).getPropertyValue('--squisq-styles-loaded');
318
+ if (!value.trim()) {
319
+ warnedMissingStyles = true;
320
+ console.warn(
321
+ '[squisq] @bendyline/squisq-react/styles is not loaded — import "@bendyline/squisq-react/styles"',
322
+ );
323
+ }
324
+ }, []);
239
325
 
240
326
  // Destructure for convenience
241
327
  const {
@@ -255,6 +341,11 @@ export function DocPlayer({
255
341
  restart,
256
342
  } = audio;
257
343
 
344
+ // Timed media clips (block.media + doc.documentMedia) resolved to absolute
345
+ // doc-timeline coordinates. Empty for documents without the media model, so
346
+ // <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
347
+ const mediaSchedule = useMemo(() => resolveMediaSchedule(doc), [doc]);
348
+
258
349
  // Refs for frequently-changing values used in the keyboard handler,
259
350
  // so the handler callback doesn't need to be recreated every frame.
260
351
  const currentTimeRef = useRef(currentTime);
@@ -307,11 +398,11 @@ export function DocPlayer({
307
398
  nextBlock: _nextBlock,
308
399
  prevBlock: _prevBlock,
309
400
  blocks: expandedBlocks,
310
- } = useDocPlayback(script, currentTime, activeViewport, renderMode, effectiveTheme);
401
+ } = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
311
402
 
312
403
  // Expand cover block (startBlock) if present - uses active viewport
313
404
  const coverBlock = useMemo((): Block | null => {
314
- const startBlockConfig = script.startBlock as StartBlockConfig | undefined;
405
+ const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
315
406
  if (!startBlockConfig) return null;
316
407
 
317
408
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
@@ -324,7 +415,7 @@ export function DocPlayer({
324
415
  audioSegment: -1,
325
416
  layers,
326
417
  };
327
- }, [script.startBlock, activeViewport, effectiveTheme]);
418
+ }, [doc.startBlock, activeViewport, effectiveTheme]);
328
419
 
329
420
  // Render-mode cover block control: allows Playwright to force-show the cover block
330
421
  const [coverForced, setCoverForced] = useState(false);
@@ -450,7 +541,13 @@ export function DocPlayer({
450
541
  const video = el as HTMLVideoElement;
451
542
  const clipStart = parseFloat(video.dataset.clipStart || '0');
452
543
  const clipEnd = parseFloat(video.dataset.clipEnd || '0');
453
- const targetTime = Math.min(clipStart + Math.max(0, blockElapsed), clipEnd);
544
+ // Honor the per-clip startAt offset: before it, hold at the
545
+ // in-point; after, advance by (blockElapsed - startAt).
546
+ const startAt = parseFloat(video.dataset.startAt || '0');
547
+ const targetTime = Math.min(
548
+ clipStart + Math.max(0, blockElapsed - startAt),
549
+ clipEnd,
550
+ );
454
551
 
455
552
  video.pause();
456
553
  video.currentTime = targetTime;
@@ -468,6 +565,30 @@ export function DocPlayer({
468
565
  });
469
566
  }
470
567
 
568
+ // Seek player-level scheduled videos (document-spanning clips
569
+ // rendered by MediaClipLayer, outside any single block). Each
570
+ // carries data-abs-start/data-abs-end/data-source-in.
571
+ document.querySelectorAll('video[data-clip-id]').forEach((el) => {
572
+ const video = el as HTMLVideoElement;
573
+ const absStart = parseFloat(video.dataset.absStart || '0');
574
+ const absEnd = parseFloat(video.dataset.absEnd || '0');
575
+ const sourceIn = parseFloat(video.dataset.sourceIn || '0');
576
+ video.pause();
577
+ if (time < absStart || time >= absEnd) return;
578
+ const targetTime = sourceIn + (time - absStart);
579
+ video.currentTime = targetTime;
580
+ videoSeekPromises.push(
581
+ new Promise<void>((r) => {
582
+ if (Math.abs(video.currentTime - targetTime) < 0.1) {
583
+ r();
584
+ } else {
585
+ video.addEventListener('seeked', () => r(), { once: true });
586
+ setTimeout(r, 200);
587
+ }
588
+ }),
589
+ );
590
+ });
591
+
471
592
  // Wait for video seeks + one more frame for the browser to render
472
593
  Promise.all(videoSeekPromises).then(() => {
473
594
  requestAnimationFrame(() => resolve());
@@ -476,14 +597,12 @@ export function DocPlayer({
476
597
  });
477
598
  };
478
599
  w.getDuration = () => {
479
- // When audio is present totalDuration comes from audio segments.
480
- // For audio-less docs, compute from block timings instead.
481
- if (totalDuration > 0) return totalDuration;
482
- if (expandedBlocks.length > 0) {
483
- const last = expandedBlocks[expandedBlocks.length - 1];
484
- return last.startTime + last.duration;
485
- }
486
- return 0;
600
+ // The larger of the audio/block timeline and any media that spills
601
+ // past the last block (block-clip spillover or document-spanning
602
+ // media), so frame capture covers the full tail.
603
+ const mediaDuration = getDocPlaybackDuration(doc);
604
+ if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
605
+ return mediaDuration;
487
606
  };
488
607
  // Expose block metadata for testing -- allows tests to find specific templates
489
608
  w.getBlocks = () =>
@@ -495,7 +614,7 @@ export function DocPlayer({
495
614
  }));
496
615
  // Audio segment info for video production -- returns the actual files in composition order
497
616
  w.getAudioSegments = () =>
498
- script.audio.segments.map((seg) => ({
617
+ doc.audio.segments.map((seg) => ({
499
618
  src: seg.src,
500
619
  name: seg.name,
501
620
  duration: seg.duration,
@@ -503,15 +622,15 @@ export function DocPlayer({
503
622
  }));
504
623
  // Caption phrases for SRT/subtitle export
505
624
  w.getCaptions = () =>
506
- script.captions?.phrases?.map((p) => ({
625
+ doc.captions?.phrases?.map((p) => ({
507
626
  text: p.text,
508
627
  startTime: p.startTime,
509
628
  endTime: p.endTime,
510
629
  })) || [];
511
630
  // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
512
631
  w.getChapters = () => {
513
- const titleMap = buildSegmentTitleMap(script);
514
- return script.audio.segments.map((seg, i) => ({
632
+ const titleMap = buildSegmentTitleMap(doc);
633
+ return doc.audio.segments.map((seg, i) => ({
515
634
  title: titleMap.get(i) || seg.name,
516
635
  startTime: seg.startTime,
517
636
  duration: seg.duration,
@@ -542,7 +661,7 @@ export function DocPlayer({
542
661
  delete w.hasCoverBlock;
543
662
  }
544
663
  };
545
- // eslint-disable-next-line react-hooks/exhaustive-deps -- script is a stable prop; re-registering on every script change is unnecessary
664
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- doc is a stable prop; re-registering on every doc change is unnecessary
546
665
  }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
547
666
 
548
667
  // Caption mode state: cycles through off → standard → social → off
@@ -552,6 +671,15 @@ export function DocPlayer({
552
671
  captionsEnabledProp === false ? 'off' : captionStyle || 'standard';
553
672
  const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultMode);
554
673
 
674
+ // Keep the internal caption mode in sync when the controlling props change
675
+ // — e.g. the editor's preview toolbar drives caption style / on-off. Keyed
676
+ // on the derived `defaultMode` string, so it only fires on a real prop
677
+ // change and never disturbs the in-player CC toggle for consumers (the
678
+ // standalone player, video export) that set these props once at mount.
679
+ useEffect(() => {
680
+ setCaptionMode(defaultMode);
681
+ }, [defaultMode]);
682
+
555
683
  // Derive captionsEnabled and active style from the mode
556
684
  const captionsEnabled = captionMode !== 'off';
557
685
  const activeCaptionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
@@ -575,10 +703,10 @@ export function DocPlayer({
575
703
  });
576
704
  }, [onCaptionsToggle]);
577
705
 
578
- const hasCaptions = script.captions && script.captions.phrases.length > 0;
706
+ const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
579
707
 
580
708
  // Map segment indices to human-readable titles (from sectionHeader blocks)
581
- const segmentTitleMap = useMemo(() => buildSegmentTitleMap(script), [script]);
709
+ const segmentTitleMap = useMemo(() => buildSegmentTitleMap(doc), [doc]);
582
710
 
583
711
  // Build shared playback state for extracted controls
584
712
  const playbackState: PlaybackState = useMemo(
@@ -595,10 +723,10 @@ export function DocPlayer({
595
723
  isFullscreen,
596
724
  currentSegmentIndex: currentSegment,
597
725
  currentSegmentName:
598
- segmentTitleMap.get(currentSegment) ?? script.audio.segments[currentSegment]?.name ?? null,
726
+ segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
599
727
  currentBlock: currentBlock ?? null,
600
728
  }),
601
- // eslint-disable-next-line react-hooks/exhaustive-deps -- script.audio.segments is stable within a given script
729
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
602
730
  [
603
731
  isPlaying,
604
732
  currentTime,
@@ -664,6 +792,18 @@ export function DocPlayer({
664
792
  [currentBlockIndex, expandedBlocks, seekTo, pause],
665
793
  );
666
794
 
795
+ // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
796
+ // interactive (not headless), and not overridden off via `enableSwipe`.
797
+ const swipeEnabled = isSlideshowMode && !isLinearMode && !renderMode && enableSwipe;
798
+ const swipe = useSlideSwipe({
799
+ enabled: swipeEnabled,
800
+ containerRef,
801
+ canGoNext: currentBlockIndex < expandedBlocks.length - 1,
802
+ canGoPrev: currentBlockIndex > 0,
803
+ onNext: slideNavActions.nextSlide,
804
+ onPrev: slideNavActions.prevSlide,
805
+ });
806
+
667
807
  // Callback for playback state changes (for external controls)
668
808
  useEffect(() => {
669
809
  onPlaybackStateChange?.(playbackState);
@@ -816,7 +956,7 @@ export function DocPlayer({
816
956
  }}
817
957
  >
818
958
  <LinearDocView
819
- doc={script}
959
+ doc={doc}
820
960
  basePath={basePath}
821
961
  viewport={activeViewport}
822
962
  theme={theme}
@@ -829,20 +969,35 @@ export function DocPlayer({
829
969
  return (
830
970
  <div
831
971
  ref={containerRef}
832
- className="doc-player"
972
+ className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
973
+ swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
974
+ }`}
833
975
  onClick={handleContainerClick}
976
+ onPointerDown={swipe.onPointerDown}
834
977
  style={{
835
978
  position: 'relative',
836
979
  width: '100%',
837
980
  aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
838
981
  margin: '0 auto',
839
982
  overflow: 'hidden',
840
- cursor: renderMode ? undefined : 'pointer',
983
+ // Swipe uses the grab/grabbing cursor via CSS classes; let vertical page
984
+ // scroll through on touch while we own horizontal drags.
985
+ cursor: renderMode || swipeEnabled ? undefined : 'pointer',
986
+ touchAction: swipeEnabled ? 'pan-y' : undefined,
841
987
  }}
842
988
  >
843
989
  {/* Hidden audio element */}
844
990
  <audio ref={audioRef} preload="auto" muted={muted} />
845
991
 
992
+ {/* Timed media clips (per-block + document-spanning audio/video). */}
993
+ <MediaClipLayer
994
+ schedule={mediaSchedule}
995
+ currentTime={currentTime}
996
+ isPlaying={isPlaying}
997
+ basePath={basePath}
998
+ renderMode={renderMode}
999
+ />
1000
+
846
1001
  {/* Block viewport */}
847
1002
  <div className="doc-player__viewport">
848
1003
  {/* Cover block (shown at rest before playback) */}
@@ -860,12 +1015,17 @@ export function DocPlayer({
860
1015
 
861
1016
  {/* Previous block (during transition) */}
862
1017
  {!showCoverBlock && previousBlock && isExiting && (
863
- <div className="doc-player__block doc-player__block--previous">
1018
+ // Keyed by block id so each block is its own DOM subtree: React never
1019
+ // reconciles one block's layers onto another's (templates reuse layer
1020
+ // ids like `title`/`background`), which would otherwise reuse stale
1021
+ // DOM / skip entrance animations mid-transition.
1022
+ <div key={previousBlock.id} className="doc-player__block doc-player__block--previous">
864
1023
  <BlockRenderer
865
1024
  block={previousBlock}
866
1025
  blockTime={blockTime}
867
1026
  basePath={basePath}
868
1027
  isExiting={true}
1028
+ transition={currentBlock?.transition}
869
1029
  viewport={activeViewport}
870
1030
  />
871
1031
  </div>
@@ -873,7 +1033,15 @@ export function DocPlayer({
873
1033
 
874
1034
  {/* Current block */}
875
1035
  {!showCoverBlock && currentBlock && (
876
- <div className="doc-player__block doc-player__block--active">
1036
+ <div
1037
+ key={currentBlock.id}
1038
+ className={`doc-player__block doc-player__block--active${
1039
+ swipe.phase !== 'idle' ? ` doc-player__block--${swipe.phase}` : ''
1040
+ }`}
1041
+ style={
1042
+ swipe.phase !== 'idle' ? { transform: `translateX(${swipe.offsetPx}px)` } : undefined
1043
+ }
1044
+ >
877
1045
  <BlockRenderer
878
1046
  block={currentBlock}
879
1047
  blockTime={blockTime}
@@ -888,7 +1056,7 @@ export function DocPlayer({
888
1056
  {/* Caption overlay -- shown during playback and in render mode when captions are enabled */}
889
1057
  {hasCaptions && (renderMode ? captionsEnabled : true) && (
890
1058
  <CaptionOverlay
891
- captions={script.captions}
1059
+ captions={doc.captions}
892
1060
  currentTime={currentTime}
893
1061
  enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
894
1062
  fontSize={16}
@@ -937,8 +1105,7 @@ export function DocPlayer({
937
1105
  <span style={{ color: '#888' }}>time:</span> {currentTime.toFixed(2)}s /{' '}
938
1106
  {totalDuration.toFixed(1)}s{' '}
939
1107
  <span style={{ color: '#666' }}>
940
- (progress: {(docProgress * 100).toFixed(1)}%, scriptDur:{' '}
941
- {script.duration.toFixed(1)})
1108
+ (progress: {(docProgress * 100).toFixed(1)}%, scriptDur: {doc.duration.toFixed(1)})
942
1109
  </span>
943
1110
  </div>
944
1111
  <div>
@@ -947,9 +1114,9 @@ export function DocPlayer({
947
1114
  </div>
948
1115
  <div>
949
1116
  <span style={{ color: '#888' }}>segment:</span> {currentSegment}/
950
- {script.audio.segments.length - 1}{' '}
1117
+ {doc.audio.segments.length - 1}{' '}
951
1118
  <span style={{ color: '#666' }}>
952
- ({script.audio.segments[currentSegment]?.name || 'none'})
1119
+ ({doc.audio.segments[currentSegment]?.name || 'none'})
953
1120
  </span>
954
1121
  </div>
955
1122
  <div>
@@ -966,13 +1133,13 @@ export function DocPlayer({
966
1133
  </div>
967
1134
  {hasCaptions &&
968
1135
  (() => {
969
- const debugPhrase = getCaptionAtTime(script.captions!, currentTime);
1136
+ const debugPhrase = getCaptionAtTime(doc.captions!, currentTime);
970
1137
  const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
971
1138
  return (
972
1139
  <Fragment>
973
1140
  <div>
974
1141
  <span style={{ color: '#888' }}>captions:</span>{' '}
975
- {script.captions?.phrases.length || 0} phrases{' '}
1142
+ {doc.captions?.phrases.length || 0} phrases{' '}
976
1143
  <span style={{ color: captionsEnabled ? '#4ade80' : '#666' }}>
977
1144
  ({captionsEnabled ? 'on' : 'off'})
978
1145
  </span>