svelte-p5-components 0.3.0 → 0.4.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.
- package/dist/CanvasFrame.svelte +129 -0
- package/dist/CanvasFrame.svelte.d.ts +52 -0
- package/dist/CanvasFrame.test.svelte.d.ts +1 -0
- package/dist/CanvasFrame.test.svelte.js +60 -0
- package/dist/DraggableWindow.svelte +63 -25
- package/dist/EntityToggleList.svelte +269 -0
- package/dist/EntityToggleList.svelte.d.ts +33 -0
- package/dist/EntityToggleList.test.svelte.d.ts +1 -0
- package/dist/EntityToggleList.test.svelte.js +69 -0
- package/dist/HoverTooltip.svelte +184 -0
- package/dist/HoverTooltip.svelte.d.ts +22 -0
- package/dist/HoverTooltip.test.svelte.d.ts +1 -0
- package/dist/HoverTooltip.test.svelte.js +66 -0
- package/dist/SplitPane.svelte +280 -0
- package/dist/SplitPane.svelte.d.ts +48 -0
- package/dist/SplitPane.test.svelte.d.ts +1 -0
- package/dist/SplitPane.test.svelte.js +85 -0
- package/dist/TimelineScrubber.svelte +214 -0
- package/dist/TimelineScrubber.svelte.d.ts +32 -0
- package/dist/TimelineScrubber.test.svelte.d.ts +1 -0
- package/dist/TimelineScrubber.test.svelte.js +66 -0
- package/dist/TimelineTrack.svelte +376 -0
- package/dist/TimelineTrack.svelte.d.ts +50 -0
- package/dist/TimelineTrack.test.svelte.d.ts +1 -0
- package/dist/TimelineTrack.test.svelte.js +72 -0
- package/dist/createMediaSync.svelte.d.ts +53 -0
- package/dist/createMediaSync.svelte.js +115 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/tests/setup.d.ts +1 -0
- package/dist/tests/setup.js +26 -0
- package/package.json +12 -3
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* A named region on the timeline. Rendered as a tinted band; clicking
|
|
4
|
+
* it emits `onSegmentClick` with the full segment.
|
|
5
|
+
*/
|
|
6
|
+
export interface TimelineSegment {
|
|
7
|
+
id: string;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TimelineViewWindow {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import { onDestroy } from 'svelte';
|
|
23
|
+
|
|
24
|
+
// Low-level timeline primitive: a horizontal track with a movable playhead,
|
|
25
|
+
// optional view window (zoom range with draggable handles), optional
|
|
26
|
+
// segment overlays, and seek/hover/segment-click event callbacks.
|
|
27
|
+
//
|
|
28
|
+
// This component is unopinionated about playback — it doesn't know how
|
|
29
|
+
// currentTime changes, only how to display it and how to collect user
|
|
30
|
+
// seek/hover input. Pair with <TimelineScrubber> (which adds play/pause
|
|
31
|
+
// and speed) or drive directly from your own animation loop.
|
|
32
|
+
//
|
|
33
|
+
// Units are arbitrary (seconds, frames, steps) — TimelineTrack just works
|
|
34
|
+
// in the same unit you pass in as `duration`.
|
|
35
|
+
//
|
|
36
|
+
// See the package README for usage examples.
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
/** Total timeline length in your chosen unit. */
|
|
40
|
+
duration: number;
|
|
41
|
+
/** Current playhead position. Bindable. */
|
|
42
|
+
currentTime?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Optional zoomed view window. When set, only this range is visible on
|
|
45
|
+
* the track; clicks and hover map into this window. Bindable.
|
|
46
|
+
*/
|
|
47
|
+
viewWindow?: TimelineViewWindow;
|
|
48
|
+
/** Highlighted regions rendered as labeled bands. */
|
|
49
|
+
segments?: TimelineSegment[];
|
|
50
|
+
/** Show draggable handles for the view window when it's set. Default: true. */
|
|
51
|
+
showViewWindowHandles?: boolean;
|
|
52
|
+
/** Show a vertical playhead line. Default: true. */
|
|
53
|
+
showPlayhead?: boolean;
|
|
54
|
+
/** Called when the user clicks or drags the playhead. */
|
|
55
|
+
onSeek?: (time: number) => void;
|
|
56
|
+
/** Called when the user drags a view window handle. */
|
|
57
|
+
onViewWindowChange?: (window: TimelineViewWindow) => void;
|
|
58
|
+
/** Called when the user clicks a segment band. */
|
|
59
|
+
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
60
|
+
/** Called whenever the mouse moves over or leaves the track. `null` on leave. */
|
|
61
|
+
onHover?: (time: number | null) => void;
|
|
62
|
+
/** Snippet rendered above the hovered-time position. Use for preview tooltips. */
|
|
63
|
+
hoverIndicator?: Snippet<[{ time: number; left: number }]>;
|
|
64
|
+
class?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let {
|
|
68
|
+
duration,
|
|
69
|
+
currentTime = $bindable(0),
|
|
70
|
+
viewWindow = $bindable(undefined),
|
|
71
|
+
segments = [],
|
|
72
|
+
showViewWindowHandles = true,
|
|
73
|
+
showPlayhead = true,
|
|
74
|
+
onSeek,
|
|
75
|
+
onViewWindowChange,
|
|
76
|
+
onSegmentClick,
|
|
77
|
+
onHover,
|
|
78
|
+
hoverIndicator,
|
|
79
|
+
class: className = ''
|
|
80
|
+
}: Props = $props();
|
|
81
|
+
|
|
82
|
+
let trackEl: HTMLDivElement | null = $state(null);
|
|
83
|
+
let dragging = $state<null | 'playhead' | 'window-start' | 'window-end'>(null);
|
|
84
|
+
let hoverX = $state<number | null>(null);
|
|
85
|
+
|
|
86
|
+
const effectiveStart = $derived(viewWindow?.start ?? 0);
|
|
87
|
+
const effectiveEnd = $derived(viewWindow?.end ?? duration);
|
|
88
|
+
const effectiveRange = $derived(Math.max(0.0001, effectiveEnd - effectiveStart));
|
|
89
|
+
|
|
90
|
+
function timeToPercent(t: number): number {
|
|
91
|
+
return ((t - effectiveStart) / effectiveRange) * 100;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eventToTime(event: PointerEvent): number | null {
|
|
95
|
+
if (!trackEl) return null;
|
|
96
|
+
const rect = trackEl.getBoundingClientRect();
|
|
97
|
+
const x = event.clientX - rect.left;
|
|
98
|
+
const pct = Math.max(0, Math.min(1, x / rect.width));
|
|
99
|
+
return effectiveStart + pct * effectiveRange;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function eventToClientX(event: PointerEvent): number | null {
|
|
103
|
+
if (!trackEl) return null;
|
|
104
|
+
const rect = trackEl.getBoundingClientRect();
|
|
105
|
+
return Math.max(0, Math.min(rect.width, event.clientX - rect.left));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleTrackPointerDown(event: PointerEvent) {
|
|
109
|
+
// Ignore clicks on segment buttons / handles (they handle themselves).
|
|
110
|
+
if ((event.target as HTMLElement).closest('[data-skip-seek]')) return;
|
|
111
|
+
|
|
112
|
+
const time = eventToTime(event);
|
|
113
|
+
if (time === null) return;
|
|
114
|
+
|
|
115
|
+
currentTime = time;
|
|
116
|
+
onSeek?.(time);
|
|
117
|
+
dragging = 'playhead';
|
|
118
|
+
document.addEventListener('pointermove', handleDocPointerMove);
|
|
119
|
+
document.addEventListener('pointerup', handleDocPointerUp);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleDocPointerMove(event: PointerEvent) {
|
|
123
|
+
if (!dragging) return;
|
|
124
|
+
const time = eventToTime(event);
|
|
125
|
+
if (time === null) return;
|
|
126
|
+
|
|
127
|
+
if (dragging === 'playhead') {
|
|
128
|
+
currentTime = time;
|
|
129
|
+
onSeek?.(time);
|
|
130
|
+
} else if (dragging === 'window-start' && viewWindow) {
|
|
131
|
+
const next = {
|
|
132
|
+
start: Math.max(0, Math.min(viewWindow.end - 0.1, time)),
|
|
133
|
+
end: viewWindow.end
|
|
134
|
+
};
|
|
135
|
+
viewWindow = next;
|
|
136
|
+
onViewWindowChange?.(next);
|
|
137
|
+
} else if (dragging === 'window-end' && viewWindow) {
|
|
138
|
+
const next = {
|
|
139
|
+
start: viewWindow.start,
|
|
140
|
+
end: Math.min(duration, Math.max(viewWindow.start + 0.1, time))
|
|
141
|
+
};
|
|
142
|
+
viewWindow = next;
|
|
143
|
+
onViewWindowChange?.(next);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleDocPointerUp() {
|
|
148
|
+
dragging = null;
|
|
149
|
+
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
150
|
+
document.removeEventListener('pointerup', handleDocPointerUp);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleHandlePointerDown(which: 'window-start' | 'window-end', event: PointerEvent) {
|
|
154
|
+
event.stopPropagation();
|
|
155
|
+
dragging = which;
|
|
156
|
+
document.addEventListener('pointermove', handleDocPointerMove);
|
|
157
|
+
document.addEventListener('pointerup', handleDocPointerUp);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleTrackPointerMove(event: PointerEvent) {
|
|
161
|
+
if (dragging) return;
|
|
162
|
+
const x = eventToClientX(event);
|
|
163
|
+
const time = eventToTime(event);
|
|
164
|
+
hoverX = x;
|
|
165
|
+
onHover?.(time);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleTrackPointerLeave() {
|
|
169
|
+
if (dragging) return;
|
|
170
|
+
hoverX = null;
|
|
171
|
+
onHover?.(null);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleSegmentClick(segment: TimelineSegment, event: MouseEvent) {
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
onSegmentClick?.(segment);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const hoveredTime = $derived.by(() => {
|
|
180
|
+
if (hoverX === null || !trackEl) return null;
|
|
181
|
+
const rect = trackEl.getBoundingClientRect();
|
|
182
|
+
const pct = Math.max(0, Math.min(1, hoverX / rect.width));
|
|
183
|
+
return effectiveStart + pct * effectiveRange;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
onDestroy(() => {
|
|
187
|
+
if (typeof document !== 'undefined') {
|
|
188
|
+
document.removeEventListener('pointermove', handleDocPointerMove);
|
|
189
|
+
document.removeEventListener('pointerup', handleDocPointerUp);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
</script>
|
|
193
|
+
|
|
194
|
+
<div
|
|
195
|
+
bind:this={trackEl}
|
|
196
|
+
class="timeline-track {className}"
|
|
197
|
+
class:is-dragging={dragging !== null}
|
|
198
|
+
onpointerdown={handleTrackPointerDown}
|
|
199
|
+
onpointermove={handleTrackPointerMove}
|
|
200
|
+
onpointerleave={handleTrackPointerLeave}
|
|
201
|
+
role="slider"
|
|
202
|
+
aria-valuemin={effectiveStart}
|
|
203
|
+
aria-valuemax={effectiveEnd}
|
|
204
|
+
aria-valuenow={currentTime}
|
|
205
|
+
tabindex="0"
|
|
206
|
+
>
|
|
207
|
+
<div class="timeline-track__rail"></div>
|
|
208
|
+
|
|
209
|
+
{#each segments as segment (segment.id)}
|
|
210
|
+
{@const left = timeToPercent(segment.start)}
|
|
211
|
+
{@const width = timeToPercent(segment.end) - left}
|
|
212
|
+
{#if width > 0 && left < 100 && left + width > 0}
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
data-skip-seek
|
|
216
|
+
class="timeline-track__segment"
|
|
217
|
+
style:left="{Math.max(0, left)}%"
|
|
218
|
+
style:width="{Math.min(100, left + width) - Math.max(0, left)}%"
|
|
219
|
+
style:background-color={segment.color ??
|
|
220
|
+
'var(--timeline-segment-bg, rgba(59, 130, 246, 0.18))'}
|
|
221
|
+
title={segment.label ?? `${segment.start.toFixed(1)}–${segment.end.toFixed(1)}`}
|
|
222
|
+
onclick={(e) => handleSegmentClick(segment, e)}
|
|
223
|
+
>
|
|
224
|
+
{#if segment.label}<span class="timeline-track__segment-label">{segment.label}</span>{/if}
|
|
225
|
+
</button>
|
|
226
|
+
{/if}
|
|
227
|
+
{/each}
|
|
228
|
+
|
|
229
|
+
{#if viewWindow && showViewWindowHandles}
|
|
230
|
+
<div
|
|
231
|
+
data-skip-seek
|
|
232
|
+
class="timeline-track__handle timeline-track__handle--start"
|
|
233
|
+
style:left="0%"
|
|
234
|
+
onpointerdown={(e) => handleHandlePointerDown('window-start', e)}
|
|
235
|
+
role="slider"
|
|
236
|
+
aria-label="View window start"
|
|
237
|
+
aria-valuenow={viewWindow.start}
|
|
238
|
+
aria-valuemin={0}
|
|
239
|
+
aria-valuemax={viewWindow.end}
|
|
240
|
+
tabindex="0"
|
|
241
|
+
></div>
|
|
242
|
+
<div
|
|
243
|
+
data-skip-seek
|
|
244
|
+
class="timeline-track__handle timeline-track__handle--end"
|
|
245
|
+
style:left="100%"
|
|
246
|
+
onpointerdown={(e) => handleHandlePointerDown('window-end', e)}
|
|
247
|
+
role="slider"
|
|
248
|
+
aria-label="View window end"
|
|
249
|
+
aria-valuenow={viewWindow.end}
|
|
250
|
+
aria-valuemin={viewWindow.start}
|
|
251
|
+
aria-valuemax={duration}
|
|
252
|
+
tabindex="0"
|
|
253
|
+
></div>
|
|
254
|
+
{/if}
|
|
255
|
+
|
|
256
|
+
{#if showPlayhead}
|
|
257
|
+
<div
|
|
258
|
+
class="timeline-track__playhead"
|
|
259
|
+
style:left="{timeToPercent(currentTime)}%"
|
|
260
|
+
aria-hidden="true"
|
|
261
|
+
></div>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
264
|
+
{#if hoverIndicator && hoveredTime !== null && hoverX !== null}
|
|
265
|
+
<div
|
|
266
|
+
class="timeline-track__hover-indicator"
|
|
267
|
+
style:left="{timeToPercent(hoveredTime)}%"
|
|
268
|
+
aria-hidden="true"
|
|
269
|
+
>
|
|
270
|
+
{@render hoverIndicator({ time: hoveredTime, left: hoverX })}
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<style>
|
|
276
|
+
.timeline-track {
|
|
277
|
+
position: relative;
|
|
278
|
+
width: 100%;
|
|
279
|
+
height: var(--timeline-track-height, 32px);
|
|
280
|
+
user-select: none;
|
|
281
|
+
touch-action: none;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
|
|
284
|
+
--timeline-rail-bg: #e5e7eb;
|
|
285
|
+
--timeline-rail-height: 6px;
|
|
286
|
+
--timeline-playhead-color: #ef4444;
|
|
287
|
+
--timeline-playhead-width: 2px;
|
|
288
|
+
--timeline-handle-color: #6b7280;
|
|
289
|
+
--timeline-handle-width: 4px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.timeline-track:focus-visible {
|
|
293
|
+
outline: 2px solid #3b82f6;
|
|
294
|
+
outline-offset: 2px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.timeline-track__rail {
|
|
298
|
+
position: absolute;
|
|
299
|
+
top: 50%;
|
|
300
|
+
left: 0;
|
|
301
|
+
right: 0;
|
|
302
|
+
height: var(--timeline-rail-height);
|
|
303
|
+
transform: translateY(-50%);
|
|
304
|
+
background: var(--timeline-rail-bg);
|
|
305
|
+
border-radius: calc(var(--timeline-rail-height) / 2);
|
|
306
|
+
pointer-events: none;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.timeline-track__segment {
|
|
310
|
+
position: absolute;
|
|
311
|
+
top: 50%;
|
|
312
|
+
transform: translateY(-50%);
|
|
313
|
+
height: calc(var(--timeline-rail-height) + 6px);
|
|
314
|
+
padding: 0 4px;
|
|
315
|
+
border: none;
|
|
316
|
+
border-radius: 3px;
|
|
317
|
+
cursor: pointer;
|
|
318
|
+
font:
|
|
319
|
+
10px/1 system-ui,
|
|
320
|
+
sans-serif;
|
|
321
|
+
color: #1f2937;
|
|
322
|
+
overflow: hidden;
|
|
323
|
+
white-space: nowrap;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.timeline-track__segment:hover {
|
|
327
|
+
filter: brightness(0.92);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.timeline-track__segment-label {
|
|
331
|
+
opacity: 0.75;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.timeline-track__playhead {
|
|
335
|
+
position: absolute;
|
|
336
|
+
top: 15%;
|
|
337
|
+
bottom: 15%;
|
|
338
|
+
width: var(--timeline-playhead-width);
|
|
339
|
+
margin-left: calc(var(--timeline-playhead-width) / -2);
|
|
340
|
+
background: var(--timeline-playhead-color);
|
|
341
|
+
pointer-events: none;
|
|
342
|
+
border-radius: 1px;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.timeline-track__handle {
|
|
346
|
+
position: absolute;
|
|
347
|
+
top: 0;
|
|
348
|
+
bottom: 0;
|
|
349
|
+
width: 10px;
|
|
350
|
+
margin-left: -5px;
|
|
351
|
+
cursor: ew-resize;
|
|
352
|
+
display: flex;
|
|
353
|
+
align-items: center;
|
|
354
|
+
justify-content: center;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.timeline-track__handle::before {
|
|
358
|
+
content: '';
|
|
359
|
+
display: block;
|
|
360
|
+
width: var(--timeline-handle-width);
|
|
361
|
+
height: 100%;
|
|
362
|
+
background: var(--timeline-handle-color);
|
|
363
|
+
border-radius: 2px;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.timeline-track__handle:focus-visible {
|
|
367
|
+
outline: 2px solid #3b82f6;
|
|
368
|
+
outline-offset: 2px;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.timeline-track__hover-indicator {
|
|
372
|
+
position: absolute;
|
|
373
|
+
bottom: 100%;
|
|
374
|
+
pointer-events: none;
|
|
375
|
+
}
|
|
376
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A named region on the timeline. Rendered as a tinted band; clicking
|
|
3
|
+
* it emits `onSegmentClick` with the full segment.
|
|
4
|
+
*/
|
|
5
|
+
export interface TimelineSegment {
|
|
6
|
+
id: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
label?: string;
|
|
10
|
+
color?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TimelineViewWindow {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
}
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
interface Props {
|
|
18
|
+
/** Total timeline length in your chosen unit. */
|
|
19
|
+
duration: number;
|
|
20
|
+
/** Current playhead position. Bindable. */
|
|
21
|
+
currentTime?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Optional zoomed view window. When set, only this range is visible on
|
|
24
|
+
* the track; clicks and hover map into this window. Bindable.
|
|
25
|
+
*/
|
|
26
|
+
viewWindow?: TimelineViewWindow;
|
|
27
|
+
/** Highlighted regions rendered as labeled bands. */
|
|
28
|
+
segments?: TimelineSegment[];
|
|
29
|
+
/** Show draggable handles for the view window when it's set. Default: true. */
|
|
30
|
+
showViewWindowHandles?: boolean;
|
|
31
|
+
/** Show a vertical playhead line. Default: true. */
|
|
32
|
+
showPlayhead?: boolean;
|
|
33
|
+
/** Called when the user clicks or drags the playhead. */
|
|
34
|
+
onSeek?: (time: number) => void;
|
|
35
|
+
/** Called when the user drags a view window handle. */
|
|
36
|
+
onViewWindowChange?: (window: TimelineViewWindow) => void;
|
|
37
|
+
/** Called when the user clicks a segment band. */
|
|
38
|
+
onSegmentClick?: (segment: TimelineSegment) => void;
|
|
39
|
+
/** Called whenever the mouse moves over or leaves the track. `null` on leave. */
|
|
40
|
+
onHover?: (time: number | null) => void;
|
|
41
|
+
/** Snippet rendered above the hovered-time position. Use for preview tooltips. */
|
|
42
|
+
hoverIndicator?: Snippet<[{
|
|
43
|
+
time: number;
|
|
44
|
+
left: number;
|
|
45
|
+
}]>;
|
|
46
|
+
class?: string;
|
|
47
|
+
}
|
|
48
|
+
declare const TimelineTrack: import("svelte").Component<Props, {}, "currentTime" | "viewWindow">;
|
|
49
|
+
type TimelineTrack = ReturnType<typeof TimelineTrack>;
|
|
50
|
+
export default TimelineTrack;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/svelte';
|
|
3
|
+
import TimelineTrack, {} from './TimelineTrack.svelte';
|
|
4
|
+
describe('<TimelineTrack>', () => {
|
|
5
|
+
it('renders a playhead at the correct percent for the currentTime', () => {
|
|
6
|
+
const { container } = render(TimelineTrack, {
|
|
7
|
+
props: { duration: 100, currentTime: 25 }
|
|
8
|
+
});
|
|
9
|
+
const playhead = container.querySelector('.timeline-track__playhead');
|
|
10
|
+
expect(playhead).not.toBeNull();
|
|
11
|
+
expect(playhead.style.left).toBe('25%');
|
|
12
|
+
});
|
|
13
|
+
it('omits the playhead when showPlayhead is false', () => {
|
|
14
|
+
const { container } = render(TimelineTrack, {
|
|
15
|
+
props: { duration: 100, currentTime: 50, showPlayhead: false }
|
|
16
|
+
});
|
|
17
|
+
expect(container.querySelector('.timeline-track__playhead')).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
it('renders one band per segment with the correct width percent', () => {
|
|
20
|
+
const segments = [
|
|
21
|
+
{ id: 's1', start: 0, end: 20 },
|
|
22
|
+
{ id: 's2', start: 40, end: 60, label: 'Middle' }
|
|
23
|
+
];
|
|
24
|
+
const { container } = render(TimelineTrack, {
|
|
25
|
+
props: { duration: 100, segments }
|
|
26
|
+
});
|
|
27
|
+
const bands = container.querySelectorAll('.timeline-track__segment');
|
|
28
|
+
expect(bands).toHaveLength(2);
|
|
29
|
+
expect(bands[0]?.style.left).toBe('0%');
|
|
30
|
+
expect(bands[0]?.style.width).toBe('20%');
|
|
31
|
+
expect(bands[1]?.style.left).toBe('40%');
|
|
32
|
+
expect(bands[1]?.style.width).toBe('20%');
|
|
33
|
+
expect(bands[1]?.textContent).toContain('Middle');
|
|
34
|
+
});
|
|
35
|
+
it('onSegmentClick fires the full segment when a band is clicked', async () => {
|
|
36
|
+
const segments = [{ id: 's1', start: 0, end: 20, label: 'Intro' }];
|
|
37
|
+
const onSegmentClick = vi.fn();
|
|
38
|
+
const { container } = render(TimelineTrack, {
|
|
39
|
+
props: { duration: 100, segments, onSegmentClick }
|
|
40
|
+
});
|
|
41
|
+
const band = container.querySelector('.timeline-track__segment');
|
|
42
|
+
await fireEvent.click(band);
|
|
43
|
+
expect(onSegmentClick).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(onSegmentClick.mock.calls[0]?.[0]?.id).toBe('s1');
|
|
45
|
+
});
|
|
46
|
+
it('view window handles render when viewWindow is provided', () => {
|
|
47
|
+
const { container } = render(TimelineTrack, {
|
|
48
|
+
props: { duration: 100, currentTime: 50, viewWindow: { start: 20, end: 80 } }
|
|
49
|
+
});
|
|
50
|
+
expect(container.querySelectorAll('.timeline-track__handle')).toHaveLength(2);
|
|
51
|
+
});
|
|
52
|
+
it('view window handles hidden when showViewWindowHandles=false', () => {
|
|
53
|
+
const { container } = render(TimelineTrack, {
|
|
54
|
+
props: {
|
|
55
|
+
duration: 100,
|
|
56
|
+
viewWindow: { start: 20, end: 80 },
|
|
57
|
+
showViewWindowHandles: false
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
expect(container.querySelectorAll('.timeline-track__handle')).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
it('has role=slider with aria-valuemin/max/now reflecting view window', () => {
|
|
63
|
+
const { container } = render(TimelineTrack, {
|
|
64
|
+
props: { duration: 100, currentTime: 30, viewWindow: { start: 10, end: 90 } }
|
|
65
|
+
});
|
|
66
|
+
const track = container.querySelector('.timeline-track');
|
|
67
|
+
expect(track.getAttribute('role')).toBe('slider');
|
|
68
|
+
expect(track.getAttribute('aria-valuemin')).toBe('10');
|
|
69
|
+
expect(track.getAttribute('aria-valuemax')).toBe('90');
|
|
70
|
+
expect(track.getAttribute('aria-valuenow')).toBe('30');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync a timeline's `currentTime` with an HTMLMediaElement (video or audio).
|
|
3
|
+
*
|
|
4
|
+
* The "speed multiplier quietly becomes a no-op while video is playing" bug
|
|
5
|
+
* appears in every app that wires a timeline to a video player without
|
|
6
|
+
* thinking about it carefully. `createMediaSync` handles the branch
|
|
7
|
+
* explicitly:
|
|
8
|
+
*
|
|
9
|
+
* - If the media element is playing, `isLocked === true` — the timeline
|
|
10
|
+
* should display the video's currentTime and should *not* advance
|
|
11
|
+
* currentTime from its own animation loop. Surface `isLocked` to the user
|
|
12
|
+
* (see `<TimelineScrubber speedLocked>`) so the speed multiplier's
|
|
13
|
+
* inactivity is visible rather than silent.
|
|
14
|
+
*
|
|
15
|
+
* - If the media element is paused or unloaded, the timeline is free to run
|
|
16
|
+
* its own animation loop and the consumer's speed multiplier applies.
|
|
17
|
+
*
|
|
18
|
+
* Consumers own the animation loop (an rAF tick that advances currentTime
|
|
19
|
+
* by `speed * deltaMs / 1000`). `createMediaSync` only bridges the
|
|
20
|
+
* media→timeline direction and surfaces the lock signal.
|
|
21
|
+
*
|
|
22
|
+
* Use `.seek(time)` to write the other direction (timeline→media) — the
|
|
23
|
+
* helper clamps to the media's duration and guards against NaN.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const sync = createMediaSync();
|
|
28
|
+
*
|
|
29
|
+
* $effect(() => { sync.attach(videoElement); });
|
|
30
|
+
*
|
|
31
|
+
* // In your rAF loop:
|
|
32
|
+
* if (sync.isLocked) currentTime = sync.mediaTime;
|
|
33
|
+
* else currentTime += speed * dt / 1000;
|
|
34
|
+
*
|
|
35
|
+
* // When user scrubs the timeline:
|
|
36
|
+
* sync.seek(newTime);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface MediaSync {
|
|
40
|
+
/** The media element's currentTime, when attached. */
|
|
41
|
+
readonly mediaTime: number;
|
|
42
|
+
/** The media element's duration (possibly Infinity for streams). */
|
|
43
|
+
readonly mediaDuration: number;
|
|
44
|
+
/** True when the media is playing — timeline should follow, not drive. */
|
|
45
|
+
readonly isLocked: boolean;
|
|
46
|
+
/** Attach the sync to an HTMLMediaElement. Re-attaching switches to the new one. */
|
|
47
|
+
attach(el: HTMLMediaElement | null): void;
|
|
48
|
+
/** Remove listeners; idempotent. Called automatically when `attach(null)`. */
|
|
49
|
+
detach(): void;
|
|
50
|
+
/** Seek the attached media to `time`, clamped into [0, duration]. */
|
|
51
|
+
seek(time: number): void;
|
|
52
|
+
}
|
|
53
|
+
export declare function createMediaSync(): MediaSync;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync a timeline's `currentTime` with an HTMLMediaElement (video or audio).
|
|
3
|
+
*
|
|
4
|
+
* The "speed multiplier quietly becomes a no-op while video is playing" bug
|
|
5
|
+
* appears in every app that wires a timeline to a video player without
|
|
6
|
+
* thinking about it carefully. `createMediaSync` handles the branch
|
|
7
|
+
* explicitly:
|
|
8
|
+
*
|
|
9
|
+
* - If the media element is playing, `isLocked === true` — the timeline
|
|
10
|
+
* should display the video's currentTime and should *not* advance
|
|
11
|
+
* currentTime from its own animation loop. Surface `isLocked` to the user
|
|
12
|
+
* (see `<TimelineScrubber speedLocked>`) so the speed multiplier's
|
|
13
|
+
* inactivity is visible rather than silent.
|
|
14
|
+
*
|
|
15
|
+
* - If the media element is paused or unloaded, the timeline is free to run
|
|
16
|
+
* its own animation loop and the consumer's speed multiplier applies.
|
|
17
|
+
*
|
|
18
|
+
* Consumers own the animation loop (an rAF tick that advances currentTime
|
|
19
|
+
* by `speed * deltaMs / 1000`). `createMediaSync` only bridges the
|
|
20
|
+
* media→timeline direction and surfaces the lock signal.
|
|
21
|
+
*
|
|
22
|
+
* Use `.seek(time)` to write the other direction (timeline→media) — the
|
|
23
|
+
* helper clamps to the media's duration and guards against NaN.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* const sync = createMediaSync();
|
|
28
|
+
*
|
|
29
|
+
* $effect(() => { sync.attach(videoElement); });
|
|
30
|
+
*
|
|
31
|
+
* // In your rAF loop:
|
|
32
|
+
* if (sync.isLocked) currentTime = sync.mediaTime;
|
|
33
|
+
* else currentTime += speed * dt / 1000;
|
|
34
|
+
*
|
|
35
|
+
* // When user scrubs the timeline:
|
|
36
|
+
* sync.seek(newTime);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function createMediaSync() {
|
|
40
|
+
let el = null;
|
|
41
|
+
let mediaTime = $state(0);
|
|
42
|
+
let mediaDuration = $state(0);
|
|
43
|
+
let isLocked = $state(false);
|
|
44
|
+
function onTimeUpdate() {
|
|
45
|
+
if (el)
|
|
46
|
+
mediaTime = el.currentTime;
|
|
47
|
+
}
|
|
48
|
+
function onDurationChange() {
|
|
49
|
+
if (el)
|
|
50
|
+
mediaDuration = isFinite(el.duration) ? el.duration : 0;
|
|
51
|
+
}
|
|
52
|
+
function onPlay() {
|
|
53
|
+
isLocked = true;
|
|
54
|
+
}
|
|
55
|
+
function onPause() {
|
|
56
|
+
isLocked = false;
|
|
57
|
+
}
|
|
58
|
+
function onEnded() {
|
|
59
|
+
isLocked = false;
|
|
60
|
+
}
|
|
61
|
+
function detachInner() {
|
|
62
|
+
if (!el)
|
|
63
|
+
return;
|
|
64
|
+
el.removeEventListener('timeupdate', onTimeUpdate);
|
|
65
|
+
el.removeEventListener('durationchange', onDurationChange);
|
|
66
|
+
el.removeEventListener('play', onPlay);
|
|
67
|
+
el.removeEventListener('pause', onPause);
|
|
68
|
+
el.removeEventListener('ended', onEnded);
|
|
69
|
+
el = null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
get mediaTime() {
|
|
73
|
+
return mediaTime;
|
|
74
|
+
},
|
|
75
|
+
get mediaDuration() {
|
|
76
|
+
return mediaDuration;
|
|
77
|
+
},
|
|
78
|
+
get isLocked() {
|
|
79
|
+
return isLocked;
|
|
80
|
+
},
|
|
81
|
+
attach(next) {
|
|
82
|
+
detachInner();
|
|
83
|
+
el = next;
|
|
84
|
+
if (!el) {
|
|
85
|
+
mediaTime = 0;
|
|
86
|
+
mediaDuration = 0;
|
|
87
|
+
isLocked = false;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
mediaTime = el.currentTime;
|
|
91
|
+
mediaDuration = isFinite(el.duration) ? el.duration : 0;
|
|
92
|
+
isLocked = !el.paused && !el.ended;
|
|
93
|
+
el.addEventListener('timeupdate', onTimeUpdate);
|
|
94
|
+
el.addEventListener('durationchange', onDurationChange);
|
|
95
|
+
el.addEventListener('play', onPlay);
|
|
96
|
+
el.addEventListener('pause', onPause);
|
|
97
|
+
el.addEventListener('ended', onEnded);
|
|
98
|
+
},
|
|
99
|
+
detach() {
|
|
100
|
+
detachInner();
|
|
101
|
+
mediaTime = 0;
|
|
102
|
+
mediaDuration = 0;
|
|
103
|
+
isLocked = false;
|
|
104
|
+
},
|
|
105
|
+
seek(time) {
|
|
106
|
+
if (!el)
|
|
107
|
+
return;
|
|
108
|
+
const dur = isFinite(el.duration) ? el.duration : Number.MAX_SAFE_INTEGER;
|
|
109
|
+
const clamped = Math.max(0, Math.min(dur, time));
|
|
110
|
+
if (!isFinite(clamped))
|
|
111
|
+
return;
|
|
112
|
+
el.currentTime = clamped;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,3 +3,10 @@ export { default as FPSMonitor } from './FPSMonitor.svelte';
|
|
|
3
3
|
export { default as SketchDebug } from './SketchDebug.svelte';
|
|
4
4
|
export { default as DraggableWindow } from './DraggableWindow.svelte';
|
|
5
5
|
export { default as DraggableSketch } from './DraggableSketch.svelte';
|
|
6
|
+
export { default as CanvasFrame } from './CanvasFrame.svelte';
|
|
7
|
+
export { default as SplitPane } from './SplitPane.svelte';
|
|
8
|
+
export { default as HoverTooltip } from './HoverTooltip.svelte';
|
|
9
|
+
export { default as EntityToggleList, type Entity } from './EntityToggleList.svelte';
|
|
10
|
+
export { default as TimelineTrack, type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
|
|
11
|
+
export { default as TimelineScrubber } from './TimelineScrubber.svelte';
|
|
12
|
+
export { createMediaSync, type MediaSync } from './createMediaSync.svelte.js';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,10 @@ export { default as FPSMonitor } from './FPSMonitor.svelte';
|
|
|
3
3
|
export { default as SketchDebug } from './SketchDebug.svelte';
|
|
4
4
|
export { default as DraggableWindow } from './DraggableWindow.svelte';
|
|
5
5
|
export { default as DraggableSketch } from './DraggableSketch.svelte';
|
|
6
|
+
export { default as CanvasFrame } from './CanvasFrame.svelte';
|
|
7
|
+
export { default as SplitPane } from './SplitPane.svelte';
|
|
8
|
+
export { default as HoverTooltip } from './HoverTooltip.svelte';
|
|
9
|
+
export { default as EntityToggleList } from './EntityToggleList.svelte';
|
|
10
|
+
export { default as TimelineTrack } from './TimelineTrack.svelte';
|
|
11
|
+
export { default as TimelineScrubber } from './TimelineScrubber.svelte';
|
|
12
|
+
export { createMediaSync } from './createMediaSync.svelte.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|