@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.
- package/README.md +30 -3
- package/dist/index.d.ts +185 -27
- package/dist/index.js +1323 -614
- 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 +235 -7
- package/src/DocPlayer.tsx +462 -195
- 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 +556 -2
- 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 +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
|
-
* -
|
|
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 */
|
|
@@ -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 ||
|
|
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 ? '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
574
|
-
const
|
|
575
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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 (
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
718
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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:
|
|
802
|
-
canGoPrev:
|
|
803
|
-
onNext:
|
|
804
|
-
onPrev:
|
|
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
|
|
882
|
-
(e: KeyboardEvent) => {
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
887
|
-
(
|
|
888
|
-
|
|
889
|
-
|
|
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;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
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={
|
|
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" />
|