@bendyline/squisq-react 1.4.1 → 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 +185 -27
  3. package/dist/index.js +1323 -614
  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 +235 -7
  11. package/src/DocPlayer.tsx +462 -195
  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 +556 -2
  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 +19 -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 */
@@ -199,6 +210,17 @@ interface DocPlayerProps {
199
210
  * template-annotated sections as inline SVG cards. No audio, no timeline.
200
211
  */
201
212
  displayMode?: DisplayMode;
213
+ /**
214
+ * Whether to synthesize and show the managed cover slide from
215
+ * `doc.startBlock`. Defaults to true for existing documents.
216
+ */
217
+ showCoverSlide?: boolean;
218
+ /**
219
+ * Optional controlled cover visibility. Intended for synchronized audience
220
+ * mirrors that follow another DocPlayer's visual cursor. When omitted, the
221
+ * player owns its normal cover lifecycle.
222
+ */
223
+ coverVisible?: boolean;
202
224
  /** Caption display style (default: 'standard').
203
225
  * 'social' shows large centered words with the active word highlighted. */
204
226
  captionStyle?: CaptionStyle;
@@ -209,6 +231,12 @@ interface DocPlayerProps {
209
231
  * `displayMode === 'slideshow'` and not in render/headless mode.
210
232
  */
211
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;
212
240
  }
213
241
 
214
242
  // Dev-only, browser-safe environment probe. Bundlers substitute the
@@ -258,6 +286,7 @@ function DocPlayerContent({
258
286
  doc,
259
287
  basePath = '.',
260
288
  renderMode = false,
289
+ animationsEnabled = true,
261
290
  autoPlay = false,
262
291
  onEnded,
263
292
  onTimeUpdate,
@@ -269,20 +298,25 @@ function DocPlayerContent({
269
298
  onCaptionsToggle,
270
299
  onPlaybackStateChange,
271
300
  onControlsReady,
301
+ onRenderAPIReady,
272
302
  isFullscreen = false,
273
303
  onFullscreenToggle,
274
304
  onBlockMarkers,
275
305
  forceViewport,
276
306
  displayMode = 'video',
307
+ showCoverSlide = true,
308
+ coverVisible,
277
309
  theme,
278
310
  surface,
279
311
  captionStyle = 'standard',
280
312
  enableSwipe = true,
313
+ globalKeyboardShortcuts = false,
281
314
  }: DocPlayerContentProps) {
282
315
  const isSlideshowMode = displayMode === 'slideshow';
283
316
  const isLinearMode = displayMode === 'linear';
284
317
  const audioRef = useRef<HTMLAudioElement>(null);
285
318
  const containerRef = useRef<HTMLDivElement>(null);
319
+ const playerId = `squisq-player-${useId().replace(/:/g, '')}`;
286
320
 
287
321
  // Tap-to-toggle play/pause feedback animation
288
322
  const [tapFeedback, setTapFeedback] = useState<'play' | 'pause' | null>(null);
@@ -302,7 +336,7 @@ function DocPlayerContent({
302
336
  }, []);
303
337
 
304
338
  // Use internal HTML5 audio sync if no external controller is given
305
- const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
339
+ const internalAudio = useAudioSync(audioRef, doc.audio, basePath, !externalAudioController);
306
340
 
307
341
  // Use external controller if provided, otherwise fall back to internal
308
342
  const audio = externalAudioController || internalAudio;
@@ -357,18 +391,28 @@ function DocPlayerContent({
357
391
  // Tap the player surface to toggle play/pause (disabled in slideshow and linear mode)
358
392
  const handleContainerClick = useCallback(
359
393
  (e: React.MouseEvent) => {
360
- if (renderMode || isSlideshowMode || isLinearMode) return;
394
+ if (renderMode || isLinearMode) return;
361
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
+ }
362
405
  // Don't toggle if user clicked a control element
363
406
  if (
364
407
  target.closest(
365
- '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',
366
409
  )
367
410
  )
368
411
  return;
412
+ containerRef.current?.focus({ preventScroll: true });
369
413
  toggle();
370
414
  // Show visual feedback (show the state we're transitioning TO)
371
- const nextState = isPlaying ? 'play' : 'pause';
415
+ const nextState = isPlaying ? 'pause' : 'play';
372
416
  setTapFeedback(nextState);
373
417
  clearTimeout(tapFeedbackTimer.current);
374
418
  tapFeedbackTimer.current = setTimeout(() => setTapFeedback(null), 600);
@@ -398,11 +442,17 @@ function DocPlayerContent({
398
442
  nextBlock: _nextBlock,
399
443
  prevBlock: _prevBlock,
400
444
  blocks: expandedBlocks,
401
- } = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
445
+ suppressOutgoingForNextBlock,
446
+ } = useDocPlayback(doc, currentTime, {
447
+ viewport: activeViewport,
448
+ theme: effectiveTheme,
449
+ onSeek: seekTo,
450
+ });
402
451
 
403
452
  // Expand cover block (startBlock) if present - uses active viewport
404
453
  const coverBlock = useMemo((): Block | null => {
405
454
  const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
455
+ if (!showCoverSlide) return null;
406
456
  if (!startBlockConfig) return null;
407
457
 
408
458
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
@@ -415,7 +465,28 @@ function DocPlayerContent({
415
465
  audioSegment: -1,
416
466
  layers,
417
467
  };
418
- }, [doc.startBlock, activeViewport, effectiveTheme]);
468
+ }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
469
+
470
+ // Slideshow mode treats the managed cover as a static slide before block 1.
471
+ // It has no timeline startTime, so keep its visibility separate from audio.
472
+ const hasManagedCover = !!coverBlock;
473
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
474
+ const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState(false);
475
+ const slideshowCoverInitKeyRef = useRef('');
476
+ useEffect(() => {
477
+ slideshowCoverInitKeyRef.current = '';
478
+ }, [doc]);
479
+ useEffect(() => {
480
+ const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
481
+ if (slideshowCoverInitKeyRef.current === initKey) return;
482
+ slideshowCoverInitKeyRef.current = initKey;
483
+ if (isSlideshowMode && hasManagedCover && !renderMode) {
484
+ setSlideshowCoverVisible(true);
485
+ pause();
486
+ } else {
487
+ setSlideshowCoverVisible(false);
488
+ }
489
+ }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
419
490
 
420
491
  // Render-mode cover block control: allows Playwright to force-show the cover block
421
492
  const [coverForced, setCoverForced] = useState(false);
@@ -428,9 +499,18 @@ function DocPlayerContent({
428
499
  // from re-appearing when paused at currentTime === 0 (e.g., no audio source).
429
500
  const hasPlayedOnce = useRef(false);
430
501
 
502
+ useEffect(() => {
503
+ hasPlayedOnce.current = false;
504
+ coverWasShowing.current = false;
505
+ clearTimeout(coverGraceTimer.current);
506
+ setCoverGraceActive(false);
507
+ setCoverForced(false);
508
+ }, [doc]);
509
+
431
510
  // Track when cover is showing at rest (before play)
432
511
  const atRest = !!(
433
512
  coverBlock &&
513
+ !isSlideshowMode &&
434
514
  !isPlaying &&
435
515
  currentTime === 0 &&
436
516
  !hasPlayedOnce.current &&
@@ -440,7 +520,7 @@ function DocPlayerContent({
440
520
  if (atRest) coverWasShowing.current = true;
441
521
 
442
522
  useEffect(() => {
443
- if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
523
+ if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
444
524
  coverWasShowing.current = false;
445
525
  hasPlayedOnce.current = true;
446
526
  setCoverGraceActive(true);
@@ -450,7 +530,7 @@ function DocPlayerContent({
450
530
  // re-run (coverWasShowing.current is now false).
451
531
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
452
532
  }
453
- }, [isPlaying, coverBlock, renderMode]);
533
+ }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
454
534
 
455
535
  // Always clear the grace timer on unmount
456
536
  useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
@@ -458,18 +538,42 @@ function DocPlayerContent({
458
538
  // Determine if we should show the cover block
459
539
  // Show cover when: has cover block, not playing, at time 0, not in render mode
460
540
  // OR during the grace period after first play, OR when coverForced (render mode)
461
- // Cover block is suppressed in slideshow and linear mode — start directly on content
462
- const showCoverBlock =
541
+ const showVideoCoverBlock =
463
542
  !isSlideshowMode &&
464
543
  !isLinearMode &&
465
- coverBlock &&
544
+ !!coverBlock &&
466
545
  (coverForced ||
467
546
  coverGraceActive ||
468
547
  (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
548
+ const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
549
+ const showSlideshowCover = !!(
550
+ isSlideshowMode &&
551
+ !isLinearMode &&
552
+ !renderMode &&
553
+ coverBlock &&
554
+ effectiveSlideshowCoverVisible
555
+ );
556
+ const showCoverBlock =
557
+ coverVisible === undefined
558
+ ? showVideoCoverBlock || showSlideshowCover
559
+ : !!coverBlock && coverVisible;
560
+
561
+ const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
562
+ const slideshowSlideIndex = slideshowHasCover
563
+ ? effectiveSlideshowCoverVisible
564
+ ? 0
565
+ : currentBlockIndex + 1
566
+ : currentBlockIndex;
567
+ const slideshowTotalSlides = slideshowHasCover
568
+ ? expandedBlocks.length + 1
569
+ : expandedBlocks.length;
469
570
 
470
571
  // Auto-play if enabled (wait for audio to be ready)
471
572
  // Use a ref to track if we've already auto-played to avoid repeating on every render
472
573
  const hasAutoPlayed = useRef(false);
574
+ useEffect(() => {
575
+ hasAutoPlayed.current = false;
576
+ }, [doc]);
473
577
  useEffect(() => {
474
578
  if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
475
579
  hasAutoPlayed.current = true;
@@ -489,180 +593,223 @@ function DocPlayerContent({
489
593
  }
490
594
  }, [isEnded, onEnded]);
491
595
 
492
- // 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.
493
623
  useEffect(() => {
494
- if ((renderMode || isDebugMode) && typeof window !== 'undefined') {
495
- const w = window as SquisqWindow;
496
- w.seekTo = (time: number) => {
497
- seekTo(time);
498
- // After React renders the correct block, advance CSS animations
499
- // (Ken Burns, transitions) to match the doc timeline position.
500
- // Without this, animations restart from zero on each seekTo because
501
- // they run on the browser's real clock, not doc time.
502
- return new Promise<void>((resolve) => {
503
- requestAnimationFrame(() => {
504
- // Find the current block's start time
505
- let blockStartTime = 0;
506
- for (let i = expandedBlocks.length - 1; i >= 0; i--) {
507
- if (time >= expandedBlocks[i].startTime) {
508
- blockStartTime = expandedBlocks[i].startTime;
509
- break;
510
- }
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;
511
647
  }
512
- const elapsedMs = (time - blockStartTime) * 1000;
513
-
514
- // Set all CSS animations to the correct timeline position
515
- document.getAnimations().forEach((anim) => {
516
- const target = (anim.effect as KeyframeEffect)?.target as Element | null;
517
- if (!target) return;
518
-
519
- // Animations on the active block: use current block elapsed time
520
- if (target.closest('.doc-player__block--active')) {
521
- anim.currentTime = Math.max(0, elapsedMs);
522
- }
523
- // Animations on the exiting block (during crossfade): use current
524
- // block elapsed for transition animations, keep Ken Burns at their
525
- // natural position based on when that block started
526
- // eslint-disable-next-line sonarjs/no-duplicated-branches
527
- else if (target.closest('.doc-player__block--previous')) {
528
- anim.currentTime = Math.max(0, elapsedMs);
529
- }
530
- });
648
+ }
649
+ const elapsedMs = (time - blockStartTime) * 1000;
531
650
 
532
- // Seek <video> elements in the active block to the correct clip position.
533
- // Each <video> carries data-clip-start/data-clip-end attributes set by
534
- // VideoLayer.tsx; we calculate targetTime = clipStart + blockElapsed.
535
- const blockElapsed = time - blockStartTime;
536
- const videoSeekPromises: Promise<void>[] = [];
537
- const activeBlockEl = document.querySelector('.doc-player__block--active');
538
- if (activeBlockEl) {
539
- const videos = activeBlockEl.querySelectorAll('video[data-clip-start]');
540
- videos.forEach((el) => {
541
- const video = el as HTMLVideoElement;
542
- const clipStart = parseFloat(video.dataset.clipStart || '0');
543
- const clipEnd = parseFloat(video.dataset.clipEnd || '0');
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
- );
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;
551
655
 
552
- video.pause();
553
- video.currentTime = targetTime;
554
-
555
- videoSeekPromises.push(
556
- new Promise<void>((r) => {
557
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
558
- r();
559
- } else {
560
- video.addEventListener('seeked', () => r(), { once: true });
561
- setTimeout(r, 200); // Fallback if seeked never fires
562
- }
563
- }),
564
- );
565
- });
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);
566
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
+ });
567
668
 
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) => {
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) => {
572
678
  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');
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
+
576
686
  video.pause();
577
- if (time < absStart || time >= absEnd) return;
578
- const targetTime = sourceIn + (time - absStart);
579
687
  video.currentTime = targetTime;
688
+
580
689
  videoSeekPromises.push(
581
690
  new Promise<void>((r) => {
582
691
  if (Math.abs(video.currentTime - targetTime) < 0.1) {
583
692
  r();
584
693
  } else {
585
694
  video.addEventListener('seeked', () => r(), { once: true });
586
- setTimeout(r, 200);
695
+ setTimeout(r, 200); // Fallback if seeked never fires
587
696
  }
588
697
  }),
589
698
  );
590
699
  });
700
+ }
591
701
 
592
- // Wait for video seeks + one more frame for the browser to render
593
- Promise.all(videoSeekPromises).then(() => {
594
- requestAnimationFrame(() => resolve());
595
- });
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());
596
729
  });
597
730
  });
598
- };
599
- w.getDuration = () => {
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;
606
- };
607
- // Expose block metadata for testing -- allows tests to find specific templates
608
- w.getBlocks = () =>
609
- expandedBlocks.map((s: Block) => ({
610
- id: s.id,
611
- template: (s as DocBlock).template ?? 'raw',
612
- startTime: s.startTime,
613
- duration: s.duration,
614
- }));
615
- // Audio segment info for video production -- returns the actual files in composition order
616
- w.getAudioSegments = () =>
617
- doc.audio.segments.map((seg) => ({
618
- src: seg.src,
619
- name: seg.name,
620
- duration: seg.duration,
621
- startTime: seg.startTime,
622
- }));
623
- // Caption phrases for SRT/subtitle export
624
- w.getCaptions = () =>
625
- doc.captions?.phrases?.map((p) => ({
626
- text: p.text,
627
- startTime: p.startTime,
628
- endTime: p.endTime,
629
- })) || [];
630
- // Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
631
- w.getChapters = () => {
632
- const titleMap = buildSegmentTitleMap(doc);
633
- return doc.audio.segments.map((seg, i) => ({
634
- title: titleMap.get(i) || seg.name,
635
- startTime: seg.startTime,
636
- duration: seg.duration,
637
- }));
638
- };
639
- // Cover block control for video pre-roll -- force-show or hide the cover block
640
- w.showCover = () => {
641
- setCoverForced(true);
642
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
643
- };
644
- w.hideCover = () => {
645
- setCoverForced(false);
646
- return new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
647
- };
648
- w.hasCoverBlock = () => !!coverBlock;
649
- }
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
+
650
797
  return () => {
651
- if (typeof window !== 'undefined') {
652
- const w = window as SquisqWindow;
653
- delete w.seekTo;
654
- delete w.getDuration;
655
- delete w.getBlocks;
656
- delete w.getAudioSegments;
657
- delete w.getCaptions;
658
- delete w.getChapters;
659
- delete w.showCover;
660
- delete w.hideCover;
661
- delete w.hasCoverBlock;
662
- }
798
+ if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
663
799
  };
664
- // eslint-disable-next-line react-hooks/exhaustive-deps -- doc is a stable prop; re-registering on every doc change is unnecessary
665
- }, [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]);
666
813
 
667
814
  // Caption mode state: cycles through off → standard → social → off
668
815
  // The captionStyle prop sets the default active style; captionsEnabledProp
@@ -714,8 +861,9 @@ function DocPlayerContent({
714
861
  isPlaying,
715
862
  currentTime,
716
863
  totalDuration,
717
- currentBlockIndex,
718
- totalBlocks: expandedBlocks.length,
864
+ isCoverVisible: showCoverBlock,
865
+ currentBlockIndex: slideshowSlideIndex,
866
+ totalBlocks: slideshowTotalSlides,
719
867
  docProgress,
720
868
  hasCaptions: !!hasCaptions,
721
869
  captionsEnabled,
@@ -724,15 +872,20 @@ function DocPlayerContent({
724
872
  currentSegmentIndex: currentSegment,
725
873
  currentSegmentName:
726
874
  segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
727
- currentBlock: currentBlock ?? null,
875
+ currentBlock: showSlideshowCover ? coverBlock : (currentBlock ?? null),
876
+ currentSlideLabel: showSlideshowCover ? 'Cover' : undefined,
877
+ currentSlideNumber:
878
+ slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : undefined,
879
+ totalSlideNumber: slideshowHasCover ? expandedBlocks.length : undefined,
728
880
  }),
729
881
  // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
730
882
  [
731
883
  isPlaying,
732
884
  currentTime,
733
885
  totalDuration,
734
- currentBlockIndex,
735
- expandedBlocks.length,
886
+ showCoverBlock,
887
+ slideshowSlideIndex,
888
+ slideshowTotalSlides,
736
889
  docProgress,
737
890
  hasCaptions,
738
891
  captionsEnabled,
@@ -741,6 +894,11 @@ function DocPlayerContent({
741
894
  currentSegment,
742
895
  segmentTitleMap,
743
896
  currentBlock,
897
+ currentBlockIndex,
898
+ showSlideshowCover,
899
+ coverBlock,
900
+ slideshowHasCover,
901
+ expandedBlocks.length,
744
902
  ],
745
903
  );
746
904
 
@@ -762,24 +920,56 @@ function DocPlayerContent({
762
920
  const slideNavActions: SlideNavActions = useMemo(
763
921
  () => ({
764
922
  nextSlide: () => {
923
+ if (slideshowHasCover && slideshowCoverVisible) {
924
+ const target = expandedBlocks[0];
925
+ if (target) {
926
+ setSlideshowCoverVisible(false);
927
+ seekTo(target.startTime);
928
+ pause();
929
+ }
930
+ return;
931
+ }
765
932
  if (currentBlockIndex < expandedBlocks.length - 1) {
766
933
  const target = expandedBlocks[currentBlockIndex + 1];
767
934
  if (target) {
935
+ setSlideshowCoverVisible(false);
768
936
  seekTo(target.startTime);
769
937
  pause();
770
938
  }
771
939
  }
772
940
  },
773
941
  prevSlide: () => {
942
+ if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
943
+ setSlideshowCoverVisible(true);
944
+ seekTo(0);
945
+ pause();
946
+ return;
947
+ }
774
948
  if (currentBlockIndex > 0) {
775
949
  const target = expandedBlocks[currentBlockIndex - 1];
776
950
  if (target) {
951
+ setSlideshowCoverVisible(false);
777
952
  seekTo(target.startTime);
778
953
  pause();
779
954
  }
780
955
  }
781
956
  },
782
957
  goToSlide: (index: number) => {
958
+ if (slideshowHasCover) {
959
+ if (index === 0) {
960
+ setSlideshowCoverVisible(true);
961
+ seekTo(0);
962
+ pause();
963
+ return;
964
+ }
965
+ const target = expandedBlocks[index - 1];
966
+ if (target) {
967
+ setSlideshowCoverVisible(false);
968
+ seekTo(target.startTime);
969
+ pause();
970
+ }
971
+ return;
972
+ }
783
973
  if (index >= 0 && index < expandedBlocks.length) {
784
974
  const target = expandedBlocks[index];
785
975
  if (target) {
@@ -789,19 +979,35 @@ function DocPlayerContent({
789
979
  }
790
980
  },
791
981
  }),
792
- [currentBlockIndex, expandedBlocks, seekTo, pause],
982
+ [currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible],
793
983
  );
794
984
 
795
985
  // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
796
986
  // interactive (not headless), and not overridden off via `enableSwipe`.
797
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]);
798
1004
  const swipe = useSlideSwipe({
799
1005
  enabled: swipeEnabled,
800
1006
  containerRef,
801
- canGoNext: currentBlockIndex < expandedBlocks.length - 1,
802
- canGoPrev: currentBlockIndex > 0,
803
- onNext: slideNavActions.nextSlide,
804
- onPrev: slideNavActions.prevSlide,
1007
+ canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
1008
+ canGoPrev: slideshowSlideIndex > 0,
1009
+ onNext: handleSwipeNext,
1010
+ onPrev: handleSwipePrev,
805
1011
  });
806
1012
 
807
1013
  // Callback for playback state changes (for external controls)
@@ -848,6 +1054,24 @@ function DocPlayerContent({
848
1054
  return block.id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
849
1055
  }, []);
850
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
+
851
1075
  // Compute block markers for progress bar (using expanded blocks)
852
1076
  const blockMarkers = useMemo(() => {
853
1077
  if (!totalDuration || !expandedBlocks.length) return [];
@@ -873,20 +1097,34 @@ function DocPlayerContent({
873
1097
  }, [blockMarkers, onBlockMarkers]);
874
1098
 
875
1099
  // Keep expandedBlocks length in a ref so keyboard handler stays stable
876
- expandedBlocksLenRef.current = expandedBlocks.length;
1100
+ expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
877
1101
 
878
1102
  // Handle keyboard controls — uses refs for frequently-changing values
879
1103
  // (currentTime, totalDuration, expandedBlocks.length) to avoid
880
1104
  // re-registering the event listener on every animation frame.
881
- const handleKeyDown = useCallback(
882
- (e: KeyboardEvent) => {
883
- // Don't capture keyboard events when focus is on an input/textarea
884
- 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"]');
885
1120
  if (
886
- activeEl &&
887
- (activeEl.tagName === 'INPUT' ||
888
- activeEl.tagName === 'TEXTAREA' ||
889
- 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)
890
1128
  ) {
891
1129
  return;
892
1130
  }
@@ -904,10 +1142,13 @@ function DocPlayerContent({
904
1142
  slideNavActions.nextSlide();
905
1143
  break;
906
1144
  case 'ArrowLeft':
907
- case 'ArrowUp':
908
1145
  e.preventDefault();
909
1146
  slideNavActions.prevSlide();
910
1147
  break;
1148
+ case 'ArrowUp':
1149
+ e.preventDefault();
1150
+ setIsSlideshowPickerOpen(true);
1151
+ break;
911
1152
  case 'Home':
912
1153
  e.preventDefault();
913
1154
  slideNavActions.goToSlide(0);
@@ -925,9 +1166,11 @@ function DocPlayerContent({
925
1166
  toggle();
926
1167
  break;
927
1168
  case 'ArrowRight':
1169
+ e.preventDefault();
928
1170
  seekTo(Math.min(currentTimeRef.current + 10, totalDurationRef.current));
929
1171
  break;
930
1172
  case 'ArrowLeft':
1173
+ e.preventDefault();
931
1174
  seekTo(Math.max(currentTimeRef.current - 10, 0));
932
1175
  break;
933
1176
  }
@@ -936,17 +1179,26 @@ function DocPlayerContent({
936
1179
  [isSlideshowMode, isLinearMode, toggle, seekTo, slideNavActions],
937
1180
  );
938
1181
 
1182
+ const handleKeyDown = useCallback(
1183
+ (e: React.KeyboardEvent<HTMLDivElement>) => handleKeyboardShortcut(e, false),
1184
+ [handleKeyboardShortcut],
1185
+ );
1186
+
939
1187
  useEffect(() => {
940
- if (renderMode) return; // No keyboard in render mode
941
- window.addEventListener('keydown', handleKeyDown);
942
- return () => window.removeEventListener('keydown', handleKeyDown);
943
- }, [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]);
944
1195
 
945
1196
  // ── Linear mode: render as scrollable document ──────────────────
946
1197
  if (isLinearMode) {
947
1198
  return (
948
1199
  <div
949
1200
  ref={containerRef}
1201
+ data-player-id={playerId}
950
1202
  className="doc-player doc-player--linear"
951
1203
  style={{
952
1204
  position: 'relative',
@@ -961,6 +1213,7 @@ function DocPlayerContent({
961
1213
  viewport={activeViewport}
962
1214
  theme={theme}
963
1215
  surface={surface}
1216
+ animationsEnabled={animationsEnabled}
964
1217
  />
965
1218
  </div>
966
1219
  );
@@ -969,6 +1222,10 @@ function DocPlayerContent({
969
1222
  return (
970
1223
  <div
971
1224
  ref={containerRef}
1225
+ data-player-id={playerId}
1226
+ tabIndex={renderMode ? -1 : 0}
1227
+ aria-label="Document player"
1228
+ onKeyDown={renderMode ? undefined : handleKeyDown}
972
1229
  className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
973
1230
  swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
974
1231
  }`}
@@ -996,6 +1253,7 @@ function DocPlayerContent({
996
1253
  isPlaying={isPlaying}
997
1254
  basePath={basePath}
998
1255
  renderMode={renderMode}
1256
+ muted={muted}
999
1257
  />
1000
1258
 
1001
1259
  {/* Block viewport */}
@@ -1009,12 +1267,13 @@ function DocPlayerContent({
1009
1267
  basePath={basePath}
1010
1268
  isEntering={false}
1011
1269
  viewport={activeViewport}
1270
+ animationsEnabled={animationsEnabled}
1012
1271
  />
1013
1272
  </div>
1014
1273
  )}
1015
1274
 
1016
1275
  {/* Previous block (during transition) */}
1017
- {!showCoverBlock && previousBlock && isExiting && (
1276
+ {animationsEnabled && !showCoverBlock && previousBlock && isExiting && (
1018
1277
  // Keyed by block id so each block is its own DOM subtree: React never
1019
1278
  // reconciles one block's layers onto another's (templates reuse layer
1020
1279
  // ids like `title`/`background`), which would otherwise reuse stale
@@ -1027,6 +1286,7 @@ function DocPlayerContent({
1027
1286
  isExiting={true}
1028
1287
  transition={currentBlock?.transition}
1029
1288
  viewport={activeViewport}
1289
+ animationsEnabled={animationsEnabled}
1030
1290
  />
1031
1291
  </div>
1032
1292
  )}
@@ -1046,9 +1306,10 @@ function DocPlayerContent({
1046
1306
  block={currentBlock}
1047
1307
  blockTime={blockTime}
1048
1308
  basePath={basePath}
1049
- isEntering={isEntering}
1309
+ isEntering={animationsEnabled && isEntering}
1050
1310
  viewport={activeViewport}
1051
1311
  isPlaying={isPlaying}
1312
+ animationsEnabled={animationsEnabled}
1052
1313
  />
1053
1314
  </div>
1054
1315
  )}
@@ -1238,13 +1499,19 @@ function DocPlayerContent({
1238
1499
  )}
1239
1500
 
1240
1501
  {/* Slideshow controls (prev / counter / next) */}
1241
- {!renderMode && isSlideshowMode && (
1242
- <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
+ />
1243
1510
  )}
1244
1511
 
1245
1512
  {/* Tap feedback animation -- shows play/pause icon briefly on tap (video mode only) */}
1246
1513
  {!isSlideshowMode && tapFeedback && (
1247
- <div className="doc-player__tap-feedback" key={Date.now()}>
1514
+ <div className="doc-player__tap-feedback" key={tapFeedback}>
1248
1515
  <svg viewBox="0 0 24 24" fill="white" width="48" height="48">
1249
1516
  {tapFeedback === 'pause' ? (
1250
1517
  <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />