@bendyline/squisq-react 1.4.0 → 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 +70 -21
- package/dist/index.js +379 -164
- 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/DocPlayer.tsx +160 -46
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/LinearDocView.tsx +59 -10
- package/src/MarkdownRenderer.tsx +52 -35
- package/src/__tests__/DocPlayer.test.tsx +34 -4
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "1.4.
|
|
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,7 +53,7 @@
|
|
|
53
53
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bendyline/squisq": "1.5.
|
|
56
|
+
"@bendyline/squisq": "1.5.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/react": "18.3.28",
|
|
@@ -66,5 +66,8 @@
|
|
|
66
66
|
"jsdom": "25.0.1",
|
|
67
67
|
"tsup": "8.5.1",
|
|
68
68
|
"typescript": "5.9.3"
|
|
69
|
-
}
|
|
69
|
+
},
|
|
70
|
+
"sideEffects": [
|
|
71
|
+
"**/*.css"
|
|
72
|
+
]
|
|
70
73
|
}
|
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).
|
|
@@ -189,16 +202,66 @@ interface DocPlayerProps {
|
|
|
189
202
|
/** Caption display style (default: 'standard').
|
|
190
203
|
* 'social' shows large centered words with the active word highlighted. */
|
|
191
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
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
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} />;
|
|
192
251
|
}
|
|
193
252
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
interface DocPlayerContentProps extends DocPlayerProps {
|
|
254
|
+
doc: Doc;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function DocPlayerContent({
|
|
258
|
+
doc,
|
|
259
|
+
basePath = '.',
|
|
197
260
|
renderMode = false,
|
|
198
261
|
autoPlay = false,
|
|
199
262
|
onEnded,
|
|
200
263
|
onTimeUpdate,
|
|
201
|
-
|
|
264
|
+
audioController: externalAudioController,
|
|
202
265
|
showControls = true,
|
|
203
266
|
showScrubber = false,
|
|
204
267
|
muted = false,
|
|
@@ -214,7 +277,8 @@ export function DocPlayer({
|
|
|
214
277
|
theme,
|
|
215
278
|
surface,
|
|
216
279
|
captionStyle = 'standard',
|
|
217
|
-
|
|
280
|
+
enableSwipe = true,
|
|
281
|
+
}: DocPlayerContentProps) {
|
|
218
282
|
const isSlideshowMode = displayMode === 'slideshow';
|
|
219
283
|
const isLinearMode = displayMode === 'linear';
|
|
220
284
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
@@ -237,11 +301,27 @@ export function DocPlayer({
|
|
|
237
301
|
return params.get('debug') === 'true';
|
|
238
302
|
}, []);
|
|
239
303
|
|
|
240
|
-
// Use internal HTML5 audio sync if no external
|
|
241
|
-
const internalAudio = useAudioSync(audioRef,
|
|
304
|
+
// Use internal HTML5 audio sync if no external controller is given
|
|
305
|
+
const internalAudio = useAudioSync(audioRef, doc.audio, basePath);
|
|
242
306
|
|
|
243
|
-
// Use external
|
|
244
|
-
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
|
+
}, []);
|
|
245
325
|
|
|
246
326
|
// Destructure for convenience
|
|
247
327
|
const {
|
|
@@ -264,7 +344,7 @@ export function DocPlayer({
|
|
|
264
344
|
// Timed media clips (block.media + doc.documentMedia) resolved to absolute
|
|
265
345
|
// doc-timeline coordinates. Empty for documents without the media model, so
|
|
266
346
|
// <MediaClipLayer> renders nothing and the legacy audio path is unaffected.
|
|
267
|
-
const mediaSchedule = useMemo(() => resolveMediaSchedule(
|
|
347
|
+
const mediaSchedule = useMemo(() => resolveMediaSchedule(doc), [doc]);
|
|
268
348
|
|
|
269
349
|
// Refs for frequently-changing values used in the keyboard handler,
|
|
270
350
|
// so the handler callback doesn't need to be recreated every frame.
|
|
@@ -318,11 +398,11 @@ export function DocPlayer({
|
|
|
318
398
|
nextBlock: _nextBlock,
|
|
319
399
|
prevBlock: _prevBlock,
|
|
320
400
|
blocks: expandedBlocks,
|
|
321
|
-
} = useDocPlayback(
|
|
401
|
+
} = useDocPlayback(doc, currentTime, activeViewport, renderMode, effectiveTheme);
|
|
322
402
|
|
|
323
403
|
// Expand cover block (startBlock) if present - uses active viewport
|
|
324
404
|
const coverBlock = useMemo((): Block | null => {
|
|
325
|
-
const startBlockConfig =
|
|
405
|
+
const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
|
|
326
406
|
if (!startBlockConfig) return null;
|
|
327
407
|
|
|
328
408
|
const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
|
|
@@ -335,7 +415,7 @@ export function DocPlayer({
|
|
|
335
415
|
audioSegment: -1,
|
|
336
416
|
layers,
|
|
337
417
|
};
|
|
338
|
-
}, [
|
|
418
|
+
}, [doc.startBlock, activeViewport, effectiveTheme]);
|
|
339
419
|
|
|
340
420
|
// Render-mode cover block control: allows Playwright to force-show the cover block
|
|
341
421
|
const [coverForced, setCoverForced] = useState(false);
|
|
@@ -520,7 +600,7 @@ export function DocPlayer({
|
|
|
520
600
|
// The larger of the audio/block timeline and any media that spills
|
|
521
601
|
// past the last block (block-clip spillover or document-spanning
|
|
522
602
|
// media), so frame capture covers the full tail.
|
|
523
|
-
const mediaDuration = getDocPlaybackDuration(
|
|
603
|
+
const mediaDuration = getDocPlaybackDuration(doc);
|
|
524
604
|
if (totalDuration > 0) return Math.max(totalDuration, mediaDuration);
|
|
525
605
|
return mediaDuration;
|
|
526
606
|
};
|
|
@@ -534,7 +614,7 @@ export function DocPlayer({
|
|
|
534
614
|
}));
|
|
535
615
|
// Audio segment info for video production -- returns the actual files in composition order
|
|
536
616
|
w.getAudioSegments = () =>
|
|
537
|
-
|
|
617
|
+
doc.audio.segments.map((seg) => ({
|
|
538
618
|
src: seg.src,
|
|
539
619
|
name: seg.name,
|
|
540
620
|
duration: seg.duration,
|
|
@@ -542,15 +622,15 @@ export function DocPlayer({
|
|
|
542
622
|
}));
|
|
543
623
|
// Caption phrases for SRT/subtitle export
|
|
544
624
|
w.getCaptions = () =>
|
|
545
|
-
|
|
625
|
+
doc.captions?.phrases?.map((p) => ({
|
|
546
626
|
text: p.text,
|
|
547
627
|
startTime: p.startTime,
|
|
548
628
|
endTime: p.endTime,
|
|
549
629
|
})) || [];
|
|
550
630
|
// Chapter markers for YouTube timestamps -- uses segment titles from sectionHeader blocks
|
|
551
631
|
w.getChapters = () => {
|
|
552
|
-
const titleMap = buildSegmentTitleMap(
|
|
553
|
-
return
|
|
632
|
+
const titleMap = buildSegmentTitleMap(doc);
|
|
633
|
+
return doc.audio.segments.map((seg, i) => ({
|
|
554
634
|
title: titleMap.get(i) || seg.name,
|
|
555
635
|
startTime: seg.startTime,
|
|
556
636
|
duration: seg.duration,
|
|
@@ -581,7 +661,7 @@ export function DocPlayer({
|
|
|
581
661
|
delete w.hasCoverBlock;
|
|
582
662
|
}
|
|
583
663
|
};
|
|
584
|
-
// 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
|
|
585
665
|
}, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock]);
|
|
586
666
|
|
|
587
667
|
// Caption mode state: cycles through off → standard → social → off
|
|
@@ -591,6 +671,15 @@ export function DocPlayer({
|
|
|
591
671
|
captionsEnabledProp === false ? 'off' : captionStyle || 'standard';
|
|
592
672
|
const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultMode);
|
|
593
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
|
+
|
|
594
683
|
// Derive captionsEnabled and active style from the mode
|
|
595
684
|
const captionsEnabled = captionMode !== 'off';
|
|
596
685
|
const activeCaptionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
|
|
@@ -614,10 +703,10 @@ export function DocPlayer({
|
|
|
614
703
|
});
|
|
615
704
|
}, [onCaptionsToggle]);
|
|
616
705
|
|
|
617
|
-
const hasCaptions =
|
|
706
|
+
const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
|
|
618
707
|
|
|
619
708
|
// Map segment indices to human-readable titles (from sectionHeader blocks)
|
|
620
|
-
const segmentTitleMap = useMemo(() => buildSegmentTitleMap(
|
|
709
|
+
const segmentTitleMap = useMemo(() => buildSegmentTitleMap(doc), [doc]);
|
|
621
710
|
|
|
622
711
|
// Build shared playback state for extracted controls
|
|
623
712
|
const playbackState: PlaybackState = useMemo(
|
|
@@ -634,10 +723,10 @@ export function DocPlayer({
|
|
|
634
723
|
isFullscreen,
|
|
635
724
|
currentSegmentIndex: currentSegment,
|
|
636
725
|
currentSegmentName:
|
|
637
|
-
segmentTitleMap.get(currentSegment) ??
|
|
726
|
+
segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
|
|
638
727
|
currentBlock: currentBlock ?? null,
|
|
639
728
|
}),
|
|
640
|
-
// 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
|
|
641
730
|
[
|
|
642
731
|
isPlaying,
|
|
643
732
|
currentTime,
|
|
@@ -703,6 +792,18 @@ export function DocPlayer({
|
|
|
703
792
|
[currentBlockIndex, expandedBlocks, seekTo, pause],
|
|
704
793
|
);
|
|
705
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
|
+
|
|
706
807
|
// Callback for playback state changes (for external controls)
|
|
707
808
|
useEffect(() => {
|
|
708
809
|
onPlaybackStateChange?.(playbackState);
|
|
@@ -855,7 +956,7 @@ export function DocPlayer({
|
|
|
855
956
|
}}
|
|
856
957
|
>
|
|
857
958
|
<LinearDocView
|
|
858
|
-
doc={
|
|
959
|
+
doc={doc}
|
|
859
960
|
basePath={basePath}
|
|
860
961
|
viewport={activeViewport}
|
|
861
962
|
theme={theme}
|
|
@@ -868,15 +969,21 @@ export function DocPlayer({
|
|
|
868
969
|
return (
|
|
869
970
|
<div
|
|
870
971
|
ref={containerRef}
|
|
871
|
-
className=
|
|
972
|
+
className={`doc-player${swipeEnabled ? ' doc-player--swipe' : ''}${
|
|
973
|
+
swipe.phase === 'dragging' ? ' doc-player--grabbing' : ''
|
|
974
|
+
}`}
|
|
872
975
|
onClick={handleContainerClick}
|
|
976
|
+
onPointerDown={swipe.onPointerDown}
|
|
873
977
|
style={{
|
|
874
978
|
position: 'relative',
|
|
875
979
|
width: '100%',
|
|
876
980
|
aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
|
|
877
981
|
margin: '0 auto',
|
|
878
982
|
overflow: 'hidden',
|
|
879
|
-
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,
|
|
880
987
|
}}
|
|
881
988
|
>
|
|
882
989
|
{/* Hidden audio element */}
|
|
@@ -926,7 +1033,15 @@ export function DocPlayer({
|
|
|
926
1033
|
|
|
927
1034
|
{/* Current block */}
|
|
928
1035
|
{!showCoverBlock && currentBlock && (
|
|
929
|
-
<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
|
+
>
|
|
930
1045
|
<BlockRenderer
|
|
931
1046
|
block={currentBlock}
|
|
932
1047
|
blockTime={blockTime}
|
|
@@ -941,7 +1056,7 @@ export function DocPlayer({
|
|
|
941
1056
|
{/* Caption overlay -- shown during playback and in render mode when captions are enabled */}
|
|
942
1057
|
{hasCaptions && (renderMode ? captionsEnabled : true) && (
|
|
943
1058
|
<CaptionOverlay
|
|
944
|
-
captions={
|
|
1059
|
+
captions={doc.captions}
|
|
945
1060
|
currentTime={currentTime}
|
|
946
1061
|
enabled={captionsEnabled && (renderMode || isPlaying || currentTime > 0)}
|
|
947
1062
|
fontSize={16}
|
|
@@ -990,8 +1105,7 @@ export function DocPlayer({
|
|
|
990
1105
|
<span style={{ color: '#888' }}>time:</span> {currentTime.toFixed(2)}s /{' '}
|
|
991
1106
|
{totalDuration.toFixed(1)}s{' '}
|
|
992
1107
|
<span style={{ color: '#666' }}>
|
|
993
|
-
(progress: {(docProgress * 100).toFixed(1)}%, scriptDur:{
|
|
994
|
-
{script.duration.toFixed(1)})
|
|
1108
|
+
(progress: {(docProgress * 100).toFixed(1)}%, scriptDur: {doc.duration.toFixed(1)})
|
|
995
1109
|
</span>
|
|
996
1110
|
</div>
|
|
997
1111
|
<div>
|
|
@@ -1000,9 +1114,9 @@ export function DocPlayer({
|
|
|
1000
1114
|
</div>
|
|
1001
1115
|
<div>
|
|
1002
1116
|
<span style={{ color: '#888' }}>segment:</span> {currentSegment}/
|
|
1003
|
-
{
|
|
1117
|
+
{doc.audio.segments.length - 1}{' '}
|
|
1004
1118
|
<span style={{ color: '#666' }}>
|
|
1005
|
-
({
|
|
1119
|
+
({doc.audio.segments[currentSegment]?.name || 'none'})
|
|
1006
1120
|
</span>
|
|
1007
1121
|
</div>
|
|
1008
1122
|
<div>
|
|
@@ -1019,13 +1133,13 @@ export function DocPlayer({
|
|
|
1019
1133
|
</div>
|
|
1020
1134
|
{hasCaptions &&
|
|
1021
1135
|
(() => {
|
|
1022
|
-
const debugPhrase = getCaptionAtTime(
|
|
1136
|
+
const debugPhrase = getCaptionAtTime(doc.captions!, currentTime);
|
|
1023
1137
|
const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
|
|
1024
1138
|
return (
|
|
1025
1139
|
<Fragment>
|
|
1026
1140
|
<div>
|
|
1027
1141
|
<span style={{ color: '#888' }}>captions:</span>{' '}
|
|
1028
|
-
{
|
|
1142
|
+
{doc.captions?.phrases.length || 0} phrases{' '}
|
|
1029
1143
|
<span style={{ color: captionsEnabled ? '#4ade80' : '#666' }}>
|
|
1030
1144
|
({captionsEnabled ? 'on' : 'off'})
|
|
1031
1145
|
</span>
|
|
@@ -20,19 +20,22 @@
|
|
|
20
20
|
|
|
21
21
|
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
22
22
|
import type { Doc } from '@bendyline/squisq/schemas';
|
|
23
|
-
import type { ViewportConfig } from '@bendyline/squisq/schemas';
|
|
24
|
-
import type {
|
|
23
|
+
import type { ViewportConfig, Theme } from '@bendyline/squisq/schemas';
|
|
24
|
+
import type { AudioController } from './hooks/AudioController';
|
|
25
25
|
import { DocPlayer } from './DocPlayer';
|
|
26
26
|
import { DocControlsSidebar } from './DocControlsSidebar';
|
|
27
27
|
import type { PlaybackState, PlaybackActions } from './types';
|
|
28
28
|
|
|
29
29
|
interface DocPlayerWithSidebarProps {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** The Doc to play */
|
|
31
|
+
doc: Doc;
|
|
32
|
+
/** Base path for resolving media URLs (default: `'.'`) */
|
|
33
|
+
basePath?: string;
|
|
32
34
|
autoPlay?: boolean;
|
|
33
35
|
onEnded?: () => void;
|
|
34
36
|
onTimeUpdate?: (time: number) => void;
|
|
35
|
-
|
|
37
|
+
/** Optional audio controller (if not provided, uses default HTML5 audio) */
|
|
38
|
+
audioController?: AudioController;
|
|
36
39
|
muted?: boolean;
|
|
37
40
|
captionsEnabled?: boolean;
|
|
38
41
|
isFullscreen?: boolean;
|
|
@@ -41,6 +44,13 @@ interface DocPlayerWithSidebarProps {
|
|
|
41
44
|
forceViewport?: ViewportConfig;
|
|
42
45
|
/** Called when playing state changes */
|
|
43
46
|
onPlayingChange?: (isPlaying: boolean) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Theme for rendering. Forwarded to the inner DocPlayer so the sidebar
|
|
49
|
+
* (portrait) layout matches the default (landscape) layout — without it the
|
|
50
|
+
* inner player falls back to DEFAULT_THEME, whose dark text is unreadable
|
|
51
|
+
* over a hero cover image.
|
|
52
|
+
*/
|
|
53
|
+
theme?: Theme;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const DEFAULT_STATE: PlaybackState = {
|
|
@@ -59,18 +69,19 @@ const DEFAULT_STATE: PlaybackState = {
|
|
|
59
69
|
};
|
|
60
70
|
|
|
61
71
|
export function DocPlayerWithSidebar({
|
|
62
|
-
|
|
72
|
+
doc,
|
|
63
73
|
basePath,
|
|
64
74
|
autoPlay = false,
|
|
65
75
|
onEnded,
|
|
66
76
|
onTimeUpdate,
|
|
67
|
-
|
|
77
|
+
audioController,
|
|
68
78
|
muted,
|
|
69
79
|
captionsEnabled,
|
|
70
80
|
isFullscreen,
|
|
71
81
|
onFullscreenToggle,
|
|
72
82
|
forceViewport,
|
|
73
83
|
onPlayingChange,
|
|
84
|
+
theme,
|
|
74
85
|
}: DocPlayerWithSidebarProps) {
|
|
75
86
|
// Store playback state in a ref to avoid triggering re-renders from DocPlayer callbacks
|
|
76
87
|
const stateRef = useRef<PlaybackState>(DEFAULT_STATE);
|
|
@@ -114,12 +125,13 @@ export function DocPlayerWithSidebar({
|
|
|
114
125
|
<div className="doc-player-sidebar-layout">
|
|
115
126
|
<div className="doc-player-sidebar-layout__video">
|
|
116
127
|
<DocPlayer
|
|
117
|
-
|
|
128
|
+
doc={doc}
|
|
129
|
+
theme={theme}
|
|
118
130
|
basePath={basePath}
|
|
119
131
|
autoPlay={autoPlay}
|
|
120
132
|
onEnded={onEnded}
|
|
121
133
|
onTimeUpdate={onTimeUpdate}
|
|
122
|
-
|
|
134
|
+
audioController={audioController}
|
|
123
135
|
muted={muted}
|
|
124
136
|
captionsEnabled={captionsEnabled}
|
|
125
137
|
showControls={isFullscreen}
|