@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.
Files changed (50) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +177 -28
  3. package/dist/index.js +1330 -611
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +57 -37
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +28 -0
  11. package/package.json +2 -2
  12. package/src/BlockRenderer.tsx +54 -17
  13. package/src/DocControlsSlideshow.tsx +222 -5
  14. package/src/DocPlayer.tsx +367 -183
  15. package/src/DocPlayerWithSidebar.tsx +4 -0
  16. package/src/DocProgressBar.tsx +40 -1
  17. package/src/LinearDocView.tsx +138 -62
  18. package/src/MarkdownRenderer.tsx +40 -97
  19. package/src/MediaClipLayer.tsx +12 -2
  20. package/src/__tests__/BlockRenderer.test.tsx +138 -8
  21. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  22. package/src/__tests__/DocPlayer.test.tsx +505 -0
  23. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  24. package/src/__tests__/LinearDocView.test.tsx +104 -11
  25. package/src/__tests__/MapLayer.test.tsx +63 -0
  26. package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
  27. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  28. package/src/__tests__/MediaContext.test.tsx +51 -0
  29. package/src/__tests__/PathLayer.test.tsx +12 -1
  30. package/src/__tests__/VideoLayer.test.tsx +94 -0
  31. package/src/__tests__/fillStyle.test.tsx +50 -2
  32. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  33. package/src/__tests__/useAudioSync.test.ts +49 -0
  34. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  35. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  36. package/src/hooks/MediaContext.tsx +12 -3
  37. package/src/hooks/useAudioSync.ts +61 -12
  38. package/src/hooks/useDocPlayback.ts +40 -12
  39. package/src/hooks/useViewportOrientation.ts +2 -4
  40. package/src/index.ts +5 -2
  41. package/src/layers/ImageLayer.tsx +106 -1
  42. package/src/layers/MapLayer.tsx +7 -6
  43. package/src/layers/PathLayer.tsx +20 -11
  44. package/src/layers/ShapeLayer.tsx +33 -9
  45. package/src/layers/TextLayer.tsx +4 -3
  46. package/src/layers/TreeLayer.tsx +167 -0
  47. package/src/layers/VideoLayer.tsx +20 -6
  48. package/src/standalone-entry.tsx +91 -14
  49. package/src/styles/doc-animations.css +36 -0
  50. package/src/types.ts +13 -13
@@ -2,6 +2,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { DocPlayer } from '../DocPlayer';
4
4
  import type { Doc } from '@bendyline/squisq/schemas';
5
+ import type { AudioController } from '../hooks/AudioController';
6
+ import type { SquisqRenderAPI } from '../types';
5
7
 
6
8
  function minimalDoc(): Doc {
7
9
  return {
@@ -22,6 +24,81 @@ function docWithCover(): Doc {
22
24
  };
23
25
  }
24
26
 
27
+ function docWithTransition(): Doc {
28
+ return {
29
+ articleId: 'transition-doc',
30
+ duration: 10,
31
+ blocks: [
32
+ { id: 'first', startTime: 0, duration: 5, audioSegment: 0, layers: [] },
33
+ {
34
+ id: 'second',
35
+ startTime: 5,
36
+ duration: 5,
37
+ audioSegment: 0,
38
+ transition: { type: 'fade', duration: 1 },
39
+ layers: [
40
+ {
41
+ type: 'text',
42
+ id: 'animated-title',
43
+ content: { text: 'Second slide', style: { fontSize: 48, color: '#fff' } },
44
+ position: { x: 100, y: 100 },
45
+ animation: { type: 'fadeIn', duration: 1 },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ audio: { segments: [] },
51
+ };
52
+ }
53
+
54
+ function docWithThreeSlides(): Doc {
55
+ return {
56
+ articleId: 'three-slide-transition-doc',
57
+ duration: 15,
58
+ blocks: [
59
+ { id: 'first', startTime: 0, duration: 5, audioSegment: 0, layers: [] },
60
+ { id: 'second', startTime: 5, duration: 5, audioSegment: 0, layers: [] },
61
+ {
62
+ id: 'third',
63
+ startTime: 10,
64
+ duration: 5,
65
+ audioSegment: 0,
66
+ transition: { type: 'fade', duration: 1 },
67
+ layers: [],
68
+ },
69
+ ],
70
+ audio: { segments: [] },
71
+ };
72
+ }
73
+
74
+ function controller(overrides: Partial<AudioController> = {}): AudioController {
75
+ return {
76
+ currentTime: 0,
77
+ isPlaying: false,
78
+ currentSegment: 0,
79
+ totalDuration: 5,
80
+ isEnded: false,
81
+ isReady: true,
82
+ isAvailable: true,
83
+ play: vi.fn(async () => {}),
84
+ pause: vi.fn(async () => {}),
85
+ toggle: vi.fn(async () => {}),
86
+ seekTo: vi.fn(async () => {}),
87
+ skipToSegment: vi.fn(async () => {}),
88
+ restart: vi.fn(async () => {}),
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ function pointerEvent(type: string, clientX: number): MouseEvent {
94
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX });
95
+ Object.defineProperties(event, {
96
+ pointerId: { value: 1 },
97
+ pointerType: { value: 'touch' },
98
+ });
99
+ return event;
100
+ }
101
+
25
102
  describe('DocPlayer smoke test', () => {
26
103
  beforeAll(() => {
27
104
  vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
@@ -43,6 +120,116 @@ describe('DocPlayer smoke test', () => {
43
120
  expect(container.firstChild).toBeTruthy();
44
121
  });
45
122
 
123
+ it('lists block summaries and jumps to a selected slideshow block', () => {
124
+ const audioController = controller({ totalDuration: 15 });
125
+ render(
126
+ <DocPlayer
127
+ doc={docWithThreeSlides()}
128
+ audioController={audioController}
129
+ displayMode="slideshow"
130
+ />,
131
+ );
132
+
133
+ fireEvent.click(screen.getByTestId('slide-counter'));
134
+ expect(screen.getByTestId('slide-picker-item-0').textContent).toContain('1First');
135
+ expect(screen.getByTestId('slide-picker-item-2').textContent).toContain('3Third');
136
+
137
+ fireEvent.click(screen.getByTestId('slide-picker-item-2'));
138
+ expect(audioController.seekTo).toHaveBeenCalledWith(10);
139
+ expect(screen.queryByTestId('slide-picker')).toBeNull();
140
+ });
141
+
142
+ it('uses global left and right arrows to change slides without focusing the player', () => {
143
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
144
+ render(
145
+ <DocPlayer
146
+ doc={docWithThreeSlides()}
147
+ audioController={audioController}
148
+ displayMode="slideshow"
149
+ globalKeyboardShortcuts
150
+ />,
151
+ );
152
+
153
+ fireEvent.keyDown(document, { key: 'ArrowLeft' });
154
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
155
+
156
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(1, 0);
157
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(2, 10);
158
+ });
159
+
160
+ it('globally opens the slide picker with ArrowUp instead of changing slides', () => {
161
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
162
+ render(
163
+ <DocPlayer
164
+ doc={docWithThreeSlides()}
165
+ audioController={audioController}
166
+ displayMode="slideshow"
167
+ globalKeyboardShortcuts
168
+ />,
169
+ );
170
+
171
+ fireEvent.keyDown(document, { key: 'ArrowUp' });
172
+
173
+ expect(screen.getByTestId('slide-picker')).toBeTruthy();
174
+ expect(audioController.seekTo).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('globally seeks video with left and right arrows', () => {
178
+ const audioController = controller({ currentTime: 20, totalDuration: 60 });
179
+ render(
180
+ <DocPlayer
181
+ doc={docWithThreeSlides()}
182
+ audioController={audioController}
183
+ globalKeyboardShortcuts
184
+ />,
185
+ );
186
+
187
+ fireEvent.keyDown(document, { key: 'ArrowRight' });
188
+ fireEvent.keyDown(document, { key: 'ArrowLeft' });
189
+
190
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(1, 30);
191
+ expect(audioController.seekTo).toHaveBeenNthCalledWith(2, 10);
192
+ });
193
+
194
+ it('handles a focused-player arrow only once when global shortcuts are enabled', () => {
195
+ const audioController = controller({ currentTime: 5, totalDuration: 15 });
196
+ const { container } = render(
197
+ <DocPlayer
198
+ doc={docWithThreeSlides()}
199
+ audioController={audioController}
200
+ displayMode="slideshow"
201
+ globalKeyboardShortcuts
202
+ />,
203
+ );
204
+
205
+ fireEvent.keyDown(container.querySelector<HTMLElement>('.doc-player')!, {
206
+ key: 'ArrowRight',
207
+ });
208
+
209
+ expect(audioController.seekTo).toHaveBeenCalledTimes(1);
210
+ expect(audioController.seekTo).toHaveBeenCalledWith(10);
211
+ });
212
+
213
+ it('does not globally intercept arrows from editable fields', () => {
214
+ const audioController = controller({ currentTime: 20, totalDuration: 60 });
215
+ render(
216
+ <>
217
+ <input aria-label="Editable field" />
218
+ <DocPlayer
219
+ doc={docWithThreeSlides()}
220
+ audioController={audioController}
221
+ globalKeyboardShortcuts
222
+ />
223
+ </>,
224
+ );
225
+
226
+ fireEvent.keyDown(screen.getByRole('textbox', { name: 'Editable field' }), {
227
+ key: 'ArrowRight',
228
+ });
229
+
230
+ expect(audioController.seekTo).not.toHaveBeenCalled();
231
+ });
232
+
46
233
  it('shows the managed cover as the first slideshow entry by default', async () => {
47
234
  const { container } = render(
48
235
  <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
@@ -64,6 +251,133 @@ describe('DocPlayer smoke test', () => {
64
251
  expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
65
252
  });
66
253
 
254
+ it('renders the managed cover without layer animation classes when animations are disabled', async () => {
255
+ const { container } = render(
256
+ <DocPlayer
257
+ doc={docWithCover()}
258
+ basePath="/test"
259
+ displayMode="slideshow"
260
+ animationsEnabled={false}
261
+ />,
262
+ );
263
+
264
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
265
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
266
+ });
267
+
268
+ it('uses static cuts without an outgoing slide while preserving timeline changes', () => {
269
+ const doc = docWithTransition();
270
+ const onRenderAPIReady = vi.fn();
271
+ const { container, rerender } = render(
272
+ <DocPlayer
273
+ doc={doc}
274
+ audioController={controller({ currentTime: 0, totalDuration: 10 })}
275
+ showControls={false}
276
+ renderMode
277
+ animationsEnabled={false}
278
+ onRenderAPIReady={onRenderAPIReady}
279
+ />,
280
+ );
281
+
282
+ rerender(
283
+ <DocPlayer
284
+ doc={doc}
285
+ audioController={controller({ currentTime: 5.1, totalDuration: 10 })}
286
+ showControls={false}
287
+ renderMode
288
+ animationsEnabled={false}
289
+ onRenderAPIReady={onRenderAPIReady}
290
+ />,
291
+ );
292
+
293
+ expect(container.querySelector('.doc-player__block--previous')).toBeNull();
294
+ expect(
295
+ container.querySelector('.doc-player__block--active [data-block-id="second"]'),
296
+ ).toBeTruthy();
297
+ expect(container.querySelector('.transition-fade-enter')).toBeNull();
298
+ expect(container.querySelector('[class*="anim-"]')).toBeNull();
299
+ });
300
+
301
+ it('keeps a swiped-to slide entrance transition without remounting the swiped-away slide', async () => {
302
+ const doc = docWithThreeSlides();
303
+ const secondSlideController = controller({ currentTime: 5, totalDuration: 15 });
304
+ const { container, rerender } = render(
305
+ <DocPlayer
306
+ doc={doc}
307
+ audioController={secondSlideController}
308
+ displayMode="slideshow"
309
+ showControls={false}
310
+ />,
311
+ );
312
+ const player = container.querySelector<HTMLElement>('.doc-player');
313
+ expect(player).not.toBeNull();
314
+ vi.spyOn(player!, 'getBoundingClientRect').mockReturnValue({
315
+ width: 1000,
316
+ height: 600,
317
+ x: 0,
318
+ y: 0,
319
+ top: 0,
320
+ right: 1000,
321
+ bottom: 600,
322
+ left: 0,
323
+ toJSON: () => ({}),
324
+ });
325
+
326
+ fireEvent(player!, pointerEvent('pointerdown', 800));
327
+ expect(player!.classList.contains('doc-player--grabbing')).toBe(true);
328
+ fireEvent(window, pointerEvent('pointermove', 400));
329
+ expect(
330
+ container.querySelector<HTMLElement>('.doc-player__block--active')?.style.transform,
331
+ ).toBe('translateX(-400px)');
332
+ fireEvent(window, pointerEvent('pointerup', 400));
333
+
334
+ await waitFor(() => expect(secondSlideController.seekTo).toHaveBeenCalledWith(10), {
335
+ timeout: 1000,
336
+ });
337
+ rerender(
338
+ <DocPlayer
339
+ doc={doc}
340
+ audioController={controller({ currentTime: 10, totalDuration: 15 })}
341
+ displayMode="slideshow"
342
+ showControls={false}
343
+ />,
344
+ );
345
+
346
+ expect(
347
+ container.querySelector(
348
+ '.doc-player__block--active [data-block-id="third"].transition-fade-enter',
349
+ ),
350
+ ).toBeTruthy();
351
+ expect(container.querySelector('.doc-player__block--previous')).toBeNull();
352
+ });
353
+
354
+ it('keeps outgoing slide context for ordinary slideshow button navigation', () => {
355
+ const doc = docWithThreeSlides();
356
+ const secondSlideController = controller({ currentTime: 5, totalDuration: 15 });
357
+ const { container, rerender } = render(
358
+ <DocPlayer doc={doc} audioController={secondSlideController} displayMode="slideshow" />,
359
+ );
360
+
361
+ fireEvent.click(screen.getByTestId('slide-next'));
362
+ expect(secondSlideController.seekTo).toHaveBeenCalledWith(10);
363
+ rerender(
364
+ <DocPlayer
365
+ doc={doc}
366
+ audioController={controller({ currentTime: 10, totalDuration: 15 })}
367
+ displayMode="slideshow"
368
+ />,
369
+ );
370
+
371
+ expect(
372
+ container.querySelector('.doc-player__block--previous [data-block-id="second"]'),
373
+ ).toBeTruthy();
374
+ expect(
375
+ container.querySelector(
376
+ '.doc-player__block--active [data-block-id="third"].transition-fade-enter',
377
+ ),
378
+ ).toBeTruthy();
379
+ });
380
+
67
381
  it('advances from slideshow cover to slide 1', async () => {
68
382
  const { container } = render(
69
383
  <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
@@ -74,6 +388,59 @@ describe('DocPlayer smoke test', () => {
74
388
  expect(container.textContent).not.toContain('Managed Cover');
75
389
  });
76
390
 
391
+ it('accepts controlled cover visibility for a synchronized audience mirror', async () => {
392
+ const states: Array<{ isCoverVisible?: boolean }> = [];
393
+ const { container, rerender } = render(
394
+ <DocPlayer
395
+ doc={docWithCover()}
396
+ audioController={controller()}
397
+ displayMode="slideshow"
398
+ showControls={false}
399
+ coverVisible={false}
400
+ onPlaybackStateChange={(state) => states.push(state)}
401
+ />,
402
+ );
403
+
404
+ await waitFor(() => expect(container.textContent).not.toContain('Managed Cover'));
405
+ expect(states[states.length - 1]?.isCoverVisible).toBe(false);
406
+ expect(screen.queryByTestId('slide-counter')).toBeNull();
407
+
408
+ rerender(
409
+ <DocPlayer
410
+ doc={docWithCover()}
411
+ audioController={controller()}
412
+ displayMode="slideshow"
413
+ showControls={false}
414
+ coverVisible
415
+ onPlaybackStateChange={(state) => states.push(state)}
416
+ />,
417
+ );
418
+
419
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
420
+ expect(states[states.length - 1]?.isCoverVisible).toBe(true);
421
+ });
422
+
423
+ it('reinitializes cover state when a new document replaces the old one', async () => {
424
+ const { container, rerender } = render(
425
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
426
+ );
427
+ fireEvent.click(screen.getByTestId('slide-next'));
428
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
429
+ rerender(
430
+ <DocPlayer
431
+ doc={{
432
+ ...docWithCover(),
433
+ articleId: 'replacement',
434
+ startBlock: { title: 'Replacement Cover' },
435
+ }}
436
+ basePath="/test"
437
+ displayMode="slideshow"
438
+ />,
439
+ );
440
+ await waitFor(() => expect(container.textContent).toContain('Replacement Cover'));
441
+ expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
442
+ });
443
+
77
444
  it('renders without crashing in linear mode', () => {
78
445
  const { container } = render(
79
446
  <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
@@ -97,6 +464,144 @@ describe('DocPlayer smoke test', () => {
97
464
  expect(typeof controls!.play).toBe('function');
98
465
  expect(typeof controls!.pause).toBe('function');
99
466
  });
467
+
468
+ it('handles keyboard shortcuts only in the focused player instance', () => {
469
+ const first = controller();
470
+ const second = controller();
471
+ const { container } = render(
472
+ <>
473
+ <DocPlayer doc={minimalDoc()} audioController={first} showControls={false} />
474
+ <DocPlayer doc={minimalDoc()} audioController={second} showControls={false} />
475
+ </>,
476
+ );
477
+ const players = container.querySelectorAll<HTMLElement>('.doc-player');
478
+ players[1].focus();
479
+ fireEvent.keyDown(players[1], { key: ' ' });
480
+ expect(second.toggle).toHaveBeenCalledOnce();
481
+ expect(first.toggle).not.toHaveBeenCalled();
482
+
483
+ const editable = document.createElement('div');
484
+ editable.setAttribute('contenteditable', 'true');
485
+ players[1].append(editable);
486
+ fireEvent.keyDown(editable, { key: ' ' });
487
+ expect(second.toggle).toHaveBeenCalledOnce();
488
+ });
489
+
490
+ it('keeps render APIs scoped to their player callbacks during sibling cleanup', () => {
491
+ const firstEvents: Array<SquisqRenderAPI | null> = [];
492
+ const secondEvents: Array<SquisqRenderAPI | null> = [];
493
+ const one = render(
494
+ <DocPlayer
495
+ doc={minimalDoc()}
496
+ renderMode
497
+ audioController={controller()}
498
+ onRenderAPIReady={(api) => firstEvents.push(api)}
499
+ />,
500
+ );
501
+ render(
502
+ <DocPlayer
503
+ doc={minimalDoc()}
504
+ renderMode
505
+ audioController={controller()}
506
+ onRenderAPIReady={(api) => secondEvents.push(api)}
507
+ />,
508
+ );
509
+ const secondAPI = secondEvents[secondEvents.length - 1];
510
+ expect(firstEvents[firstEvents.length - 1]).not.toBeNull();
511
+ expect(secondAPI).not.toBeNull();
512
+
513
+ one.unmount();
514
+
515
+ expect(firstEvents[firstEvents.length - 1]).toBeNull();
516
+ expect(secondEvents[secondEvents.length - 1]).toBe(secondAPI);
517
+ expect(typeof secondAPI?.seekTo).toBe('function');
518
+ expect('seekTo' in window).toBe(false);
519
+ expect('getDuration' in window).toBe(false);
520
+ expect('squisqActivePlayerId' in window).toBe(false);
521
+ expect('squisqPlayers' in window).toBe(false);
522
+ });
523
+
524
+ it('delivers and cleans up an instance render API through the callback', () => {
525
+ const observed: Array<SquisqRenderAPI | null> = [];
526
+ const { unmount } = render(
527
+ <DocPlayer
528
+ doc={minimalDoc()}
529
+ renderMode
530
+ audioController={controller()}
531
+ onRenderAPIReady={(api) => observed.push(api)}
532
+ />,
533
+ );
534
+
535
+ expect(observed[observed.length - 1]?.getDuration()).toBeGreaterThan(0);
536
+ unmount();
537
+ expect(observed[observed.length - 1]).toBeNull();
538
+ });
539
+
540
+ it('keeps the render API identity stable while dispatching to the latest controller', async () => {
541
+ const firstController = controller({ currentSegment: 0 });
542
+ const secondController = controller({ currentSegment: 1 });
543
+ const observed: Array<SquisqRenderAPI | null> = [];
544
+ const onRenderAPIReady = (api: SquisqRenderAPI | null) => observed.push(api);
545
+ const { rerender } = render(
546
+ <DocPlayer
547
+ doc={minimalDoc()}
548
+ renderMode
549
+ audioController={firstController}
550
+ onRenderAPIReady={onRenderAPIReady}
551
+ />,
552
+ );
553
+ const api = observed[observed.length - 1];
554
+ expect(api).not.toBeNull();
555
+
556
+ rerender(
557
+ <DocPlayer
558
+ doc={minimalDoc()}
559
+ renderMode
560
+ audioController={secondController}
561
+ onRenderAPIReady={onRenderAPIReady}
562
+ />,
563
+ );
564
+
565
+ expect(observed[observed.length - 1]).toBe(api);
566
+ await api!.seekTo(3.25);
567
+ expect(secondController.seekTo).toHaveBeenCalledWith(3.25);
568
+ expect(firstController.seekTo).not.toHaveBeenCalled();
569
+ });
570
+
571
+ it('refreshes instance render metadata when the doc prop changes', () => {
572
+ const firstDoc: Doc = { ...minimalDoc(), captions: { version: 1, phrases: [] } };
573
+ const observed: Array<SquisqRenderAPI | null> = [];
574
+ const onRenderAPIReady = (next: SquisqRenderAPI | null) => {
575
+ observed.push(next);
576
+ };
577
+ const { container, rerender } = render(
578
+ <DocPlayer
579
+ doc={firstDoc}
580
+ renderMode
581
+ audioController={controller()}
582
+ onRenderAPIReady={onRenderAPIReady}
583
+ />,
584
+ );
585
+ expect(container.querySelector('.doc-player')).toBeTruthy();
586
+ const nextDoc: Doc = {
587
+ ...minimalDoc(),
588
+ captions: {
589
+ version: 1,
590
+ phrases: [{ text: 'new caption', startTime: 0, endTime: 1, audioSegment: 0 }],
591
+ },
592
+ };
593
+ rerender(
594
+ <DocPlayer
595
+ doc={nextDoc}
596
+ renderMode
597
+ audioController={controller()}
598
+ onRenderAPIReady={onRenderAPIReady}
599
+ />,
600
+ );
601
+ expect(observed[observed.length - 1]?.getCaptions()).toEqual([
602
+ { text: 'new caption', startTime: 0, endTime: 1 },
603
+ ]);
604
+ });
100
605
  });
101
606
 
102
607
  describe('DocPlayer front door (doc / markdown resolution)', () => {
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
3
  import { DocProgressBar } from '../DocProgressBar';
4
4
  import type { PlaybackState, PlaybackActions } from '../types';
5
5
 
@@ -73,4 +73,30 @@ describe('DocProgressBar fill', () => {
73
73
  );
74
74
  expect(fillWidth(container)).toBe('100%');
75
75
  });
76
+
77
+ it('exposes keyboard seeking and accessible marker buttons', () => {
78
+ const seekTo = vi.fn();
79
+ render(
80
+ <DocProgressBar
81
+ state={makeState({ currentTime: 12, totalDuration: 23 })}
82
+ actions={{ ...actions, seekTo }}
83
+ blockMarkers={[
84
+ {
85
+ block: { id: 'b', startTime: 8, duration: 5, audioSegment: 0, layers: [] },
86
+ index: 0,
87
+ position: 35,
88
+ title: 'Chapter two',
89
+ isSectionStart: false,
90
+ },
91
+ ]}
92
+ expandedBlocks={[]}
93
+ />,
94
+ );
95
+ const slider = screen.getByRole('slider', { name: 'Playback position' });
96
+ expect(slider.getAttribute('aria-valuenow')).toBe('12');
97
+ fireEvent.keyDown(slider, { key: 'ArrowRight' });
98
+ expect(seekTo).toHaveBeenCalledWith(17);
99
+ fireEvent.click(screen.getByRole('button', { name: 'Seek to Chapter two' }));
100
+ expect(seekTo).toHaveBeenLastCalledWith(8);
101
+ });
76
102
  });