@bendyline/squisq-react 2.0.1 → 2.2.0
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/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
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, vi } from 'vitest';
|
|
14
|
-
import { act, 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: 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: VIEWPORT_PRESETS.landscape })).result
|
|
65
|
-
.current.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
|
-
|
|
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
|
-
});
|
|
113
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
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,22 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AudioController - Abstraction for audio playback in DocPlayer
|
|
3
|
-
*
|
|
4
|
-
* This module defines an interface for audio playback operations that can have
|
|
5
|
-
* different implementations depending on the runtime environment:
|
|
6
|
-
*
|
|
7
|
-
* - Site/Browser: Uses HTML5 Audio element directly
|
|
8
|
-
* - EFB/MSFS: Routes through CompanionAPI to Electron app
|
|
9
|
-
*
|
|
10
|
-
* The DocPlayer uses this abstraction instead of directly manipulating audio,
|
|
11
|
-
* allowing the same component code to work in both environments.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { AudioTrack, AudioSegment } from '@bendyline/squisq/schemas';
|
|
15
|
-
|
|
16
|
-
export interface AudioState {
|
|
17
|
-
/** Current time in overall timeline (seconds) */
|
|
18
|
-
currentTime: number;
|
|
19
|
-
/** Whether audio is currently playing */
|
|
20
|
-
isPlaying: boolean;
|
|
21
|
-
/** Index of current audio segment */
|
|
22
|
-
currentSegment: number;
|
|
23
|
-
/** Total duration of all segments */
|
|
24
|
-
totalDuration: number;
|
|
25
|
-
/** Whether audio has finished */
|
|
26
|
-
isEnded: boolean;
|
|
27
|
-
/** Whether audio is loaded and ready to play */
|
|
28
|
-
isReady: boolean;
|
|
29
|
-
/** Whether the audio backend is available/connected */
|
|
30
|
-
isAvailable: boolean;
|
|
31
|
-
/** Message to show when not available */
|
|
32
|
-
unavailableMessage?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface AudioActions {
|
|
36
|
-
/** Start or resume playback */
|
|
37
|
-
play: () => Promise<void>;
|
|
38
|
-
/** Pause playback */
|
|
39
|
-
pause: () => Promise<void>;
|
|
40
|
-
/** Toggle play/pause */
|
|
41
|
-
toggle: () => Promise<void>;
|
|
42
|
-
/** Seek to specific time in timeline */
|
|
43
|
-
seekTo: (time: number) => Promise<void>;
|
|
44
|
-
/** Skip to specific segment */
|
|
45
|
-
skipToSegment: (index: number) => Promise<void>;
|
|
46
|
-
/** Restart from beginning */
|
|
47
|
-
restart: () => Promise<void>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type AudioController = AudioState & AudioActions;
|
|
51
|
-
|
|
52
|
-
export interface AudioControllerConfig {
|
|
53
|
-
/** Audio track with segments */
|
|
54
|
-
audioTrack: AudioTrack | undefined;
|
|
55
|
-
/** Base path for resolving audio URLs */
|
|
56
|
-
basePath: string;
|
|
57
|
-
/** Article ID (for EFB companion) */
|
|
58
|
-
articleId?: string;
|
|
59
|
-
/** Tile/geohash (for EFB companion) */
|
|
60
|
-
tile?: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Calculate segment start times and total duration from an audio track
|
|
65
|
-
*/
|
|
66
|
-
export function calculateSegmentTiming(segments: AudioSegment[] | undefined): {
|
|
67
|
-
segmentStarts: number[];
|
|
68
|
-
totalDuration: number;
|
|
69
|
-
} {
|
|
70
|
-
if (!segments?.length) {
|
|
71
|
-
return { segmentStarts: [], totalDuration: 0 };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let time = 0;
|
|
75
|
-
const segmentStarts = segments.map((seg) => {
|
|
76
|
-
const start = time;
|
|
77
|
-
time += seg.duration;
|
|
78
|
-
return start;
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
return { segmentStarts, totalDuration: time };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Find which segment a given time falls into
|
|
86
|
-
*/
|
|
87
|
-
export function findSegmentAtTime(
|
|
88
|
-
time: number,
|
|
89
|
-
segments: AudioSegment[] | undefined,
|
|
90
|
-
segmentStarts: number[],
|
|
91
|
-
): { segmentIndex: number; segmentStart: number } {
|
|
92
|
-
if (!segments?.length) {
|
|
93
|
-
return { segmentIndex: 0, segmentStart: 0 };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let segmentIndex = 0;
|
|
97
|
-
let segmentStart = 0;
|
|
98
|
-
|
|
99
|
-
for (let i = 0; i < segments.length; i++) {
|
|
100
|
-
const segEnd = segmentStarts[i] + segments[i].duration;
|
|
101
|
-
if (time < segEnd) {
|
|
102
|
-
segmentIndex = i;
|
|
103
|
-
segmentStart = segmentStarts[i];
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
// Handle edge case: time exactly at end goes to last segment
|
|
107
|
-
if (i === segments.length - 1) {
|
|
108
|
-
segmentIndex = i;
|
|
109
|
-
segmentStart = segmentStarts[i];
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return { segmentIndex, segmentStart };
|
|
114
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MediaContext — React context for providing a MediaProvider to layer components.
|
|
3
|
-
*
|
|
4
|
-
* When a MediaProvider is available in context, layer components (ImageLayer,
|
|
5
|
-
* VideoLayer) will use it to resolve media URLs instead of the basePath prop.
|
|
6
|
-
* This enables slot-based media storage where binary assets are served from
|
|
7
|
-
* IndexedDB as blob URLs.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* const provider = createSlotMediaProvider(slotId);
|
|
11
|
-
* <MediaContext.Provider value={provider}>
|
|
12
|
-
* <DocPlayer doc={doc} basePath="/" />
|
|
13
|
-
* </MediaContext.Provider>
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createContext, useContext, useState, useEffect, useMemo } from 'react';
|
|
17
|
-
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* React context holding the current MediaProvider (or null if none provided).
|
|
21
|
-
*/
|
|
22
|
-
export const MediaContext = createContext<MediaProvider | null>(null);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Hook to access the current MediaProvider from context.
|
|
26
|
-
* Returns null if no provider is set.
|
|
27
|
-
*/
|
|
28
|
-
export function useMediaProvider(): MediaProvider | null {
|
|
29
|
-
return useContext(MediaContext);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Hook to resolve a media URL via the MediaProvider (if available),
|
|
34
|
-
* falling back to basePath-based resolution.
|
|
35
|
-
*
|
|
36
|
-
* Returns the resolved URL string. Updates when the provider or path changes.
|
|
37
|
-
*
|
|
38
|
-
* @param relativePath - Relative media path from the document (e.g., 'hero.jpg')
|
|
39
|
-
* @param basePath - Fallback base path for URL construction
|
|
40
|
-
*/
|
|
41
|
-
export function useMediaUrl(relativePath: string, basePath: string): string {
|
|
42
|
-
const provider = useMediaProvider();
|
|
43
|
-
|
|
44
|
-
// Defensive: callers (esp. preview surfaces like InlinePreviewGutter)
|
|
45
|
-
// sometimes feed in template-generated layers whose `content.src` is
|
|
46
|
-
// undefined while the user is still authoring the block. Treat that
|
|
47
|
-
// as an empty string rather than crashing the whole React tree.
|
|
48
|
-
const safePath = typeof relativePath === 'string' ? relativePath : '';
|
|
49
|
-
|
|
50
|
-
// For absolute/http URLs, skip resolution entirely
|
|
51
|
-
const isAbsolute =
|
|
52
|
-
!safePath ||
|
|
53
|
-
safePath.startsWith('http') ||
|
|
54
|
-
safePath.startsWith('/') ||
|
|
55
|
-
safePath.startsWith('data:') ||
|
|
56
|
-
safePath.startsWith('blob:');
|
|
57
|
-
|
|
58
|
-
// Memoize fallback to avoid recalculating on every render
|
|
59
|
-
const fallback = useMemo(
|
|
60
|
-
() => (isAbsolute ? safePath : `${basePath}/${safePath}`),
|
|
61
|
-
[isAbsolute, safePath, basePath],
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
// Fast path: no provider or absolute URL — return synchronously, skip effect entirely
|
|
65
|
-
const needsProvider = !isAbsolute && !!provider;
|
|
66
|
-
|
|
67
|
-
const [url, setUrl] = useState(fallback);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
if (!needsProvider) {
|
|
71
|
-
setUrl(fallback);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
let cancelled = false;
|
|
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
|
-
);
|
|
88
|
-
|
|
89
|
-
return () => {
|
|
90
|
-
cancelled = true;
|
|
91
|
-
};
|
|
92
|
-
}, [needsProvider, provider, safePath, fallback]);
|
|
93
|
-
|
|
94
|
-
// When provider is not needed, return fallback directly to avoid
|
|
95
|
-
// the one-frame delay from the initial useState → useEffect cycle
|
|
96
|
-
return needsProvider ? url : fallback;
|
|
97
|
-
}
|
package/src/hooks/index.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export { calculateSegmentTiming, findSegmentAtTime } from './AudioController';
|
|
2
|
-
export type {
|
|
3
|
-
AudioState,
|
|
4
|
-
AudioActions,
|
|
5
|
-
AudioController,
|
|
6
|
-
AudioControllerConfig,
|
|
7
|
-
} from './AudioController';
|
|
8
|
-
|
|
9
|
-
export { useAudioSync } from './useAudioSync';
|
|
10
|
-
export { useDocPlayback } from './useDocPlayback';
|
|
11
|
-
export { useViewportOrientation } from './useViewportOrientation';
|