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