@bendyline/squisq-react 0.1.2

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.
Files changed (45) hide show
  1. package/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. package/src/utils/mapTileUtils.ts +375 -0
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { LinearDocView } from '../LinearDocView';
4
+ import type { Doc, Block } from '@bendyline/squisq/schemas';
5
+ import type {
6
+ MarkdownBlockNode,
7
+ MarkdownInlineNode,
8
+ MarkdownHeading,
9
+ } from '@bendyline/squisq/markdown';
10
+
11
+ // ── Helpers ────────────────────────────────────────────────────────
12
+
13
+ function text(value: string): MarkdownInlineNode {
14
+ return { type: 'text', value };
15
+ }
16
+
17
+ function paragraph(...children: MarkdownInlineNode[]): MarkdownBlockNode {
18
+ return { type: 'paragraph', children };
19
+ }
20
+
21
+ function mkHeading(depth: 1 | 2 | 3, value: string): MarkdownHeading {
22
+ return { type: 'heading', depth, children: [text(value)] };
23
+ }
24
+
25
+ function mkBlock(overrides: Partial<Block> = {}): Block {
26
+ return {
27
+ id: 'block-1',
28
+ startTime: 0,
29
+ duration: 3,
30
+ audioSegment: 0,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function mkDoc(blocks: Block[]): Doc {
36
+ return {
37
+ articleId: 'test-article',
38
+ duration: 10,
39
+ blocks,
40
+ audio: { segments: [] },
41
+ };
42
+ }
43
+
44
+ // ── Tests ──────────────────────────────────────────────────────────
45
+
46
+ describe('LinearDocView', () => {
47
+ it('renders a scrollable container', () => {
48
+ const doc = mkDoc([
49
+ mkBlock({
50
+ id: 'preamble',
51
+ contents: [paragraph(text('Introduction text'))],
52
+ }),
53
+ ]);
54
+ const { container } = render(<LinearDocView doc={doc} />);
55
+ const el = container.querySelector('.squisq-linear');
56
+ expect(el).toBeTruthy();
57
+ expect((el as HTMLElement).style.overflowY).toBe('auto');
58
+ });
59
+
60
+ it('renders preamble content (no heading)', () => {
61
+ const doc = mkDoc([
62
+ mkBlock({
63
+ id: 'preamble',
64
+ contents: [paragraph(text('Preamble body'))],
65
+ }),
66
+ ]);
67
+ const { container } = render(<LinearDocView doc={doc} />);
68
+ // Should render paragraph but no heading
69
+ expect(container.textContent).toContain('Preamble body');
70
+ const headings = container.querySelectorAll('h1, h2, h3');
71
+ expect(headings.length).toBe(0);
72
+ });
73
+
74
+ it('renders non-annotated block with heading + content', () => {
75
+ const doc = mkDoc([
76
+ mkBlock({
77
+ id: 'section-1',
78
+ sourceHeading: mkHeading(2, 'My Section'),
79
+ contents: [paragraph(text('Section body text'))],
80
+ }),
81
+ ]);
82
+ const { container } = render(<LinearDocView doc={doc} />);
83
+ expect(container.querySelector('h2')?.textContent).toBe('My Section');
84
+ expect(container.textContent).toContain('Section body text');
85
+ });
86
+
87
+ it('renders annotated block as SVG card', () => {
88
+ const doc = mkDoc([
89
+ mkBlock({
90
+ id: 'annotated-1',
91
+ template: 'sectionHeader',
92
+ sourceHeading: {
93
+ type: 'heading',
94
+ depth: 2,
95
+ children: [text('Visual Block')],
96
+ templateAnnotation: {
97
+ template: 'sectionHeader',
98
+ },
99
+ },
100
+ contents: [paragraph(text('Body'))],
101
+ }),
102
+ ]);
103
+ const { container } = render(<LinearDocView doc={doc} />);
104
+ // Should have a card wrapper
105
+ const card = container.querySelector('.squisq-linear-card');
106
+ expect(card).toBeTruthy();
107
+ // Should contain an SVG (from BlockRenderer)
108
+ const svg = card?.querySelector('svg');
109
+ expect(svg).toBeTruthy();
110
+ });
111
+
112
+ it('renders children recursively', () => {
113
+ const doc = mkDoc([
114
+ mkBlock({
115
+ id: 'parent',
116
+ sourceHeading: mkHeading(1, 'Parent'),
117
+ contents: [paragraph(text('Parent body'))],
118
+ children: [
119
+ mkBlock({
120
+ id: 'child',
121
+ sourceHeading: mkHeading(2, 'Child'),
122
+ contents: [paragraph(text('Child body'))],
123
+ }),
124
+ ],
125
+ }),
126
+ ]);
127
+ const { container } = render(<LinearDocView doc={doc} />);
128
+ expect(container.querySelector('h1')?.textContent).toBe('Parent');
129
+ expect(container.querySelector('h2')?.textContent).toBe('Child');
130
+ expect(container.textContent).toContain('Parent body');
131
+ expect(container.textContent).toContain('Child body');
132
+ });
133
+
134
+ it('renders multiple top-level blocks', () => {
135
+ const doc = mkDoc([
136
+ mkBlock({
137
+ id: 'b1',
138
+ sourceHeading: mkHeading(1, 'First'),
139
+ contents: [],
140
+ }),
141
+ mkBlock({
142
+ id: 'b2',
143
+ sourceHeading: mkHeading(1, 'Second'),
144
+ contents: [],
145
+ }),
146
+ ]);
147
+ const { container } = render(<LinearDocView doc={doc} />);
148
+ const sections = container.querySelectorAll('.squisq-linear-section');
149
+ expect(sections.length).toBe(2);
150
+ });
151
+
152
+ it('does not render SVG for non-annotated blocks', () => {
153
+ const doc = mkDoc([
154
+ mkBlock({
155
+ id: 'plain',
156
+ sourceHeading: mkHeading(2, 'Plain Section'),
157
+ contents: [paragraph(text('Just text'))],
158
+ }),
159
+ ]);
160
+ const { container } = render(<LinearDocView doc={doc} />);
161
+ expect(container.querySelector('.squisq-linear-card')).toBeNull();
162
+ expect(container.querySelector('svg')).toBeNull();
163
+ });
164
+
165
+ it('applies custom className', () => {
166
+ const doc = mkDoc([mkBlock({ id: 'x', contents: [] })]);
167
+ const { container } = render(<LinearDocView doc={doc} className="my-class" />);
168
+ expect(container.querySelector('.squisq-linear.my-class')).toBeTruthy();
169
+ });
170
+
171
+ it('sets data-block-id on each section', () => {
172
+ const doc = mkDoc([
173
+ mkBlock({ id: 'alpha', contents: [] }),
174
+ mkBlock({ id: 'beta', contents: [] }),
175
+ ]);
176
+ const { container } = render(<LinearDocView doc={doc} />);
177
+ expect(container.querySelector('[data-block-id="alpha"]')).toBeTruthy();
178
+ expect(container.querySelector('[data-block-id="beta"]')).toBeTruthy();
179
+ });
180
+ });
@@ -0,0 +1,234 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { MarkdownRenderer } from '../MarkdownRenderer';
4
+ import type { MarkdownBlockNode, MarkdownInlineNode } from '@bendyline/squisq/markdown';
5
+
6
+ // ── Helpers ────────────────────────────────────────────────────────
7
+
8
+ function text(value: string): MarkdownInlineNode {
9
+ return { type: 'text', value };
10
+ }
11
+
12
+ function paragraph(...children: MarkdownInlineNode[]): MarkdownBlockNode {
13
+ return { type: 'paragraph', children };
14
+ }
15
+
16
+ function heading(
17
+ depth: 1 | 2 | 3 | 4 | 5 | 6,
18
+ ...children: MarkdownInlineNode[]
19
+ ): MarkdownBlockNode {
20
+ return { type: 'heading', depth, children };
21
+ }
22
+
23
+ // ── Tests ──────────────────────────────────────────────────────────
24
+
25
+ describe('MarkdownRenderer', () => {
26
+ it('renders null for empty nodes', () => {
27
+ const { container } = render(<MarkdownRenderer nodes={[]} />);
28
+ expect(container.innerHTML).toBe('');
29
+ });
30
+
31
+ it('renders a paragraph', () => {
32
+ const { container } = render(<MarkdownRenderer nodes={[paragraph(text('Hello world'))]} />);
33
+ const p = container.querySelector('p.squisq-md-p');
34
+ expect(p).toBeTruthy();
35
+ expect(p?.textContent).toBe('Hello world');
36
+ });
37
+
38
+ it('renders headings at correct depth', () => {
39
+ const nodes: MarkdownBlockNode[] = [
40
+ heading(1, text('Title')),
41
+ heading(2, text('Subtitle')),
42
+ heading(3, text('Section')),
43
+ ];
44
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
45
+ expect(container.querySelector('h1')?.textContent).toBe('Title');
46
+ expect(container.querySelector('h2')?.textContent).toBe('Subtitle');
47
+ expect(container.querySelector('h3')?.textContent).toBe('Section');
48
+ });
49
+
50
+ it('renders emphasis and strong inline', () => {
51
+ const nodes: MarkdownBlockNode[] = [
52
+ paragraph(text('normal '), { type: 'emphasis', children: [text('italic')] }, text(' and '), {
53
+ type: 'strong',
54
+ children: [text('bold')],
55
+ }),
56
+ ];
57
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
58
+ expect(container.querySelector('em')?.textContent).toBe('italic');
59
+ expect(container.querySelector('strong')?.textContent).toBe('bold');
60
+ });
61
+
62
+ it('renders inline code', () => {
63
+ const nodes: MarkdownBlockNode[] = [
64
+ paragraph(text('run '), { type: 'inlineCode', value: 'npm install' }),
65
+ ];
66
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
67
+ const code = container.querySelector('code.squisq-md-inline-code');
68
+ expect(code?.textContent).toBe('npm install');
69
+ });
70
+
71
+ it('renders a link with target _blank', () => {
72
+ const nodes: MarkdownBlockNode[] = [
73
+ paragraph({
74
+ type: 'link',
75
+ url: 'https://example.com',
76
+ title: 'Example',
77
+ children: [text('click')],
78
+ }),
79
+ ];
80
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
81
+ const a = container.querySelector('a.squisq-md-link') as HTMLAnchorElement;
82
+ expect(a).toBeTruthy();
83
+ expect(a.href).toContain('example.com');
84
+ expect(a.target).toBe('_blank');
85
+ expect(a.textContent).toBe('click');
86
+ });
87
+
88
+ it('renders an image', () => {
89
+ const nodes: MarkdownBlockNode[] = [
90
+ paragraph({
91
+ type: 'image',
92
+ url: '/cat.jpg',
93
+ alt: 'A cat',
94
+ }),
95
+ ];
96
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
97
+ const img = container.querySelector('img.squisq-md-image') as HTMLImageElement;
98
+ expect(img).toBeTruthy();
99
+ expect(img.alt).toBe('A cat');
100
+ });
101
+
102
+ it('renders an unordered list', () => {
103
+ const nodes: MarkdownBlockNode[] = [
104
+ {
105
+ type: 'list',
106
+ ordered: false,
107
+ children: [
108
+ { type: 'listItem', children: [paragraph(text('Item A'))] },
109
+ { type: 'listItem', children: [paragraph(text('Item B'))] },
110
+ ],
111
+ },
112
+ ];
113
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
114
+ const ul = container.querySelector('ul.squisq-md-ul');
115
+ expect(ul).toBeTruthy();
116
+ const items = ul?.querySelectorAll('li');
117
+ expect(items?.length).toBe(2);
118
+ expect(items?.[0]?.textContent).toBe('Item A');
119
+ });
120
+
121
+ it('renders an ordered list with start number', () => {
122
+ const nodes: MarkdownBlockNode[] = [
123
+ {
124
+ type: 'list',
125
+ ordered: true,
126
+ start: 3,
127
+ children: [{ type: 'listItem', children: [paragraph(text('Third'))] }],
128
+ },
129
+ ];
130
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
131
+ const ol = container.querySelector('ol.squisq-md-ol') as HTMLOListElement;
132
+ expect(ol).toBeTruthy();
133
+ expect(ol.start).toBe(3);
134
+ });
135
+
136
+ it('renders a task list item with checkbox', () => {
137
+ const nodes: MarkdownBlockNode[] = [
138
+ {
139
+ type: 'list',
140
+ ordered: false,
141
+ children: [
142
+ { type: 'listItem', checked: true, children: [paragraph(text('Done'))] },
143
+ { type: 'listItem', checked: false, children: [paragraph(text('Todo'))] },
144
+ ],
145
+ },
146
+ ];
147
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
148
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]');
149
+ expect(checkboxes.length).toBe(2);
150
+ expect((checkboxes[0] as HTMLInputElement).checked).toBe(true);
151
+ expect((checkboxes[1] as HTMLInputElement).checked).toBe(false);
152
+ });
153
+
154
+ it('renders a code block', () => {
155
+ const nodes: MarkdownBlockNode[] = [
156
+ { type: 'code', lang: 'typescript', value: 'const x = 1;' },
157
+ ];
158
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
159
+ const pre = container.querySelector('pre.squisq-md-code-block');
160
+ expect(pre).toBeTruthy();
161
+ const code = pre?.querySelector('code.language-typescript');
162
+ expect(code?.textContent).toBe('const x = 1;');
163
+ });
164
+
165
+ it('renders a blockquote', () => {
166
+ const nodes: MarkdownBlockNode[] = [
167
+ { type: 'blockquote', children: [paragraph(text('Quoted text'))] },
168
+ ];
169
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
170
+ const bq = container.querySelector('blockquote.squisq-md-blockquote');
171
+ expect(bq).toBeTruthy();
172
+ expect(bq?.textContent).toBe('Quoted text');
173
+ });
174
+
175
+ it('renders a thematic break', () => {
176
+ const nodes: MarkdownBlockNode[] = [
177
+ paragraph(text('Before')),
178
+ { type: 'thematicBreak' },
179
+ paragraph(text('After')),
180
+ ];
181
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
182
+ expect(container.querySelector('hr.squisq-md-hr')).toBeTruthy();
183
+ });
184
+
185
+ it('renders a table', () => {
186
+ const nodes: MarkdownBlockNode[] = [
187
+ {
188
+ type: 'table',
189
+ align: ['left', 'right'],
190
+ children: [
191
+ {
192
+ type: 'tableRow',
193
+ children: [
194
+ { type: 'tableCell', isHeader: true, children: [text('Name')] },
195
+ { type: 'tableCell', isHeader: true, children: [text('Value')] },
196
+ ],
197
+ },
198
+ {
199
+ type: 'tableRow',
200
+ children: [
201
+ { type: 'tableCell', children: [text('A')] },
202
+ { type: 'tableCell', children: [text('1')] },
203
+ ],
204
+ },
205
+ ],
206
+ },
207
+ ];
208
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
209
+ const table = container.querySelector('table.squisq-md-table');
210
+ expect(table).toBeTruthy();
211
+ expect(table?.querySelectorAll('th').length).toBe(2);
212
+ expect(table?.querySelectorAll('td').length).toBe(2);
213
+ });
214
+
215
+ it('renders strikethrough', () => {
216
+ const nodes: MarkdownBlockNode[] = [paragraph({ type: 'delete', children: [text('removed')] })];
217
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
218
+ const del = container.querySelector('del.squisq-md-del');
219
+ expect(del?.textContent).toBe('removed');
220
+ });
221
+
222
+ it('renders a hard break', () => {
223
+ const nodes: MarkdownBlockNode[] = [paragraph(text('line1'), { type: 'break' }, text('line2'))];
224
+ const { container } = render(<MarkdownRenderer nodes={nodes} />);
225
+ expect(container.querySelector('br')).toBeTruthy();
226
+ });
227
+
228
+ it('applies custom className', () => {
229
+ const { container } = render(
230
+ <MarkdownRenderer nodes={[paragraph(text('test'))]} className="custom" />,
231
+ );
232
+ expect(container.querySelector('.squisq-md.custom')).toBeTruthy();
233
+ });
234
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as squisqReact from '../index';
3
+
4
+ describe('@bendyline/squisq-react exports', () => {
5
+ it('exports DocPlayer component', () => {
6
+ expect(squisqReact.DocPlayer).toBeDefined();
7
+ expect(typeof squisqReact.DocPlayer).toBe('function');
8
+ });
9
+
10
+ it('exports BlockRenderer component', () => {
11
+ expect(squisqReact.BlockRenderer).toBeDefined();
12
+ expect(typeof squisqReact.BlockRenderer).toBe('function');
13
+ });
14
+
15
+ it('exports all layer components', () => {
16
+ expect(typeof squisqReact.ImageLayer).toBe('function');
17
+ expect(typeof squisqReact.TextLayer).toBe('function');
18
+ expect(typeof squisqReact.ShapeLayer).toBe('function');
19
+ expect(typeof squisqReact.VideoLayer).toBe('function');
20
+ expect(typeof squisqReact.MapLayer).toBe('function');
21
+ });
22
+
23
+ it('exports control components', () => {
24
+ expect(typeof squisqReact.CaptionOverlay).toBe('function');
25
+ expect(typeof squisqReact.DocControlsOverlay).toBe('function');
26
+ expect(typeof squisqReact.DocControlsBottom).toBe('function');
27
+ expect(typeof squisqReact.DocControlsSidebar).toBe('function');
28
+ expect(typeof squisqReact.DocControlsSlideshow).toBe('function');
29
+ expect(typeof squisqReact.DocPlayerWithSidebar).toBe('function');
30
+ expect(typeof squisqReact.DocProgressBar).toBe('function');
31
+ });
32
+
33
+ it('exports MarkdownRenderer and LinearDocView', () => {
34
+ expect(typeof squisqReact.MarkdownRenderer).toBe('function');
35
+ expect(typeof squisqReact.LinearDocView).toBe('function');
36
+ });
37
+
38
+ it('exports hooks', () => {
39
+ expect(typeof squisqReact.useAudioSync).toBe('function');
40
+ expect(typeof squisqReact.useDocPlayback).toBe('function');
41
+ expect(typeof squisqReact.useViewportOrientation).toBe('function');
42
+ });
43
+
44
+ it('exports utility functions', () => {
45
+ expect(typeof squisqReact.getAnimationStyle).toBe('function');
46
+ expect(typeof squisqReact.getTransitionClass).toBe('function');
47
+ });
48
+
49
+ it('exports formatTime utility', () => {
50
+ expect(typeof squisqReact.formatTime).toBe('function');
51
+ expect(squisqReact.formatTime(65)).toBe('1:05');
52
+ expect(squisqReact.formatTime(0)).toBe('0:00');
53
+ expect(squisqReact.formatTime(3661)).toBe('61:01');
54
+ });
55
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * AudioProvider - 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 AudioProvider = AudioState & AudioActions;
51
+
52
+ export interface AudioProviderConfig {
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
+ }
@@ -0,0 +1,81 @@
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
+ // For absolute/http URLs, skip resolution entirely
45
+ const isAbsolute =
46
+ relativePath.startsWith('http') ||
47
+ relativePath.startsWith('/') ||
48
+ relativePath.startsWith('data:') ||
49
+ relativePath.startsWith('blob:');
50
+
51
+ // Memoize fallback to avoid recalculating on every render
52
+ const fallback = useMemo(
53
+ () => (isAbsolute ? relativePath : `${basePath}/${relativePath}`),
54
+ [isAbsolute, relativePath, basePath],
55
+ );
56
+
57
+ // Fast path: no provider or absolute URL — return synchronously, skip effect entirely
58
+ const needsProvider = !isAbsolute && !!provider;
59
+
60
+ const [url, setUrl] = useState(fallback);
61
+
62
+ useEffect(() => {
63
+ if (!needsProvider) {
64
+ setUrl(fallback);
65
+ return;
66
+ }
67
+
68
+ let cancelled = false;
69
+ provider!.resolveUrl(relativePath).then((resolved) => {
70
+ if (!cancelled) setUrl(resolved);
71
+ });
72
+
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, [needsProvider, provider, relativePath, fallback]);
77
+
78
+ // When provider is not needed, return fallback directly to avoid
79
+ // the one-frame delay from the initial useState → useEffect cycle
80
+ return needsProvider ? url : fallback;
81
+ }
@@ -0,0 +1,6 @@
1
+ export { calculateSegmentTiming, findSegmentAtTime } from './AudioProvider';
2
+ export type { AudioState, AudioActions, AudioProvider, AudioProviderConfig } from './AudioProvider';
3
+
4
+ export { useAudioSync } from './useAudioSync';
5
+ export { useDocPlayback } from './useDocPlayback';
6
+ export { useViewportOrientation } from './useViewportOrientation';