@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,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DocPlayerWithSidebar Component
|
|
3
|
-
*
|
|
4
|
-
* Wrapper that composes DocPlayer + DocControlsSidebar in a horizontal
|
|
5
|
-
* flex layout. The video renders on the left with only a scrubber at the
|
|
6
|
-
* bottom, and playback controls render in a vertical sidebar on the right.
|
|
7
|
-
*
|
|
8
|
-
* This layout eliminates black bars for portrait (9:16) video in portrait
|
|
9
|
-
* viewports by placing controls in the space that would otherwise be unused.
|
|
10
|
-
*
|
|
11
|
-
* Uses refs + forceUpdate pattern to avoid infinite render loops between
|
|
12
|
-
* the DocPlayer's callbacks and this component's state. The DocPlayer
|
|
13
|
-
* fires onPlaybackStateChange frequently (every currentTime tick), so we
|
|
14
|
-
* store state in a ref and only trigger re-renders at controlled intervals.
|
|
15
|
-
*
|
|
16
|
-
* Related Files:
|
|
17
|
-
* - DocPlayer.tsx -- Core player (renders with showControls=false, showScrubber=true)
|
|
18
|
-
* - DocControlsSidebar.tsx -- Vertical sidebar controls
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
22
|
-
import type { Doc } from '@bendyline/squisq/schemas';
|
|
23
|
-
import type { ViewportConfig, Theme } from '@bendyline/squisq/schemas';
|
|
24
|
-
import type { AudioController } from './hooks/AudioController';
|
|
25
|
-
import { DocPlayer } from './DocPlayer';
|
|
26
|
-
import { DocControlsSidebar } from './DocControlsSidebar';
|
|
27
|
-
import type { PlaybackState, PlaybackActions } from './types';
|
|
28
|
-
|
|
29
|
-
interface DocPlayerWithSidebarProps {
|
|
30
|
-
/** The Doc to play */
|
|
31
|
-
doc: Doc;
|
|
32
|
-
/** Base path for resolving media URLs (default: `'.'`) */
|
|
33
|
-
basePath?: string;
|
|
34
|
-
autoPlay?: boolean;
|
|
35
|
-
onEnded?: () => void;
|
|
36
|
-
onTimeUpdate?: (time: number) => void;
|
|
37
|
-
/** Optional audio controller (if not provided, uses default HTML5 audio) */
|
|
38
|
-
audioController?: AudioController;
|
|
39
|
-
/** Whether to render slide transitions and per-layer animations (default: true). */
|
|
40
|
-
animationsEnabled?: boolean;
|
|
41
|
-
muted?: boolean;
|
|
42
|
-
captionsEnabled?: boolean;
|
|
43
|
-
isFullscreen?: boolean;
|
|
44
|
-
onFullscreenToggle?: () => void;
|
|
45
|
-
/** Force a specific viewport preset, bypassing window-based orientation detection. */
|
|
46
|
-
forceViewport?: ViewportConfig;
|
|
47
|
-
/** Called when playing state changes */
|
|
48
|
-
onPlayingChange?: (isPlaying: boolean) => void;
|
|
49
|
-
/**
|
|
50
|
-
* Theme for rendering. Forwarded to the inner DocPlayer so the sidebar
|
|
51
|
-
* (portrait) layout matches the default (landscape) layout — without it the
|
|
52
|
-
* inner player falls back to DEFAULT_THEME, whose dark text is unreadable
|
|
53
|
-
* over a hero cover image.
|
|
54
|
-
*/
|
|
55
|
-
theme?: Theme;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const DEFAULT_STATE: PlaybackState = {
|
|
59
|
-
isPlaying: false,
|
|
60
|
-
currentTime: 0,
|
|
61
|
-
totalDuration: 0,
|
|
62
|
-
currentBlockIndex: 0,
|
|
63
|
-
totalBlocks: 0,
|
|
64
|
-
docProgress: 0,
|
|
65
|
-
hasCaptions: false,
|
|
66
|
-
captionsEnabled: false,
|
|
67
|
-
captionMode: 'off',
|
|
68
|
-
currentSegmentIndex: 0,
|
|
69
|
-
currentSegmentName: null,
|
|
70
|
-
currentBlock: null,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export function DocPlayerWithSidebar({
|
|
74
|
-
doc,
|
|
75
|
-
basePath,
|
|
76
|
-
autoPlay = false,
|
|
77
|
-
onEnded,
|
|
78
|
-
onTimeUpdate,
|
|
79
|
-
audioController,
|
|
80
|
-
animationsEnabled = true,
|
|
81
|
-
muted,
|
|
82
|
-
captionsEnabled,
|
|
83
|
-
isFullscreen,
|
|
84
|
-
onFullscreenToggle,
|
|
85
|
-
forceViewport,
|
|
86
|
-
onPlayingChange,
|
|
87
|
-
theme,
|
|
88
|
-
}: DocPlayerWithSidebarProps) {
|
|
89
|
-
// Store playback state in a ref to avoid triggering re-renders from DocPlayer callbacks
|
|
90
|
-
const stateRef = useRef<PlaybackState>(DEFAULT_STATE);
|
|
91
|
-
const actionsRef = useRef<PlaybackActions | null>(null);
|
|
92
|
-
const wasPlayingRef = useRef(false);
|
|
93
|
-
|
|
94
|
-
// Use a counter to force sidebar re-renders at controlled intervals
|
|
95
|
-
const [, setTick] = useState(0);
|
|
96
|
-
|
|
97
|
-
// Update ref without triggering re-render
|
|
98
|
-
const handleStateChange = useCallback(
|
|
99
|
-
(state: PlaybackState) => {
|
|
100
|
-
stateRef.current = state;
|
|
101
|
-
if (onPlayingChange && state.isPlaying !== wasPlayingRef.current) {
|
|
102
|
-
wasPlayingRef.current = state.isPlaying;
|
|
103
|
-
onPlayingChange(state.isPlaying);
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
[onPlayingChange],
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const handleControlsReady = useCallback(
|
|
110
|
-
(controls: PlaybackActions & { play: () => void; pause: () => void }) => {
|
|
111
|
-
const isFirst = !actionsRef.current;
|
|
112
|
-
actionsRef.current = controls;
|
|
113
|
-
// Force one re-render on first call to show the sidebar
|
|
114
|
-
if (isFirst) setTick((t) => t + 1);
|
|
115
|
-
},
|
|
116
|
-
[],
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
// Periodically sync the ref to trigger sidebar UI updates
|
|
120
|
-
useEffect(() => {
|
|
121
|
-
const interval = setInterval(() => {
|
|
122
|
-
setTick((t) => t + 1);
|
|
123
|
-
}, 250); // 4 updates per second is smooth enough for time/progress display
|
|
124
|
-
return () => clearInterval(interval);
|
|
125
|
-
}, []);
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<div className="doc-player-sidebar-layout">
|
|
129
|
-
<div className="doc-player-sidebar-layout__video">
|
|
130
|
-
<DocPlayer
|
|
131
|
-
doc={doc}
|
|
132
|
-
theme={theme}
|
|
133
|
-
basePath={basePath}
|
|
134
|
-
autoPlay={autoPlay}
|
|
135
|
-
onEnded={onEnded}
|
|
136
|
-
onTimeUpdate={onTimeUpdate}
|
|
137
|
-
audioController={audioController}
|
|
138
|
-
animationsEnabled={animationsEnabled}
|
|
139
|
-
muted={muted}
|
|
140
|
-
captionsEnabled={captionsEnabled}
|
|
141
|
-
showControls={isFullscreen}
|
|
142
|
-
showScrubber={!isFullscreen}
|
|
143
|
-
onPlaybackStateChange={handleStateChange}
|
|
144
|
-
onControlsReady={handleControlsReady}
|
|
145
|
-
isFullscreen={isFullscreen}
|
|
146
|
-
onFullscreenToggle={onFullscreenToggle}
|
|
147
|
-
forceViewport={forceViewport}
|
|
148
|
-
/>
|
|
149
|
-
</div>
|
|
150
|
-
{actionsRef.current && (
|
|
151
|
-
<DocControlsSidebar state={stateRef.current} actions={actionsRef.current} />
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
);
|
|
155
|
-
}
|
package/src/DocProgressBar.tsx
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DocProgressBar Component
|
|
3
|
-
*
|
|
4
|
-
* Interactive progress/scrubber bar for doc playback. Shows a track with
|
|
5
|
-
* progress fill, block markers as clickable dots, hover tooltip with time
|
|
6
|
-
* and block title, and a hover line indicator.
|
|
7
|
-
*
|
|
8
|
-
* Extracted from DocPlayer to enable reuse across different control layouts
|
|
9
|
-
* (overlay, sidebar, bottom). When used in sidebar/bottom layouts, this
|
|
10
|
-
* component renders at the bottom of the video while other controls are
|
|
11
|
-
* externalized.
|
|
12
|
-
*
|
|
13
|
-
* Related Files:
|
|
14
|
-
* - DocPlayer.tsx -- Parent component
|
|
15
|
-
* - DocControlsOverlay.tsx -- Uses this within the overlay
|
|
16
|
-
* - types.ts -- Shared type definitions
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { useRef, useState, useCallback } from 'react';
|
|
20
|
-
import type { PlaybackState, PlaybackActions, BlockMarker } from './types';
|
|
21
|
-
import { formatTime } from './types';
|
|
22
|
-
import type { Block } from '@bendyline/squisq/schemas';
|
|
23
|
-
|
|
24
|
-
interface DocProgressBarProps {
|
|
25
|
-
state: PlaybackState;
|
|
26
|
-
actions: PlaybackActions;
|
|
27
|
-
blockMarkers: BlockMarker[];
|
|
28
|
-
/** All expanded blocks for hover lookup */
|
|
29
|
-
expandedBlocks: Block[];
|
|
30
|
-
/** Optional: get block title for hover tooltip */
|
|
31
|
-
getBlockTitle?: (block: Block) => string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function DocProgressBar({
|
|
35
|
-
state,
|
|
36
|
-
actions,
|
|
37
|
-
blockMarkers,
|
|
38
|
-
expandedBlocks,
|
|
39
|
-
getBlockTitle,
|
|
40
|
-
}: DocProgressBarProps) {
|
|
41
|
-
const progressBarRef = useRef<HTMLDivElement>(null);
|
|
42
|
-
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
|
|
43
|
-
|
|
44
|
-
// Fill position tracks the same timeline as the clock readout and the block
|
|
45
|
-
// marker dots — elapsed playback time over the audio `totalDuration`. (We
|
|
46
|
-
// deliberately don't use `state.docProgress`, which is keyed to the doc's
|
|
47
|
-
// estimated `script.duration`; when that diverges from the actual playback
|
|
48
|
-
// length — e.g. the editor preview's reading-time estimate — the fill would
|
|
49
|
-
// desync from the clock and the dots.)
|
|
50
|
-
const playProgress =
|
|
51
|
-
state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
|
|
52
|
-
|
|
53
|
-
const handleProgressHover = useCallback((e: React.MouseEvent) => {
|
|
54
|
-
const bar = progressBarRef.current;
|
|
55
|
-
if (!bar) return;
|
|
56
|
-
const rect = bar.getBoundingClientRect();
|
|
57
|
-
const x = e.clientX - rect.left;
|
|
58
|
-
const progress = Math.max(0, Math.min(1, x / rect.width));
|
|
59
|
-
setHoverPosition(progress);
|
|
60
|
-
}, []);
|
|
61
|
-
|
|
62
|
-
const handleProgressLeave = useCallback(() => {
|
|
63
|
-
setHoverPosition(null);
|
|
64
|
-
}, []);
|
|
65
|
-
|
|
66
|
-
const handleProgressKeyDown = useCallback(
|
|
67
|
-
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
68
|
-
let next: number | null = null;
|
|
69
|
-
switch (e.key) {
|
|
70
|
-
case 'ArrowLeft':
|
|
71
|
-
case 'ArrowDown':
|
|
72
|
-
next = state.currentTime - 5;
|
|
73
|
-
break;
|
|
74
|
-
case 'ArrowRight':
|
|
75
|
-
case 'ArrowUp':
|
|
76
|
-
next = state.currentTime + 5;
|
|
77
|
-
break;
|
|
78
|
-
case 'Home':
|
|
79
|
-
next = 0;
|
|
80
|
-
break;
|
|
81
|
-
case 'End':
|
|
82
|
-
next = state.totalDuration;
|
|
83
|
-
break;
|
|
84
|
-
}
|
|
85
|
-
if (next == null) return;
|
|
86
|
-
e.preventDefault();
|
|
87
|
-
actions.seekTo(Math.max(0, Math.min(state.totalDuration, next)));
|
|
88
|
-
},
|
|
89
|
-
[actions, state.currentTime, state.totalDuration],
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const getBlockAtTimeLocal = useCallback(
|
|
93
|
-
(time: number): { block: Block; index: number } | null => {
|
|
94
|
-
for (let i = expandedBlocks.length - 1; i >= 0; i--) {
|
|
95
|
-
const blk = expandedBlocks[i];
|
|
96
|
-
if (time >= blk.startTime) {
|
|
97
|
-
return { block: blk, index: i };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return expandedBlocks.length > 0 ? { block: expandedBlocks[0], index: 0 } : null;
|
|
101
|
-
},
|
|
102
|
-
[expandedBlocks],
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div
|
|
107
|
-
ref={progressBarRef}
|
|
108
|
-
role="group"
|
|
109
|
-
aria-label="Playback timeline"
|
|
110
|
-
style={{
|
|
111
|
-
flex: 1,
|
|
112
|
-
height: '24px',
|
|
113
|
-
cursor: 'pointer',
|
|
114
|
-
position: 'relative',
|
|
115
|
-
display: 'flex',
|
|
116
|
-
alignItems: 'center',
|
|
117
|
-
}}
|
|
118
|
-
onClick={(e) => {
|
|
119
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
120
|
-
const x = e.clientX - rect.left;
|
|
121
|
-
const progress = x / rect.width;
|
|
122
|
-
actions.seekTo(progress * state.totalDuration);
|
|
123
|
-
}}
|
|
124
|
-
onMouseMove={handleProgressHover}
|
|
125
|
-
onMouseLeave={handleProgressLeave}
|
|
126
|
-
>
|
|
127
|
-
{/* Track background */}
|
|
128
|
-
<div
|
|
129
|
-
role="slider"
|
|
130
|
-
tabIndex={0}
|
|
131
|
-
aria-label="Playback position"
|
|
132
|
-
aria-valuemin={0}
|
|
133
|
-
aria-valuemax={state.totalDuration}
|
|
134
|
-
aria-valuenow={Math.max(0, Math.min(state.totalDuration, state.currentTime))}
|
|
135
|
-
aria-valuetext={`${formatTime(state.currentTime)} of ${formatTime(state.totalDuration)}`}
|
|
136
|
-
onKeyDown={handleProgressKeyDown}
|
|
137
|
-
style={{
|
|
138
|
-
position: 'absolute',
|
|
139
|
-
left: 0,
|
|
140
|
-
right: 0,
|
|
141
|
-
height: '6px',
|
|
142
|
-
background: 'rgba(255,255,255,0.2)',
|
|
143
|
-
borderRadius: '3px',
|
|
144
|
-
}}
|
|
145
|
-
/>
|
|
146
|
-
|
|
147
|
-
{/* Progress fill.
|
|
148
|
-
No CSS `width` transition: the fill is driven by frequent JS updates
|
|
149
|
-
(the synthetic fallback timer runs ~60fps; real audio fires
|
|
150
|
-
`timeupdate` several times/sec), so it's already smooth. A CSS
|
|
151
|
-
transition here actively breaks playback — after a discontinuous
|
|
152
|
-
backward seek, the per-frame inline-width updates on resume keep
|
|
153
|
-
interrupting the in-flight transition before it advances, so the
|
|
154
|
-
*rendered* width latches at the seek position even though the inline
|
|
155
|
-
style (and the clock) keep climbing. The result is a frozen-looking
|
|
156
|
-
bar while time advances. Update width instantly instead. */}
|
|
157
|
-
<div
|
|
158
|
-
data-testid="doc-progress-fill"
|
|
159
|
-
style={{
|
|
160
|
-
position: 'absolute',
|
|
161
|
-
left: 0,
|
|
162
|
-
width: `${playProgress * 100}%`,
|
|
163
|
-
height: '6px',
|
|
164
|
-
background: '#5b9bd5',
|
|
165
|
-
borderRadius: '3px',
|
|
166
|
-
}}
|
|
167
|
-
/>
|
|
168
|
-
|
|
169
|
-
{/* Block markers (dots) */}
|
|
170
|
-
{blockMarkers.map((marker, i) => (
|
|
171
|
-
<button
|
|
172
|
-
type="button"
|
|
173
|
-
key={`${marker.block.id}-${i}`}
|
|
174
|
-
style={{
|
|
175
|
-
position: 'absolute',
|
|
176
|
-
left: `${marker.position}%`,
|
|
177
|
-
transform: 'translateX(-50%)',
|
|
178
|
-
width: '10px',
|
|
179
|
-
height: '10px',
|
|
180
|
-
borderRadius: '50%',
|
|
181
|
-
background:
|
|
182
|
-
marker.index === state.currentBlockIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
|
183
|
-
border: '2px solid #5b9bd5',
|
|
184
|
-
padding: 0,
|
|
185
|
-
cursor: 'pointer',
|
|
186
|
-
zIndex: 2,
|
|
187
|
-
transition: 'transform 0.15s, background 0.15s',
|
|
188
|
-
}}
|
|
189
|
-
title={marker.title}
|
|
190
|
-
aria-label={`Seek to ${marker.title}`}
|
|
191
|
-
onClick={(e) => {
|
|
192
|
-
e.stopPropagation();
|
|
193
|
-
actions.seekTo(marker.block.startTime);
|
|
194
|
-
}}
|
|
195
|
-
onMouseEnter={(e) => {
|
|
196
|
-
(e.currentTarget as HTMLElement).style.transform = 'translateX(-50%) scale(1.3)';
|
|
197
|
-
}}
|
|
198
|
-
onMouseLeave={(e) => {
|
|
199
|
-
(e.currentTarget as HTMLElement).style.transform = 'translateX(-50%)';
|
|
200
|
-
}}
|
|
201
|
-
/>
|
|
202
|
-
))}
|
|
203
|
-
|
|
204
|
-
{/* Hover tooltip */}
|
|
205
|
-
{hoverPosition !== null && (
|
|
206
|
-
<div
|
|
207
|
-
style={{
|
|
208
|
-
position: 'absolute',
|
|
209
|
-
left: `${hoverPosition * 100}%`,
|
|
210
|
-
bottom: '100%',
|
|
211
|
-
transform: 'translateX(-50%)',
|
|
212
|
-
marginBottom: '8px',
|
|
213
|
-
padding: '6px 10px',
|
|
214
|
-
background: 'rgba(0,0,0,0.9)',
|
|
215
|
-
borderRadius: '4px',
|
|
216
|
-
whiteSpace: 'nowrap',
|
|
217
|
-
pointerEvents: 'none',
|
|
218
|
-
zIndex: 10,
|
|
219
|
-
}}
|
|
220
|
-
>
|
|
221
|
-
<div style={{ color: 'white', fontSize: '12px', fontFamily: 'monospace' }}>
|
|
222
|
-
{formatTime(hoverPosition * state.totalDuration)}
|
|
223
|
-
</div>
|
|
224
|
-
{(() => {
|
|
225
|
-
const hoverTime = hoverPosition * state.totalDuration;
|
|
226
|
-
const slideInfo = getBlockAtTimeLocal(hoverTime);
|
|
227
|
-
if (slideInfo && getBlockTitle) {
|
|
228
|
-
return (
|
|
229
|
-
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px', marginTop: '2px' }}>
|
|
230
|
-
{getBlockTitle(slideInfo.block)}
|
|
231
|
-
</div>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
return null;
|
|
235
|
-
})()}
|
|
236
|
-
</div>
|
|
237
|
-
)}
|
|
238
|
-
|
|
239
|
-
{/* Hover line indicator */}
|
|
240
|
-
{hoverPosition !== null && (
|
|
241
|
-
<div
|
|
242
|
-
style={{
|
|
243
|
-
position: 'absolute',
|
|
244
|
-
left: `${hoverPosition * 100}%`,
|
|
245
|
-
top: '50%',
|
|
246
|
-
transform: 'translate(-50%, -50%)',
|
|
247
|
-
width: '2px',
|
|
248
|
-
height: '16px',
|
|
249
|
-
background: 'rgba(255,255,255,0.6)',
|
|
250
|
-
pointerEvents: 'none',
|
|
251
|
-
zIndex: 1,
|
|
252
|
-
}}
|
|
253
|
-
/>
|
|
254
|
-
)}
|
|
255
|
-
</div>
|
|
256
|
-
);
|
|
257
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* InlineAudioPlayer
|
|
3
|
-
*
|
|
4
|
-
* Small inline HTML5 audio player for `<audio>` tags embedded directly in
|
|
5
|
-
* a markdown document — e.g. a narration recording the user wants to
|
|
6
|
-
* audition in the editor. Resolves the `src` through `MediaContext` so a
|
|
7
|
-
* container-backed `audio/foo.webm` path returns a playable blob URL.
|
|
8
|
-
*
|
|
9
|
-
* Deliberately thin: native browser controls, no autoplay. The
|
|
10
|
-
* MarkdownRenderer swaps this in for raw `<audio>` htmlElements; the
|
|
11
|
-
* editor-react Tiptap audio node reuses it for its NodeView.
|
|
12
|
-
*/
|
|
13
|
-
import { useMediaUrl } from './hooks/MediaContext.js';
|
|
14
|
-
|
|
15
|
-
export interface InlineAudioPlayerProps {
|
|
16
|
-
/** Source path — resolved through MediaContext when relative. */
|
|
17
|
-
src: string;
|
|
18
|
-
/** Base path used when no MediaProvider is in context. */
|
|
19
|
-
basePath?: string;
|
|
20
|
-
/** Whether to show native controls. Defaults to true. */
|
|
21
|
-
controls?: boolean;
|
|
22
|
-
/** `preload` attribute passthrough. Defaults to `'metadata'`. */
|
|
23
|
-
preload?: 'none' | 'metadata' | 'auto';
|
|
24
|
-
/** Extra className on the wrapper. */
|
|
25
|
-
className?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function InlineAudioPlayer({
|
|
29
|
-
src,
|
|
30
|
-
basePath = '',
|
|
31
|
-
controls = true,
|
|
32
|
-
preload = 'metadata',
|
|
33
|
-
className,
|
|
34
|
-
}: InlineAudioPlayerProps) {
|
|
35
|
-
const resolvedSrc = useMediaUrl(src, basePath);
|
|
36
|
-
|
|
37
|
-
if (!resolvedSrc) return null;
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<span className={`squisq-inline-audio-player ${className ?? ''}`.trim()}>
|
|
41
|
-
<audio src={resolvedSrc} controls={controls} preload={preload} />
|
|
42
|
-
</span>
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export default InlineAudioPlayer;
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* InlineVideoPlayer
|
|
3
|
-
*
|
|
4
|
-
* Small inline HTML5 video player for `<video>` tags embedded directly in
|
|
5
|
-
* a markdown document — e.g. a recording dropped into the doc by the
|
|
6
|
-
* recorder. Resolves the `src` through `MediaContext` (same path
|
|
7
|
-
* `VideoLayer` uses) so a container-backed `audio/foo.webm` or
|
|
8
|
-
* `video/foo.mp4` resolves to a blob URL instead of 404-ing on the
|
|
9
|
-
* filesystem path.
|
|
10
|
-
*
|
|
11
|
-
* Deliberately thin: native browser controls, intrinsic aspect ratio,
|
|
12
|
-
* no autoplay. The MarkdownRenderer swaps this in for a raw `<video>`
|
|
13
|
-
* htmlElement; the editor-react Tiptap video node uses the same
|
|
14
|
-
* component for its NodeView.
|
|
15
|
-
*/
|
|
16
|
-
import { useMediaUrl } from './hooks/MediaContext.js';
|
|
17
|
-
|
|
18
|
-
export interface InlineVideoPlayerProps {
|
|
19
|
-
/** Source path — resolved through MediaContext when relative. */
|
|
20
|
-
src: string;
|
|
21
|
-
/** Base path used when no MediaProvider is in context. */
|
|
22
|
-
basePath?: string;
|
|
23
|
-
/** Optional explicit width (pixels or CSS length). */
|
|
24
|
-
width?: number | string;
|
|
25
|
-
/** Optional explicit height (pixels or CSS length). */
|
|
26
|
-
height?: number | string;
|
|
27
|
-
/** Optional poster image src — also resolved through MediaContext. */
|
|
28
|
-
poster?: string;
|
|
29
|
-
/** Whether to show native controls. Defaults to true. */
|
|
30
|
-
controls?: boolean;
|
|
31
|
-
/** `preload` attribute passthrough. Defaults to `'metadata'`. */
|
|
32
|
-
preload?: 'none' | 'metadata' | 'auto';
|
|
33
|
-
/** Extra className on the wrapper. */
|
|
34
|
-
className?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function InlineVideoPlayer({
|
|
38
|
-
src,
|
|
39
|
-
basePath = '',
|
|
40
|
-
width,
|
|
41
|
-
height,
|
|
42
|
-
poster,
|
|
43
|
-
controls = true,
|
|
44
|
-
preload = 'metadata',
|
|
45
|
-
className,
|
|
46
|
-
}: InlineVideoPlayerProps) {
|
|
47
|
-
const resolvedSrc = useMediaUrl(src, basePath);
|
|
48
|
-
// Always call the hook (Rules of Hooks); only use the result if a poster
|
|
49
|
-
// was actually requested. Empty input → fallback path which we discard.
|
|
50
|
-
const resolvedPoster = useMediaUrl(poster ?? '', basePath);
|
|
51
|
-
const posterUrl = poster ? resolvedPoster : undefined;
|
|
52
|
-
|
|
53
|
-
if (!resolvedSrc) return null;
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
<span className={`squisq-inline-video-player ${className ?? ''}`.trim()}>
|
|
57
|
-
<video
|
|
58
|
-
src={resolvedSrc}
|
|
59
|
-
poster={posterUrl}
|
|
60
|
-
controls={controls}
|
|
61
|
-
preload={preload}
|
|
62
|
-
width={width}
|
|
63
|
-
height={height}
|
|
64
|
-
playsInline
|
|
65
|
-
/>
|
|
66
|
-
</span>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export default InlineVideoPlayer;
|