@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.4.0",
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": "./src/styles/index.css",
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.0"
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 provider for different environments (browser, EFB)
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 type { AudioProvider } from './hooks/AudioProvider';
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(script: Doc): Map<number, string> {
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 script.blocks as DocBlock[]) {
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 < script.audio.segments.length; i++) {
105
+ for (let i = 0; i < doc.audio.segments.length; i++) {
103
106
  if (!map.has(i)) {
104
- const name = script.audio.segments[i].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
- /** Doc script to play */
127
- script: Doc;
128
- /** Base path for resolving media URLs */
129
- basePath: string;
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 provider (if not provided, uses default HTML5 audio) */
139
- audioProvider?: AudioProvider;
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
- export function DocPlayer({
195
- script,
196
- basePath,
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
- audioProvider: externalAudioProvider,
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
- }: DocPlayerProps) {
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 provider is given
241
- const internalAudio = useAudioSync(audioRef, script.audio, basePath);
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 provider if provided, otherwise fall back to internal
244
- const audio = externalAudioProvider || internalAudio;
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(script), [script]);
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(script, currentTime, activeViewport, renderMode, effectiveTheme);
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 = script.startBlock as StartBlockConfig | undefined;
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
- }, [script.startBlock, activeViewport, effectiveTheme]);
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(script);
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
- script.audio.segments.map((seg) => ({
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
- script.captions?.phrases?.map((p) => ({
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(script);
553
- return script.audio.segments.map((seg, i) => ({
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 -- script is a stable prop; re-registering on every script change is unnecessary
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 = script.captions && script.captions.phrases.length > 0;
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(script), [script]);
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) ?? script.audio.segments[currentSegment]?.name ?? null,
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 -- script.audio.segments is stable within a given script
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={script}
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="doc-player"
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: renderMode ? undefined : 'pointer',
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 key={currentBlock.id} className="doc-player__block doc-player__block--active">
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={script.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
- {script.audio.segments.length - 1}{' '}
1117
+ {doc.audio.segments.length - 1}{' '}
1004
1118
  <span style={{ color: '#666' }}>
1005
- ({script.audio.segments[currentSegment]?.name || 'none'})
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(script.captions!, currentTime);
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
- {script.captions?.phrases.length || 0} phrases{' '}
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 { AudioProvider } from './hooks/AudioProvider';
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
- script: Doc;
31
- basePath: string;
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
- audioProvider?: AudioProvider;
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
- script,
72
+ doc,
63
73
  basePath,
64
74
  autoPlay = false,
65
75
  onEnded,
66
76
  onTimeUpdate,
67
- audioProvider,
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
- script={script}
128
+ doc={doc}
129
+ theme={theme}
118
130
  basePath={basePath}
119
131
  autoPlay={autoPlay}
120
132
  onEnded={onEnded}
121
133
  onTimeUpdate={onTimeUpdate}
122
- audioProvider={audioProvider}
134
+ audioController={audioController}
123
135
  muted={muted}
124
136
  captionsEnabled={captionsEnabled}
125
137
  showControls={isFullscreen}