@bendyline/squisq-react 1.4.2 → 2.0.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 +30 -3
- package/dist/index.d.ts +177 -28
- package/dist/index.js +1330 -611
- 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 +57 -37
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +28 -0
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +54 -17
- package/src/DocControlsSlideshow.tsx +222 -5
- package/src/DocPlayer.tsx +367 -183
- package/src/DocPlayerWithSidebar.tsx +4 -0
- package/src/DocProgressBar.tsx +40 -1
- package/src/LinearDocView.tsx +138 -62
- package/src/MarkdownRenderer.tsx +40 -97
- package/src/MediaClipLayer.tsx +12 -2
- package/src/__tests__/BlockRenderer.test.tsx +138 -8
- package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
- package/src/__tests__/DocPlayer.test.tsx +505 -0
- package/src/__tests__/DocProgressBar.test.tsx +28 -2
- package/src/__tests__/LinearDocView.test.tsx +104 -11
- package/src/__tests__/MapLayer.test.tsx +63 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
- package/src/__tests__/MediaClipLayer.test.tsx +70 -0
- package/src/__tests__/MediaContext.test.tsx +51 -0
- package/src/__tests__/PathLayer.test.tsx +12 -1
- package/src/__tests__/VideoLayer.test.tsx +94 -0
- package/src/__tests__/fillStyle.test.tsx +50 -2
- package/src/__tests__/standaloneEntry.test.tsx +103 -0
- package/src/__tests__/useAudioSync.test.ts +49 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
- package/src/__tests__/useViewportOrientation.test.ts +22 -0
- package/src/hooks/MediaContext.tsx +12 -3
- package/src/hooks/useAudioSync.ts +61 -12
- package/src/hooks/useDocPlayback.ts +40 -12
- package/src/hooks/useViewportOrientation.ts +2 -4
- package/src/index.ts +5 -2
- package/src/layers/ImageLayer.tsx +106 -1
- package/src/layers/MapLayer.tsx +7 -6
- package/src/layers/PathLayer.tsx +20 -11
- package/src/layers/ShapeLayer.tsx +33 -9
- package/src/layers/TextLayer.tsx +4 -3
- package/src/layers/TreeLayer.tsx +167 -0
- package/src/layers/VideoLayer.tsx +20 -6
- package/src/standalone-entry.tsx +91 -14
- package/src/styles/doc-animations.css +36 -0
- package/src/types.ts +13 -13
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { useAudioSync } from '../hooks/useAudioSync';
|
|
5
|
+
|
|
6
|
+
const track: AudioTrack = {
|
|
7
|
+
segments: [{ src: 'https://cdn.example.test/a.mp3', name: 'a', duration: 2, startTime: 0 }],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
afterEach(() => vi.restoreAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('useAudioSync resource loading', () => {
|
|
13
|
+
it('does not prefix absolute URLs and revokes a blob that resolves after cleanup', async () => {
|
|
14
|
+
let resolveFetch!: (value: unknown) => void;
|
|
15
|
+
const fetchPromise = new Promise((resolve) => {
|
|
16
|
+
resolveFetch = resolve;
|
|
17
|
+
});
|
|
18
|
+
const fetchSpy = vi
|
|
19
|
+
.spyOn(globalThis, 'fetch')
|
|
20
|
+
.mockReturnValue(fetchPromise as Promise<Response>);
|
|
21
|
+
const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:late');
|
|
22
|
+
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
23
|
+
const audioRef = { current: null };
|
|
24
|
+
const { unmount } = renderHook(() => useAudioSync(audioRef, track, '.'));
|
|
25
|
+
|
|
26
|
+
await waitFor(() => expect(fetchSpy).toHaveBeenCalled());
|
|
27
|
+
expect(fetchSpy.mock.calls[0][0]).toBe('https://cdn.example.test/a.mp3');
|
|
28
|
+
unmount();
|
|
29
|
+
|
|
30
|
+
await act(async () => {
|
|
31
|
+
resolveFetch({ ok: true, blob: async () => new Blob(['audio']) });
|
|
32
|
+
await fetchPromise;
|
|
33
|
+
await Promise.resolve();
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
});
|
|
36
|
+
expect(create).toHaveBeenCalled();
|
|
37
|
+
expect(revoke).toHaveBeenCalledWith('blob:late');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('does not preload when an external controller disables the hook', async () => {
|
|
41
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
42
|
+
ok: true,
|
|
43
|
+
blob: async () => new Blob(['audio']),
|
|
44
|
+
} as Response);
|
|
45
|
+
renderHook(() => useAudioSync({ current: null }, track, '.', false));
|
|
46
|
+
await act(async () => Promise.resolve());
|
|
47
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* only set it after a `setTimeout`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { describe, it, expect } from 'vitest';
|
|
14
|
-
import { renderHook } from '@testing-library/react';
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import { act, renderHook } from '@testing-library/react';
|
|
15
15
|
import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
|
|
16
16
|
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
17
17
|
import { useDocPlayback } from '../hooks/useDocPlayback';
|
|
@@ -37,7 +37,7 @@ const doc: Doc = {
|
|
|
37
37
|
describe('useDocPlayback — synchronous block transitions', () => {
|
|
38
38
|
it('exposes the entering block + outgoing previousBlock on the same render (no effect flush)', () => {
|
|
39
39
|
const { result, rerender } = renderHook(
|
|
40
|
-
({ t }: { t: number }) => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape),
|
|
40
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
41
41
|
{ initialProps: { t: 0 } },
|
|
42
42
|
);
|
|
43
43
|
|
|
@@ -61,10 +61,53 @@ describe('useDocPlayback — synchronous block transitions', () => {
|
|
|
61
61
|
|
|
62
62
|
it('isEntering is a pure function of blockTime vs the transition duration', () => {
|
|
63
63
|
const enteringAt = (t: number) =>
|
|
64
|
-
renderHook(() => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape)).result
|
|
65
|
-
.isEntering;
|
|
64
|
+
renderHook(() => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape })).result
|
|
65
|
+
.current.isEntering;
|
|
66
66
|
expect(enteringAt(5.0)).toBe(true); // blockTime 0.0 < 0.5
|
|
67
67
|
expect(enteringAt(5.4)).toBe(true); // blockTime 0.4 < 0.5
|
|
68
68
|
expect(enteringAt(5.6)).toBe(false); // blockTime 0.6 >= 0.5
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('can enter a swipe destination without restoring the outgoing block', () => {
|
|
72
|
+
const { result, rerender } = renderHook(
|
|
73
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
74
|
+
{ initialProps: { t: 0 } },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
act(() => result.current.suppressOutgoingForNextBlock('b'));
|
|
78
|
+
rerender({ t: 5 });
|
|
79
|
+
|
|
80
|
+
// The destination still gets its own entrance animation, but the slide
|
|
81
|
+
// already carried away by the swipe is not mounted again as context.
|
|
82
|
+
expect(result.current.isEntering).toBe(true);
|
|
83
|
+
expect(result.current.isExiting).toBe(false);
|
|
84
|
+
expect(result.current.previousBlock).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('clears stale outgoing context when a cover reveals the already-active block', () => {
|
|
88
|
+
const { result, rerender } = renderHook(
|
|
89
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, { viewport: VIEWPORT_PRESETS.landscape }),
|
|
90
|
+
{ initialProps: { t: 0 } },
|
|
91
|
+
);
|
|
92
|
+
rerender({ t: 5 });
|
|
93
|
+
expect(result.current.previousBlock?.id).toBe('a');
|
|
94
|
+
|
|
95
|
+
act(() => result.current.suppressOutgoingForNextBlock('b'));
|
|
96
|
+
rerender({ t: 5 });
|
|
97
|
+
|
|
98
|
+
expect(result.current.isEntering).toBe(true);
|
|
99
|
+
expect(result.current.isExiting).toBe(false);
|
|
100
|
+
expect(result.current.previousBlock).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('navigation actions seek to the target block', () => {
|
|
104
|
+
const seek = vi.fn();
|
|
105
|
+
const { result } = renderHook(() =>
|
|
106
|
+
useDocPlayback(doc, 0, { viewport: VIEWPORT_PRESETS.landscape, onSeek: seek }),
|
|
107
|
+
);
|
|
108
|
+
act(() => result.current.nextBlock());
|
|
109
|
+
expect(seek).toHaveBeenCalledWith(5);
|
|
110
|
+
act(() => result.current.goToBlock(0));
|
|
111
|
+
expect(seek).toHaveBeenLastCalledWith(0);
|
|
112
|
+
});
|
|
70
113
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { useViewportOrientation } from '../hooks/useViewportOrientation';
|
|
5
|
+
|
|
6
|
+
const originalWidth = window.innerWidth;
|
|
7
|
+
const originalHeight = window.innerHeight;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth });
|
|
11
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalHeight });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('useViewportOrientation', () => {
|
|
15
|
+
it('uses the square preset for near-square viewports', () => {
|
|
16
|
+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
|
|
17
|
+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 });
|
|
18
|
+
const { result } = renderHook(() => useViewportOrientation());
|
|
19
|
+
expect(result.current.orientation).toBe('square');
|
|
20
|
+
expect(result.current.viewport).toBe(VIEWPORT_PRESETS.square);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -73,9 +73,18 @@ export function useMediaUrl(relativePath: string, basePath: string): string {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
let cancelled = false;
|
|
76
|
-
provider
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
// Never show the prior asset while a new provider/path is resolving.
|
|
77
|
+
setUrl(fallback);
|
|
78
|
+
provider!.resolveUrl(safePath).then(
|
|
79
|
+
(resolved) => {
|
|
80
|
+
if (!cancelled) setUrl(resolved);
|
|
81
|
+
},
|
|
82
|
+
() => {
|
|
83
|
+
// Resolution failures should be non-fatal and must not become
|
|
84
|
+
// unhandled promise rejections in rendering surfaces.
|
|
85
|
+
if (!cancelled) setUrl(fallback);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
79
88
|
|
|
80
89
|
return () => {
|
|
81
90
|
cancelled = true;
|
|
@@ -17,10 +17,19 @@ import type { RefObject } from 'react';
|
|
|
17
17
|
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
18
18
|
import type { AudioController } from './AudioController';
|
|
19
19
|
|
|
20
|
+
function resolveAudioUrl(src: string, basePath: string): string {
|
|
21
|
+
// Preserve absolute/protocol-relative/data/blob URLs. Prefixing an absolute
|
|
22
|
+
// URL with the common default base path (`.`) produces `./https://...`.
|
|
23
|
+
if (!src || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(src)) return src;
|
|
24
|
+
if (!basePath) return src;
|
|
25
|
+
return `${basePath.replace(/\/$/, '')}/${src.replace(/^\//, '')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
export function useAudioSync(
|
|
21
29
|
audioRef: RefObject<HTMLAudioElement>,
|
|
22
30
|
audioTrack: AudioTrack | undefined,
|
|
23
31
|
basePath: string = '',
|
|
32
|
+
enabled: boolean = true,
|
|
24
33
|
): AudioController {
|
|
25
34
|
const [currentTime, setCurrentTime] = useState(0);
|
|
26
35
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
@@ -39,13 +48,27 @@ export function useAudioSync(
|
|
|
39
48
|
// Preloaded audio blob URLs (for seeking without range request support)
|
|
40
49
|
const blobUrls = useRef<Map<string, string>>(new Map());
|
|
41
50
|
const loadingPromises = useRef<Map<string, Promise<string>>>(new Map());
|
|
51
|
+
const abortControllers = useRef<Set<AbortController>>(new Set());
|
|
52
|
+
const loadGeneration = useRef(0);
|
|
42
53
|
|
|
43
54
|
// Fallback timer: when audio.play() is blocked (e.g., autoplay policy),
|
|
44
55
|
// advance currentTime synthetically so blocks still progress without audio.
|
|
45
56
|
const fallbackMode = useRef(false);
|
|
46
57
|
|
|
47
58
|
useEffect(() => {
|
|
48
|
-
|
|
59
|
+
loadGeneration.current += 1;
|
|
60
|
+
pendingSeekTime.current = null;
|
|
61
|
+
shouldPlayAfterLoad.current = false;
|
|
62
|
+
fallbackMode.current = false;
|
|
63
|
+
setCurrentTime(0);
|
|
64
|
+
setCurrentSegment(0);
|
|
65
|
+
setIsPlaying(false);
|
|
66
|
+
setIsEnded(false);
|
|
67
|
+
setIsAudioReady(false);
|
|
68
|
+
|
|
69
|
+
if (!enabled || !audioTrack?.segments) {
|
|
70
|
+
segmentStarts.current = [];
|
|
71
|
+
setTotalDuration(0);
|
|
49
72
|
return;
|
|
50
73
|
}
|
|
51
74
|
|
|
@@ -56,12 +79,12 @@ export function useAudioSync(
|
|
|
56
79
|
return start;
|
|
57
80
|
});
|
|
58
81
|
setTotalDuration(time);
|
|
59
|
-
}, [audioTrack]);
|
|
82
|
+
}, [audioTrack, enabled]);
|
|
60
83
|
|
|
61
84
|
// Preload audio file as blob (enables seeking without range request support)
|
|
62
85
|
const preloadAudio = useCallback(
|
|
63
86
|
async (src: string): Promise<string> => {
|
|
64
|
-
const audioUrl =
|
|
87
|
+
const audioUrl = resolveAudioUrl(src, basePath);
|
|
65
88
|
|
|
66
89
|
// Return cached blob URL if available
|
|
67
90
|
if (blobUrls.current.has(src)) {
|
|
@@ -74,18 +97,26 @@ export function useAudioSync(
|
|
|
74
97
|
}
|
|
75
98
|
|
|
76
99
|
// Start loading
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
abortControllers.current.add(controller);
|
|
102
|
+
const generation = loadGeneration.current;
|
|
77
103
|
const loadPromise = (async () => {
|
|
78
104
|
try {
|
|
79
|
-
const response = await fetch(audioUrl);
|
|
105
|
+
const response = await fetch(audioUrl, { signal: controller.signal });
|
|
80
106
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
81
107
|
const blob = await response.blob();
|
|
82
108
|
const blobUrl = URL.createObjectURL(blob);
|
|
109
|
+
if (controller.signal.aborted || generation !== loadGeneration.current) {
|
|
110
|
+
URL.revokeObjectURL(blobUrl);
|
|
111
|
+
return audioUrl;
|
|
112
|
+
}
|
|
83
113
|
blobUrls.current.set(src, blobUrl);
|
|
84
114
|
return blobUrl;
|
|
85
115
|
} catch {
|
|
86
116
|
// Fall back to direct URL if blob loading fails
|
|
87
117
|
return audioUrl;
|
|
88
118
|
} finally {
|
|
119
|
+
abortControllers.current.delete(controller);
|
|
89
120
|
loadingPromises.current.delete(src);
|
|
90
121
|
}
|
|
91
122
|
})();
|
|
@@ -98,7 +129,7 @@ export function useAudioSync(
|
|
|
98
129
|
|
|
99
130
|
// Preload all audio segments on mount
|
|
100
131
|
useEffect(() => {
|
|
101
|
-
if (!audioTrack?.segments) return;
|
|
132
|
+
if (!enabled || !audioTrack?.segments) return;
|
|
102
133
|
|
|
103
134
|
// Preload all segments in parallel
|
|
104
135
|
audioTrack.segments.forEach((segment) => {
|
|
@@ -107,16 +138,23 @@ export function useAudioSync(
|
|
|
107
138
|
|
|
108
139
|
// Cleanup blob URLs on unmount
|
|
109
140
|
const currentBlobUrls = blobUrls.current;
|
|
141
|
+
const currentAbortControllers = abortControllers.current;
|
|
142
|
+
const currentLoadingPromises = loadingPromises.current;
|
|
110
143
|
return () => {
|
|
144
|
+
loadGeneration.current += 1;
|
|
145
|
+
currentAbortControllers.forEach((controller) => controller.abort());
|
|
146
|
+
currentAbortControllers.clear();
|
|
147
|
+
currentLoadingPromises.clear();
|
|
111
148
|
currentBlobUrls.forEach((url) => {
|
|
112
149
|
URL.revokeObjectURL(url);
|
|
113
150
|
});
|
|
114
151
|
currentBlobUrls.clear();
|
|
115
152
|
};
|
|
116
|
-
}, [audioTrack, preloadAudio]);
|
|
153
|
+
}, [audioTrack, preloadAudio, enabled]);
|
|
117
154
|
|
|
118
155
|
// Handle audio time updates
|
|
119
156
|
useEffect(() => {
|
|
157
|
+
if (!enabled) return;
|
|
120
158
|
const audio = audioRef.current;
|
|
121
159
|
if (!audio) return;
|
|
122
160
|
|
|
@@ -176,10 +214,11 @@ export function useAudioSync(
|
|
|
176
214
|
audio.removeEventListener('ended', handleEnded);
|
|
177
215
|
audio.removeEventListener('error', handleError);
|
|
178
216
|
};
|
|
179
|
-
}, [audioRef, currentSegment, audioTrack]);
|
|
217
|
+
}, [audioRef, currentSegment, audioTrack, enabled]);
|
|
180
218
|
|
|
181
219
|
// Load new segment when currentSegment changes
|
|
182
220
|
useEffect(() => {
|
|
221
|
+
if (!enabled) return;
|
|
183
222
|
const audio = audioRef.current;
|
|
184
223
|
if (!audio || !audioTrack?.segments) return;
|
|
185
224
|
|
|
@@ -209,15 +248,20 @@ export function useAudioSync(
|
|
|
209
248
|
const isSameSource =
|
|
210
249
|
currentSrc && (currentSrc === cachedBlobUrl || currentSrc.endsWith(segment.src));
|
|
211
250
|
|
|
251
|
+
let cancelled = false;
|
|
252
|
+
let handleCanPlay: (() => void) | null = null;
|
|
253
|
+
|
|
212
254
|
if (!isSameSource) {
|
|
213
255
|
// Need to load new source - use preloaded blob URL
|
|
214
256
|
const loadAndPlay = async () => {
|
|
215
257
|
const blobUrl = await preloadAudio(segment.src);
|
|
258
|
+
if (cancelled) return;
|
|
216
259
|
|
|
217
|
-
|
|
260
|
+
handleCanPlay = () => {
|
|
261
|
+
if (cancelled) return;
|
|
218
262
|
setIsAudioReady(true);
|
|
219
263
|
applyPendingSeek();
|
|
220
|
-
audio.removeEventListener('canplay', handleCanPlay);
|
|
264
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
221
265
|
};
|
|
222
266
|
|
|
223
267
|
audio.addEventListener('canplay', handleCanPlay);
|
|
@@ -228,18 +272,23 @@ export function useAudioSync(
|
|
|
228
272
|
// Check after a microtask to see if it's ready
|
|
229
273
|
await Promise.resolve();
|
|
230
274
|
if (audio.readyState >= 3) {
|
|
231
|
-
audio.removeEventListener('canplay', handleCanPlay);
|
|
275
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
232
276
|
setIsAudioReady(true);
|
|
233
277
|
applyPendingSeek();
|
|
234
278
|
}
|
|
235
279
|
};
|
|
236
280
|
|
|
237
|
-
loadAndPlay();
|
|
281
|
+
void loadAndPlay();
|
|
238
282
|
} else {
|
|
239
283
|
// Same source - apply seek directly
|
|
240
284
|
applyPendingSeek();
|
|
241
285
|
}
|
|
242
|
-
|
|
286
|
+
|
|
287
|
+
return () => {
|
|
288
|
+
cancelled = true;
|
|
289
|
+
if (handleCanPlay) audio.removeEventListener('canplay', handleCanPlay);
|
|
290
|
+
};
|
|
291
|
+
}, [audioRef, currentSegment, audioTrack, preloadAudio, enabled]);
|
|
243
292
|
|
|
244
293
|
const play = useCallback(() => {
|
|
245
294
|
const audio = audioRef.current;
|
|
@@ -58,18 +58,28 @@ interface PlaybackActions {
|
|
|
58
58
|
prevBlock: () => void;
|
|
59
59
|
/** Go to specific block by index */
|
|
60
60
|
goToBlock: (index: number) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Let the identified block enter without remounting the outgoing block.
|
|
63
|
+
* Used when another interaction (such as a swipe) already removed it.
|
|
64
|
+
*/
|
|
65
|
+
suppressOutgoingForNextBlock: (blockId: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UseDocPlaybackOptions {
|
|
69
|
+
/** Target viewport used to materialize template blocks. */
|
|
70
|
+
viewport?: ViewportConfig;
|
|
71
|
+
/** Active theme used for materialization and transition defaults. */
|
|
72
|
+
theme?: Theme;
|
|
73
|
+
/** Host seek callback used by block navigation actions. */
|
|
74
|
+
onSeek?: (time: number) => void;
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
export function useDocPlayback(
|
|
64
78
|
script: Doc | null,
|
|
65
79
|
currentTime: number,
|
|
66
|
-
|
|
67
|
-
renderMode: boolean = false,
|
|
68
|
-
theme?: Theme,
|
|
80
|
+
options: UseDocPlaybackOptions = {},
|
|
69
81
|
): PlaybackState & PlaybackActions {
|
|
70
|
-
|
|
71
|
-
// are now computed identically for real-time and render (export) modes.
|
|
72
|
-
void renderMode;
|
|
82
|
+
const { viewport = VIEWPORT_PRESETS.landscape, theme, onSeek } = options;
|
|
73
83
|
// Expand any template blocks into full blocks
|
|
74
84
|
const blocks = useMemo(() => {
|
|
75
85
|
if (!script?.blocks) {
|
|
@@ -174,8 +184,28 @@ export function useDocPlayback(
|
|
|
174
184
|
const outgoingBlockRef = useRef<Block | null>(null);
|
|
175
185
|
const activeBlockIdRef = useRef<string | null>(null);
|
|
176
186
|
const lastRenderedBlockRef = useRef<Block | null>(null);
|
|
187
|
+
const suppressOutgoingTargetRef = useRef<string | null>(null);
|
|
188
|
+
const suppressOutgoingForNextBlock = useCallback((blockId: string) => {
|
|
189
|
+
// The managed cover is outside the document timeline, so revealing block
|
|
190
|
+
// one can target the block that is already active underneath it. Clear any
|
|
191
|
+
// stale outgoing context immediately; the cover visibility update will
|
|
192
|
+
// provide the render that observes this ref change.
|
|
193
|
+
if (activeBlockIdRef.current === blockId) {
|
|
194
|
+
outgoingBlockRef.current = null;
|
|
195
|
+
suppressOutgoingTargetRef.current = null;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
suppressOutgoingTargetRef.current = blockId;
|
|
199
|
+
}, []);
|
|
177
200
|
if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
|
|
178
|
-
|
|
201
|
+
// A swipe has already carried the outgoing slide fully off-screen. Keep
|
|
202
|
+
// the incoming block's own transition, but do not re-mount the old block
|
|
203
|
+
// as transition context when the armed destination becomes active.
|
|
204
|
+
const suppressOutgoing = suppressOutgoingTargetRef.current === currentBlock.id;
|
|
205
|
+
outgoingBlockRef.current = suppressOutgoing ? null : lastRenderedBlockRef.current;
|
|
206
|
+
// Consume on the first real block change even if the destination did not
|
|
207
|
+
// match (for example, a host performed a different seek in between).
|
|
208
|
+
suppressOutgoingTargetRef.current = null;
|
|
179
209
|
activeBlockIdRef.current = currentBlock.id;
|
|
180
210
|
}
|
|
181
211
|
lastRenderedBlockRef.current = currentBlock;
|
|
@@ -194,15 +224,12 @@ export function useDocPlayback(
|
|
|
194
224
|
const goToBlock = useCallback(
|
|
195
225
|
(index: number) => {
|
|
196
226
|
if (!script || index < 0 || index >= blocks.length) return;
|
|
197
|
-
// This would need to be coordinated with seekTo from audio sync
|
|
198
|
-
// For now, just return the target block's start time
|
|
199
227
|
const targetBlock = blocks[index];
|
|
200
228
|
if (targetBlock) {
|
|
201
|
-
|
|
202
|
-
return targetBlock.startTime;
|
|
229
|
+
onSeek?.(targetBlock.startTime);
|
|
203
230
|
}
|
|
204
231
|
},
|
|
205
|
-
[script, blocks],
|
|
232
|
+
[script, blocks, onSeek],
|
|
206
233
|
);
|
|
207
234
|
|
|
208
235
|
const nextBlock = useCallback(() => {
|
|
@@ -229,6 +256,7 @@ export function useDocPlayback(
|
|
|
229
256
|
nextBlock,
|
|
230
257
|
prevBlock,
|
|
231
258
|
goToBlock,
|
|
259
|
+
suppressOutgoingForNextBlock,
|
|
232
260
|
/** Expanded blocks (templates converted to full blocks with layers) */
|
|
233
261
|
blocks,
|
|
234
262
|
};
|
|
@@ -36,15 +36,13 @@ function getOrientationFromWindow(width: number, height: number): ViewportOrient
|
|
|
36
36
|
// Use thresholds to determine orientation
|
|
37
37
|
// - Ratio > 1.2 = landscape (wider than tall)
|
|
38
38
|
// - Ratio < 0.83 (1/1.2) = portrait (taller than wide)
|
|
39
|
-
// - Otherwise = square-ish
|
|
39
|
+
// - Otherwise = square-ish
|
|
40
40
|
if (ratio > 1.2) {
|
|
41
41
|
return 'landscape';
|
|
42
42
|
} else if (ratio < 0.83) {
|
|
43
43
|
return 'portrait';
|
|
44
44
|
} else {
|
|
45
|
-
|
|
46
|
-
// Could also use 'square' preset if available and desired
|
|
47
|
-
return 'landscape';
|
|
45
|
+
return 'square';
|
|
48
46
|
}
|
|
49
47
|
}
|
|
50
48
|
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Main components
|
|
2
2
|
export { DocPlayer } from './DocPlayer.js';
|
|
3
|
-
export {
|
|
3
|
+
export type { DocPlayerProps } from './DocPlayer.js';
|
|
4
|
+
export type { MountOptions, SquisqPlayerHandle } from './standalone-entry.js';
|
|
5
|
+
export { BlockRenderer } from './BlockRenderer.js';
|
|
4
6
|
export { CaptionOverlay } from './CaptionOverlay.js';
|
|
5
7
|
export { SocialCaptionOverlay } from './SocialCaptionOverlay.js';
|
|
6
8
|
export { DocControlsOverlay } from './DocControlsOverlay.js';
|
|
@@ -24,6 +26,7 @@ export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
|
24
26
|
export { PathLayer } from './layers/PathLayer.js';
|
|
25
27
|
export { VideoLayer } from './layers/VideoLayer.js';
|
|
26
28
|
export { TableLayer } from './layers/TableLayer.js';
|
|
29
|
+
export { TreeLayer } from './layers/TreeLayer.js';
|
|
27
30
|
export { MapLayer } from './layers/MapLayer.js';
|
|
28
31
|
|
|
29
32
|
// Timed media clips (block.media + doc.documentMedia playback)
|
|
@@ -35,6 +38,7 @@ export { useAudioSync } from './hooks/useAudioSync.js';
|
|
|
35
38
|
export { useMediaSchedule } from './hooks/useMediaSchedule.js';
|
|
36
39
|
export type { MediaScheduleController } from './hooks/useMediaSchedule.js';
|
|
37
40
|
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
41
|
+
export type { UseDocPlaybackOptions } from './hooks/useDocPlayback.js';
|
|
38
42
|
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
39
43
|
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
40
44
|
export { useAutoSurface } from './hooks/useAutoSurface.js';
|
|
@@ -51,7 +55,6 @@ export type {
|
|
|
51
55
|
CaptionMode,
|
|
52
56
|
SlideNavActions,
|
|
53
57
|
SquisqRenderAPI,
|
|
54
|
-
SquisqWindow,
|
|
55
58
|
RenderBlockInfo,
|
|
56
59
|
RenderAudioSegmentInfo,
|
|
57
60
|
RenderCaptionInfo,
|
|
@@ -20,9 +20,17 @@ interface ImageLayerProps {
|
|
|
20
20
|
viewport: { width: number; height: number };
|
|
21
21
|
/** Current time relative to block start (for animation timing) */
|
|
22
22
|
blockTime: number;
|
|
23
|
+
/** Whether authored and responsive image motion should run. */
|
|
24
|
+
animationsEnabled?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function ImageLayer({
|
|
27
|
+
export function ImageLayer({
|
|
28
|
+
layer,
|
|
29
|
+
basePath,
|
|
30
|
+
viewport,
|
|
31
|
+
blockTime,
|
|
32
|
+
animationsEnabled = true,
|
|
33
|
+
}: ImageLayerProps) {
|
|
26
34
|
const { content, position, animation } = layer;
|
|
27
35
|
|
|
28
36
|
// Resolve position values to pixels
|
|
@@ -55,6 +63,61 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
55
63
|
// than shifting the entire image container.
|
|
56
64
|
const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
|
|
57
65
|
|
|
66
|
+
// Wide cover photos lose most of their composition when centered inside a
|
|
67
|
+
// portrait frame. For dominant imagery, scan the cover crop horizontally so
|
|
68
|
+
// the whole width is revealed over time. `object-position` only moves when
|
|
69
|
+
// the fitted image actually has horizontal overflow, so portrait sources stay
|
|
70
|
+
// visually still. This path deliberately avoids the extra Ken Burns scale:
|
|
71
|
+
// cover already scales the source by the minimum amount needed to fit one
|
|
72
|
+
// viewport dimension.
|
|
73
|
+
const usesPortraitPan =
|
|
74
|
+
isCover && shouldUsePortraitPan(viewport, width, height, animationsEnabled, animation);
|
|
75
|
+
|
|
76
|
+
if (usesPortraitPan) {
|
|
77
|
+
const panClass = getPortraitPanClass(animation);
|
|
78
|
+
const panStyle = getPortraitPanStyle(isSpatialAnim ? animation : undefined);
|
|
79
|
+
// Entrance/opacity animations can safely run on the fixed container while
|
|
80
|
+
// object-position pans the image. Spatial transforms are replaced by the
|
|
81
|
+
// pan so they cannot zoom an already aggressive portrait crop.
|
|
82
|
+
const containerAnim = isSpatialAnim ? { className: '', style: {} } : animStyle;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<g
|
|
86
|
+
className={`block-layer block-layer--image ${containerAnim.className}`}
|
|
87
|
+
style={containerAnim.style}
|
|
88
|
+
data-layer-id={layer.id}
|
|
89
|
+
data-image-framing="portrait-pan"
|
|
90
|
+
>
|
|
91
|
+
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
92
|
+
<div
|
|
93
|
+
style={{
|
|
94
|
+
width: `${width}px`,
|
|
95
|
+
height: `${height}px`,
|
|
96
|
+
overflow: 'hidden',
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
src={src}
|
|
101
|
+
alt={content.alt || ''}
|
|
102
|
+
className={panClass}
|
|
103
|
+
style={{
|
|
104
|
+
width: `${width}px`,
|
|
105
|
+
height: `${height}px`,
|
|
106
|
+
objectFit: 'cover',
|
|
107
|
+
objectPosition: 'center',
|
|
108
|
+
display: 'block',
|
|
109
|
+
pointerEvents: 'none',
|
|
110
|
+
...(filter ? { filter } : {}),
|
|
111
|
+
...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
|
|
112
|
+
...panStyle,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</foreignObject>
|
|
117
|
+
</g>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
58
121
|
// Ken Burns mode: cover image with spatial animation.
|
|
59
122
|
// Keep the container static, animate the inner <img> within clipped bounds.
|
|
60
123
|
if (isCover && isSpatialAnim && animation) {
|
|
@@ -166,6 +229,48 @@ function getPreserveAspectRatio(fit?: 'cover' | 'contain' | 'fill'): string {
|
|
|
166
229
|
/** Animation types that use CSS transforms (translate/scale). */
|
|
167
230
|
const SPATIAL_ANIMATION_TYPES = new Set(['panLeft', 'panRight', 'slowZoom', 'zoomIn', 'zoomOut']);
|
|
168
231
|
|
|
232
|
+
/** Portrait threshold matches useViewportOrientation's 0.83 aspect cutoff. */
|
|
233
|
+
const PORTRAIT_ASPECT_CUTOFF = 0.83;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Only auto-pan images that act as the slide's dominant visual. This keeps
|
|
237
|
+
* photo-grid tiles, thumbnails, and small authored insets from all moving at
|
|
238
|
+
* once while covering the full-screen autogenerated layouts.
|
|
239
|
+
*/
|
|
240
|
+
function shouldUsePortraitPan(
|
|
241
|
+
viewport: { width: number; height: number },
|
|
242
|
+
layerWidth: number,
|
|
243
|
+
layerHeight: number,
|
|
244
|
+
animationsEnabled: boolean,
|
|
245
|
+
animation: Animation | undefined,
|
|
246
|
+
): boolean {
|
|
247
|
+
if (!animationsEnabled || animation?.type === 'none') return false;
|
|
248
|
+
if (viewport.width / viewport.height >= PORTRAIT_ASPECT_CUTOFF) return false;
|
|
249
|
+
|
|
250
|
+
return layerWidth >= viewport.width * 0.7 && layerHeight >= viewport.height * 0.7;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `panLeft` means the image moves left (the framing scans toward its right
|
|
255
|
+
* edge); `panRight` is the reverse. Unspecified motion defaults to revealing
|
|
256
|
+
* the source from left to right.
|
|
257
|
+
*/
|
|
258
|
+
function getPortraitPanClass(animation: Animation | undefined): string {
|
|
259
|
+
const pansBack =
|
|
260
|
+
animation?.type === 'panRight' ||
|
|
261
|
+
(animation?.type === 'slowZoom' && animation.panDirection === 'right');
|
|
262
|
+
return pansBack ? 'squisq-image--portrait-pan-left' : 'squisq-image--portrait-pan-right';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Use authored spatial timing when present, otherwise a slow ambient scan. */
|
|
266
|
+
function getPortraitPanStyle(animation: Animation | undefined): Record<string, string> {
|
|
267
|
+
return {
|
|
268
|
+
'--portrait-pan-duration': `${animation?.duration ?? 12}s`,
|
|
269
|
+
'--portrait-pan-delay': `${animation?.delay ?? 0}s`,
|
|
270
|
+
'--portrait-pan-easing': animation?.easing ?? 'ease-in-out',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
169
274
|
/**
|
|
170
275
|
* Remap animation for Ken Burns inner-image rendering.
|
|
171
276
|
*
|