@bendyline/squisq-react 1.3.2 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -23
- package/dist/index.d.ts +131 -26
- package/dist/index.js +1475 -755
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +49 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import type { TransitionDirection, TransitionType } from '@bendyline/squisq/schemas';
|
|
5
|
+
import { TRANSITION_TYPES } from '@bendyline/squisq/schemas';
|
|
6
|
+
import { getTransitionClass } from '../utils/animationUtils';
|
|
7
|
+
|
|
8
|
+
const transitionCss = readFileSync(
|
|
9
|
+
resolve(process.cwd(), 'packages/react/src/styles/doc-animations.css'),
|
|
10
|
+
'utf8',
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const directionalTypes: TransitionType[] = [
|
|
14
|
+
'cover',
|
|
15
|
+
'pan',
|
|
16
|
+
'pull',
|
|
17
|
+
'push',
|
|
18
|
+
'reveal',
|
|
19
|
+
'strips',
|
|
20
|
+
'uncover',
|
|
21
|
+
'wipe',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const axisTypes: TransitionType[] = ['blinds', 'comb', 'randomBar', 'randomBars', 'split'];
|
|
25
|
+
const cardinalDirections = [
|
|
26
|
+
'left',
|
|
27
|
+
'right',
|
|
28
|
+
'up',
|
|
29
|
+
'down',
|
|
30
|
+
] as const satisfies readonly TransitionDirection[];
|
|
31
|
+
const axisDirections = ['horizontal', 'vertical'] as const satisfies readonly TransitionDirection[];
|
|
32
|
+
|
|
33
|
+
function collectGeneratedTransitionClasses(): string[] {
|
|
34
|
+
const classNames = new Set<string>();
|
|
35
|
+
|
|
36
|
+
for (const type of TRANSITION_TYPES) {
|
|
37
|
+
if (type === 'cut') continue;
|
|
38
|
+
classNames.add(getTransitionClass(type, true));
|
|
39
|
+
classNames.add(getTransitionClass(type, false));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const type of directionalTypes) {
|
|
43
|
+
for (const direction of cardinalDirections) {
|
|
44
|
+
classNames.add(getTransitionClass(type, true, direction));
|
|
45
|
+
classNames.add(getTransitionClass(type, false, direction));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const type of axisTypes) {
|
|
50
|
+
for (const direction of axisDirections) {
|
|
51
|
+
classNames.add(getTransitionClass(type, true, direction));
|
|
52
|
+
classNames.add(getTransitionClass(type, false, direction));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [...classNames].sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('transition CSS coverage', () => {
|
|
60
|
+
it('contains enter and exit selectors for every generated transition class', () => {
|
|
61
|
+
for (const className of collectGeneratedTransitionClasses()) {
|
|
62
|
+
expect(transitionCss, className).toContain(`.${className}`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** All `@keyframes <name>` defined in the stylesheet. */
|
|
68
|
+
function definedKeyframes(): Set<string> {
|
|
69
|
+
const names = new Set<string>();
|
|
70
|
+
for (const m of transitionCss.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)/g)) {
|
|
71
|
+
names.add(m[1]);
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Map each `.transition-<x>-(enter|exit)` selector to the keyframe its rule
|
|
78
|
+
* animates. Parses top-level `selectors { body }` blocks and reads the first
|
|
79
|
+
* identifier of the `animation:` shorthand. Returns null for a transition rule
|
|
80
|
+
* with no `animation` property (itself a defect).
|
|
81
|
+
*/
|
|
82
|
+
function transitionRuleKeyframes(): Map<string, string | null> {
|
|
83
|
+
const map = new Map<string, string | null>();
|
|
84
|
+
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
|
|
85
|
+
let rule: RegExpExecArray | null;
|
|
86
|
+
while ((rule = ruleRe.exec(transitionCss)) !== null) {
|
|
87
|
+
const selectors = rule[1].split(',').map((s) => s.trim());
|
|
88
|
+
const transitionSelectors = selectors.filter((s) =>
|
|
89
|
+
/\.transition-[A-Za-z0-9-]+-(?:enter|exit)\b/.test(s),
|
|
90
|
+
);
|
|
91
|
+
if (transitionSelectors.length === 0) continue;
|
|
92
|
+
const animMatch = rule[2].match(/animation:\s*([A-Za-z0-9_-]+)/);
|
|
93
|
+
const keyframe = animMatch ? animMatch[1] : null;
|
|
94
|
+
for (const selector of transitionSelectors) {
|
|
95
|
+
const classMatch = selector.match(/\.(transition-[A-Za-z0-9-]+-(?:enter|exit))/);
|
|
96
|
+
if (classMatch && !map.has(classMatch[1])) map.set(classMatch[1], keyframe);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return map;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('transition keyframe integrity', () => {
|
|
103
|
+
it('every transition rule animates a defined @keyframes (catches typos)', () => {
|
|
104
|
+
const keyframes = definedKeyframes();
|
|
105
|
+
const broken: string[] = [];
|
|
106
|
+
for (const [selector, keyframe] of transitionRuleKeyframes()) {
|
|
107
|
+
if (keyframe === null) broken.push(`${selector}: no animation property`);
|
|
108
|
+
else if (!keyframes.has(keyframe)) broken.push(`${selector}: missing @keyframes ${keyframe}`);
|
|
109
|
+
}
|
|
110
|
+
expect(broken, broken.join('\n')).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('every generated transition class resolves to a defined keyframe', () => {
|
|
114
|
+
// Ties the function output to the CSS end-to-end: a class the player can
|
|
115
|
+
// emit must have a rule, and that rule must animate a real keyframe.
|
|
116
|
+
const keyframes = definedKeyframes();
|
|
117
|
+
const rules = transitionRuleKeyframes();
|
|
118
|
+
for (const className of collectGeneratedTransitionClasses()) {
|
|
119
|
+
const keyframe = rules.get(className);
|
|
120
|
+
expect(keyframe, `${className} has no transition rule`).toBeDefined();
|
|
121
|
+
expect(keyframe, `${className} animates undefined keyframe ${keyframe}`).not.toBeNull();
|
|
122
|
+
if (keyframe) expect(keyframes.has(keyframe), `${className} -> ${keyframe}`).toBe(true);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDocPlayback — block transition state is derived synchronously.
|
|
3
|
+
*
|
|
4
|
+
* Regression guard for the between-block "flash": a newly active block must
|
|
5
|
+
* report `isEntering` (and its outgoing `previousBlock`) on the SAME render it
|
|
6
|
+
* becomes current — not a frame later via an effect. Otherwise the block paints
|
|
7
|
+
* fully settled for a frame and then snaps back to the start of its entrance
|
|
8
|
+
* animation. The distinguishing assertion is `previousBlock` during the next
|
|
9
|
+
* block's entrance with NO timer/effect advanced (the old effect-based path
|
|
10
|
+
* only set it after a `setTimeout`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { renderHook } from '@testing-library/react';
|
|
15
|
+
import { VIEWPORT_PRESETS } from '@bendyline/squisq/doc';
|
|
16
|
+
import type { Doc, Block } from '@bendyline/squisq/schemas';
|
|
17
|
+
import { useDocPlayback } from '../hooks/useDocPlayback';
|
|
18
|
+
|
|
19
|
+
function block(id: string, startTime: number): Block {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
startTime,
|
|
23
|
+
duration: 5,
|
|
24
|
+
audioSegment: 0,
|
|
25
|
+
transition: { type: 'fade', duration: 0.5 },
|
|
26
|
+
layers: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const doc: Doc = {
|
|
31
|
+
articleId: 'd',
|
|
32
|
+
duration: 10,
|
|
33
|
+
blocks: [block('a', 0), block('b', 5)],
|
|
34
|
+
audio: { segments: [] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('useDocPlayback — synchronous block transitions', () => {
|
|
38
|
+
it('exposes the entering block + outgoing previousBlock on the same render (no effect flush)', () => {
|
|
39
|
+
const { result, rerender } = renderHook(
|
|
40
|
+
({ t }: { t: number }) => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape),
|
|
41
|
+
{ initialProps: { t: 0 } },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Establish block A as the current block first.
|
|
45
|
+
expect(result.current.currentBlockIndex).toBe(0);
|
|
46
|
+
|
|
47
|
+
// Cross into block B's entrance window: it is entering AND crossfading from
|
|
48
|
+
// A immediately — the old code left previousBlock null until a setTimeout.
|
|
49
|
+
rerender({ t: 5.1 });
|
|
50
|
+
expect(result.current.currentBlockIndex).toBe(1);
|
|
51
|
+
expect(result.current.isEntering).toBe(true);
|
|
52
|
+
expect(result.current.isExiting).toBe(true);
|
|
53
|
+
expect(result.current.previousBlock?.id).toBe('a');
|
|
54
|
+
|
|
55
|
+
// Past the entrance window: settled, no crossfade.
|
|
56
|
+
rerender({ t: 7 });
|
|
57
|
+
expect(result.current.isEntering).toBe(false);
|
|
58
|
+
expect(result.current.isExiting).toBe(false);
|
|
59
|
+
expect(result.current.previousBlock).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('isEntering is a pure function of blockTime vs the transition duration', () => {
|
|
63
|
+
const enteringAt = (t: number) =>
|
|
64
|
+
renderHook(() => useDocPlayback(doc, t, VIEWPORT_PRESETS.landscape)).result.current
|
|
65
|
+
.isEntering;
|
|
66
|
+
expect(enteringAt(5.0)).toBe(true); // blockTime 0.0 < 0.5
|
|
67
|
+
expect(enteringAt(5.4)).toBe(true); // blockTime 0.4 < 0.5
|
|
68
|
+
expect(enteringAt(5.6)).toBe(false); // blockTime 0.6 >= 0.5
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { DARK_SURFACE, LIGHT_SURFACE, DEFAULT_THEME } from '@bendyline/squisq/schemas';
|
|
4
|
+
import { useJsonViewTokens } from '../jsonView/useJsonViewTokens';
|
|
5
|
+
|
|
6
|
+
type StyleBag = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
function mockPrefersDark(dark: boolean): void {
|
|
9
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
10
|
+
configurable: true,
|
|
11
|
+
value: (query: string) => ({
|
|
12
|
+
matches: dark && query.includes('dark'),
|
|
13
|
+
media: query,
|
|
14
|
+
onchange: null,
|
|
15
|
+
addListener: vi.fn(),
|
|
16
|
+
removeListener: vi.fn(),
|
|
17
|
+
addEventListener: vi.fn(),
|
|
18
|
+
removeEventListener: vi.fn(),
|
|
19
|
+
dispatchEvent: vi.fn(() => false),
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('useJsonViewTokens', () => {
|
|
25
|
+
afterEach(() => vi.restoreAllMocks());
|
|
26
|
+
|
|
27
|
+
it('emits json-prefixed tokens for a static surface', () => {
|
|
28
|
+
const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, LIGHT_SURFACE));
|
|
29
|
+
const style = result.current.style as StyleBag;
|
|
30
|
+
expect(style['--squisq-json-bg']).toBe(LIGHT_SURFACE.background);
|
|
31
|
+
expect(style['--squisq-json-text']).toBe(LIGHT_SURFACE.text);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tracks a dark OS preference under surface='auto'", () => {
|
|
35
|
+
mockPrefersDark(true);
|
|
36
|
+
const { result } = renderHook(() => useJsonViewTokens(DEFAULT_THEME, 'auto'));
|
|
37
|
+
const style = result.current.style as StyleBag;
|
|
38
|
+
expect(style['--squisq-json-bg']).toBe(DARK_SURFACE.background);
|
|
39
|
+
expect(result.current.theme.colors.background).toBe(DARK_SURFACE.background);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { decideSwipe, type SwipeDecisionInput } from '../hooks/useSlideSwipe';
|
|
3
|
+
|
|
4
|
+
/** A slow drag: long duration so velocity stays well under the flick threshold. */
|
|
5
|
+
function slow(overrides: Partial<SwipeDecisionInput>): SwipeDecisionInput {
|
|
6
|
+
return {
|
|
7
|
+
dx: 0,
|
|
8
|
+
width: 1000,
|
|
9
|
+
elapsedMs: 1000,
|
|
10
|
+
canNext: true,
|
|
11
|
+
canPrev: true,
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('decideSwipe', () => {
|
|
17
|
+
describe('distance threshold (30% of width)', () => {
|
|
18
|
+
it('snaps back when the drag is below the threshold', () => {
|
|
19
|
+
// 100px of 1000px = 10% < 30%, and slow enough not to be a flick.
|
|
20
|
+
expect(decideSwipe(slow({ dx: -100 }))).toBe('snap');
|
|
21
|
+
expect(decideSwipe(slow({ dx: 100 }))).toBe('snap');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('advances to the next slide when dragged left past the threshold', () => {
|
|
25
|
+
expect(decideSwipe(slow({ dx: -350 }))).toBe('next');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('goes to the previous slide when dragged right past the threshold', () => {
|
|
29
|
+
expect(decideSwipe(slow({ dx: 350 }))).toBe('prev');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('commits exactly at the threshold', () => {
|
|
33
|
+
expect(decideSwipe(slow({ dx: -300 }))).toBe('next');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('flick (fast, short drag)', () => {
|
|
38
|
+
it('commits on a quick flick even below the distance threshold', () => {
|
|
39
|
+
// 100px in 100ms = 1.0 px/ms >= 0.5, distance 100 >= 12.
|
|
40
|
+
expect(decideSwipe(slow({ dx: -100, elapsedMs: 100 }))).toBe('next');
|
|
41
|
+
expect(decideSwipe(slow({ dx: 100, elapsedMs: 100 }))).toBe('prev');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('ignores a fast micro-jitter shorter than the minimum flick distance', () => {
|
|
45
|
+
// 8px in 5ms = 1.6 px/ms (fast) but distance 8 < 12 → treat as a click.
|
|
46
|
+
expect(decideSwipe(slow({ dx: -8, elapsedMs: 5 }))).toBe('snap');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('deck boundaries', () => {
|
|
51
|
+
it('snaps back instead of advancing when there is no next slide', () => {
|
|
52
|
+
expect(decideSwipe(slow({ dx: -350, canNext: false }))).toBe('snap');
|
|
53
|
+
// A flick past the last slide is also refused.
|
|
54
|
+
expect(decideSwipe(slow({ dx: -100, elapsedMs: 100, canNext: false }))).toBe('snap');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('snaps back instead of rewinding when there is no previous slide', () => {
|
|
58
|
+
expect(decideSwipe(slow({ dx: 350, canPrev: false }))).toBe('snap');
|
|
59
|
+
expect(decideSwipe(slow({ dx: 100, elapsedMs: 100, canPrev: false }))).toBe('snap');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('degenerate inputs', () => {
|
|
64
|
+
it('snaps back on a zero-distance release', () => {
|
|
65
|
+
expect(decideSwipe(slow({ dx: 0 }))).toBe('snap');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('never commits by distance when the container has no measurable width', () => {
|
|
69
|
+
// width 0 → distance threshold is Infinity; only a flick can commit.
|
|
70
|
+
// 500px over 2000ms = 0.25 px/ms, safely below the flick velocity.
|
|
71
|
+
expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 2000 }))).toBe('snap');
|
|
72
|
+
expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 100 }))).toBe('next');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not divide by zero on an instantaneous release', () => {
|
|
76
|
+
// elapsedMs 0 → velocity treated as 0; falls back to the distance rule.
|
|
77
|
+
expect(decideSwipe(slow({ dx: -350, elapsedMs: 0 }))).toBe('next');
|
|
78
|
+
expect(decideSwipe(slow({ dx: -100, elapsedMs: 0 }))).toBe('snap');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* AudioController - Abstraction for audio playback in DocPlayer
|
|
3
3
|
*
|
|
4
4
|
* This module defines an interface for audio playback operations that can have
|
|
5
5
|
* different implementations depending on the runtime environment:
|
|
@@ -47,9 +47,9 @@ export interface AudioActions {
|
|
|
47
47
|
restart: () => Promise<void>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export type
|
|
50
|
+
export type AudioController = AudioState & AudioActions;
|
|
51
51
|
|
|
52
|
-
export interface
|
|
52
|
+
export interface AudioControllerConfig {
|
|
53
53
|
/** Audio track with segments */
|
|
54
54
|
audioTrack: AudioTrack | undefined;
|
|
55
55
|
/** Base path for resolving audio URLs */
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
export { calculateSegmentTiming, findSegmentAtTime } from './
|
|
2
|
-
export type {
|
|
1
|
+
export { calculateSegmentTiming, findSegmentAtTime } from './AudioController';
|
|
2
|
+
export type {
|
|
3
|
+
AudioState,
|
|
4
|
+
AudioActions,
|
|
5
|
+
AudioController,
|
|
6
|
+
AudioControllerConfig,
|
|
7
|
+
} from './AudioController';
|
|
3
8
|
|
|
4
9
|
export { useAudioSync } from './useAudioSync';
|
|
5
10
|
export { useDocPlayback } from './useDocPlayback';
|
|
@@ -7,20 +7,21 @@
|
|
|
7
7
|
* Handles multiple audio segments (MP3 files) by tracking which segment
|
|
8
8
|
* is currently playing and calculating the overall timeline position.
|
|
9
9
|
*
|
|
10
|
-
* This is the HTML5 Audio implementation of the
|
|
11
|
-
*
|
|
10
|
+
* This is the HTML5 Audio implementation of the AudioController interface.
|
|
11
|
+
* Hosts that drive audio through an external player (e.g. a native shell)
|
|
12
|
+
* can supply their own AudioController to DocPlayer instead of this hook.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
15
16
|
import type { RefObject } from 'react';
|
|
16
17
|
import type { AudioTrack } from '@bendyline/squisq/schemas';
|
|
17
|
-
import type {
|
|
18
|
+
import type { AudioController } from './AudioController';
|
|
18
19
|
|
|
19
20
|
export function useAudioSync(
|
|
20
21
|
audioRef: RefObject<HTMLAudioElement>,
|
|
21
22
|
audioTrack: AudioTrack | undefined,
|
|
22
23
|
basePath: string = '',
|
|
23
|
-
):
|
|
24
|
+
): AudioController {
|
|
24
25
|
const [currentTime, setCurrentTime] = useState(0);
|
|
25
26
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
26
27
|
const [currentSegment, setCurrentSegment] = useState(0);
|
|
@@ -120,6 +121,13 @@ export function useAudioSync(
|
|
|
120
121
|
if (!audio) return;
|
|
121
122
|
|
|
122
123
|
const handleTimeUpdate = () => {
|
|
124
|
+
// In fallback mode the <audio> element is NOT the clock — there's no real
|
|
125
|
+
// source (e.g. the editor preview's synthetic, empty-src track), so the
|
|
126
|
+
// synthetic timer and `seekTo` own `currentTime`. A `timeupdate` fired as
|
|
127
|
+
// a side effect of programmatically setting `audio.currentTime` (during a
|
|
128
|
+
// seek) would otherwise clobber the just-seeked position with the empty
|
|
129
|
+
// element's unreliable `currentTime`, snapping the scrubber back.
|
|
130
|
+
if (fallbackMode.current) return;
|
|
123
131
|
// Calculate overall timeline position
|
|
124
132
|
const segmentStart = segmentStarts.current[currentSegment] || 0;
|
|
125
133
|
const overallTime = segmentStart + audio.currentTime;
|
|
@@ -127,7 +135,13 @@ export function useAudioSync(
|
|
|
127
135
|
};
|
|
128
136
|
|
|
129
137
|
const handlePlay = () => {
|
|
130
|
-
fallbackMode.
|
|
138
|
+
// Don't clear `fallbackMode` here. Whether the <audio> element is really
|
|
139
|
+
// the clock is decided authoritatively by the play() promise: it only
|
|
140
|
+
// resolves (clearing fallback, see `play`) when a real source actually
|
|
141
|
+
// plays. The 'play' event, by contrast, can fire spuriously on the
|
|
142
|
+
// source-less preview element — and clearing fallback there makes the
|
|
143
|
+
// synthetic timer's tick guard bail on its next frame, freezing the
|
|
144
|
+
// clock and the scrubber after a seek/resume.
|
|
131
145
|
setIsPlaying(true);
|
|
132
146
|
};
|
|
133
147
|
const handlePause = () => setIsPlaying(false);
|
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* - Automatic expansion of template blocks
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
16
16
|
import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
|
|
17
17
|
import type { Theme } from '@bendyline/squisq/schemas';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_THEME,
|
|
20
|
+
getBlockAtTime,
|
|
21
|
+
resolveBlockTransition,
|
|
22
|
+
resolveTransitionDuration,
|
|
23
|
+
} from '@bendyline/squisq/schemas';
|
|
19
24
|
import {
|
|
20
25
|
expandDocBlocks,
|
|
21
|
-
|
|
26
|
+
flattenRenderableBlocks,
|
|
22
27
|
isTemplateBlock,
|
|
28
|
+
resolvePersistentLayers,
|
|
23
29
|
VIEWPORT_PRESETS,
|
|
24
30
|
type ViewportConfig,
|
|
25
31
|
} from '@bendyline/squisq/doc';
|
|
@@ -61,29 +67,34 @@ export function useDocPlayback(
|
|
|
61
67
|
renderMode: boolean = false,
|
|
62
68
|
theme?: Theme,
|
|
63
69
|
): PlaybackState & PlaybackActions {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
previousBlock: Block | null;
|
|
68
|
-
}>({
|
|
69
|
-
entering: false,
|
|
70
|
-
exiting: false,
|
|
71
|
-
previousBlock: null,
|
|
72
|
-
});
|
|
73
|
-
|
|
70
|
+
// `renderMode` is retained for API/signature compatibility; block transitions
|
|
71
|
+
// are now computed identically for real-time and render (export) modes.
|
|
72
|
+
void renderMode;
|
|
74
73
|
// Expand any template blocks into full blocks
|
|
75
74
|
const blocks = useMemo(() => {
|
|
76
75
|
if (!script?.blocks) {
|
|
77
76
|
return [];
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
// Flatten nested block hierarchy (markdown-derived docs have children)
|
|
79
|
+
// Flatten nested block hierarchy (markdown-derived docs have children).
|
|
80
|
+
// `flattenRenderableBlocks` skips the children of container templates
|
|
81
|
+
// (`diagram`, `drawing`) — those are consumed by the parent's render as
|
|
82
|
+
// nodes/shapes, so they must not also appear as their own slides.
|
|
81
83
|
const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
|
|
82
|
-
const flatBlocks = hasChildren ?
|
|
84
|
+
const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
|
|
83
85
|
|
|
84
86
|
// Check if any blocks are templates
|
|
85
87
|
const hasTemplates = flatBlocks.some(isTemplateBlock);
|
|
86
88
|
|
|
89
|
+
// Doc persistent layers win wholesale; docs without any inherit the
|
|
90
|
+
// theme's (see resolvePersistentLayers). Passed as a narrow object so
|
|
91
|
+
// the memo deps stay field-precise.
|
|
92
|
+
const resolvedTheme = theme ?? DEFAULT_THEME;
|
|
93
|
+
const persistentLayers = resolvePersistentLayers(
|
|
94
|
+
{ persistentLayers: script.persistentLayers },
|
|
95
|
+
resolvedTheme,
|
|
96
|
+
);
|
|
97
|
+
|
|
87
98
|
if (hasTemplates) {
|
|
88
99
|
// Extract audio segment timing for proper block synchronization
|
|
89
100
|
const audioSegments = script.audio?.segments?.map((seg) => ({
|
|
@@ -95,15 +106,31 @@ export function useDocPlayback(
|
|
|
95
106
|
const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
|
|
96
107
|
audioSegments,
|
|
97
108
|
viewport,
|
|
98
|
-
persistentLayers
|
|
109
|
+
persistentLayers,
|
|
99
110
|
theme,
|
|
111
|
+
// Custom (user-defined) templates inlined into the doc's
|
|
112
|
+
// frontmatter — see CustomTemplates.ts. Merged onto the
|
|
113
|
+
// built-in registry so blocks annotated with `{[myhero]}`
|
|
114
|
+
// resolve through the user's design.
|
|
115
|
+
customTemplates: script.customTemplates,
|
|
100
116
|
});
|
|
101
117
|
return expanded;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
// All raw blocks
|
|
105
|
-
|
|
106
|
-
|
|
120
|
+
// All raw blocks — used as-is except for the theme's default transition
|
|
121
|
+
// fallback (copies, never mutations: these blocks are caller-owned).
|
|
122
|
+
return flatBlocks.map((block, index) => {
|
|
123
|
+
const transition = resolveBlockTransition(block, resolvedTheme, index);
|
|
124
|
+
return transition !== block.transition ? { ...block, transition } : block;
|
|
125
|
+
});
|
|
126
|
+
}, [
|
|
127
|
+
script?.blocks,
|
|
128
|
+
script?.audio?.segments,
|
|
129
|
+
script?.persistentLayers,
|
|
130
|
+
script?.customTemplates,
|
|
131
|
+
viewport,
|
|
132
|
+
theme,
|
|
133
|
+
]);
|
|
107
134
|
|
|
108
135
|
// Find current block based on time
|
|
109
136
|
const currentBlock = useMemo(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
|
|
@@ -130,78 +157,38 @@ export function useDocPlayback(
|
|
|
130
157
|
return Math.min(1, currentTime / script.duration);
|
|
131
158
|
}, [script, currentTime]);
|
|
132
159
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
166
|
-
}, transitionDuration * 1000);
|
|
167
|
-
|
|
168
|
-
return () => clearTimeout(timer);
|
|
169
|
-
} else {
|
|
170
|
-
// Instant cut
|
|
171
|
-
setTransitionState({
|
|
172
|
-
entering: false,
|
|
173
|
-
exiting: false,
|
|
174
|
-
previousBlock: currentBlock,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on currentBlock?.id only; reading transitionState.previousBlock without dep to avoid infinite loop
|
|
179
|
-
}, [currentBlock?.id, renderMode]);
|
|
180
|
-
|
|
181
|
-
// Render mode: track previous block via ref and compute transition from time
|
|
182
|
-
const renderPrevBlockRef = useRef<Block | null>(null);
|
|
183
|
-
|
|
184
|
-
useEffect(() => {
|
|
185
|
-
if (!renderMode || !currentBlock) return;
|
|
186
|
-
|
|
187
|
-
if (transitionState.previousBlock?.id !== currentBlock.id) {
|
|
188
|
-
// Block changed — remember the old block for crossfade
|
|
189
|
-
const oldPrev = transitionState.previousBlock;
|
|
190
|
-
renderPrevBlockRef.current = oldPrev;
|
|
191
|
-
// Store current block as the "last seen" for next transition
|
|
192
|
-
setTransitionState((prev) => ({
|
|
193
|
-
...prev,
|
|
194
|
-
previousBlock: currentBlock,
|
|
195
|
-
}));
|
|
196
|
-
}
|
|
197
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- same pattern: keyed on block identity change
|
|
198
|
-
}, [currentBlock?.id, renderMode]);
|
|
199
|
-
|
|
200
|
-
// In render mode, derive entering/exiting from blockTime
|
|
201
|
-
const renderTransitionDuration = currentBlock?.transition?.duration || 0;
|
|
202
|
-
const renderIsEntering =
|
|
203
|
-
renderMode && renderTransitionDuration > 0 && blockTime < renderTransitionDuration;
|
|
204
|
-
const renderIsExiting = renderIsEntering && renderPrevBlockRef.current !== null;
|
|
160
|
+
// ─── Transition tracking (synchronous — no effect lag) ──────────────
|
|
161
|
+
// `isEntering` is simply "we are within the block's entrance window"
|
|
162
|
+
// (blockTime < the transition's duration). Deriving it during render —
|
|
163
|
+
// rather than flipping it in an effect a frame AFTER the block changes —
|
|
164
|
+
// means a newly-active block renders WITH its entrance state on its very
|
|
165
|
+
// first frame. Otherwise the block paints once fully settled and then, a
|
|
166
|
+
// frame later, snaps back to the start of its entrance animation: the brief
|
|
167
|
+
// "flash then re-animate" seen between blocks. This runs identically for
|
|
168
|
+
// real-time playback and frame-seeked render (export) mode.
|
|
169
|
+
//
|
|
170
|
+
// The block we transitioned FROM (for the crossfade) is tracked with refs
|
|
171
|
+
// updated during render — the standard "previous value" pattern — so the
|
|
172
|
+
// outgoing block is known on the SAME frame the new block becomes active
|
|
173
|
+
// (an effect would lag a frame and drop the crossfade's first frames).
|
|
174
|
+
const outgoingBlockRef = useRef<Block | null>(null);
|
|
175
|
+
const activeBlockIdRef = useRef<string | null>(null);
|
|
176
|
+
const lastRenderedBlockRef = useRef<Block | null>(null);
|
|
177
|
+
if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
|
|
178
|
+
outgoingBlockRef.current = lastRenderedBlockRef.current;
|
|
179
|
+
activeBlockIdRef.current = currentBlock.id;
|
|
180
|
+
}
|
|
181
|
+
lastRenderedBlockRef.current = currentBlock;
|
|
182
|
+
|
|
183
|
+
const transitionDuration = currentBlock?.transition
|
|
184
|
+
? resolveTransitionDuration(currentBlock.transition)
|
|
185
|
+
: 0;
|
|
186
|
+
const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
|
|
187
|
+
const outgoingBlock = outgoingBlockRef.current;
|
|
188
|
+
// Only crossfade a genuinely different outgoing block (guards restarts/seeks
|
|
189
|
+
// where the "previous" resolves to the same block).
|
|
190
|
+
const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
|
|
191
|
+
const previousBlock = isExiting ? outgoingBlock : null;
|
|
205
192
|
|
|
206
193
|
// Manual navigation
|
|
207
194
|
const goToBlock = useCallback(
|
|
@@ -233,15 +220,9 @@ export function useDocPlayback(
|
|
|
233
220
|
return {
|
|
234
221
|
currentBlock,
|
|
235
222
|
currentBlockIndex,
|
|
236
|
-
previousBlock
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
: null
|
|
240
|
-
: transitionState.exiting
|
|
241
|
-
? transitionState.previousBlock
|
|
242
|
-
: null,
|
|
243
|
-
isEntering: renderMode ? renderIsEntering : transitionState.entering,
|
|
244
|
-
isExiting: renderMode ? renderIsExiting : transitionState.exiting,
|
|
223
|
+
previousBlock,
|
|
224
|
+
isEntering,
|
|
225
|
+
isExiting,
|
|
245
226
|
blockTime,
|
|
246
227
|
blockProgress,
|
|
247
228
|
docProgress,
|