svelte-p5-components 0.2.1 → 0.4.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.
@@ -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';