@bendyline/squisq-react 1.0.2 → 1.0.3
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/dist/CaptionOverlay.d.ts +16 -7
- package/dist/CaptionOverlay.d.ts.map +1 -1
- package/dist/CaptionOverlay.js +7 -3
- package/dist/CaptionOverlay.js.map +1 -1
- package/dist/DocControlsBottom.d.ts.map +1 -1
- package/dist/DocControlsBottom.js +5 -1
- package/dist/DocControlsBottom.js.map +1 -1
- package/dist/DocControlsOverlay.d.ts.map +1 -1
- package/dist/DocControlsOverlay.js +9 -4
- package/dist/DocControlsOverlay.js.map +1 -1
- package/dist/DocControlsSidebar.d.ts.map +1 -1
- package/dist/DocControlsSidebar.js +5 -1
- package/dist/DocControlsSidebar.js.map +1 -1
- package/dist/DocPlayer.d.ts +5 -2
- package/dist/DocPlayer.d.ts.map +1 -1
- package/dist/DocPlayer.js +36 -7
- package/dist/DocPlayer.js.map +1 -1
- package/dist/DocPlayerWithSidebar.d.ts.map +1 -1
- package/dist/DocPlayerWithSidebar.js +1 -0
- package/dist/DocPlayerWithSidebar.js.map +1 -1
- package/dist/DocProgressBar.js +2 -2
- package/dist/LinearDocView.d.ts.map +1 -1
- package/dist/LinearDocView.js +88 -6
- package/dist/LinearDocView.js.map +1 -1
- package/dist/SocialCaptionOverlay.d.ts +27 -0
- package/dist/SocialCaptionOverlay.d.ts.map +1 -0
- package/dist/SocialCaptionOverlay.js +184 -0
- package/dist/SocialCaptionOverlay.js.map +1 -0
- package/dist/__tests__/DocControlsSlideshow.test.js +1 -0
- package/dist/__tests__/DocControlsSlideshow.test.js.map +1 -1
- package/dist/hooks/useAudioSync.d.ts.map +1 -1
- package/dist/hooks/useAudioSync.js +4 -2
- package/dist/hooks/useAudioSync.js.map +1 -1
- package/dist/hooks/useDocPlayback.d.ts.map +1 -1
- package/dist/hooks/useDocPlayback.js +7 -4
- package/dist/hooks/useDocPlayback.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/layers/ShapeLayer.d.ts.map +1 -1
- package/dist/layers/ShapeLayer.js +7 -3
- package/dist/layers/ShapeLayer.js.map +1 -1
- package/dist/layers/TextLayer.js +1 -1
- package/dist/layers/TextLayer.js.map +1 -1
- package/dist/squisq-player.global.js +70 -5
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-entry.d.ts +8 -0
- package/dist/standalone-entry.d.ts.map +1 -1
- package/dist/standalone-entry.js +6 -3
- package/dist/standalone-entry.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/CaptionOverlay.tsx +33 -8
- package/src/DocControlsBottom.tsx +11 -5
- package/src/DocControlsOverlay.tsx +18 -6
- package/src/DocControlsSidebar.tsx +11 -5
- package/src/DocPlayer.tsx +56 -9
- package/src/DocPlayerWithSidebar.tsx +1 -0
- package/src/DocProgressBar.tsx +2 -2
- package/src/LinearDocView.tsx +103 -11
- package/src/SocialCaptionOverlay.tsx +254 -0
- package/src/__tests__/DocControlsSlideshow.test.tsx +1 -0
- package/src/hooks/useAudioSync.ts +5 -2
- package/src/hooks/useDocPlayback.ts +8 -3
- package/src/index.ts +3 -0
- package/src/layers/ShapeLayer.tsx +8 -3
- package/src/layers/TextLayer.tsx +1 -1
- package/src/standalone-entry.tsx +23 -3
- package/src/types.ts +21 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SocialCaptionOverlay Component
|
|
3
|
+
*
|
|
4
|
+
* Social media-style captions (Instagram/TikTok): large centered words
|
|
5
|
+
* showing 3-5 words at a time with the currently-spoken word highlighted
|
|
6
|
+
* in the theme's primary color. Font and colors are pulled from the
|
|
7
|
+
* active theme.
|
|
8
|
+
*
|
|
9
|
+
* Words are gathered across all caption phrases into a continuous stream,
|
|
10
|
+
* then chunked into uniform groups for smooth, consistent pacing.
|
|
11
|
+
*
|
|
12
|
+
* Supports two timing modes:
|
|
13
|
+
* 1. Precise: uses per-word timestamps from CaptionPhrase.words
|
|
14
|
+
* 2. Interpolated: distributes timing evenly within each phrase
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useMemo } from 'react';
|
|
18
|
+
import type { CaptionTrack, CaptionPhrase, ViewportConfig } from '@bendyline/squisq/schemas';
|
|
19
|
+
import type { Theme } from '@bendyline/squisq/schemas';
|
|
20
|
+
|
|
21
|
+
/** Target words per visible chunk. */
|
|
22
|
+
const TARGET_CHUNK_SIZE = 4;
|
|
23
|
+
const MIN_CHUNK_SIZE = 2;
|
|
24
|
+
const MAX_CHUNK_SIZE = 6;
|
|
25
|
+
|
|
26
|
+
/** A timed word derived from phrase data. */
|
|
27
|
+
interface TimedWord {
|
|
28
|
+
text: string;
|
|
29
|
+
startTime: number;
|
|
30
|
+
endTime: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A chunk of words displayed together. */
|
|
34
|
+
interface WordChunk {
|
|
35
|
+
words: TimedWord[];
|
|
36
|
+
startTime: number;
|
|
37
|
+
endTime: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve per-word timing for a single phrase.
|
|
42
|
+
* Uses precise word timestamps when available, otherwise interpolates.
|
|
43
|
+
*/
|
|
44
|
+
function resolvePhraseTiming(phrase: CaptionPhrase): TimedWord[] {
|
|
45
|
+
// Use precise timing when available
|
|
46
|
+
if (phrase.words && phrase.words.length > 0) {
|
|
47
|
+
return phrase.words.map((w) => ({
|
|
48
|
+
text: w.text,
|
|
49
|
+
startTime: w.startTime,
|
|
50
|
+
endTime: w.endTime,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Interpolate: split text into words, distribute evenly
|
|
55
|
+
const rawWords = phrase.text.split(/\s+/).filter((w) => w.length > 0);
|
|
56
|
+
if (rawWords.length === 0) return [];
|
|
57
|
+
|
|
58
|
+
const duration = phrase.endTime - phrase.startTime;
|
|
59
|
+
const wordDuration = duration / rawWords.length;
|
|
60
|
+
|
|
61
|
+
return rawWords.map((text, i) => ({
|
|
62
|
+
text,
|
|
63
|
+
startTime: phrase.startTime + i * wordDuration,
|
|
64
|
+
endTime: phrase.startTime + (i + 1) * wordDuration,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a continuous stream of timed words from ALL caption phrases,
|
|
70
|
+
* then group into uniform display chunks. This eliminates the "pulsy"
|
|
71
|
+
* feeling caused by per-phrase chunking with variable phrase lengths.
|
|
72
|
+
*/
|
|
73
|
+
function buildWordStream(captions: CaptionTrack): { words: TimedWord[]; chunks: WordChunk[] } {
|
|
74
|
+
// Gather all words across all phrases
|
|
75
|
+
const allWords: TimedWord[] = [];
|
|
76
|
+
for (const phrase of captions.phrases) {
|
|
77
|
+
allWords.push(...resolvePhraseTiming(phrase));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (allWords.length === 0) return { words: [], chunks: [] };
|
|
81
|
+
|
|
82
|
+
// Determine uniform chunk size
|
|
83
|
+
let chunkSize = TARGET_CHUNK_SIZE;
|
|
84
|
+
if (allWords.length > chunkSize) {
|
|
85
|
+
const numChunks = Math.ceil(allWords.length / chunkSize);
|
|
86
|
+
chunkSize = Math.ceil(allWords.length / numChunks);
|
|
87
|
+
chunkSize = Math.min(MAX_CHUNK_SIZE, Math.max(MIN_CHUNK_SIZE, chunkSize));
|
|
88
|
+
} else {
|
|
89
|
+
chunkSize = Math.max(MIN_CHUNK_SIZE, allWords.length);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build chunks
|
|
93
|
+
const chunks: WordChunk[] = [];
|
|
94
|
+
for (let i = 0; i < allWords.length; i += chunkSize) {
|
|
95
|
+
const chunkWords = allWords.slice(i, i + chunkSize);
|
|
96
|
+
chunks.push({
|
|
97
|
+
words: chunkWords,
|
|
98
|
+
startTime: chunkWords[0].startTime,
|
|
99
|
+
endTime: chunkWords[chunkWords.length - 1].endTime,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { words: allWords, chunks };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface SocialCaptionOverlayProps {
|
|
107
|
+
captions: CaptionTrack | undefined;
|
|
108
|
+
currentTime: number;
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
theme?: Theme;
|
|
111
|
+
viewport?: ViewportConfig;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function SocialCaptionOverlay({
|
|
115
|
+
captions,
|
|
116
|
+
currentTime,
|
|
117
|
+
enabled = true,
|
|
118
|
+
theme,
|
|
119
|
+
viewport,
|
|
120
|
+
}: SocialCaptionOverlayProps) {
|
|
121
|
+
// Build the word stream once when captions change (memoized)
|
|
122
|
+
const { chunks } = useMemo(
|
|
123
|
+
() => (captions ? buildWordStream(captions) : { words: [], chunks: [] }),
|
|
124
|
+
[captions],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!enabled || chunks.length === 0) {
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className="social-caption-overlay"
|
|
131
|
+
style={{
|
|
132
|
+
position: 'absolute',
|
|
133
|
+
bottom: '18%',
|
|
134
|
+
left: 0,
|
|
135
|
+
right: 0,
|
|
136
|
+
zIndex: 50,
|
|
137
|
+
pointerEvents: 'none',
|
|
138
|
+
opacity: 0,
|
|
139
|
+
transition: 'opacity 0.15s ease-in-out',
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Find the active chunk and word using binary-style search
|
|
146
|
+
let activeChunk: WordChunk | null = null;
|
|
147
|
+
let activeWordIndex = -1;
|
|
148
|
+
|
|
149
|
+
for (const chunk of chunks) {
|
|
150
|
+
if (currentTime >= chunk.startTime && currentTime < chunk.endTime) {
|
|
151
|
+
activeChunk = chunk;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// If between chunks (gap), show the nearest chunk
|
|
157
|
+
if (!activeChunk) {
|
|
158
|
+
for (let i = 0; i < chunks.length - 1; i++) {
|
|
159
|
+
if (currentTime >= chunks[i].endTime && currentTime < chunks[i + 1].startTime) {
|
|
160
|
+
// In a gap — show the chunk we just left (feels more natural)
|
|
161
|
+
activeChunk = chunks[i];
|
|
162
|
+
activeWordIndex = activeChunk.words.length - 1;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Past all chunks
|
|
167
|
+
if (!activeChunk && chunks.length > 0 && currentTime >= chunks[chunks.length - 1].startTime) {
|
|
168
|
+
activeChunk = chunks[chunks.length - 1];
|
|
169
|
+
activeWordIndex = activeChunk.words.length - 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!activeChunk) return null;
|
|
174
|
+
|
|
175
|
+
// Find active word within chunk (if not already set from gap handling)
|
|
176
|
+
if (activeWordIndex === -1) {
|
|
177
|
+
for (let i = 0; i < activeChunk.words.length; i++) {
|
|
178
|
+
const word = activeChunk.words[i];
|
|
179
|
+
if (currentTime >= word.startTime && currentTime < word.endTime) {
|
|
180
|
+
activeWordIndex = i;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Fallback: last word before currentTime
|
|
185
|
+
if (activeWordIndex === -1) {
|
|
186
|
+
for (let i = activeChunk.words.length - 1; i >= 0; i--) {
|
|
187
|
+
if (currentTime >= activeChunk.words[i].startTime) {
|
|
188
|
+
activeWordIndex = i;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Final fallback
|
|
194
|
+
if (activeWordIndex === -1) activeWordIndex = 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Theme-derived styling
|
|
198
|
+
const primaryColor = theme?.colors?.primary ?? '#5b9bd5';
|
|
199
|
+
const fontFamily = theme?.typography?.titleFontFamily
|
|
200
|
+
? `"${theme.typography.titleFontFamily}", system-ui, sans-serif`
|
|
201
|
+
: '"PT Serif", Georgia, serif';
|
|
202
|
+
|
|
203
|
+
// Scale font to viewport — aim for ~5.5% of viewport height
|
|
204
|
+
const viewportHeight = viewport?.height ?? 720;
|
|
205
|
+
const baseFontSize = Math.round(viewportHeight * 0.055);
|
|
206
|
+
const fontSize = Math.max(24, Math.min(72, baseFontSize));
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div
|
|
210
|
+
className="social-caption-overlay"
|
|
211
|
+
style={{
|
|
212
|
+
position: 'absolute',
|
|
213
|
+
bottom: '18%',
|
|
214
|
+
left: 0,
|
|
215
|
+
right: 0,
|
|
216
|
+
zIndex: 50,
|
|
217
|
+
pointerEvents: 'none',
|
|
218
|
+
textAlign: 'center',
|
|
219
|
+
padding: '0 8%',
|
|
220
|
+
boxSizing: 'border-box',
|
|
221
|
+
opacity: 1,
|
|
222
|
+
transition: 'opacity 0.15s ease-in-out',
|
|
223
|
+
}}
|
|
224
|
+
>
|
|
225
|
+
<div
|
|
226
|
+
style={{
|
|
227
|
+
display: 'inline-block',
|
|
228
|
+
lineHeight: 1.3,
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
{activeChunk.words.map((word, i) => {
|
|
232
|
+
const isActive = i === activeWordIndex;
|
|
233
|
+
return (
|
|
234
|
+
<span
|
|
235
|
+
key={`${word.startTime}-${i}`}
|
|
236
|
+
style={{
|
|
237
|
+
fontFamily,
|
|
238
|
+
fontSize: `${fontSize}px`,
|
|
239
|
+
fontWeight: isActive ? 800 : 600,
|
|
240
|
+
color: isActive ? primaryColor : 'rgba(255, 255, 255, 0.9)',
|
|
241
|
+
textShadow: '0 2px 8px rgba(0,0,0,0.7), 0 0 20px rgba(0,0,0,0.4)',
|
|
242
|
+
marginRight: i < activeChunk!.words.length - 1 ? '0.3em' : undefined,
|
|
243
|
+
transition: 'color 0.1s ease, font-weight 0.1s ease',
|
|
244
|
+
textTransform: 'uppercase',
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
{word.text}
|
|
248
|
+
</span>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -277,8 +277,11 @@ export function useAudioSync(
|
|
|
277
277
|
const audio = audioRef.current;
|
|
278
278
|
if (!audio || !audioTrack?.segments) return;
|
|
279
279
|
|
|
280
|
-
// Clamp time to valid range
|
|
281
|
-
|
|
280
|
+
// Clamp time to valid range.
|
|
281
|
+
// When totalDuration is 0 (no audio segments), don't clamp — allow
|
|
282
|
+
// seeking by block timing alone (used in render mode / preview).
|
|
283
|
+
const clampedTime =
|
|
284
|
+
totalDuration > 0 ? Math.max(0, Math.min(time, totalDuration)) : Math.max(0, time);
|
|
282
285
|
|
|
283
286
|
// Find which segment this time falls into
|
|
284
287
|
let segmentIndex = 0;
|
|
@@ -18,6 +18,7 @@ import type { Theme } from '@bendyline/squisq/schemas';
|
|
|
18
18
|
import { getBlockAtTime } from '@bendyline/squisq/schemas';
|
|
19
19
|
import {
|
|
20
20
|
expandDocBlocks,
|
|
21
|
+
flattenBlocks,
|
|
21
22
|
isTemplateBlock,
|
|
22
23
|
VIEWPORT_PRESETS,
|
|
23
24
|
type ViewportConfig,
|
|
@@ -76,8 +77,12 @@ export function useDocPlayback(
|
|
|
76
77
|
return [];
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
// Flatten nested block hierarchy (markdown-derived docs have children)
|
|
81
|
+
const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
|
|
82
|
+
const flatBlocks = hasChildren ? flattenBlocks(script.blocks) : script.blocks;
|
|
83
|
+
|
|
79
84
|
// Check if any blocks are templates
|
|
80
|
-
const hasTemplates =
|
|
85
|
+
const hasTemplates = flatBlocks.some(isTemplateBlock);
|
|
81
86
|
|
|
82
87
|
if (hasTemplates) {
|
|
83
88
|
// Extract audio segment timing for proper block synchronization
|
|
@@ -87,7 +92,7 @@ export function useDocPlayback(
|
|
|
87
92
|
}));
|
|
88
93
|
|
|
89
94
|
// Expand template blocks with audio segment timing, viewport, and persistent layers
|
|
90
|
-
const expanded = expandDocBlocks(
|
|
95
|
+
const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
|
|
91
96
|
audioSegments,
|
|
92
97
|
viewport,
|
|
93
98
|
persistentLayers: script.persistentLayers,
|
|
@@ -97,7 +102,7 @@ export function useDocPlayback(
|
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// All raw blocks, use as-is
|
|
100
|
-
return
|
|
105
|
+
return flatBlocks;
|
|
101
106
|
}, [script?.blocks, script?.audio?.segments, script?.persistentLayers, viewport, theme]);
|
|
102
107
|
|
|
103
108
|
// Find current block based on time
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export { DocPlayer } from './DocPlayer.js';
|
|
3
3
|
export { BlockRenderer, VIEWPORT } from './BlockRenderer.js';
|
|
4
4
|
export { CaptionOverlay } from './CaptionOverlay.js';
|
|
5
|
+
export { SocialCaptionOverlay } from './SocialCaptionOverlay.js';
|
|
5
6
|
export { DocControlsOverlay } from './DocControlsOverlay.js';
|
|
6
7
|
export { DocControlsBottom } from './DocControlsBottom.js';
|
|
7
8
|
export { DocControlsSidebar } from './DocControlsSidebar.js';
|
|
@@ -32,6 +33,8 @@ export type {
|
|
|
32
33
|
BlockMarker,
|
|
33
34
|
ControlsLayout,
|
|
34
35
|
DisplayMode,
|
|
36
|
+
CaptionStyle,
|
|
37
|
+
CaptionMode,
|
|
35
38
|
SlideNavActions,
|
|
36
39
|
SquisqRenderAPI,
|
|
37
40
|
SquisqWindow,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
9
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
|
-
import { resolveValue } from '../utils/layerUtils';
|
|
10
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
11
11
|
|
|
12
12
|
interface ShapeLayerProps {
|
|
13
13
|
layer: ShapeLayerType;
|
|
@@ -21,11 +21,16 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
21
21
|
const { content, position, animation } = layer;
|
|
22
22
|
|
|
23
23
|
// Resolve position values to pixels
|
|
24
|
-
const
|
|
25
|
-
const
|
|
24
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
25
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
26
26
|
const width = position.width ? resolveValue(position.width, viewport.width) : 100;
|
|
27
27
|
const height = position.height ? resolveValue(position.height, viewport.height) : 100;
|
|
28
28
|
|
|
29
|
+
// Apply anchor offset (e.g., 'center' shifts x by -width/2 and y by -height/2)
|
|
30
|
+
const anchorOffset = getAnchorOffset(position.anchor, width, height);
|
|
31
|
+
const x = rawX + anchorOffset.x;
|
|
32
|
+
const y = rawY + anchorOffset.y;
|
|
33
|
+
|
|
29
34
|
// Get animation styles
|
|
30
35
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
31
36
|
|
package/src/layers/TextLayer.tsx
CHANGED
|
@@ -38,7 +38,7 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
38
38
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
39
39
|
|
|
40
40
|
// Split text into lines, and wrap if maxWidth is specified
|
|
41
|
-
const rawLines = text.split('\n');
|
|
41
|
+
const rawLines = (text ?? '').split('\n');
|
|
42
42
|
let lines = maxWidth
|
|
43
43
|
? rawLines.reduce<string[]>(
|
|
44
44
|
(acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
|
package/src/standalone-entry.tsx
CHANGED
|
@@ -53,6 +53,14 @@ export interface MountOptions {
|
|
|
53
53
|
theme?: Theme;
|
|
54
54
|
/** Auto-play on mount (only for slideshow mode, default: false) */
|
|
55
55
|
autoPlay?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Enable render mode for headless frame capture.
|
|
58
|
+
* Exposes window.seekTo(), getDuration(), getCaptions(), etc.
|
|
59
|
+
* Disables controls and auto-play. Used by Playwright video export.
|
|
60
|
+
*/
|
|
61
|
+
renderMode?: boolean;
|
|
62
|
+
/** Caption style: 'standard' or 'social'. Omit or set to undefined for no captions. */
|
|
63
|
+
captionStyle?: 'standard' | 'social';
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
// ── CSS Injection ──────────────────────────────────────────────────
|
|
@@ -159,7 +167,16 @@ const roots = new WeakMap<Element, Root>();
|
|
|
159
167
|
export function mount(element: Element, doc: Doc, options: MountOptions = {}): void {
|
|
160
168
|
injectCss();
|
|
161
169
|
|
|
162
|
-
const {
|
|
170
|
+
const {
|
|
171
|
+
mode = 'slideshow',
|
|
172
|
+
basePath = '.',
|
|
173
|
+
images,
|
|
174
|
+
audio,
|
|
175
|
+
autoPlay = false,
|
|
176
|
+
theme,
|
|
177
|
+
renderMode = false,
|
|
178
|
+
captionStyle,
|
|
179
|
+
} = options;
|
|
163
180
|
|
|
164
181
|
// Rewrite audio URLs if map provided
|
|
165
182
|
const finalDoc = audio ? rewriteAudioUrls(doc, audio) : doc;
|
|
@@ -180,9 +197,12 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
|
|
|
180
197
|
script: finalDoc,
|
|
181
198
|
basePath,
|
|
182
199
|
displayMode: 'slideshow',
|
|
183
|
-
autoPlay,
|
|
184
|
-
showControls:
|
|
200
|
+
autoPlay: renderMode ? false : autoPlay,
|
|
201
|
+
showControls: !renderMode,
|
|
202
|
+
renderMode,
|
|
185
203
|
theme,
|
|
204
|
+
captionsEnabled: !!captionStyle,
|
|
205
|
+
captionStyle: captionStyle ?? 'standard',
|
|
186
206
|
});
|
|
187
207
|
}
|
|
188
208
|
|
package/src/types.ts
CHANGED
|
@@ -32,6 +32,23 @@ export type ControlsLayout = 'overlay' | 'sidebar' | 'bottom';
|
|
|
32
32
|
*/
|
|
33
33
|
export type DisplayMode = 'video' | 'slideshow' | 'linear';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Caption display style.
|
|
37
|
+
*
|
|
38
|
+
* - `'standard'` — Traditional broadcast-style captions: small white text
|
|
39
|
+
* on a semi-transparent black badge at the top of the player.
|
|
40
|
+
* - `'social'` — Social media-style (Instagram/TikTok): large centered words
|
|
41
|
+
* showing 3-5 words at a time with the active word highlighted in the
|
|
42
|
+
* theme's primary color. Font and colors pulled from the active theme.
|
|
43
|
+
*/
|
|
44
|
+
export type CaptionStyle = 'standard' | 'social';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Caption display mode — combines enable/disable with style selection.
|
|
48
|
+
* The CC button cycles through: off → standard → social → off.
|
|
49
|
+
*/
|
|
50
|
+
export type CaptionMode = 'off' | 'standard' | 'social';
|
|
51
|
+
|
|
35
52
|
/** Slide navigation actions for slideshow display mode */
|
|
36
53
|
export interface SlideNavActions {
|
|
37
54
|
/** Navigate to the next slide */
|
|
@@ -52,6 +69,8 @@ export interface PlaybackState {
|
|
|
52
69
|
docProgress: number;
|
|
53
70
|
hasCaptions: boolean;
|
|
54
71
|
captionsEnabled: boolean;
|
|
72
|
+
/** Current caption display mode (off, standard, social). */
|
|
73
|
+
captionMode: CaptionMode;
|
|
55
74
|
isFullscreen?: boolean;
|
|
56
75
|
/** Current audio segment index (0-based) */
|
|
57
76
|
currentSegmentIndex: number;
|
|
@@ -67,6 +86,8 @@ export interface PlaybackActions {
|
|
|
67
86
|
restart: () => void;
|
|
68
87
|
seekTo: (time: number) => void;
|
|
69
88
|
setCaptionsEnabled: (enabled: boolean) => void;
|
|
89
|
+
/** Cycle caption mode: off → standard → social → off */
|
|
90
|
+
cycleCaptionMode: () => void;
|
|
70
91
|
toggleFullscreen?: () => void;
|
|
71
92
|
}
|
|
72
93
|
|