svelte-p5-components 0.4.1 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * A named region on the timeline. Rendered as a tinted band; clicking
3
- * it emits `onSegmentClick` with the full segment.
2
+ * A labeled band on the track. Rendered as a tinted region; clicking
3
+ * emits `onSegmentClick` with the full segment.
4
4
  */
5
5
  export interface TimelineSegment {
6
6
  id: string;
@@ -9,42 +9,57 @@ export interface TimelineSegment {
9
9
  label?: string;
10
10
  color?: string;
11
11
  }
12
- export interface TimelineViewWindow {
12
+ /**
13
+ * A {start,end} clip range overlaid on the track. When present, the
14
+ * region is highlighted and draggable handles appear at both edges.
15
+ * Use this for Twitch-style clip editing or TE-style filter windows.
16
+ */
17
+ export interface TimelineSelection {
13
18
  start: number;
14
19
  end: number;
15
20
  }
16
21
  import type { Snippet } from 'svelte';
17
22
  interface Props {
18
- /** Total timeline length in your chosen unit. */
23
+ /** Total timeline length. */
19
24
  duration: number;
20
25
  /** Current playhead position. Bindable. */
21
26
  currentTime?: number;
27
+ /** Selection range start. Bindable. */
28
+ selectionStart?: number;
29
+ /** Selection range end. Bindable. */
30
+ selectionEnd?: number;
22
31
  /**
23
- * Optional zoomed view window. When set, only this range is visible on
24
- * the track; clicks and hover map into this window. Bindable.
32
+ * Render the selection range + its handles. Default: true when both
33
+ * selectionStart and selectionEnd are provided.
25
34
  */
26
- viewWindow?: TimelineViewWindow;
27
- /** Highlighted regions rendered as labeled bands. */
35
+ showSelection?: boolean;
36
+ /** Highlighted labeled regions. */
28
37
  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. */
38
+ /** Hide the playhead knob but still let the rail respond to clicks. */
32
39
  showPlayhead?: boolean;
33
40
  /** Called when the user clicks or drags the playhead. */
34
41
  onSeek?: (time: number) => void;
35
- /** Called when the user drags a view window handle. */
36
- onViewWindowChange?: (window: TimelineViewWindow) => void;
42
+ /** Called while the user drags a selection handle or the selection band. */
43
+ onSelectionChange?: (selection: TimelineSelection) => void;
44
+ /** Called at the end of a selection interaction (drag release). */
45
+ onSelectionCommit?: (selection: TimelineSelection) => void;
37
46
  /** Called when the user clicks a segment band. */
38
47
  onSegmentClick?: (segment: TimelineSegment) => void;
39
48
  /** 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<[{
49
+ onHoverTime?: (time: number | null) => void;
50
+ /** Snippet rendered above the hover x position. Use for preview tooltips. */
51
+ hoverPreview?: Snippet<[{
43
52
  time: number;
44
- left: number;
53
+ xPercent: number;
45
54
  }]>;
55
+ /**
56
+ * When dragging the selection start handle, also move the playhead to
57
+ * the new start (and fire `onSeek`). Handy for "scrub-to-trim" UIs where
58
+ * the start handle should preview the frame it lands on. Default: false.
59
+ */
60
+ playheadFollowsSelectionStart?: boolean;
46
61
  class?: string;
47
62
  }
48
- declare const TimelineTrack: import("svelte").Component<Props, {}, "currentTime" | "viewWindow">;
63
+ declare const TimelineTrack: import("svelte").Component<Props, {}, "currentTime" | "selectionStart" | "selectionEnd">;
49
64
  type TimelineTrack = ReturnType<typeof TimelineTrack>;
50
65
  export default TimelineTrack;
@@ -43,30 +43,91 @@ describe('<TimelineTrack>', () => {
43
43
  expect(onSegmentClick).toHaveBeenCalledTimes(1);
44
44
  expect(onSegmentClick.mock.calls[0]?.[0]?.id).toBe('s1');
45
45
  });
46
- it('view window handles render when viewWindow is provided', () => {
46
+ it('selection handles render when selectionStart/End are provided', () => {
47
47
  const { container } = render(TimelineTrack, {
48
- props: { duration: 100, currentTime: 50, viewWindow: { start: 20, end: 80 } }
48
+ props: { duration: 100, currentTime: 50, selectionStart: 20, selectionEnd: 80 }
49
49
  });
50
50
  expect(container.querySelectorAll('.timeline-track__handle')).toHaveLength(2);
51
51
  });
52
- it('view window handles hidden when showViewWindowHandles=false', () => {
52
+ it('selection handles hidden when showSelection=false', () => {
53
53
  const { container } = render(TimelineTrack, {
54
54
  props: {
55
55
  duration: 100,
56
- viewWindow: { start: 20, end: 80 },
57
- showViewWindowHandles: false
56
+ selectionStart: 20,
57
+ selectionEnd: 80,
58
+ showSelection: false
58
59
  }
59
60
  });
60
61
  expect(container.querySelectorAll('.timeline-track__handle')).toHaveLength(0);
61
62
  });
62
- it('has role=slider with aria-valuemin/max/now reflecting view window', () => {
63
+ // --- playheadFollowsSelectionStart -------------------------------------
64
+ //
65
+ // Dragging the start handle goes through document-level pointermove
66
+ // listeners, and the time math depends on the track's bounding rect.
67
+ // happy-dom reports a 0-width rect by default, so we stub it to map
68
+ // clientX 1:1 onto a 100-unit timeline (width 100 at left 0).
69
+ function stubTrackRect(container) {
70
+ const track = container.querySelector('.timeline-track');
71
+ track.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 32, right: 100, bottom: 32, x: 0, y: 0 });
72
+ return track;
73
+ }
74
+ async function dragStartHandle(container, toClientX) {
75
+ const handle = container.querySelector('.timeline-track__handle--start');
76
+ handle.setPointerCapture = () => { };
77
+ await fireEvent.pointerDown(handle, { clientX: 20, pointerId: 1 });
78
+ // Drag handled at the document level.
79
+ await fireEvent.pointerMove(document, { clientX: toClientX, pointerId: 1 });
80
+ await fireEvent.pointerUp(document, { clientX: toClientX, pointerId: 1 });
81
+ }
82
+ it('with playheadFollowsSelectionStart, dragging the start handle moves currentTime + seeks', async () => {
83
+ const onSeek = vi.fn();
84
+ const { container } = render(TimelineTrack, {
85
+ props: {
86
+ duration: 100,
87
+ currentTime: 0,
88
+ selectionStart: 20,
89
+ selectionEnd: 80,
90
+ playheadFollowsSelectionStart: true,
91
+ onSeek
92
+ }
93
+ });
94
+ stubTrackRect(container);
95
+ await dragStartHandle(container, 30);
96
+ // Moving the start handle to clientX 30 maps to time 30 on the track.
97
+ expect(onSeek).toHaveBeenCalled();
98
+ expect(onSeek.mock.calls.at(-1)?.[0]).toBeCloseTo(30, 5);
99
+ const playhead = container.querySelector('.timeline-track__playhead');
100
+ expect(playhead.style.left).toBe('30%');
101
+ });
102
+ it('by default, dragging the start handle does NOT move the playhead', async () => {
103
+ const onSeek = vi.fn();
104
+ const onSelectionChange = vi.fn();
105
+ const { container } = render(TimelineTrack, {
106
+ props: {
107
+ duration: 100,
108
+ currentTime: 0,
109
+ selectionStart: 20,
110
+ selectionEnd: 80,
111
+ onSeek,
112
+ onSelectionChange
113
+ }
114
+ });
115
+ stubTrackRect(container);
116
+ await dragStartHandle(container, 30);
117
+ // Selection still updates, but the playhead stays put and no seek fires.
118
+ expect(onSelectionChange).toHaveBeenCalled();
119
+ expect(onSeek).not.toHaveBeenCalled();
120
+ const playhead = container.querySelector('.timeline-track__playhead');
121
+ expect(playhead.style.left).toBe('0%');
122
+ });
123
+ it('has role=slider with aria covering full duration', () => {
63
124
  const { container } = render(TimelineTrack, {
64
- props: { duration: 100, currentTime: 30, viewWindow: { start: 10, end: 90 } }
125
+ props: { duration: 100, currentTime: 30, selectionStart: 10, selectionEnd: 90 }
65
126
  });
66
127
  const track = container.querySelector('.timeline-track');
67
128
  expect(track.getAttribute('role')).toBe('slider');
68
- expect(track.getAttribute('aria-valuemin')).toBe('10');
69
- expect(track.getAttribute('aria-valuemax')).toBe('90');
129
+ expect(track.getAttribute('aria-valuemin')).toBe('0');
130
+ expect(track.getAttribute('aria-valuemax')).toBe('100');
70
131
  expect(track.getAttribute('aria-valuenow')).toBe('30');
71
132
  });
72
133
  });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Drive an HTMLMediaElement with per-visualization playback strategies.
3
+ *
4
+ * Where `createMediaSync` only bridges the media→timeline direction (and
5
+ * surfaces the speed-lock signal), `createMediaPlayback` owns the playback
6
+ * verbs that consuming apps reach for repeatedly:
7
+ *
8
+ * - `playFrom(time)` — seek somewhere and start playing. The bread-and-butter
9
+ * "jump to this word / this event and watch it" action.
10
+ *
11
+ * - `playSnippets(times, secondsEach)` — play a series of short clips back to
12
+ * back (each `times[i]` for `secondsEach` seconds), then stop. Think
13
+ * "preview every match" or "play the highlight reel". The orchestrator
14
+ * advances the queue on each `timeupdate` so the consumer doesn't have to
15
+ * babysit timers.
16
+ *
17
+ * - `pause()` / `stop()` — the obvious two, where `stop()` also clears any
18
+ * pending snippet queue.
19
+ *
20
+ * `isPlaying` and `activeSnippetIndex` are `$state`, so a component can react
21
+ * to them (highlight the snippet currently playing, flip a play/pause icon).
22
+ *
23
+ * Seeks are clamped exactly like `createMediaSync.seek` — guarding NaN /
24
+ * Infinity and clamping into [0, duration].
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const playback = createMediaPlayback();
29
+ *
30
+ * $effect(() => { playback.attach(videoElement); });
31
+ *
32
+ * // Jump to a word and play it:
33
+ * playback.playFrom(word.start);
34
+ *
35
+ * // Preview every match for 2s each:
36
+ * playback.playSnippets(matches.map((m) => m.start), 2);
37
+ *
38
+ * // React in markup:
39
+ * class:is-active={i === playback.activeSnippetIndex}
40
+ * ```
41
+ */
42
+ export interface MediaPlayback {
43
+ /** True while the attached media is playing. */
44
+ readonly isPlaying: boolean;
45
+ /** Index into the current snippet queue, or -1 when not in a snippet sequence. */
46
+ readonly activeSnippetIndex: number;
47
+ /** Attach to an HTMLMediaElement. Re-attaching switches to the new one. */
48
+ attach(el: HTMLMediaElement | null): void;
49
+ /** Remove listeners + clear the queue; idempotent. Called automatically when `attach(null)`. */
50
+ detach(): void;
51
+ /** Seek to `time` (clamped) and play. Cancels any active snippet sequence. */
52
+ playFrom(time: number): void;
53
+ /** Play each start time in `times` for `secondsEach` seconds, in order, then stop. */
54
+ playSnippets(times: number[], secondsEach: number): void;
55
+ /** Pause without clearing position. */
56
+ pause(): void;
57
+ /** Stop playback and clear any snippet queue. */
58
+ stop(): void;
59
+ }
60
+ export declare function createMediaPlayback(): MediaPlayback;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Drive an HTMLMediaElement with per-visualization playback strategies.
3
+ *
4
+ * Where `createMediaSync` only bridges the media→timeline direction (and
5
+ * surfaces the speed-lock signal), `createMediaPlayback` owns the playback
6
+ * verbs that consuming apps reach for repeatedly:
7
+ *
8
+ * - `playFrom(time)` — seek somewhere and start playing. The bread-and-butter
9
+ * "jump to this word / this event and watch it" action.
10
+ *
11
+ * - `playSnippets(times, secondsEach)` — play a series of short clips back to
12
+ * back (each `times[i]` for `secondsEach` seconds), then stop. Think
13
+ * "preview every match" or "play the highlight reel". The orchestrator
14
+ * advances the queue on each `timeupdate` so the consumer doesn't have to
15
+ * babysit timers.
16
+ *
17
+ * - `pause()` / `stop()` — the obvious two, where `stop()` also clears any
18
+ * pending snippet queue.
19
+ *
20
+ * `isPlaying` and `activeSnippetIndex` are `$state`, so a component can react
21
+ * to them (highlight the snippet currently playing, flip a play/pause icon).
22
+ *
23
+ * Seeks are clamped exactly like `createMediaSync.seek` — guarding NaN /
24
+ * Infinity and clamping into [0, duration].
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const playback = createMediaPlayback();
29
+ *
30
+ * $effect(() => { playback.attach(videoElement); });
31
+ *
32
+ * // Jump to a word and play it:
33
+ * playback.playFrom(word.start);
34
+ *
35
+ * // Preview every match for 2s each:
36
+ * playback.playSnippets(matches.map((m) => m.start), 2);
37
+ *
38
+ * // React in markup:
39
+ * class:is-active={i === playback.activeSnippetIndex}
40
+ * ```
41
+ */
42
+ export function createMediaPlayback() {
43
+ let el = null;
44
+ let isPlaying = $state(false);
45
+ let activeSnippetIndex = $state(-1);
46
+ // Snippet queue: the start times to play and how long to hold each.
47
+ let queue = [];
48
+ let snippetSeconds = 0;
49
+ function clampTime(time) {
50
+ if (!el)
51
+ return null;
52
+ const dur = isFinite(el.duration) ? el.duration : Number.MAX_SAFE_INTEGER;
53
+ const clamped = Math.max(0, Math.min(dur, time));
54
+ if (!isFinite(clamped))
55
+ return null;
56
+ return clamped;
57
+ }
58
+ function clearQueue() {
59
+ queue = [];
60
+ snippetSeconds = 0;
61
+ activeSnippetIndex = -1;
62
+ }
63
+ function playEl() {
64
+ // play() can reject under autoplay policies — swallow it safely.
65
+ el?.play()?.catch(() => { });
66
+ }
67
+ function onPlay() {
68
+ isPlaying = true;
69
+ }
70
+ function onPause() {
71
+ isPlaying = false;
72
+ }
73
+ function onEnded() {
74
+ isPlaying = false;
75
+ }
76
+ function onTimeUpdate() {
77
+ // Only meaningful while running a snippet sequence.
78
+ if (!el || activeSnippetIndex < 0)
79
+ return;
80
+ const start = queue[activeSnippetIndex];
81
+ if (start === undefined)
82
+ return;
83
+ if (el.currentTime - start < snippetSeconds)
84
+ return;
85
+ // This snippet's time is up — advance to the next, or stop.
86
+ const nextIndex = activeSnippetIndex + 1;
87
+ if (nextIndex >= queue.length) {
88
+ stopInner();
89
+ return;
90
+ }
91
+ const nextStart = clampTime(queue[nextIndex]);
92
+ activeSnippetIndex = nextIndex;
93
+ if (nextStart !== null)
94
+ el.currentTime = nextStart;
95
+ }
96
+ function stopInner() {
97
+ clearQueue();
98
+ if (el)
99
+ el.pause();
100
+ }
101
+ function detachInner() {
102
+ if (!el)
103
+ return;
104
+ el.removeEventListener('timeupdate', onTimeUpdate);
105
+ el.removeEventListener('play', onPlay);
106
+ el.removeEventListener('pause', onPause);
107
+ el.removeEventListener('ended', onEnded);
108
+ el = null;
109
+ }
110
+ return {
111
+ get isPlaying() {
112
+ return isPlaying;
113
+ },
114
+ get activeSnippetIndex() {
115
+ return activeSnippetIndex;
116
+ },
117
+ attach(next) {
118
+ detachInner();
119
+ clearQueue();
120
+ el = next;
121
+ if (!el) {
122
+ isPlaying = false;
123
+ return;
124
+ }
125
+ isPlaying = !el.paused && !el.ended;
126
+ el.addEventListener('timeupdate', onTimeUpdate);
127
+ el.addEventListener('play', onPlay);
128
+ el.addEventListener('pause', onPause);
129
+ el.addEventListener('ended', onEnded);
130
+ },
131
+ detach() {
132
+ detachInner();
133
+ clearQueue();
134
+ isPlaying = false;
135
+ },
136
+ playFrom(time) {
137
+ if (!el)
138
+ return;
139
+ clearQueue();
140
+ const clamped = clampTime(time);
141
+ if (clamped === null)
142
+ return;
143
+ el.currentTime = clamped;
144
+ playEl();
145
+ },
146
+ playSnippets(times, secondsEach) {
147
+ if (!el)
148
+ return;
149
+ const finite = times.filter((t) => isFinite(t));
150
+ if (finite.length === 0)
151
+ return;
152
+ queue = finite;
153
+ snippetSeconds = secondsEach;
154
+ activeSnippetIndex = 0;
155
+ const start = clampTime(queue[0]);
156
+ if (start !== null)
157
+ el.currentTime = start;
158
+ playEl();
159
+ },
160
+ pause() {
161
+ if (!el)
162
+ return;
163
+ el.pause();
164
+ },
165
+ stop() {
166
+ stopInner();
167
+ }
168
+ };
169
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createMediaPlayback } from './createMediaPlayback.svelte.js';
3
+ // A minimal stand-in for HTMLMediaElement. We only need the surface the
4
+ // orchestrator touches: currentTime, duration, paused, play(), pause(), and
5
+ // add/removeEventListener. Built on EventTarget so we can dispatch real
6
+ // 'timeupdate' events to drive the snippet queue.
7
+ function createFakeMedia(duration = 100) {
8
+ const target = new EventTarget();
9
+ const el = {
10
+ currentTime: 0,
11
+ duration,
12
+ paused: true,
13
+ ended: false,
14
+ play: vi.fn(function () {
15
+ el.paused = false;
16
+ target.dispatchEvent(new Event('play'));
17
+ return Promise.resolve();
18
+ }),
19
+ pause: vi.fn(function () {
20
+ el.paused = true;
21
+ target.dispatchEvent(new Event('pause'));
22
+ }),
23
+ addEventListener: vi.fn((type, cb) => target.addEventListener(type, cb)),
24
+ removeEventListener: vi.fn((type, cb) => target.removeEventListener(type, cb)),
25
+ // Test helper: set currentTime then fire a timeupdate.
26
+ tick(time) {
27
+ el.currentTime = time;
28
+ target.dispatchEvent(new Event('timeupdate'));
29
+ }
30
+ };
31
+ return el;
32
+ }
33
+ describe('createMediaPlayback', () => {
34
+ it('playFrom seeks (clamped) and plays', () => {
35
+ const media = createFakeMedia(100);
36
+ const pb = createMediaPlayback();
37
+ pb.attach(media);
38
+ pb.playFrom(42);
39
+ expect(media.currentTime).toBe(42);
40
+ expect(media.play).toHaveBeenCalled();
41
+ expect(pb.isPlaying).toBe(true);
42
+ // Out-of-range seeks clamp into [0, duration].
43
+ pb.playFrom(999);
44
+ expect(media.currentTime).toBe(100);
45
+ pb.playFrom(-5);
46
+ expect(media.currentTime).toBe(0);
47
+ });
48
+ it('playFrom ignores NaN; Infinity clamps to duration (matching createMediaSync.seek)', () => {
49
+ const media = createFakeMedia(100);
50
+ const pb = createMediaPlayback();
51
+ pb.attach(media);
52
+ media.currentTime = 10;
53
+ // NaN never clears the isFinite guard — left untouched.
54
+ pb.playFrom(NaN);
55
+ expect(media.currentTime).toBe(10);
56
+ // Infinity clamps to the (finite) duration, exactly like createMediaSync.
57
+ pb.playFrom(Infinity);
58
+ expect(media.currentTime).toBe(100);
59
+ });
60
+ it('playSnippets advances on timeupdate and stops at the end', () => {
61
+ const media = createFakeMedia(100);
62
+ const pb = createMediaPlayback();
63
+ pb.attach(media);
64
+ pb.playSnippets([10, 30, 50], 2);
65
+ expect(media.currentTime).toBe(10);
66
+ expect(pb.activeSnippetIndex).toBe(0);
67
+ expect(media.play).toHaveBeenCalled();
68
+ // Still within snippet 0 — no advance.
69
+ media.tick(11);
70
+ expect(pb.activeSnippetIndex).toBe(0);
71
+ // Snippet 0 duration elapsed — advance to snippet 1.
72
+ media.tick(12);
73
+ expect(pb.activeSnippetIndex).toBe(1);
74
+ expect(media.currentTime).toBe(30);
75
+ // Advance to snippet 2.
76
+ media.tick(32);
77
+ expect(pb.activeSnippetIndex).toBe(2);
78
+ expect(media.currentTime).toBe(50);
79
+ // Queue exhausted — stop.
80
+ media.tick(52);
81
+ expect(pb.activeSnippetIndex).toBe(-1);
82
+ expect(media.pause).toHaveBeenCalled();
83
+ });
84
+ it('playSnippets filters non-finite times and does nothing on an empty list', () => {
85
+ const media = createFakeMedia(100);
86
+ const pb = createMediaPlayback();
87
+ pb.attach(media);
88
+ pb.playSnippets([NaN, 20, Infinity], 2);
89
+ expect(media.currentTime).toBe(20);
90
+ expect(pb.activeSnippetIndex).toBe(0);
91
+ // All non-finite collapses to empty — a no-op that leaves prior state
92
+ // (the in-progress queue from above) untouched.
93
+ const before = media.currentTime;
94
+ pb.playSnippets([NaN, Infinity], 2);
95
+ expect(pb.activeSnippetIndex).toBe(0);
96
+ expect(media.currentTime).toBe(before);
97
+ });
98
+ it('playFrom cancels an active snippet sequence', () => {
99
+ const media = createFakeMedia(100);
100
+ const pb = createMediaPlayback();
101
+ pb.attach(media);
102
+ pb.playSnippets([10, 30], 2);
103
+ expect(pb.activeSnippetIndex).toBe(0);
104
+ pb.playFrom(70);
105
+ expect(pb.activeSnippetIndex).toBe(-1);
106
+ expect(media.currentTime).toBe(70);
107
+ // A timeupdate that would have advanced the old queue does nothing now.
108
+ media.tick(73);
109
+ expect(pb.activeSnippetIndex).toBe(-1);
110
+ });
111
+ it('stop() clears the queue, pauses, and resets activeSnippetIndex', () => {
112
+ const media = createFakeMedia(100);
113
+ const pb = createMediaPlayback();
114
+ pb.attach(media);
115
+ pb.playSnippets([10, 30], 2);
116
+ expect(pb.activeSnippetIndex).toBe(0);
117
+ pb.stop();
118
+ expect(pb.activeSnippetIndex).toBe(-1);
119
+ expect(media.pause).toHaveBeenCalled();
120
+ // Subsequent timeupdates are inert.
121
+ media.tick(40);
122
+ expect(pb.activeSnippetIndex).toBe(-1);
123
+ });
124
+ it('pause() pauses without clearing the queue position', () => {
125
+ const media = createFakeMedia(100);
126
+ const pb = createMediaPlayback();
127
+ pb.attach(media);
128
+ pb.playSnippets([10, 30], 2);
129
+ pb.pause();
130
+ expect(media.pause).toHaveBeenCalled();
131
+ // Queue index is preserved (pause is not stop).
132
+ expect(pb.activeSnippetIndex).toBe(0);
133
+ });
134
+ it('detach() removes listeners and is idempotent', () => {
135
+ const media = createFakeMedia(100);
136
+ const pb = createMediaPlayback();
137
+ pb.attach(media);
138
+ pb.playSnippets([10, 30], 2);
139
+ pb.detach();
140
+ expect(media.removeEventListener).toHaveBeenCalledWith('timeupdate', expect.any(Function));
141
+ expect(media.removeEventListener).toHaveBeenCalledWith('play', expect.any(Function));
142
+ expect(media.removeEventListener).toHaveBeenCalledWith('pause', expect.any(Function));
143
+ expect(media.removeEventListener).toHaveBeenCalledWith('ended', expect.any(Function));
144
+ expect(pb.activeSnippetIndex).toBe(-1);
145
+ expect(pb.isPlaying).toBe(false);
146
+ // After detach, dispatching a timeupdate must not throw or mutate state.
147
+ media.tick(50);
148
+ expect(pb.activeSnippetIndex).toBe(-1);
149
+ // Idempotent.
150
+ expect(() => pb.detach()).not.toThrow();
151
+ });
152
+ it('attach(null) detaches the previous element', () => {
153
+ const media = createFakeMedia(100);
154
+ const pb = createMediaPlayback();
155
+ pb.attach(media);
156
+ pb.attach(null);
157
+ expect(media.removeEventListener).toHaveBeenCalled();
158
+ // Calls into a null element are safe no-ops.
159
+ expect(() => pb.playFrom(10)).not.toThrow();
160
+ expect(media.play).not.toHaveBeenCalled();
161
+ });
162
+ it('re-attaching detaches the old element and clears the queue', () => {
163
+ const a = createFakeMedia(100);
164
+ const b = createFakeMedia(100);
165
+ const pb = createMediaPlayback();
166
+ pb.attach(a);
167
+ pb.playSnippets([10, 30], 2);
168
+ expect(pb.activeSnippetIndex).toBe(0);
169
+ pb.attach(b);
170
+ expect(a.removeEventListener).toHaveBeenCalled();
171
+ expect(pb.activeSnippetIndex).toBe(-1);
172
+ // The old element's timeupdate no longer drives the queue.
173
+ a.tick(40);
174
+ expect(pb.activeSnippetIndex).toBe(-1);
175
+ });
176
+ });
package/dist/index.d.ts CHANGED
@@ -5,8 +5,12 @@ export { default as DraggableWindow } from './DraggableWindow.svelte';
5
5
  export { default as DraggableSketch } from './DraggableSketch.svelte';
6
6
  export { default as CanvasFrame } from './CanvasFrame.svelte';
7
7
  export { default as SplitPane } from './SplitPane.svelte';
8
+ export { default as ActivityBar, type ActivityBarItem } from './ActivityBar.svelte';
9
+ export { default as SidePanel } from './SidePanel.svelte';
8
10
  export { default as HoverTooltip } from './HoverTooltip.svelte';
11
+ export { default as ContextMenu, type ContextMenuItem } from './ContextMenu.svelte';
9
12
  export { default as EntityToggleList, type Entity } from './EntityToggleList.svelte';
10
- export { default as TimelineTrack, type TimelineSegment, type TimelineViewWindow } from './TimelineTrack.svelte';
13
+ export { default as TimelineTrack, type TimelineSegment, type TimelineSelection } from './TimelineTrack.svelte';
11
14
  export { default as TimelineScrubber } from './TimelineScrubber.svelte';
12
15
  export { createMediaSync, type MediaSync } from './createMediaSync.svelte.js';
16
+ export { createMediaPlayback, type MediaPlayback } from './createMediaPlayback.svelte.js';
package/dist/index.js CHANGED
@@ -5,8 +5,12 @@ export { default as DraggableWindow } from './DraggableWindow.svelte';
5
5
  export { default as DraggableSketch } from './DraggableSketch.svelte';
6
6
  export { default as CanvasFrame } from './CanvasFrame.svelte';
7
7
  export { default as SplitPane } from './SplitPane.svelte';
8
+ export { default as ActivityBar } from './ActivityBar.svelte';
9
+ export { default as SidePanel } from './SidePanel.svelte';
8
10
  export { default as HoverTooltip } from './HoverTooltip.svelte';
11
+ export { default as ContextMenu } from './ContextMenu.svelte';
9
12
  export { default as EntityToggleList } from './EntityToggleList.svelte';
10
13
  export { default as TimelineTrack } from './TimelineTrack.svelte';
11
14
  export { default as TimelineScrubber } from './TimelineScrubber.svelte';
12
15
  export { createMediaSync } from './createMediaSync.svelte.js';
16
+ export { createMediaPlayback } from './createMediaPlayback.svelte.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-p5-components",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Higher-level Svelte 5 components built on svelte-p5: responsive canvas, FPS monitor, sketch debug overlay, draggable windows",
5
5
  "keywords": [
6
6
  "svelte",