@bendyline/squisq-react 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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:
@@ -39,14 +39,17 @@ import { useAutoSurface } from './hooks/useAutoSurface';
39
39
  import { useAudioSync } from './hooks/useAudioSync';
40
40
  import { useDocPlayback } from './hooks/useDocPlayback';
41
41
  import { useViewportOrientation } from './hooks/useViewportOrientation';
42
- import type { AudioProvider } from './hooks/AudioProvider';
42
+ import { useSlideSwipe } from './hooks/useSlideSwipe';
43
+ import type { AudioController } from './hooks/AudioController';
43
44
  import {
44
45
  expandCoverBlock,
45
46
  createTemplateContext,
47
+ markdownToDoc,
46
48
  DEFAULT_THEME,
47
49
  VIEWPORT_PRESETS,
48
50
  type ViewportConfig,
49
51
  } from '@bendyline/squisq/doc';
52
+ import { parseMarkdown } from '@bendyline/squisq/markdown';
50
53
  import { DocControlsOverlay } from './DocControlsOverlay';
51
54
  import { DocControlsSlideshow } from './DocControlsSlideshow';
52
55
  import { DocProgressBar } from './DocProgressBar';
@@ -85,11 +88,11 @@ const SMALL_WORDS = new Set([
85
88
  * Uses sectionHeader blocks to find real titles, with fallbacks
86
89
  * for "intro" and slug-based names.
87
90
  */
88
- function buildSegmentTitleMap(script: Doc): Map<number, string> {
91
+ function buildSegmentTitleMap(doc: Doc): Map<number, string> {
89
92
  const map = new Map<number, string>();
90
93
 
91
94
  // Scan blocks for sectionHeader templates which carry the real title
92
- for (const block of script.blocks as DocBlock[]) {
95
+ for (const block of doc.blocks as DocBlock[]) {
93
96
  if (isTemplateBlock(block) && block.template === 'sectionHeader' && 'title' in block) {
94
97
  const segIdx = block.audioSegment;
95
98
  if (!map.has(segIdx)) {
@@ -99,9 +102,9 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
99
102
  }
100
103
 
101
104
  // Fill in any segments that weren't covered by sectionHeader blocks
102
- for (let i = 0; i < script.audio.segments.length; i++) {
105
+ for (let i = 0; i < doc.audio.segments.length; i++) {
103
106
  if (!map.has(i)) {
104
- const name = script.audio.segments[i].name;
107
+ const name = doc.audio.segments[i].name;
105
108
  if (name === 'intro' || name.includes('intro')) {
106
109
  map.set(i, 'Introduction');
107
110
  } else if (name === 'flight-context' || name.includes('flight-context')) {
@@ -123,10 +126,20 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
123
126
  }
124
127
 
125
128
  interface DocPlayerProps {
126
- /** Doc script to play */
127
- script: Doc;
128
- /** Base path for resolving media URLs */
129
- 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;
130
143
  /** Render mode for video capture (hides controls, exposes seekTo) */
131
144
  renderMode?: boolean;
132
145
  /** Auto-play when loaded */
@@ -135,8 +148,8 @@ interface DocPlayerProps {
135
148
  onEnded?: () => void;
136
149
  /** Callback for time updates */
137
150
  onTimeUpdate?: (time: number) => void;
138
- /** Optional audio provider (if not provided, uses default HTML5 audio) */
139
- audioProvider?: AudioProvider;
151
+ /** Optional audio controller (if not provided, uses default HTML5 audio) */
152
+ audioController?: AudioController;
140
153
  /** Show built-in controls (default: true). Set to false for custom controls. */
141
154
  showControls?: boolean;
142
155
  /** Show only the progress bar/scrubber at bottom (no other controls).
@@ -186,19 +199,74 @@ interface DocPlayerProps {
186
199
  * template-annotated sections as inline SVG cards. No audio, no timeline.
187
200
  */
188
201
  displayMode?: DisplayMode;
202
+ /**
203
+ * Whether to synthesize and show the managed cover slide from
204
+ * `doc.startBlock`. Defaults to true for existing documents.
205
+ */
206
+ showCoverSlide?: boolean;
189
207
  /** Caption display style (default: 'standard').
190
208
  * 'social' shows large centered words with the active word highlighted. */
191
209
  captionStyle?: CaptionStyle;
210
+ /**
211
+ * Enable drag-to-swipe slide navigation in slideshow mode (default: true).
212
+ * When enabled, press-and-drag on a slide advances/rewinds on release past a
213
+ * threshold (or a quick flick), and snaps back otherwise. Only applies when
214
+ * `displayMode === 'slideshow'` and not in render/headless mode.
215
+ */
216
+ enableSwipe?: boolean;
192
217
  }
193
218
 
194
- export function DocPlayer({
195
- script,
196
- basePath,
219
+ // Dev-only, browser-safe environment probe. Bundlers substitute the
220
+ // `process.env.NODE_ENV` expression; bare browsers without a bundler have
221
+ // no `process` at all and are treated as production (no warning noise).
222
+ function isDevEnvironment(): boolean {
223
+ try {
224
+ return typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
225
+ } catch {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ // One-shot flag for the missing-stylesheet warning (module-level so the
231
+ // warning fires at most once per page, not once per player instance).
232
+ let warnedMissingStyles = false;
233
+
234
+ /**
235
+ * Front-door component: resolves the `doc` / `markdown` props into a Doc
236
+ * and renders a themed empty state when neither is provided. The playback
237
+ * machinery lives in `DocPlayerContent` so its hook order never changes
238
+ * when a doc appears or disappears.
239
+ */
240
+ export function DocPlayer(props: DocPlayerProps) {
241
+ const { doc, markdown } = props;
242
+
243
+ // Parse markdown into a Doc only when no explicit doc is supplied.
244
+ const markdownDoc = useMemo(
245
+ () => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
246
+ [doc, markdown],
247
+ );
248
+
249
+ const resolvedDoc = doc ?? markdownDoc;
250
+
251
+ if (!resolvedDoc) {
252
+ return <div className="doc-player doc-player--empty" />;
253
+ }
254
+
255
+ return <DocPlayerContent {...props} doc={resolvedDoc} />;
256
+ }
257
+
258
+ interface DocPlayerContentProps extends DocPlayerProps {
259
+ doc: Doc;
260
+ }
261
+
262
+ function DocPlayerContent({
263
+ doc,
264
+ basePath = '.',
197
265
  renderMode = false,
198
266
  autoPlay = false,
199
267
  onEnded,
200
268
  onTimeUpdate,
201
- audioProvider: externalAudioProvider,
269
+ audioController: externalAudioController,
202
270
  showControls = true,
203
271
  showScrubber = false,
204
272
  muted = false,
@@ -211,10 +279,12 @@ export function DocPlayer({
211
279
  onBlockMarkers,
212
280
  forceViewport,
213
281
  displayMode = 'video',
282
+ showCoverSlide = true,
214
283
  theme,
215
284
  surface,
216
285
  captionStyle = 'standard',
217
- }: DocPlayerProps) {
286
+ enableSwipe = true,
287
+ }: DocPlayerContentProps) {
218
288
  const isSlideshowMode = displayMode === 'slideshow';
219
289
  const isLinearMode = displayMode === 'linear';
220
290
  const audioRef = useRef<HTMLAudioElement>(null);
@@ -237,11 +307,27 @@ export function DocPlayer({
237
307
  return params.get('debug') === 'true';
238
308
  }, []);
239
309
 
240
- // Use internal HTML5 audio sync if no external provider is given
241
- const internalAudio = useAudioSync(audioRef, script.audio, basePath);
310
+ // Use internal HTML5 audio sync if no external controller is given
311
+ const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
242
312
 
243
- // Use external provider if provided, otherwise fall back to internal
244
- const audio = externalAudioProvider || internalAudio;
313
+ // Use external controller if provided, otherwise fall back to internal
314
+ const audio = externalAudioController || internalAudio;
315
+
316
+ // Dev-only sentinel: warn once when the package stylesheet isn't loaded.
317
+ // The stylesheet sets `--squisq-styles-loaded: 1` on `.doc-player`; if the
318
+ // mounted container computes an empty value, the CSS never made it in.
319
+ useEffect(() => {
320
+ if (warnedMissingStyles || !isDevEnvironment()) return;
321
+ const el = containerRef.current;
322
+ if (!el || typeof getComputedStyle !== 'function') return;
323
+ const value = getComputedStyle(el).getPropertyValue('--squisq-styles-loaded');
324
+ if (!value.trim()) {
325
+ warnedMissingStyles = true;
326
+ console.warn(
327
+ '[squisq] @bendyline/squisq-react/styles is not loaded — import "@bendyline/squisq-react/styles"',
328
+ );
329
+ }
330
+ }, []);
245
331
 
246
332
  // Destructure for convenience
247
333
  const {
@@ -264,7 +350,7 @@ export function DocPlayer({
264
350
  // Timed media clips (block.media + doc.documentMedia) resolved to absolute
265
351
  // doc-timeline coordinates. Empty for documents without the media model, so
266
352
  // <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
267
- const mediaSchedule = useMemo(() => resolveMediaSchedule(script), [script]);
353
+ const mediaSchedule = useMemo(() => resolveMediaSchedule(doc), [doc]);
268
354
 
269
355
  // Refs for frequently-changing values used in the keyboard handler,
270
356
  // so the handler callback doesn't need to be recreated every frame.
@@ -318,11 +404,12 @@ export function DocPlayer({
318
404
  nextBlock: _nextBlock,
319
405
  prevBlock: _prevBlock,
320
406
  blocks: expandedBlocks,
321
- } = useDocPlayback(script, currentTime, activeViewport, renderMode, effectiveTheme);
407
+ } = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
322
408
 
323
409
  // Expand cover block (startBlock) if present - uses active viewport
324
410
  const coverBlock = useMemo((): Block | null => {
325
- const startBlockConfig = script.startBlock as StartBlockConfig | undefined;
411
+ const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
412
+ if (!showCoverSlide) return null;
326
413
  if (!startBlockConfig) return null;
327
414
 
328
415
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
@@ -335,7 +422,24 @@ export function DocPlayer({
335
422
  audioSegment: -1,
336
423
  layers,
337
424
  };
338
- }, [script.startBlock, activeViewport, effectiveTheme]);
425
+ }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
426
+
427
+ // Slideshow mode treats the managed cover as a static slide before block 1.
428
+ // It has no timeline startTime, so keep its visibility separate from audio.
429
+ const hasManagedCover = !!coverBlock;
430
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
431
+ const slideshowCoverInitKeyRef = useRef('');
432
+ useEffect(() => {
433
+ const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
434
+ if (slideshowCoverInitKeyRef.current === initKey) return;
435
+ slideshowCoverInitKeyRef.current = initKey;
436
+ if (isSlideshowMode && hasManagedCover && !renderMode) {
437
+ setSlideshowCoverVisible(true);
438
+ pause();
439
+ } else {
440
+ setSlideshowCoverVisible(false);
441
+ }
442
+ }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
339
443
 
340
444
  // Render-mode cover block control: allows Playwright to force-show the cover block
341
445
  const [coverForced, setCoverForced] = useState(false);
@@ -351,6 +455,7 @@ export function DocPlayer({
351
455
  // Track when cover is showing at rest (before play)
352
456
  const atRest = !!(
353
457
  coverBlock &&
458
+ !isSlideshowMode &&
354
459
  !isPlaying &&
355
460
  currentTime === 0 &&
356
461
  !hasPlayedOnce.current &&
@@ -360,7 +465,7 @@ export function DocPlayer({
360
465
  if (atRest) coverWasShowing.current = true;
361
466
 
362
467
  useEffect(() => {
363
- if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
468
+ if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
364
469
  coverWasShowing.current = false;
365
470
  hasPlayedOnce.current = true;
366
471
  setCoverGraceActive(true);
@@ -370,7 +475,7 @@ export function DocPlayer({
370
475
  // re-run (coverWasShowing.current is now false).
371
476
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
372
477
  }
373
- }, [isPlaying, coverBlock, renderMode]);
478
+ }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
374
479
 
375
480
  // Always clear the grace timer on unmount
376
481
  useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
@@ -378,14 +483,31 @@ export function DocPlayer({
378
483
  // Determine if we should show the cover block
379
484
  // Show cover when: has cover block, not playing, at time 0, not in render mode
380
485
  // OR during the grace period after first play, OR when coverForced (render mode)
381
- // Cover block is suppressed in slideshow and linear mode — start directly on content
382
- const showCoverBlock =
486
+ const showVideoCoverBlock =
383
487
  !isSlideshowMode &&
384
488
  !isLinearMode &&
385
- coverBlock &&
489
+ !!coverBlock &&
386
490
  (coverForced ||
387
491
  coverGraceActive ||
388
492
  (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
493
+ const showSlideshowCover = !!(
494
+ isSlideshowMode &&
495
+ !isLinearMode &&
496
+ !renderMode &&
497
+ coverBlock &&
498
+ slideshowCoverVisible
499
+ );
500
+ const showCoverBlock = showVideoCoverBlock || showSlideshowCover;
501
+
502
+ const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
503
+ const slideshowSlideIndex = slideshowHasCover
504
+ ? slideshowCoverVisible
505
+ ? 0
506
+ : currentBlockIndex + 1
507
+ : currentBlockIndex;
508
+ const slideshowTotalSlides = slideshowHasCover
509
+ ? expandedBlocks.length + 1
510
+ : expandedBlocks.length;
389
511
 
390
512
  // Auto-play if enabled (wait for audio to be ready)
391
513
  // Use a ref to track if we've already auto-played to avoid repeating on every render
@@ -520,7 +642,7 @@ export function DocPlayer({
520
642
  // The larger of the audio/block timeline and any media that spills
521
643
  // past the last block (block-clip spillover or document-spanning
522
644
  // media), so frame capture covers the full tail.
523
- const mediaDuration = getDocPlaybackDuration(script);
645
+ const mediaDuration = getDocPlaybackDuration(doc);
524
646
  if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
525
647
  return mediaDuration;
526
648
  };
@@ -534,7 +656,7 @@ export function DocPlayer({
534
656
  }));
535
657
  // Audio segment info for video production -- returns the actual files in composition order
536
658
  w.getAudioSegments = () =>
537
- script.audio.segments.map((seg) => ({
659
+ doc.audio.segments.map((seg) => ({
538
660
  src: seg.src,
539
661
  name: seg.name,
540
662
  duration: seg.duration,
@@ -542,15 +664,15 @@ export function DocPlayer({
542
664
  }));
543
665
  // Caption phrases for SRT/subtitle export
544
666
  w.getCaptions = () =>
545
- script.captions?.phrases?.map((p) => ({
667
+ doc.captions?.phrases?.map((p) => ({
546
668
  text: p.text,
547
669
  startTime: p.startTime,
548
670
  endTime: p.endTime,
549
671
  })) || [];
550
672
  // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
551
673
  w.getChapters = () => {
552
- const titleMap = buildSegmentTitleMap(script);
553
- return script.audio.segments.map((seg, i) => ({
674
+ const titleMap = buildSegmentTitleMap(doc);
675
+ return doc.audio.segments.map((seg, i) => ({
554
676
  title: titleMap.get(i) || seg.name,
555
677
  startTime: seg.startTime,
556
678
  duration: seg.duration,
@@ -581,7 +703,7 @@ export function DocPlayer({
581
703
  delete w.hasCoverBlock;
582
704
  }
583
705
  };
584
- // eslint-disable-next-line react-hooks/exhaustive-deps -- script is a stable prop; re-registering on every script change is unnecessary
706
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- doc is a stable prop; re-registering on every doc change is unnecessary
585
707
  }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
586
708
 
587
709
  // Caption mode state: cycles through off → standard → social → off
@@ -591,6 +713,15 @@ export function DocPlayer({
591
713
  captionsEnabledProp === false ? 'off' : captionStyle || 'standard';
592
714
  const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultMode);
593
715
 
716
+ // Keep the internal caption mode in sync when the controlling props change
717
+ // — e.g. the editor's preview toolbar drives caption style / on-off. Keyed
718
+ // on the derived `defaultMode` string, so it only fires on a real prop
719
+ // change and never disturbs the in-player CC toggle for consumers (the
720
+ // standalone player, video export) that set these props once at mount.
721
+ useEffect(() => {
722
+ setCaptionMode(defaultMode);
723
+ }, [defaultMode]);
724
+
594
725
  // Derive captionsEnabled and active style from the mode
595
726
  const captionsEnabled = captionMode !== 'off';
596
727
  const activeCaptionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
@@ -614,10 +745,10 @@ export function DocPlayer({
614
745
  });
615
746
  }, [onCaptionsToggle]);
616
747
 
617
- const hasCaptions = script.captions && script.captions.phrases.length > 0;
748
+ const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
618
749
 
619
750
  // Map segment indices to human-readable titles (from sectionHeader blocks)
620
- const segmentTitleMap = useMemo(() => buildSegmentTitleMap(script), [script]);
751
+ const segmentTitleMap = useMemo(() => buildSegmentTitleMap(doc), [doc]);
621
752
 
622
753
  // Build shared playback state for extracted controls
623
754
  const playbackState: PlaybackState = useMemo(
@@ -625,8 +756,8 @@ export function DocPlayer({
625
756
  isPlaying,
626
757
  currentTime,
627
758
  totalDuration,
628
- currentBlockIndex,
629
- totalBlocks: expandedBlocks.length,
759
+ currentBlockIndex: slideshowSlideIndex,
760
+ totalBlocks: slideshowTotalSlides,
630
761
  docProgress,
631
762
  hasCaptions: !!hasCaptions,
632
763
  captionsEnabled,
@@ -634,16 +765,20 @@ export function DocPlayer({
634
765
  isFullscreen,
635
766
  currentSegmentIndex: currentSegment,
636
767
  currentSegmentName:
637
- segmentTitleMap.get(currentSegment) ?? script.audio.segments[currentSegment]?.name ?? null,
638
- currentBlock: currentBlock ?? null,
768
+ segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
769
+ currentBlock: showSlideshowCover ? coverBlock : (currentBlock ?? null),
770
+ currentSlideLabel: showSlideshowCover ? 'Cover' : undefined,
771
+ currentSlideNumber:
772
+ slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : undefined,
773
+ totalSlideNumber: slideshowHasCover ? expandedBlocks.length : undefined,
639
774
  }),
640
- // eslint-disable-next-line react-hooks/exhaustive-deps -- script.audio.segments is stable within a given script
775
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
641
776
  [
642
777
  isPlaying,
643
778
  currentTime,
644
779
  totalDuration,
645
- currentBlockIndex,
646
- expandedBlocks.length,
780
+ slideshowSlideIndex,
781
+ slideshowTotalSlides,
647
782
  docProgress,
648
783
  hasCaptions,
649
784
  captionsEnabled,
@@ -652,6 +787,11 @@ export function DocPlayer({
652
787
  currentSegment,
653
788
  segmentTitleMap,
654
789
  currentBlock,
790
+ currentBlockIndex,
791
+ showSlideshowCover,
792
+ coverBlock,
793
+ slideshowHasCover,
794
+ expandedBlocks.length,
655
795
  ],
656
796
  );
657
797
 
@@ -673,24 +813,56 @@ export function DocPlayer({
673
813
  const slideNavActions: SlideNavActions = useMemo(
674
814
  () => ({
675
815
  nextSlide: () => {
816
+ if (slideshowHasCover && slideshowCoverVisible) {
817
+ const target = expandedBlocks[0];
818
+ if (target) {
819
+ setSlideshowCoverVisible(false);
820
+ seekTo(target.startTime);
821
+ pause();
822
+ }
823
+ return;
824
+ }
676
825
  if (currentBlockIndex < expandedBlocks.length - 1) {
677
826
  const target = expandedBlocks[currentBlockIndex + 1];
678
827
  if (target) {
828
+ setSlideshowCoverVisible(false);
679
829
  seekTo(target.startTime);
680
830
  pause();
681
831
  }
682
832
  }
683
833
  },
684
834
  prevSlide: () => {
835
+ if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
836
+ setSlideshowCoverVisible(true);
837
+ seekTo(0);
838
+ pause();
839
+ return;
840
+ }
685
841
  if (currentBlockIndex > 0) {
686
842
  const target = expandedBlocks[currentBlockIndex - 1];
687
843
  if (target) {
844
+ setSlideshowCoverVisible(false);
688
845
  seekTo(target.startTime);
689
846
  pause();
690
847
  }
691
848
  }
692
849
  },
693
850
  goToSlide: (index: number) => {
851
+ if (slideshowHasCover) {
852
+ if (index === 0) {
853
+ setSlideshowCoverVisible(true);
854
+ seekTo(0);
855
+ pause();
856
+ return;
857
+ }
858
+ const target = expandedBlocks[index - 1];
859
+ if (target) {
860
+ setSlideshowCoverVisible(false);
861
+ seekTo(target.startTime);
862
+ pause();
863
+ }
864
+ return;
865
+ }
694
866
  if (index >= 0 && index < expandedBlocks.length) {
695
867
  const target = expandedBlocks[index];
696
868
  if (target) {
@@ -700,9 +872,21 @@ export function DocPlayer({
700
872
  }
701
873
  },
702
874
  }),
703
- [currentBlockIndex, expandedBlocks, seekTo, pause],
875
+ [currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible],
704
876
  );
705
877
 
878
+ // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
879
+ // interactive (not headless), and not overridden off via `enableSwipe`.
880
+ const swipeEnabled = isSlideshowMode && !isLinearMode && !renderMode && enableSwipe;
881
+ const swipe = useSlideSwipe({
882
+ enabled: swipeEnabled,
883
+ containerRef,
884
+ canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
885
+ canGoPrev: slideshowSlideIndex > 0,
886
+ onNext: slideNavActions.nextSlide,
887
+ onPrev: slideNavActions.prevSlide,
888
+ });
889
+
706
890
  // Callback for playback state changes (for external controls)
707
891
  useEffect(() => {
708
892
  onPlaybackStateChange?.(playbackState);
@@ -772,7 +956,7 @@ export function DocPlayer({
772
956
  }, [blockMarkers, onBlockMarkers]);
773
957
 
774
958
  // Keep expandedBlocks length in a ref so keyboard handler stays stable
775
- expandedBlocksLenRef.current = expandedBlocks.length;
959
+ expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
776
960
 
777
961
  // Handle keyboard controls — uses refs for frequently-changing values
778
962
  // (currentTime, totalDuration, expandedBlocks.length) to avoid
@@ -855,7 +1039,7 @@ export function DocPlayer({
855
1039
  }}
856
1040
  >
857
1041
  <LinearDocView
858
- doc={script}
1042
+ doc={doc}
859
1043
  basePath={basePath}
860
1044
  viewport={activeViewport}
861
1045
  theme={theme}
@@ -868,15 +1052,21 @@ export function DocPlayer({
868
1052
  return (
869
1053
  <div
870
1054
  ref={containerRef}
871
- className="doc-player"
1055
+ className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
1056
+ swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
1057
+ }`}
872
1058
  onClick={handleContainerClick}
1059
+ onPointerDown={swipe.onPointerDown}
873
1060
  style={{
874
1061
  position: 'relative',
875
1062
  width: '100%',
876
1063
  aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
877
1064
  margin: '0 auto',
878
1065
  overflow: 'hidden',
879
- cursor: renderMode ? undefined : 'pointer',
1066
+ // Swipe uses the grab/grabbing cursor via CSS classes; let vertical page
1067
+ // scroll through on touch while we own horizontal drags.
1068
+ cursor: renderMode || swipeEnabled ? undefined : 'pointer',
1069
+ touchAction: swipeEnabled ? 'pan-y' : undefined,
880
1070
  }}
881
1071
  >
882
1072
  {/* Hidden audio element */}
@@ -926,7 +1116,15 @@ export function DocPlayer({
926
1116
 
927
1117
  {/* Current block */}
928
1118
  {!showCoverBlock && currentBlock && (
929
- <div key={currentBlock.id} className="doc-player__block doc-player__block--active">
1119
+ <div
1120
+ key={currentBlock.id}
1121
+ className={`doc-player__block doc-player__block--active${
1122
+ swipe.phase !== 'idle' ? ` doc-player__block--${swipe.phase}` : ''
1123
+ }`}
1124
+ style={
1125
+ swipe.phase !== 'idle' ? { transform: `translateX(${swipe.offsetPx}px)` } : undefined
1126
+ }
1127
+ >
930
1128
  <BlockRenderer
931
1129
  block={currentBlock}
932
1130
  blockTime={blockTime}
@@ -941,7 +1139,7 @@ export function DocPlayer({
941
1139
  {/* Caption overlay -- shown during playback and in render mode when captions are enabled */}
942
1140
  {hasCaptions && (renderMode ? captionsEnabled : true) && (
943
1141
  <CaptionOverlay
944
- captions={script.captions}
1142
+ captions={doc.captions}
945
1143
  currentTime={currentTime}
946
1144
  enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
947
1145
  fontSize={16}
@@ -990,8 +1188,7 @@ export function DocPlayer({
990
1188
  <span style={{ color: '#888' }}>time:</span> {currentTime.toFixed(2)}s /{' '}
991
1189
  {totalDuration.toFixed(1)}s{' '}
992
1190
  <span style={{ color: '#666' }}>
993
- (progress: {(docProgress * 100).toFixed(1)}%, scriptDur:{' '}
994
- {script.duration.toFixed(1)})
1191
+ (progress: {(docProgress * 100).toFixed(1)}%, scriptDur: {doc.duration.toFixed(1)})
995
1192
  </span>
996
1193
  </div>
997
1194
  <div>
@@ -1000,9 +1197,9 @@ export function DocPlayer({
1000
1197
  </div>
1001
1198
  <div>
1002
1199
  <span style={{ color: '#888' }}>segment:</span> {currentSegment}/
1003
- {script.audio.segments.length - 1}{' '}
1200
+ {doc.audio.segments.length - 1}{' '}
1004
1201
  <span style={{ color: '#666' }}>
1005
- ({script.audio.segments[currentSegment]?.name || 'none'})
1202
+ ({doc.audio.segments[currentSegment]?.name || 'none'})
1006
1203
  </span>
1007
1204
  </div>
1008
1205
  <div>
@@ -1019,13 +1216,13 @@ export function DocPlayer({
1019
1216
  </div>
1020
1217
  {hasCaptions &&
1021
1218
  (() => {
1022
- const debugPhrase = getCaptionAtTime(script.captions!, currentTime);
1219
+ const debugPhrase = getCaptionAtTime(doc.captions!, currentTime);
1023
1220
  const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
1024
1221
  return (
1025
1222
  <Fragment>
1026
1223
  <div>
1027
1224
  <span style={{ color: '#888' }}>captions:</span>{' '}
1028
- {script.captions?.phrases.length || 0} phrases{' '}
1225
+ {doc.captions?.phrases.length || 0} phrases{' '}
1029
1226
  <span style={{ color: captionsEnabled ? '#4ade80' : '#666' }}>
1030
1227
  ({captionsEnabled ? 'on' : 'off'})
1031
1228
  </span>