@bendyline/squisq-react 1.4.1 → 1.4.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.
- package/dist/index.d.ts +11 -0
- package/dist/index.js +85 -17
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.global.js +34 -34
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +2 -2
- package/src/DocControlsSlideshow.tsx +13 -2
- package/src/DocPlayer.tsx +98 -15
- package/src/__tests__/DocPlayer.test.tsx +51 -2
- package/src/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "React component library for doc playback, block rendering, and media layers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bendyline/squisq": "1.5.
|
|
56
|
+
"@bendyline/squisq": "1.5.2"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/react": "18.3.28",
|
|
@@ -19,9 +19,20 @@ interface DocControlsSlideshowProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowProps) {
|
|
22
|
-
const {
|
|
22
|
+
const {
|
|
23
|
+
currentBlockIndex,
|
|
24
|
+
currentSlideLabel,
|
|
25
|
+
currentSlideNumber,
|
|
26
|
+
totalBlocks,
|
|
27
|
+
totalSlideNumber,
|
|
28
|
+
} = state;
|
|
23
29
|
const isFirst = currentBlockIndex <= 0;
|
|
24
30
|
const isLast = currentBlockIndex >= totalBlocks - 1;
|
|
31
|
+
const counterText =
|
|
32
|
+
totalBlocks > 0
|
|
33
|
+
? (currentSlideLabel ??
|
|
34
|
+
`${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}`)
|
|
35
|
+
: '—';
|
|
25
36
|
|
|
26
37
|
return (
|
|
27
38
|
<div
|
|
@@ -91,7 +102,7 @@ export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowPr
|
|
|
91
102
|
letterSpacing: '0.02em',
|
|
92
103
|
}}
|
|
93
104
|
>
|
|
94
|
-
{
|
|
105
|
+
{counterText}
|
|
95
106
|
</span>
|
|
96
107
|
|
|
97
108
|
{/* Next button */}
|
package/src/DocPlayer.tsx
CHANGED
|
@@ -199,6 +199,11 @@ interface DocPlayerProps {
|
|
|
199
199
|
* template-annotated sections as inline SVG cards. No audio, no timeline.
|
|
200
200
|
*/
|
|
201
201
|
displayMode?: DisplayMode;
|
|
202
|
+
/**
|
|
203
|
+
* Whether to synthesize and show the managed cover slide from
|
|
204
|
+
* `doc.startBlock`. Defaults to true for existing documents.
|
|
205
|
+
*/
|
|
206
|
+
showCoverSlide?: boolean;
|
|
202
207
|
/** Caption display style (default: 'standard').
|
|
203
208
|
* 'social' shows large centered words with the active word highlighted. */
|
|
204
209
|
captionStyle?: CaptionStyle;
|
|
@@ -274,6 +279,7 @@ function DocPlayerContent({
|
|
|
274
279
|
onBlockMarkers,
|
|
275
280
|
forceViewport,
|
|
276
281
|
displayMode = 'video',
|
|
282
|
+
showCoverSlide = true,
|
|
277
283
|
theme,
|
|
278
284
|
surface,
|
|
279
285
|
captionStyle = 'standard',
|
|
@@ -403,6 +409,7 @@ function DocPlayerContent({
|
|
|
403
409
|
// Expand cover block (startBlock) if present - uses active viewport
|
|
404
410
|
const coverBlock = useMemo((): Block | null => {
|
|
405
411
|
const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
|
|
412
|
+
if (!showCoverSlide) return null;
|
|
406
413
|
if (!startBlockConfig) return null;
|
|
407
414
|
|
|
408
415
|
const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
|
|
@@ -415,7 +422,24 @@ function DocPlayerContent({
|
|
|
415
422
|
audioSegment: -1,
|
|
416
423
|
layers,
|
|
417
424
|
};
|
|
418
|
-
}, [doc.startBlock, activeViewport, effectiveTheme]);
|
|
425
|
+
}, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
|
|
426
|
+
|
|
427
|
+
// Slideshow mode treats the managed cover as a static slide before block 1.
|
|
428
|
+
// It has no timeline startTime, so keep its visibility separate from audio.
|
|
429
|
+
const hasManagedCover = !!coverBlock;
|
|
430
|
+
const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
|
|
431
|
+
const slideshowCoverInitKeyRef = useRef('');
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
|
|
434
|
+
if (slideshowCoverInitKeyRef.current === initKey) return;
|
|
435
|
+
slideshowCoverInitKeyRef.current = initKey;
|
|
436
|
+
if (isSlideshowMode && hasManagedCover && !renderMode) {
|
|
437
|
+
setSlideshowCoverVisible(true);
|
|
438
|
+
pause();
|
|
439
|
+
} else {
|
|
440
|
+
setSlideshowCoverVisible(false);
|
|
441
|
+
}
|
|
442
|
+
}, [isSlideshowMode, hasManagedCover, renderMode, pause]);
|
|
419
443
|
|
|
420
444
|
// Render-mode cover block control: allows Playwright to force-show the cover block
|
|
421
445
|
const [coverForced, setCoverForced] = useState(false);
|
|
@@ -431,6 +455,7 @@ function DocPlayerContent({
|
|
|
431
455
|
// Track when cover is showing at rest (before play)
|
|
432
456
|
const atRest = !!(
|
|
433
457
|
coverBlock &&
|
|
458
|
+
!isSlideshowMode &&
|
|
434
459
|
!isPlaying &&
|
|
435
460
|
currentTime === 0 &&
|
|
436
461
|
!hasPlayedOnce.current &&
|
|
@@ -440,7 +465,7 @@ function DocPlayerContent({
|
|
|
440
465
|
if (atRest) coverWasShowing.current = true;
|
|
441
466
|
|
|
442
467
|
useEffect(() => {
|
|
443
|
-
if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
|
|
468
|
+
if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
|
|
444
469
|
coverWasShowing.current = false;
|
|
445
470
|
hasPlayedOnce.current = true;
|
|
446
471
|
setCoverGraceActive(true);
|
|
@@ -450,7 +475,7 @@ function DocPlayerContent({
|
|
|
450
475
|
// re-run (coverWasShowing.current is now false).
|
|
451
476
|
coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
|
|
452
477
|
}
|
|
453
|
-
}, [isPlaying, coverBlock, renderMode]);
|
|
478
|
+
}, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
|
|
454
479
|
|
|
455
480
|
// Always clear the grace timer on unmount
|
|
456
481
|
useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
|
|
@@ -458,14 +483,31 @@ function DocPlayerContent({
|
|
|
458
483
|
// Determine if we should show the cover block
|
|
459
484
|
// Show cover when: has cover block, not playing, at time 0, not in render mode
|
|
460
485
|
// OR during the grace period after first play, OR when coverForced (render mode)
|
|
461
|
-
|
|
462
|
-
const showCoverBlock =
|
|
486
|
+
const showVideoCoverBlock =
|
|
463
487
|
!isSlideshowMode &&
|
|
464
488
|
!isLinearMode &&
|
|
465
|
-
coverBlock &&
|
|
489
|
+
!!coverBlock &&
|
|
466
490
|
(coverForced ||
|
|
467
491
|
coverGraceActive ||
|
|
468
492
|
(!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
|
|
493
|
+
const showSlideshowCover = !!(
|
|
494
|
+
isSlideshowMode &&
|
|
495
|
+
!isLinearMode &&
|
|
496
|
+
!renderMode &&
|
|
497
|
+
coverBlock &&
|
|
498
|
+
slideshowCoverVisible
|
|
499
|
+
);
|
|
500
|
+
const showCoverBlock = showVideoCoverBlock || showSlideshowCover;
|
|
501
|
+
|
|
502
|
+
const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
|
|
503
|
+
const slideshowSlideIndex = slideshowHasCover
|
|
504
|
+
? slideshowCoverVisible
|
|
505
|
+
? 0
|
|
506
|
+
: currentBlockIndex + 1
|
|
507
|
+
: currentBlockIndex;
|
|
508
|
+
const slideshowTotalSlides = slideshowHasCover
|
|
509
|
+
? expandedBlocks.length + 1
|
|
510
|
+
: expandedBlocks.length;
|
|
469
511
|
|
|
470
512
|
// Auto-play if enabled (wait for audio to be ready)
|
|
471
513
|
// Use a ref to track if we've already auto-played to avoid repeating on every render
|
|
@@ -714,8 +756,8 @@ function DocPlayerContent({
|
|
|
714
756
|
isPlaying,
|
|
715
757
|
currentTime,
|
|
716
758
|
totalDuration,
|
|
717
|
-
currentBlockIndex,
|
|
718
|
-
totalBlocks:
|
|
759
|
+
currentBlockIndex: slideshowSlideIndex,
|
|
760
|
+
totalBlocks: slideshowTotalSlides,
|
|
719
761
|
docProgress,
|
|
720
762
|
hasCaptions: !!hasCaptions,
|
|
721
763
|
captionsEnabled,
|
|
@@ -724,15 +766,19 @@ function DocPlayerContent({
|
|
|
724
766
|
currentSegmentIndex: currentSegment,
|
|
725
767
|
currentSegmentName:
|
|
726
768
|
segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
|
|
727
|
-
currentBlock: currentBlock ?? null,
|
|
769
|
+
currentBlock: showSlideshowCover ? coverBlock : (currentBlock ?? null),
|
|
770
|
+
currentSlideLabel: showSlideshowCover ? 'Cover' : undefined,
|
|
771
|
+
currentSlideNumber:
|
|
772
|
+
slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : undefined,
|
|
773
|
+
totalSlideNumber: slideshowHasCover ? expandedBlocks.length : undefined,
|
|
728
774
|
}),
|
|
729
775
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
|
|
730
776
|
[
|
|
731
777
|
isPlaying,
|
|
732
778
|
currentTime,
|
|
733
779
|
totalDuration,
|
|
734
|
-
|
|
735
|
-
|
|
780
|
+
slideshowSlideIndex,
|
|
781
|
+
slideshowTotalSlides,
|
|
736
782
|
docProgress,
|
|
737
783
|
hasCaptions,
|
|
738
784
|
captionsEnabled,
|
|
@@ -741,6 +787,11 @@ function DocPlayerContent({
|
|
|
741
787
|
currentSegment,
|
|
742
788
|
segmentTitleMap,
|
|
743
789
|
currentBlock,
|
|
790
|
+
currentBlockIndex,
|
|
791
|
+
showSlideshowCover,
|
|
792
|
+
coverBlock,
|
|
793
|
+
slideshowHasCover,
|
|
794
|
+
expandedBlocks.length,
|
|
744
795
|
],
|
|
745
796
|
);
|
|
746
797
|
|
|
@@ -762,24 +813,56 @@ function DocPlayerContent({
|
|
|
762
813
|
const slideNavActions: SlideNavActions = useMemo(
|
|
763
814
|
() => ({
|
|
764
815
|
nextSlide: () => {
|
|
816
|
+
if (slideshowHasCover && slideshowCoverVisible) {
|
|
817
|
+
const target = expandedBlocks[0];
|
|
818
|
+
if (target) {
|
|
819
|
+
setSlideshowCoverVisible(false);
|
|
820
|
+
seekTo(target.startTime);
|
|
821
|
+
pause();
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
765
825
|
if (currentBlockIndex < expandedBlocks.length - 1) {
|
|
766
826
|
const target = expandedBlocks[currentBlockIndex + 1];
|
|
767
827
|
if (target) {
|
|
828
|
+
setSlideshowCoverVisible(false);
|
|
768
829
|
seekTo(target.startTime);
|
|
769
830
|
pause();
|
|
770
831
|
}
|
|
771
832
|
}
|
|
772
833
|
},
|
|
773
834
|
prevSlide: () => {
|
|
835
|
+
if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
|
|
836
|
+
setSlideshowCoverVisible(true);
|
|
837
|
+
seekTo(0);
|
|
838
|
+
pause();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
774
841
|
if (currentBlockIndex > 0) {
|
|
775
842
|
const target = expandedBlocks[currentBlockIndex - 1];
|
|
776
843
|
if (target) {
|
|
844
|
+
setSlideshowCoverVisible(false);
|
|
777
845
|
seekTo(target.startTime);
|
|
778
846
|
pause();
|
|
779
847
|
}
|
|
780
848
|
}
|
|
781
849
|
},
|
|
782
850
|
goToSlide: (index: number) => {
|
|
851
|
+
if (slideshowHasCover) {
|
|
852
|
+
if (index === 0) {
|
|
853
|
+
setSlideshowCoverVisible(true);
|
|
854
|
+
seekTo(0);
|
|
855
|
+
pause();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const target = expandedBlocks[index - 1];
|
|
859
|
+
if (target) {
|
|
860
|
+
setSlideshowCoverVisible(false);
|
|
861
|
+
seekTo(target.startTime);
|
|
862
|
+
pause();
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
783
866
|
if (index >= 0 && index < expandedBlocks.length) {
|
|
784
867
|
const target = expandedBlocks[index];
|
|
785
868
|
if (target) {
|
|
@@ -789,7 +872,7 @@ function DocPlayerContent({
|
|
|
789
872
|
}
|
|
790
873
|
},
|
|
791
874
|
}),
|
|
792
|
-
[currentBlockIndex, expandedBlocks, seekTo, pause],
|
|
875
|
+
[currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible],
|
|
793
876
|
);
|
|
794
877
|
|
|
795
878
|
// Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
|
|
@@ -798,8 +881,8 @@ function DocPlayerContent({
|
|
|
798
881
|
const swipe = useSlideSwipe({
|
|
799
882
|
enabled: swipeEnabled,
|
|
800
883
|
containerRef,
|
|
801
|
-
canGoNext:
|
|
802
|
-
canGoPrev:
|
|
884
|
+
canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
|
|
885
|
+
canGoPrev: slideshowSlideIndex > 0,
|
|
803
886
|
onNext: slideNavActions.nextSlide,
|
|
804
887
|
onPrev: slideNavActions.prevSlide,
|
|
805
888
|
});
|
|
@@ -873,7 +956,7 @@ function DocPlayerContent({
|
|
|
873
956
|
}, [blockMarkers, onBlockMarkers]);
|
|
874
957
|
|
|
875
958
|
// Keep expandedBlocks length in a ref so keyboard handler stays stable
|
|
876
|
-
expandedBlocksLenRef.current = expandedBlocks.length;
|
|
959
|
+
expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
|
|
877
960
|
|
|
878
961
|
// Handle keyboard controls — uses refs for frequently-changing values
|
|
879
962
|
// (currentTime, totalDuration, expandedBlocks.length) to avoid
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it,
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
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
5
|
|
|
@@ -12,7 +12,25 @@ function minimalDoc(): Doc {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function docWithCover(): Doc {
|
|
16
|
+
return {
|
|
17
|
+
...minimalDoc(),
|
|
18
|
+
startBlock: {
|
|
19
|
+
title: 'Managed Cover',
|
|
20
|
+
subtitle: 'Generated by Squisq',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
describe('DocPlayer smoke test', () => {
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
16
34
|
it('renders without crashing in video mode (default)', () => {
|
|
17
35
|
const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
|
|
18
36
|
expect(container.firstChild).toBeTruthy();
|
|
@@ -25,6 +43,37 @@ describe('DocPlayer smoke test', () => {
|
|
|
25
43
|
expect(container.firstChild).toBeTruthy();
|
|
26
44
|
});
|
|
27
45
|
|
|
46
|
+
it('shows the managed cover as the first slideshow entry by default', async () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
|
|
49
|
+
);
|
|
50
|
+
await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
|
|
51
|
+
expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('can suppress the managed cover slide', () => {
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<DocPlayer
|
|
57
|
+
doc={docWithCover()}
|
|
58
|
+
basePath="/test"
|
|
59
|
+
displayMode="slideshow"
|
|
60
|
+
showCoverSlide={false}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
expect(container.textContent).not.toContain('Managed Cover');
|
|
64
|
+
expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('advances from slideshow cover to slide 1', async () => {
|
|
68
|
+
const { container } = render(
|
|
69
|
+
<DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
|
|
70
|
+
);
|
|
71
|
+
await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('Cover'));
|
|
72
|
+
fireEvent.click(screen.getByTestId('slide-next'));
|
|
73
|
+
await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
|
|
74
|
+
expect(container.textContent).not.toContain('Managed Cover');
|
|
75
|
+
});
|
|
76
|
+
|
|
28
77
|
it('renders without crashing in linear mode', () => {
|
|
29
78
|
const { container } = render(
|
|
30
79
|
<DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
|
package/src/types.ts
CHANGED
|
@@ -82,6 +82,12 @@ export interface PlaybackState {
|
|
|
82
82
|
currentSegmentName: string | null;
|
|
83
83
|
/** Current block data (for extracting image info, etc.) */
|
|
84
84
|
currentBlock: Block | null;
|
|
85
|
+
/** Optional display label for non-block slides, e.g. the managed cover. */
|
|
86
|
+
currentSlideLabel?: string;
|
|
87
|
+
/** Optional human-facing slide number, separate from internal nav index. */
|
|
88
|
+
currentSlideNumber?: number;
|
|
89
|
+
/** Optional human-facing slide total, separate from internal nav total. */
|
|
90
|
+
totalSlideNumber?: number;
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
/** Playback actions exposed to external control components */
|