@bendyline/squisq-react 1.3.2 → 1.4.1
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 +131 -26
- package/dist/index.js +1475 -755
- 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 -13
- 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 +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -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 +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "React component library for doc playback, block rendering, and media layers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"types": "./dist/index.d.ts",
|
|
35
35
|
"import": "./dist/index.js"
|
|
36
36
|
},
|
|
37
|
-
"./styles": "./
|
|
37
|
+
"./styles": "./dist/styles/index.css",
|
|
38
38
|
"./standalone": "./dist/squisq-player.global.js",
|
|
39
39
|
"./standalone-source": {
|
|
40
40
|
"types": "./src/standalone-source.d.ts",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
|
-
"build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
|
|
45
|
+
"build": "tsup && tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs && node ../../scripts/build-styles.mjs",
|
|
46
46
|
"build:esm": "tsup",
|
|
47
47
|
"build:standalone": "tsup --config tsup.standalone.config.ts && node scripts/generate-standalone-source.mjs",
|
|
48
48
|
"dev": "npm run build:standalone && concurrently -n js,dts -c blue,gray -r \"tsup --watch --no-dts --no-clean\" \"tsup --watch --dts-only --no-clean\"",
|
|
@@ -53,17 +53,21 @@
|
|
|
53
53
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bendyline/squisq": "1.
|
|
56
|
+
"@bendyline/squisq": "1.5.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/react": "18.3.28",
|
|
60
60
|
"preact": "10.29.0",
|
|
61
61
|
"react": "18.3.1",
|
|
62
62
|
"react-dom": "18.3.1",
|
|
63
|
+
"@testing-library/dom": "10.4.1",
|
|
63
64
|
"@testing-library/react": "16.3.2",
|
|
64
65
|
"@testing-library/jest-dom": "6.9.1",
|
|
65
66
|
"jsdom": "25.0.1",
|
|
66
67
|
"tsup": "8.5.1",
|
|
67
68
|
"typescript": "5.9.3"
|
|
68
|
-
}
|
|
69
|
+
},
|
|
70
|
+
"sideEffects": [
|
|
71
|
+
"**/*.css"
|
|
72
|
+
]
|
|
69
73
|
}
|
package/src/BlockRenderer.tsx
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* Handles positioning, animations, and transitions.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { Block, Layer } from '@bendyline/squisq/schemas';
|
|
9
|
+
import type { Block, Layer, Transition } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { resolveTransitionDuration } from '@bendyline/squisq/schemas';
|
|
10
11
|
import { ImageLayer } from './layers/ImageLayer';
|
|
11
12
|
import { TextLayer } from './layers/TextLayer';
|
|
12
13
|
import { ShapeLayer } from './layers/ShapeLayer';
|
|
14
|
+
import { PathLayer } from './layers/PathLayer';
|
|
13
15
|
import { MapLayer } from './layers/MapLayer';
|
|
14
16
|
import { VideoLayer } from './layers/VideoLayer';
|
|
15
17
|
import { TableLayer } from './layers/TableLayer';
|
|
@@ -39,6 +41,8 @@ interface BlockRendererProps {
|
|
|
39
41
|
isEntering?: boolean;
|
|
40
42
|
/** Whether this block is exiting (for transition) */
|
|
41
43
|
isExiting?: boolean;
|
|
44
|
+
/** Transition to apply. Defaults to block.transition. */
|
|
45
|
+
transition?: Transition;
|
|
42
46
|
/** Viewport dimensions (defaults to 1920x1080 landscape) */
|
|
43
47
|
viewport?: ViewportDimensions;
|
|
44
48
|
/** Whether the doc is currently playing (controls video playback) */
|
|
@@ -51,18 +55,20 @@ export function BlockRenderer({
|
|
|
51
55
|
basePath,
|
|
52
56
|
isEntering = false,
|
|
53
57
|
isExiting = false,
|
|
58
|
+
transition,
|
|
54
59
|
viewport = VIEWPORT,
|
|
55
60
|
isPlaying,
|
|
56
61
|
}: BlockRendererProps) {
|
|
57
62
|
// Build transition class and inline style for dynamic duration
|
|
58
63
|
let transitionClass = '';
|
|
59
64
|
const transitionStyle: Record<string, string> = {};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
const activeTransition = transition ?? block.transition;
|
|
66
|
+
if (activeTransition && isEntering) {
|
|
67
|
+
transitionClass = getTransitionClass(activeTransition.type, true, activeTransition.direction);
|
|
68
|
+
transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
|
|
69
|
+
} else if (activeTransition && isExiting) {
|
|
70
|
+
transitionClass = getTransitionClass(activeTransition.type, false, activeTransition.direction);
|
|
71
|
+
transitionStyle['--transition-duration'] = `${resolveTransitionDuration(activeTransition)}s`;
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
// Unique clip path ID per block to avoid conflicts when multiple blocks render simultaneously
|
|
@@ -124,6 +130,8 @@ function LayerRenderer({ layer, basePath, viewport, blockTime, isPlaying }: Laye
|
|
|
124
130
|
return <TextLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
|
|
125
131
|
case 'shape':
|
|
126
132
|
return <ShapeLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
|
|
133
|
+
case 'path':
|
|
134
|
+
return <PathLayer layer={layer} viewport={viewport} blockTime={blockTime} />;
|
|
127
135
|
case 'map':
|
|
128
136
|
return (
|
|
129
137
|
<MapLayer layer={layer} basePath={basePath} viewport={viewport} blockTime={blockTime} />
|
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:
|
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
|
|
25
25
|
import { Fragment, useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
|
26
26
|
import type { Doc, Block, TextLayer, StartBlockConfig, DocBlock } from '@bendyline/squisq/schemas';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
isTemplateBlock,
|
|
29
|
+
getCaptionAtTime,
|
|
30
|
+
resolveMediaSchedule,
|
|
31
|
+
getDocPlaybackDuration,
|
|
32
|
+
} from '@bendyline/squisq/schemas';
|
|
33
|
+
import { MediaClipLayer } from './MediaClipLayer';
|
|
28
34
|
import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
|
|
29
35
|
import { applySurface } from '@bendyline/squisq/schemas';
|
|
30
36
|
import { BlockRenderer } from './BlockRenderer';
|
|
@@ -33,14 +39,17 @@ import { useAutoSurface } from './hooks/useAutoSurface';
|
|
|
33
39
|
import { useAudioSync } from './hooks/useAudioSync';
|
|
34
40
|
import { useDocPlayback } from './hooks/useDocPlayback';
|
|
35
41
|
import { useViewportOrientation } from './hooks/useViewportOrientation';
|
|
36
|
-
import
|
|
42
|
+
import { useSlideSwipe } from './hooks/useSlideSwipe';
|
|
43
|
+
import type { AudioController } from './hooks/AudioController';
|
|
37
44
|
import {
|
|
38
45
|
expandCoverBlock,
|
|
39
46
|
createTemplateContext,
|
|
47
|
+
markdownToDoc,
|
|
40
48
|
DEFAULT_THEME,
|
|
41
49
|
VIEWPORT_PRESETS,
|
|
42
50
|
type ViewportConfig,
|
|
43
51
|
} from '@bendyline/squisq/doc';
|
|
52
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
44
53
|
import { DocControlsOverlay } from './DocControlsOverlay';
|
|
45
54
|
import { DocControlsSlideshow } from './DocControlsSlideshow';
|
|
46
55
|
import { DocProgressBar } from './DocProgressBar';
|
|
@@ -79,11 +88,11 @@ const SMALL_WORDS = new Set([
|
|
|
79
88
|
* Uses sectionHeader blocks to find real titles, with fallbacks
|
|
80
89
|
* for "intro" and slug-based names.
|
|
81
90
|
*/
|
|
82
|
-
function buildSegmentTitleMap(
|
|
91
|
+
function buildSegmentTitleMap(doc: Doc): Map<number, string> {
|
|
83
92
|
const map = new Map<number, string>();
|
|
84
93
|
|
|
85
94
|
// Scan blocks for sectionHeader templates which carry the real title
|
|
86
|
-
for (const block of
|
|
95
|
+
for (const block of doc.blocks as DocBlock[]) {
|
|
87
96
|
if (isTemplateBlock(block) && block.template === 'sectionHeader' && 'title' in block) {
|
|
88
97
|
const segIdx = block.audioSegment;
|
|
89
98
|
if (!map.has(segIdx)) {
|
|
@@ -93,9 +102,9 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Fill in any segments that weren't covered by sectionHeader blocks
|
|
96
|
-
for (let i = 0; i <
|
|
105
|
+
for (let i = 0; i < doc.audio.segments.length; i++) {
|
|
97
106
|
if (!map.has(i)) {
|
|
98
|
-
const name =
|
|
107
|
+
const name = doc.audio.segments[i].name;
|
|
99
108
|
if (name === 'intro' || name.includes('intro')) {
|
|
100
109
|
map.set(i, 'Introduction');
|
|
101
110
|
} else if (name === 'flight-context' || name.includes('flight-context')) {
|
|
@@ -117,10 +126,20 @@ function buildSegmentTitleMap(script: Doc): Map<number, string> {
|
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
interface DocPlayerProps {
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
124
143
|
/** Render mode for video capture (hides controls, exposes seekTo) */
|
|
125
144
|
renderMode?: boolean;
|
|
126
145
|
/** Auto-play when loaded */
|
|
@@ -129,8 +148,8 @@ interface DocPlayerProps {
|
|
|
129
148
|
onEnded?: () => void;
|
|
130
149
|
/** Callback for time updates */
|
|
131
150
|
onTimeUpdate?: (time: number) => void;
|
|
132
|
-
/** Optional audio
|
|
133
|
-
|
|
151
|
+
/** Optional audio controller (if not provided, uses default HTML5 audio) */
|
|
152
|
+
audioController?: AudioController;
|
|
134
153
|
/** Show built-in controls (default: true). Set to false for custom controls. */
|
|
135
154
|
showControls?: boolean;
|
|
136
155
|
/** Show only the progress bar/scrubber at bottom (no other controls).
|
|
@@ -183,16 +202,66 @@ interface DocPlayerProps {
|
|
|
183
202
|
/** Caption display style (default: 'standard').
|
|
184
203
|
* 'social' shows large centered words with the active word highlighted. */
|
|
185
204
|
captionStyle?: CaptionStyle;
|
|
205
|
+
/**
|
|
206
|
+
* Enable drag-to-swipe slide navigation in slideshow mode (default: true).
|
|
207
|
+
* When enabled, press-and-drag on a slide advances/rewinds on release past a
|
|
208
|
+
* threshold (or a quick flick), and snaps back otherwise. Only applies when
|
|
209
|
+
* `displayMode === 'slideshow'` and not in render/headless mode.
|
|
210
|
+
*/
|
|
211
|
+
enableSwipe?: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Dev-only, browser-safe environment probe. Bundlers substitute the
|
|
215
|
+
// `process.env.NODE_ENV` expression; bare browsers without a bundler have
|
|
216
|
+
// no `process` at all and are treated as production (no warning noise).
|
|
217
|
+
function isDevEnvironment(): boolean {
|
|
218
|
+
try {
|
|
219
|
+
return typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
186
223
|
}
|
|
187
224
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
// One-shot flag for the missing-stylesheet warning (module-level so the
|
|
226
|
+
// warning fires at most once per page, not once per player instance).
|
|
227
|
+
let warnedMissingStyles = false;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Front-door component: resolves the `doc` / `markdown` props into a Doc
|
|
231
|
+
* and renders a themed empty state when neither is provided. The playback
|
|
232
|
+
* machinery lives in `DocPlayerContent` so its hook order never changes
|
|
233
|
+
* when a doc appears or disappears.
|
|
234
|
+
*/
|
|
235
|
+
export function DocPlayer(props: DocPlayerProps) {
|
|
236
|
+
const { doc, markdown } = props;
|
|
237
|
+
|
|
238
|
+
// Parse markdown into a Doc only when no explicit doc is supplied.
|
|
239
|
+
const markdownDoc = useMemo(
|
|
240
|
+
() => (!doc && markdown !== undefined ? markdownToDoc(parseMarkdown(markdown)) : undefined),
|
|
241
|
+
[doc, markdown],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const resolvedDoc = doc ?? markdownDoc;
|
|
245
|
+
|
|
246
|
+
if (!resolvedDoc) {
|
|
247
|
+
return <div className="doc-player doc-player--empty" />;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return <DocPlayerContent {...props} doc={resolvedDoc} />;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
interface DocPlayerContentProps extends DocPlayerProps {
|
|
254
|
+
doc: Doc;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function DocPlayerContent({
|
|
258
|
+
doc,
|
|
259
|
+
basePath = '.',
|
|
191
260
|
renderMode = false,
|
|
192
261
|
autoPlay = false,
|
|
193
262
|
onEnded,
|
|
194
263
|
onTimeUpdate,
|
|
195
|
-
|
|
264
|
+
audioController: externalAudioController,
|
|
196
265
|
showControls = true,
|
|
197
266
|
showScrubber = false,
|
|
198
267
|
muted = false,
|
|
@@ -208,7 +277,8 @@ export function DocPlayer({
|
|
|
208
277
|
theme,
|
|
209
278
|
surface,
|
|
210
279
|
captionStyle = 'standard',
|
|
211
|
-
|
|
280
|
+
enableSwipe = true,
|
|
281
|
+
}: DocPlayerContentProps) {
|
|
212
282
|
const isSlideshowMode = displayMode === 'slideshow';
|
|
213
283
|
const isLinearMode = displayMode === 'linear';
|
|
214
284
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
@@ -231,11 +301,27 @@ export function DocPlayer({
|
|
|
231
301
|
return params.get('debug') === 'true';
|
|
232
302
|
}, []);
|
|
233
303
|
|
|
234
|
-
// Use internal HTML5 audio sync if no external
|
|
235
|
-
const internalAudio = useAudioSync(audioRef,
|
|
304
|
+
// Use internal HTML5 audio sync if no external controller is given
|
|
305
|
+
const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
|
|
236
306
|
|
|
237
|
-
// Use external
|
|
238
|
-
const audio =
|
|
307
|
+
// Use external controller if provided, otherwise fall back to internal
|
|
308
|
+
const audio = externalAudioController || internalAudio;
|
|
309
|
+
|
|
310
|
+
// Dev-only sentinel: warn once when the package stylesheet isn't loaded.
|
|
311
|
+
// The stylesheet sets `--squisq-styles-loaded: 1` on `.doc-player`; if the
|
|
312
|
+
// mounted container computes an empty value, the CSS never made it in.
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (warnedMissingStyles || !isDevEnvironment()) return;
|
|
315
|
+
const el = containerRef.current;
|
|
316
|
+
if (!el || typeof getComputedStyle !== 'function') return;
|
|
317
|
+
const value = getComputedStyle(el).getPropertyValue('--squisq-styles-loaded');
|
|
318
|
+
if (!value.trim()) {
|
|
319
|
+
warnedMissingStyles = true;
|
|
320
|
+
console.warn(
|
|
321
|
+
'[squisq] @bendyline/squisq-react/styles is not loaded — import "@bendyline/squisq-react/styles"',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}, []);
|
|
239
325
|
|
|
240
326
|
// Destructure for convenience
|
|
241
327
|
const {
|
|
@@ -255,6 +341,11 @@ export function DocPlayer({
|
|
|
255
341
|
restart,
|
|
256
342
|
} = audio;
|
|
257
343
|
|
|
344
|
+
// Timed media clips (block.media + doc.documentMedia) resolved to absolute
|
|
345
|
+
// doc-timeline coordinates. Empty for documents without the media model, so
|
|
346
|
+
// <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
|
|
347
|
+
const mediaSchedule = useMemo(() => resolveMediaSchedule(doc), [doc]);
|
|
348
|
+
|
|
258
349
|
// Refs for frequently-changing values used in the keyboard handler,
|
|
259
350
|
// so the handler callback doesn't need to be recreated every frame.
|
|
260
351
|
const currentTimeRef = useRef(currentTime);
|
|
@@ -307,11 +398,11 @@ export function DocPlayer({
|
|
|
307
398
|
nextBlock: _nextBlock,
|
|
308
399
|
prevBlock: _prevBlock,
|
|
309
400
|
blocks: expandedBlocks,
|
|
310
|
-
} = useDocPlayback(
|
|
401
|
+
} = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
|
|
311
402
|
|
|
312
403
|
// Expand cover block (startBlock) if present - uses active viewport
|
|
313
404
|
const coverBlock = useMemo((): Block | null => {
|
|
314
|
-
const startBlockConfig =
|
|
405
|
+
const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
|
|
315
406
|
if (!startBlockConfig) return null;
|
|
316
407
|
|
|
317
408
|
const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
|
|
@@ -324,7 +415,7 @@ export function DocPlayer({
|
|
|
324
415
|
audioSegment: -1,
|
|
325
416
|
layers,
|
|
326
417
|
};
|
|
327
|
-
}, [
|
|
418
|
+
}, [doc.startBlock, activeViewport, effectiveTheme]);
|
|
328
419
|
|
|
329
420
|
// Render-mode cover block control: allows Playwright to force-show the cover block
|
|
330
421
|
const [coverForced, setCoverForced] = useState(false);
|
|
@@ -450,7 +541,13 @@ export function DocPlayer({
|
|
|
450
541
|
const video = el as HTMLVideoElement;
|
|
451
542
|
const clipStart = parseFloat(video.dataset.clipStart || '0');
|
|
452
543
|
const clipEnd = parseFloat(video.dataset.clipEnd || '0');
|
|
453
|
-
|
|
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
|
+
);
|
|
454
551
|
|
|
455
552
|
video.pause();
|
|
456
553
|
video.currentTime = targetTime;
|
|
@@ -468,6 +565,30 @@ export function DocPlayer({
|
|
|
468
565
|
});
|
|
469
566
|
}
|
|
470
567
|
|
|
568
|
+
// Seek player-level scheduled videos (document-spanning clips
|
|
569
|
+
// rendered by MediaClipLayer, outside any single block). Each
|
|
570
|
+
// carries data-abs-start/data-abs-end/data-source-in.
|
|
571
|
+
document.querySelectorAll('video[data-clip-id]').forEach((el) => {
|
|
572
|
+
const video = el as HTMLVideoElement;
|
|
573
|
+
const absStart = parseFloat(video.dataset.absStart || '0');
|
|
574
|
+
const absEnd = parseFloat(video.dataset.absEnd || '0');
|
|
575
|
+
const sourceIn = parseFloat(video.dataset.sourceIn || '0');
|
|
576
|
+
video.pause();
|
|
577
|
+
if (time < absStart || time >= absEnd) return;
|
|
578
|
+
const targetTime = sourceIn + (time - absStart);
|
|
579
|
+
video.currentTime = targetTime;
|
|
580
|
+
videoSeekPromises.push(
|
|
581
|
+
new Promise<void>((r) => {
|
|
582
|
+
if (Math.abs(video.currentTime - targetTime) < 0.1) {
|
|
583
|
+
r();
|
|
584
|
+
} else {
|
|
585
|
+
video.addEventListener('seeked', () => r(), { once: true });
|
|
586
|
+
setTimeout(r, 200);
|
|
587
|
+
}
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
|
|
471
592
|
// Wait for video seeks + one more frame for the browser to render
|
|
472
593
|
Promise.all(videoSeekPromises).then(() => {
|
|
473
594
|
requestAnimationFrame(() => resolve());
|
|
@@ -476,14 +597,12 @@ export function DocPlayer({
|
|
|
476
597
|
});
|
|
477
598
|
};
|
|
478
599
|
w.getDuration = () => {
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
return 0;
|
|
600
|
+
// The larger of the audio/block timeline and any media that spills
|
|
601
|
+
// past the last block (block-clip spillover or document-spanning
|
|
602
|
+
// media), so frame capture covers the full tail.
|
|
603
|
+
const mediaDuration = getDocPlaybackDuration(doc);
|
|
604
|
+
if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
|
|
605
|
+
return mediaDuration;
|
|
487
606
|
};
|
|
488
607
|
// Expose block metadata for testing -- allows tests to find specific templates
|
|
489
608
|
w.getBlocks = () =>
|
|
@@ -495,7 +614,7 @@ export function DocPlayer({
|
|
|
495
614
|
}));
|
|
496
615
|
// Audio segment info for video production -- returns the actual files in composition order
|
|
497
616
|
w.getAudioSegments = () =>
|
|
498
|
-
|
|
617
|
+
doc.audio.segments.map((seg) => ({
|
|
499
618
|
src: seg.src,
|
|
500
619
|
name: seg.name,
|
|
501
620
|
duration: seg.duration,
|
|
@@ -503,15 +622,15 @@ export function DocPlayer({
|
|
|
503
622
|
}));
|
|
504
623
|
// Caption phrases for SRT/subtitle export
|
|
505
624
|
w.getCaptions = () =>
|
|
506
|
-
|
|
625
|
+
doc.captions?.phrases?.map((p) => ({
|
|
507
626
|
text: p.text,
|
|
508
627
|
startTime: p.startTime,
|
|
509
628
|
endTime: p.endTime,
|
|
510
629
|
})) || [];
|
|
511
630
|
// Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
|
|
512
631
|
w.getChapters = () => {
|
|
513
|
-
const titleMap = buildSegmentTitleMap(
|
|
514
|
-
return
|
|
632
|
+
const titleMap = buildSegmentTitleMap(doc);
|
|
633
|
+
return doc.audio.segments.map((seg, i) => ({
|
|
515
634
|
title: titleMap.get(i) || seg.name,
|
|
516
635
|
startTime: seg.startTime,
|
|
517
636
|
duration: seg.duration,
|
|
@@ -542,7 +661,7 @@ export function DocPlayer({
|
|
|
542
661
|
delete w.hasCoverBlock;
|
|
543
662
|
}
|
|
544
663
|
};
|
|
545
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps --
|
|
664
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- doc is a stable prop; re-registering on every doc change is unnecessary
|
|
546
665
|
}, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
|
|
547
666
|
|
|
548
667
|
// Caption mode state: cycles through off → standard → social → off
|
|
@@ -552,6 +671,15 @@ export function DocPlayer({
|
|
|
552
671
|
captionsEnabledProp === false ? 'off' : captionStyle || 'standard';
|
|
553
672
|
const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultMode);
|
|
554
673
|
|
|
674
|
+
// Keep the internal caption mode in sync when the controlling props change
|
|
675
|
+
// — e.g. the editor's preview toolbar drives caption style / on-off. Keyed
|
|
676
|
+
// on the derived `defaultMode` string, so it only fires on a real prop
|
|
677
|
+
// change and never disturbs the in-player CC toggle for consumers (the
|
|
678
|
+
// standalone player, video export) that set these props once at mount.
|
|
679
|
+
useEffect(() => {
|
|
680
|
+
setCaptionMode(defaultMode);
|
|
681
|
+
}, [defaultMode]);
|
|
682
|
+
|
|
555
683
|
// Derive captionsEnabled and active style from the mode
|
|
556
684
|
const captionsEnabled = captionMode !== 'off';
|
|
557
685
|
const activeCaptionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
|
|
@@ -575,10 +703,10 @@ export function DocPlayer({
|
|
|
575
703
|
});
|
|
576
704
|
}, [onCaptionsToggle]);
|
|
577
705
|
|
|
578
|
-
const hasCaptions =
|
|
706
|
+
const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
|
|
579
707
|
|
|
580
708
|
// Map segment indices to human-readable titles (from sectionHeader blocks)
|
|
581
|
-
const segmentTitleMap = useMemo(() => buildSegmentTitleMap(
|
|
709
|
+
const segmentTitleMap = useMemo(() => buildSegmentTitleMap(doc), [doc]);
|
|
582
710
|
|
|
583
711
|
// Build shared playback state for extracted controls
|
|
584
712
|
const playbackState: PlaybackState = useMemo(
|
|
@@ -595,10 +723,10 @@ export function DocPlayer({
|
|
|
595
723
|
isFullscreen,
|
|
596
724
|
currentSegmentIndex: currentSegment,
|
|
597
725
|
currentSegmentName:
|
|
598
|
-
segmentTitleMap.get(currentSegment) ??
|
|
726
|
+
segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
|
|
599
727
|
currentBlock: currentBlock ?? null,
|
|
600
728
|
}),
|
|
601
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps --
|
|
729
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
|
|
602
730
|
[
|
|
603
731
|
isPlaying,
|
|
604
732
|
currentTime,
|
|
@@ -664,6 +792,18 @@ export function DocPlayer({
|
|
|
664
792
|
[currentBlockIndex, expandedBlocks, seekTo, pause],
|
|
665
793
|
);
|
|
666
794
|
|
|
795
|
+
// Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
|
|
796
|
+
// interactive (not headless), and not overridden off via `enableSwipe`.
|
|
797
|
+
const swipeEnabled = isSlideshowMode && !isLinearMode && !renderMode && enableSwipe;
|
|
798
|
+
const swipe = useSlideSwipe({
|
|
799
|
+
enabled: swipeEnabled,
|
|
800
|
+
containerRef,
|
|
801
|
+
canGoNext: currentBlockIndex < expandedBlocks.length - 1,
|
|
802
|
+
canGoPrev: currentBlockIndex > 0,
|
|
803
|
+
onNext: slideNavActions.nextSlide,
|
|
804
|
+
onPrev: slideNavActions.prevSlide,
|
|
805
|
+
});
|
|
806
|
+
|
|
667
807
|
// Callback for playback state changes (for external controls)
|
|
668
808
|
useEffect(() => {
|
|
669
809
|
onPlaybackStateChange?.(playbackState);
|
|
@@ -816,7 +956,7 @@ export function DocPlayer({
|
|
|
816
956
|
}}
|
|
817
957
|
>
|
|
818
958
|
<LinearDocView
|
|
819
|
-
doc={
|
|
959
|
+
doc={doc}
|
|
820
960
|
basePath={basePath}
|
|
821
961
|
viewport={activeViewport}
|
|
822
962
|
theme={theme}
|
|
@@ -829,20 +969,35 @@ export function DocPlayer({
|
|
|
829
969
|
return (
|
|
830
970
|
<div
|
|
831
971
|
ref={containerRef}
|
|
832
|
-
className=
|
|
972
|
+
className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
|
|
973
|
+
swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
|
|
974
|
+
}`}
|
|
833
975
|
onClick={handleContainerClick}
|
|
976
|
+
onPointerDown={swipe.onPointerDown}
|
|
834
977
|
style={{
|
|
835
978
|
position: 'relative',
|
|
836
979
|
width: '100%',
|
|
837
980
|
aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
|
|
838
981
|
margin: '0 auto',
|
|
839
982
|
overflow: 'hidden',
|
|
840
|
-
cursor
|
|
983
|
+
// Swipe uses the grab/grabbing cursor via CSS classes; let vertical page
|
|
984
|
+
// scroll through on touch while we own horizontal drags.
|
|
985
|
+
cursor: renderMode || swipeEnabled ? undefined : 'pointer',
|
|
986
|
+
touchAction: swipeEnabled ? 'pan-y' : undefined,
|
|
841
987
|
}}
|
|
842
988
|
>
|
|
843
989
|
{/* Hidden audio element */}
|
|
844
990
|
<audio ref={audioRef} preload="auto" muted={muted} />
|
|
845
991
|
|
|
992
|
+
{/* Timed media clips (per-block + document-spanning audio/video). */}
|
|
993
|
+
<MediaClipLayer
|
|
994
|
+
schedule={mediaSchedule}
|
|
995
|
+
currentTime={currentTime}
|
|
996
|
+
isPlaying={isPlaying}
|
|
997
|
+
basePath={basePath}
|
|
998
|
+
renderMode={renderMode}
|
|
999
|
+
/>
|
|
1000
|
+
|
|
846
1001
|
{/* Block viewport */}
|
|
847
1002
|
<div className="doc-player__viewport">
|
|
848
1003
|
{/* Cover block (shown at rest before playback) */}
|
|
@@ -860,12 +1015,17 @@ export function DocPlayer({
|
|
|
860
1015
|
|
|
861
1016
|
{/* Previous block (during transition) */}
|
|
862
1017
|
{!showCoverBlock && previousBlock && isExiting && (
|
|
863
|
-
|
|
1018
|
+
// Keyed by block id so each block is its own DOM subtree: React never
|
|
1019
|
+
// reconciles one block's layers onto another's (templates reuse layer
|
|
1020
|
+
// ids like `title`/`background`), which would otherwise reuse stale
|
|
1021
|
+
// DOM / skip entrance animations mid-transition.
|
|
1022
|
+
<div key={previousBlock.id} className="doc-player__block doc-player__block--previous">
|
|
864
1023
|
<BlockRenderer
|
|
865
1024
|
block={previousBlock}
|
|
866
1025
|
blockTime={blockTime}
|
|
867
1026
|
basePath={basePath}
|
|
868
1027
|
isExiting={true}
|
|
1028
|
+
transition={currentBlock?.transition}
|
|
869
1029
|
viewport={activeViewport}
|
|
870
1030
|
/>
|
|
871
1031
|
</div>
|
|
@@ -873,7 +1033,15 @@ export function DocPlayer({
|
|
|
873
1033
|
|
|
874
1034
|
{/* Current block */}
|
|
875
1035
|
{!showCoverBlock && currentBlock && (
|
|
876
|
-
<div
|
|
1036
|
+
<div
|
|
1037
|
+
key={currentBlock.id}
|
|
1038
|
+
className={`doc-player__block doc-player__block--active${
|
|
1039
|
+
swipe.phase !== 'idle' ? ` doc-player__block--${swipe.phase}` : ''
|
|
1040
|
+
}`}
|
|
1041
|
+
style={
|
|
1042
|
+
swipe.phase !== 'idle' ? { transform: `translateX(${swipe.offsetPx}px)` } : undefined
|
|
1043
|
+
}
|
|
1044
|
+
>
|
|
877
1045
|
<BlockRenderer
|
|
878
1046
|
block={currentBlock}
|
|
879
1047
|
blockTime={blockTime}
|
|
@@ -888,7 +1056,7 @@ export function DocPlayer({
|
|
|
888
1056
|
{/* Caption overlay -- shown during playback and in render mode when captions are enabled */}
|
|
889
1057
|
{hasCaptions && (renderMode ? captionsEnabled : true) && (
|
|
890
1058
|
<CaptionOverlay
|
|
891
|
-
captions={
|
|
1059
|
+
captions={doc.captions}
|
|
892
1060
|
currentTime={currentTime}
|
|
893
1061
|
enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
|
|
894
1062
|
fontSize={16}
|
|
@@ -937,8 +1105,7 @@ export function DocPlayer({
|
|
|
937
1105
|
<span style={{ color: '#888' }}>time:</span> {currentTime.toFixed(2)}s /{' '}
|
|
938
1106
|
{totalDuration.toFixed(1)}s{' '}
|
|
939
1107
|
<span style={{ color: '#666' }}>
|
|
940
|
-
(progress: {(docProgress * 100).toFixed(1)}%, scriptDur:{
|
|
941
|
-
{script.duration.toFixed(1)})
|
|
1108
|
+
(progress: {(docProgress * 100).toFixed(1)}%, scriptDur: {doc.duration.toFixed(1)})
|
|
942
1109
|
</span>
|
|
943
1110
|
</div>
|
|
944
1111
|
<div>
|
|
@@ -947,9 +1114,9 @@ export function DocPlayer({
|
|
|
947
1114
|
</div>
|
|
948
1115
|
<div>
|
|
949
1116
|
<span style={{ color: '#888' }}>segment:</span> {currentSegment}/
|
|
950
|
-
{
|
|
1117
|
+
{doc.audio.segments.length - 1}{' '}
|
|
951
1118
|
<span style={{ color: '#666' }}>
|
|
952
|
-
({
|
|
1119
|
+
({doc.audio.segments[currentSegment]?.name || 'none'})
|
|
953
1120
|
</span>
|
|
954
1121
|
</div>
|
|
955
1122
|
<div>
|
|
@@ -966,13 +1133,13 @@ export function DocPlayer({
|
|
|
966
1133
|
</div>
|
|
967
1134
|
{hasCaptions &&
|
|
968
1135
|
(() => {
|
|
969
|
-
const debugPhrase = getCaptionAtTime(
|
|
1136
|
+
const debugPhrase = getCaptionAtTime(doc.captions!, currentTime);
|
|
970
1137
|
const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
|
|
971
1138
|
return (
|
|
972
1139
|
<Fragment>
|
|
973
1140
|
<div>
|
|
974
1141
|
<span style={{ color: '#888' }}>captions:</span>{' '}
|
|
975
|
-
{
|
|
1142
|
+
{doc.captions?.phrases.length || 0} phrases{' '}
|
|
976
1143
|
<span style={{ color: captionsEnabled ? '#4ade80' : '#666' }}>
|
|
977
1144
|
({captionsEnabled ? 'on' : 'off'})
|
|
978
1145
|
</span>
|