@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.
- package/README.md +30 -3
- package/dist/index.d.ts +174 -27
- package/dist/index.js +1244 -603
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +54 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +53 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +135 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +79 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +91 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +13 -2
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +3 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +4 -2
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- 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
|
-
* -
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
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 ? '
|
|
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
|
-
|
|
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
|
-
|
|
554
|
+
effectiveSlideshowCoverVisible
|
|
499
555
|
);
|
|
500
|
-
const showCoverBlock =
|
|
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
|
-
?
|
|
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
|
-
//
|
|
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 (
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
616
|
-
const
|
|
617
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
};
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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 (
|
|
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
|
-
|
|
707
|
-
|
|
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:
|
|
887
|
-
onPrev:
|
|
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
|
|
965
|
-
(e: KeyboardEvent) => {
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
970
|
-
(
|
|
971
|
-
|
|
972
|
-
|
|
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;
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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={
|
|
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" />
|