@bendyline/squisq-react 1.4.2 → 2.0.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 (45) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +174 -27
  3. package/dist/index.js +1244 -603
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.global.js +54 -37
  6. package/dist/squisq-player.global.js.map +1 -1
  7. package/dist/standalone-source.js +1 -1
  8. package/package.json +2 -2
  9. package/src/BlockRenderer.tsx +53 -17
  10. package/src/DocControlsSlideshow.tsx +222 -5
  11. package/src/DocPlayer.tsx +367 -183
  12. package/src/DocPlayerWithSidebar.tsx +4 -0
  13. package/src/DocProgressBar.tsx +40 -1
  14. package/src/LinearDocView.tsx +135 -62
  15. package/src/MarkdownRenderer.tsx +40 -97
  16. package/src/MediaClipLayer.tsx +12 -2
  17. package/src/__tests__/BlockRenderer.test.tsx +79 -8
  18. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  19. package/src/__tests__/DocPlayer.test.tsx +505 -0
  20. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  21. package/src/__tests__/LinearDocView.test.tsx +91 -11
  22. package/src/__tests__/MapLayer.test.tsx +63 -0
  23. package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
  24. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  25. package/src/__tests__/MediaContext.test.tsx +51 -0
  26. package/src/__tests__/PathLayer.test.tsx +12 -1
  27. package/src/__tests__/VideoLayer.test.tsx +94 -0
  28. package/src/__tests__/fillStyle.test.tsx +3 -2
  29. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  30. package/src/__tests__/useAudioSync.test.ts +49 -0
  31. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  32. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  33. package/src/hooks/MediaContext.tsx +12 -3
  34. package/src/hooks/useAudioSync.ts +61 -12
  35. package/src/hooks/useDocPlayback.ts +40 -12
  36. package/src/hooks/useViewportOrientation.ts +2 -4
  37. package/src/index.ts +5 -2
  38. package/src/layers/MapLayer.tsx +7 -6
  39. package/src/layers/PathLayer.tsx +20 -11
  40. package/src/layers/ShapeLayer.tsx +4 -2
  41. package/src/layers/TextLayer.tsx +4 -3
  42. package/src/layers/TreeLayer.tsx +167 -0
  43. package/src/layers/VideoLayer.tsx +20 -6
  44. package/src/standalone-entry.tsx +91 -14
  45. package/src/types.ts +13 -13
package/src/DocPlayer.tsx CHANGED
@@ -10,7 +10,7 @@
10
10
  * - Block transitions (fade, dissolve, slide)
11
11
  * - Playback controls (play/pause, seek, next/prev)
12
12
  * - Progress display
13
- * - Render mode for video capture (via window.seekTo)
13
+ * - Instance-scoped render API for deterministic video capture
14
14
  * - Pluggable audio controller for different environments (browser, EFB)
15
15
  * - Multiple control layouts: overlay (default), sidebar, bottom
16
16
  *
@@ -22,7 +22,7 @@
22
22
  * - types.ts -- Shared control types
23
23
  */
24
24
 
25
- import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
25
+ import { Fragment, useId, useRef, useState, useEffect, useCallback, useMemo } from 'react';
26
26
  import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
27
27
  import {
28
28
  isTemplateBlock,
@@ -62,7 +62,7 @@ import type {
62
62
  CaptionStyle,
63
63
  CaptionMode,
64
64
  SlideNavActions,
65
- SquisqWindow,
65
+ SquisqRenderAPI,
66
66
  } from './types';
67
67
 
68
68
  const SMALL_WORDS = new Set([
@@ -125,7 +125,7 @@ function buildSegmentTitleMap(doc: Doc): Map<number, string> {
125
125
  return map;
126
126
  }
127
127
 
128
- interface DocPlayerProps {
128
+ export interface DocPlayerProps {
129
129
  /**
130
130
  * The Doc to play. Wins over `markdown` when both are provided.
131
131
  * When neither `doc` nor `markdown` is given, the player renders a
@@ -140,8 +140,19 @@ interface DocPlayerProps {
140
140
  markdown?: string;
141
141
  /** Base path for resolving media URLs (default: `'.'`) */
142
142
  basePath?: string;
143
- /** Render mode for video capture (hides controls, exposes seekTo) */
143
+ /** Render mode for video capture (hides controls and creates a render API). */
144
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;
145
156
  /** Auto-play when loaded */
146
157
  autoPlay?: boolean;
147
158
  /** Callback when playback ends */
@@ -204,6 +215,12 @@ interface DocPlayerProps {
204
215
  * `doc.startBlock`. Defaults to true for existing documents.
205
216
  */
206
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;
207
224
  /** Caption display style (default: 'standard').
208
225
  * 'social' shows large centered words with the active word highlighted. */
209
226
  captionStyle?: CaptionStyle;
@@ -214,6 +231,12 @@ interface DocPlayerProps {
214
231
  * `displayMode === 'slideshow'` and not in render/headless mode.
215
232
  */
216
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;
217
240
  }
218
241
 
219
242
  // Dev-only, browser-safe environment probe. Bundlers substitute the
@@ -263,6 +286,7 @@ function DocPlayerContent({
263
286
  doc,
264
287
  basePath = '.',
265
288
  renderMode = false,
289
+ animationsEnabled = true,
266
290
  autoPlay = false,
267
291
  onEnded,
268
292
  onTimeUpdate,
@@ -274,21 +298,25 @@ function DocPlayerContent({
274
298
  onCaptionsToggle,
275
299
  onPlaybackStateChange,
276
300
  onControlsReady,
301
+ onRenderAPIReady,
277
302
  isFullscreen = false,
278
303
  onFullscreenToggle,
279
304
  onBlockMarkers,
280
305
  forceViewport,
281
306
  displayMode = 'video',
282
307
  showCoverSlide = true,
308
+ coverVisible,
283
309
  theme,
284
310
  surface,
285
311
  captionStyle = 'standard',
286
312
  enableSwipe = true,
313
+ globalKeyboardShortcuts = false,
287
314
  }: DocPlayerContentProps) {
288
315
  const isSlideshowMode = displayMode === 'slideshow';
289
316
  const isLinearMode = displayMode === 'linear';
290
317
  const audioRef = useRef<HTMLAudioElement>(null);
291
318
  const containerRef = useRef<HTMLDivElement>(null);
319
+ const playerId = `squisq-player-${useId().replace(/:/g, '')}`;
292
320
 
293
321
  // Tap-to-toggle play/pause feedback animation
294
322
  const [tapFeedback, setTapFeedback] = useState<'play' | 'pause' | null>(null);
@@ -308,7 +336,7 @@ function DocPlayerContent({
308
336
  }, []);
309
337
 
310
338
  // Use internal HTML5 audio sync if no external controller is given
311
- const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
339
+ const internalAudio = useAudioSync(audioRef, doc.audio, basePath, !externalAudioController);
312
340
 
313
341
  // Use external controller if provided, otherwise fall back to internal
314
342
  const audio = externalAudioController || internalAudio;
@@ -363,18 +391,28 @@ function DocPlayerContent({
363
391
  // Tap the player surface to toggle play/pause (disabled in slideshow and linear mode)
364
392
  const handleContainerClick = useCallback(
365
393
  (e: React.MouseEvent) => {
366
- if (renderMode || isSlideshowMode || isLinearMode) return;
394
+ if (renderMode || isLinearMode) return;
367
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
+ }
368
405
  // Don't toggle if user clicked a control element
369
406
  if (
370
407
  target.closest(
371
- 'button, a, input, .doc-player__controls, .doc-player__scrubber, .doc-controls-sidebar, .doc-controls-slideshow',
408
+ 'button, a, input, textarea, select, [contenteditable="true"], .doc-player__controls, .doc-player__scrubber, .doc-controls-sidebar, .doc-controls-slideshow',
372
409
  )
373
410
  )
374
411
  return;
412
+ containerRef.current?.focus({ preventScroll: true });
375
413
  toggle();
376
414
  // Show visual feedback (show the state we're transitioning TO)
377
- const nextState = isPlaying ? 'play' : 'pause';
415
+ const nextState = isPlaying ? 'pause' : 'play';
378
416
  setTapFeedback(nextState);
379
417
  clearTimeout(tapFeedbackTimer.current);
380
418
  tapFeedbackTimer.current = setTimeout(() => setTapFeedback(null), 600);
@@ -404,7 +442,12 @@ function DocPlayerContent({
404
442
  nextBlock: _nextBlock,
405
443
  prevBlock: _prevBlock,
406
444
  blocks: expandedBlocks,
407
- } = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
445
+ suppressOutgoingForNextBlock,
446
+ } = useDocPlayback(doc, currentTime, {
447
+ viewport: activeViewport,
448
+ theme: effectiveTheme,
449
+ onSeek: seekTo,
450
+ });
408
451
 
409
452
  // Expand cover block (startBlock) if present - uses active viewport
410
453
  const coverBlock = useMemo((): Block | null => {
@@ -428,7 +471,11 @@ function DocPlayerContent({
428
471
  // It has no timeline startTime, so keep its visibility separate from audio.
429
472
  const hasManagedCover = !!coverBlock;
430
473
  const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
474
+ const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState(false);
431
475
  const slideshowCoverInitKeyRef = useRef('');
476
+ useEffect(() => {
477
+ slideshowCoverInitKeyRef.current = '';
478
+ }, [doc]);
432
479
  useEffect(() => {
433
480
  const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
434
481
  if (slideshowCoverInitKeyRef.current === initKey) return;
@@ -452,6 +499,14 @@ function DocPlayerContent({
452
499
  // from re-appearing when paused at currentTime === 0 (e.g., no audio source).
453
500
  const hasPlayedOnce = useRef(false);
454
501
 
502
+ useEffect(() => {
503
+ hasPlayedOnce.current = false;
504
+ coverWasShowing.current = false;
505
+ clearTimeout(coverGraceTimer.current);
506
+ setCoverGraceActive(false);
507
+ setCoverForced(false);
508
+ }, [doc]);
509
+
455
510
  // Track when cover is showing at rest (before play)
456
511
  const atRest = !!(
457
512
  coverBlock &&
@@ -490,18 +545,22 @@ function DocPlayerContent({
490
545
  (coverForced ||
491
546
  coverGraceActive ||
492
547
  (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
548
+ const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
493
549
  const showSlideshowCover = !!(
494
550
  isSlideshowMode &&
495
551
  !isLinearMode &&
496
552
  !renderMode &&
497
553
  coverBlock &&
498
- slideshowCoverVisible
554
+ effectiveSlideshowCoverVisible
499
555
  );
500
- const showCoverBlock = showVideoCoverBlock || showSlideshowCover;
556
+ const showCoverBlock =
557
+ coverVisible === undefined
558
+ ? showVideoCoverBlock || showSlideshowCover
559
+ : !!coverBlock && coverVisible;
501
560
 
502
561
  const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
503
562
  const slideshowSlideIndex = slideshowHasCover
504
- ? slideshowCoverVisible
563
+ ? effectiveSlideshowCoverVisible
505
564
  ? 0
506
565
  : currentBlockIndex + 1
507
566
  : currentBlockIndex;
@@ -512,6 +571,9 @@ function DocPlayerContent({
512
571
  // Auto-play if enabled (wait for audio to be ready)
513
572
  // Use a ref to track if we've already auto-played to avoid repeating on every render
514
573
  const hasAutoPlayed = useRef(false);
574
+ useEffect(() => {
575
+ hasAutoPlayed.current = false;
576
+ }, [doc]);
515
577
  useEffect(() => {
516
578
  if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
517
579
  hasAutoPlayed.current = true;
@@ -531,180 +593,223 @@ function DocPlayerContent({
531
593
  }
532
594
  }, [isEnded, onEnded]);
533
595
 
534
- // Expose seekTo globally for render mode (Playwright) and debug mode (testing)
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.
535
623
  useEffect(() => {
536
- if ((renderMode || isDebugMode) && typeof window !== 'undefined') {
537
- const w = window as SquisqWindow;
538
- w.seekTo = (time: number) => {
539
- seekTo(time);
540
- // After React renders the correct block, advance CSS animations
541
- // (Ken Burns, transitions) to match the doc timeline position.
542
- // Without this, animations restart from zero on each seekTo because
543
- // they run on the browser's real clock, not doc time.
544
- return new Promise<void>((resolve) => {
545
- requestAnimationFrame(() => {
546
- // Find the current block's start time
547
- let blockStartTime = 0;
548
- for (let i = expandedBlocks.length - 1; i >= 0; i--) {
549
- if (time >= expandedBlocks[i].startTime) {
550
- blockStartTime = expandedBlocks[i].startTime;
551
- break;
552
- }
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;
553
647
  }
554
- const elapsedMs = (time - blockStartTime) * 1000;
555
-
556
- // Set all CSS animations to the correct timeline position
557
- document.getAnimations().forEach((anim) => {
558
- const target = (anim.effect as KeyframeEffect)?.target as Element | null;
559
- if (!target) return;
560
-
561
- // Animations on the active block: use current block elapsed time
562
- if (target.closest('.doc-player__block--active')) {
563
- anim.currentTime = Math.max(0, elapsedMs);
564
- }
565
- // Animations on the exiting block (during crossfade): use current
566
- // block elapsed for transition animations, keep Ken Burns at their
567
- // natural position based on when that block started
568
- // eslint-disable-next-line sonarjs/no-duplicated-branches
569
- else if (target.closest('.doc-player__block--previous')) {
570
- anim.currentTime = Math.max(0, elapsedMs);
571
- }
572
- });
648
+ }
649
+ const elapsedMs = (time - blockStartTime) * 1000;
573
650
 
574
- // Seek <video> elements in the active block to the correct clip position.
575
- // Each <video> carries data-clip-start/data-clip-end attributes set by
576
- // VideoLayer.tsx; we calculate targetTime = clipStart + blockElapsed.
577
- const blockElapsed = time - blockStartTime;
578
- const videoSeekPromises: Promise<void>[] = [];
579
- const activeBlockEl = document.querySelector('.doc-player__block--active');
580
- if (activeBlockEl) {
581
- const videos = activeBlockEl.querySelectorAll('video[data-clip-start]');
582
- videos.forEach((el) => {
583
- const video = el as HTMLVideoElement;
584
- const clipStart = parseFloat(video.dataset.clipStart || '0');
585
- const clipEnd = parseFloat(video.dataset.clipEnd || '0');
586
- // Honor the per-clip startAt offset: before it, hold at the
587
- // in-point; after, advance by (blockElapsed - startAt).
588
- const startAt = parseFloat(video.dataset.startAt || '0');
589
- const targetTime = Math.min(
590
- clipStart + Math.max(0, blockElapsed - startAt),
591
- clipEnd,
592
- );
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;
593
655
 
594
- video.pause();
595
- video.currentTime = targetTime;
596
-
597
- videoSeekPromises.push(
598
- new Promise<void>((r) => {
599
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
600
- r();
601
- } else {
602
- video.addEventListener('seeked', () => r(), { once: true });
603
- setTimeout(r, 200); // Fallback if seeked never fires
604
- }
605
- }),
606
- );
607
- });
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);
608
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
+ });
609
668
 
610
- // Seek player-level scheduled videos (document-spanning clips
611
- // rendered by MediaClipLayer, outside any single block). Each
612
- // carries data-abs-start/data-abs-end/data-source-in.
613
- document.querySelectorAll('video[data-clip-id]').forEach((el) => {
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) => {
614
678
  const video = el as HTMLVideoElement;
615
- const absStart = parseFloat(video.dataset.absStart || '0');
616
- const absEnd = parseFloat(video.dataset.absEnd || '0');
617
- const sourceIn = parseFloat(video.dataset.sourceIn || '0');
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
+
618
686
  video.pause();
619
- if (time < absStart || time >= absEnd) return;
620
- const targetTime = sourceIn + (time - absStart);
621
687
  video.currentTime = targetTime;
688
+
622
689
  videoSeekPromises.push(
623
690
  new Promise<void>((r) => {
624
691
  if (Math.abs(video.currentTime - targetTime) < 0.1) {
625
692
  r();
626
693
  } else {
627
694
  video.addEventListener('seeked', () => r(), { once: true });
628
- setTimeout(r, 200);
695
+ setTimeout(r, 200); // Fallback if seeked never fires
629
696
  }
630
697
  }),
631
698
  );
632
699
  });
700
+ }
633
701
 
634
- // Wait for video seeks + one more frame for the browser to render
635
- Promise.all(videoSeekPromises).then(() => {
636
- requestAnimationFrame(() => resolve());
637
- });
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());
638
729
  });
639
730
  });
640
- };
641
- w.getDuration = () => {
642
- // The larger of the audio/block timeline and any media that spills
643
- // past the last block (block-clip spillover or document-spanning
644
- // media), so frame capture covers the full tail.
645
- const mediaDuration = getDocPlaybackDuration(doc);
646
- if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
647
- return mediaDuration;
648
- };
649
- // Expose block metadata for testing -- allows tests to find specific templates
650
- w.getBlocks = () =>
651
- expandedBlocks.map((s: Block) => ({
652
- id: s.id,
653
- template: (s as DocBlock).template ?? 'raw',
654
- startTime: s.startTime,
655
- duration: s.duration,
656
- }));
657
- // Audio segment info for video production -- returns the actual files in composition order
658
- w.getAudioSegments = () =>
659
- doc.audio.segments.map((seg) => ({
660
- src: seg.src,
661
- name: seg.name,
662
- duration: seg.duration,
663
- startTime: seg.startTime,
664
- }));
665
- // Caption phrases for SRT/subtitle export
666
- w.getCaptions = () =>
667
- doc.captions?.phrases?.map((p) => ({
668
- text: p.text,
669
- startTime: p.startTime,
670
- endTime: p.endTime,
671
- })) || [];
672
- // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
673
- w.getChapters = () => {
674
- const titleMap = buildSegmentTitleMap(doc);
675
- return doc.audio.segments.map((seg, i) => ({
676
- title: titleMap.get(i) || seg.name,
677
- startTime: seg.startTime,
678
- duration: seg.duration,
679
- }));
680
- };
681
- // Cover block control for video pre-roll -- force-show or hide the cover block
682
- w.showCover = () => {
683
- setCoverForced(true);
684
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
685
- };
686
- w.hideCover = () => {
687
- setCoverForced(false);
688
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
689
- };
690
- w.hasCoverBlock = () => !!coverBlock;
691
- }
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
+
692
797
  return () => {
693
- if (typeof window !== 'undefined') {
694
- const w = window as SquisqWindow;
695
- delete w.seekTo;
696
- delete w.getDuration;
697
- delete w.getBlocks;
698
- delete w.getAudioSegments;
699
- delete w.getCaptions;
700
- delete w.getChapters;
701
- delete w.showCover;
702
- delete w.hideCover;
703
- delete w.hasCoverBlock;
704
- }
798
+ if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
705
799
  };
706
- // eslint-disable-next-line react-hooks/exhaustive-deps -- doc is a stable prop; re-registering on every doc change is unnecessary
707
- }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
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]);
708
813
 
709
814
  // Caption mode state: cycles through off → standard → social → off
710
815
  // The captionStyle prop sets the default active style; captionsEnabledProp
@@ -756,6 +861,7 @@ function DocPlayerContent({
756
861
  isPlaying,
757
862
  currentTime,
758
863
  totalDuration,
864
+ isCoverVisible: showCoverBlock,
759
865
  currentBlockIndex: slideshowSlideIndex,
760
866
  totalBlocks: slideshowTotalSlides,
761
867
  docProgress,
@@ -777,6 +883,7 @@ function DocPlayerContent({
777
883
  isPlaying,
778
884
  currentTime,
779
885
  totalDuration,
886
+ showCoverBlock,
780
887
  slideshowSlideIndex,
781
888
  slideshowTotalSlides,
782
889
  docProgress,
@@ -878,13 +985,29 @@ function DocPlayerContent({
878
985
  // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
879
986
  // interactive (not headless), and not overridden off via `enableSwipe`.
880
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]);
881
1004
  const swipe = useSlideSwipe({
882
1005
  enabled: swipeEnabled,
883
1006
  containerRef,
884
1007
  canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
885
1008
  canGoPrev: slideshowSlideIndex > 0,
886
- onNext: slideNavActions.nextSlide,
887
- onPrev: slideNavActions.prevSlide,
1009
+ onNext: handleSwipeNext,
1010
+ onPrev: handleSwipePrev,
888
1011
  });
889
1012
 
890
1013
  // Callback for playback state changes (for external controls)
@@ -931,6 +1054,24 @@ function DocPlayerContent({
931
1054
  return block.id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
932
1055
  }, []);
933
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
+
934
1075
  // Compute block markers for progress bar (using expanded blocks)
935
1076
  const blockMarkers = useMemo(() => {
936
1077
  if (!totalDuration || !expandedBlocks.length) return [];
@@ -961,15 +1102,29 @@ function DocPlayerContent({
961
1102
  // Handle keyboard controls — uses refs for frequently-changing values
962
1103
  // (currentTime, totalDuration, expandedBlocks.length) to avoid
963
1104
  // re-registering the event listener on every animation frame.
964
- const handleKeyDown = useCallback(
965
- (e: KeyboardEvent) => {
966
- // Don't capture keyboard events when focus is on an input/textarea
967
- const activeEl = document.activeElement;
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"]');
968
1120
  if (
969
- activeEl &&
970
- (activeEl.tagName === 'INPUT' ||
971
- activeEl.tagName === 'TEXTAREA' ||
972
- activeEl.tagName === 'SELECT')
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)
973
1128
  ) {
974
1129
  return;
975
1130
  }
@@ -987,10 +1142,13 @@ function DocPlayerContent({
987
1142
  slideNavActions.nextSlide();
988
1143
  break;
989
1144
  case 'ArrowLeft':
990
- case 'ArrowUp':
991
1145
  e.preventDefault();
992
1146
  slideNavActions.prevSlide();
993
1147
  break;
1148
+ case 'ArrowUp':
1149
+ e.preventDefault();
1150
+ setIsSlideshowPickerOpen(true);
1151
+ break;
994
1152
  case 'Home':
995
1153
  e.preventDefault();
996
1154
  slideNavActions.goToSlide(0);
@@ -1008,9 +1166,11 @@ function DocPlayerContent({
1008
1166
  toggle();
1009
1167
  break;
1010
1168
  case 'ArrowRight':
1169
+ e.preventDefault();
1011
1170
  seekTo(Math.min(currentTimeRef.current + 10, totalDurationRef.current));
1012
1171
  break;
1013
1172
  case 'ArrowLeft':
1173
+ e.preventDefault();
1014
1174
  seekTo(Math.max(currentTimeRef.current - 10, 0));
1015
1175
  break;
1016
1176
  }
@@ -1019,17 +1179,26 @@ function DocPlayerContent({
1019
1179
  [isSlideshowMode, isLinearMode, toggle, seekTo, slideNavActions],
1020
1180
  );
1021
1181
 
1182
+ const handleKeyDown = useCallback(
1183
+ (e: React.KeyboardEvent<HTMLDivElement>) => handleKeyboardShortcut(e, false),
1184
+ [handleKeyboardShortcut],
1185
+ );
1186
+
1022
1187
  useEffect(() => {
1023
- if (renderMode) return; // No keyboard in render mode
1024
- window.addEventListener('keydown', handleKeyDown);
1025
- return () => window.removeEventListener('keydown', handleKeyDown);
1026
- }, [handleKeyDown, renderMode]);
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]);
1027
1195
 
1028
1196
  // ── Linear mode: render as scrollable document ──────────────────
1029
1197
  if (isLinearMode) {
1030
1198
  return (
1031
1199
  <div
1032
1200
  ref={containerRef}
1201
+ data-player-id={playerId}
1033
1202
  className="doc-player doc-player--linear"
1034
1203
  style={{
1035
1204
  position: 'relative',
@@ -1044,6 +1213,7 @@ function DocPlayerContent({
1044
1213
  viewport={activeViewport}
1045
1214
  theme={theme}
1046
1215
  surface={surface}
1216
+ animationsEnabled={animationsEnabled}
1047
1217
  />
1048
1218
  </div>
1049
1219
  );
@@ -1052,6 +1222,10 @@ function DocPlayerContent({
1052
1222
  return (
1053
1223
  <div
1054
1224
  ref={containerRef}
1225
+ data-player-id={playerId}
1226
+ tabIndex={renderMode ? -1 : 0}
1227
+ aria-label="Document player"
1228
+ onKeyDown={renderMode ? undefined : handleKeyDown}
1055
1229
  className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
1056
1230
  swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
1057
1231
  }`}
@@ -1079,6 +1253,7 @@ function DocPlayerContent({
1079
1253
  isPlaying={isPlaying}
1080
1254
  basePath={basePath}
1081
1255
  renderMode={renderMode}
1256
+ muted={muted}
1082
1257
  />
1083
1258
 
1084
1259
  {/* Block viewport */}
@@ -1092,12 +1267,13 @@ function DocPlayerContent({
1092
1267
  basePath={basePath}
1093
1268
  isEntering={false}
1094
1269
  viewport={activeViewport}
1270
+ animationsEnabled={animationsEnabled}
1095
1271
  />
1096
1272
  </div>
1097
1273
  )}
1098
1274
 
1099
1275
  {/* Previous block (during transition) */}
1100
- {!showCoverBlock && previousBlock && isExiting && (
1276
+ {animationsEnabled && !showCoverBlock && previousBlock && isExiting && (
1101
1277
  // Keyed by block id so each block is its own DOM subtree: React never
1102
1278
  // reconciles one block's layers onto another's (templates reuse layer
1103
1279
  // ids like `title`/`background`), which would otherwise reuse stale
@@ -1110,6 +1286,7 @@ function DocPlayerContent({
1110
1286
  isExiting={true}
1111
1287
  transition={currentBlock?.transition}
1112
1288
  viewport={activeViewport}
1289
+ animationsEnabled={animationsEnabled}
1113
1290
  />
1114
1291
  </div>
1115
1292
  )}
@@ -1129,9 +1306,10 @@ function DocPlayerContent({
1129
1306
  block={currentBlock}
1130
1307
  blockTime={blockTime}
1131
1308
  basePath={basePath}
1132
- isEntering={isEntering}
1309
+ isEntering={animationsEnabled && isEntering}
1133
1310
  viewport={activeViewport}
1134
1311
  isPlaying={isPlaying}
1312
+ animationsEnabled={animationsEnabled}
1135
1313
  />
1136
1314
  </div>
1137
1315
  )}
@@ -1321,13 +1499,19 @@ function DocPlayerContent({
1321
1499
  )}
1322
1500
 
1323
1501
  {/* Slideshow controls (prev / counter / next) */}
1324
- {!renderMode && isSlideshowMode && (
1325
- <DocControlsSlideshow state={playbackState} slideNav={slideNavActions} />
1502
+ {!renderMode && isSlideshowMode && showControls && (
1503
+ <DocControlsSlideshow
1504
+ state={playbackState}
1505
+ slideNav={slideNavActions}
1506
+ slides={slideshowPickerItems}
1507
+ pickerOpen={isSlideshowPickerOpen}
1508
+ onPickerOpenChange={setIsSlideshowPickerOpen}
1509
+ />
1326
1510
  )}
1327
1511
 
1328
1512
  {/* Tap feedback animation -- shows play/pause icon briefly on tap (video mode only) */}
1329
1513
  {!isSlideshowMode && tapFeedback && (
1330
- <div className="doc-player__tap-feedback" key={Date.now()}>
1514
+ <div className="doc-player__tap-feedback" key={tapFeedback}>
1331
1515
  <svg viewBox="0 0 24 24" fill="white" width="48" height="48">
1332
1516
  {tapFeedback === 'pause' ? (
1333
1517
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />