@r2digisolutions/components 0.2.0 → 0.3.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.
Files changed (65) hide show
  1. package/README.md +10 -0
  2. package/dist/components/atoms/Playhead/Playhead.svelte +44 -5
  3. package/dist/components/atoms/Playhead/Playhead.svelte.d.ts +8 -0
  4. package/dist/components/atoms/TimeRuler/TimeRuler.svelte +75 -16
  5. package/dist/components/atoms/TimeRuler/TimeRuler.svelte.d.ts +7 -0
  6. package/dist/components/atoms/TimelineGrid/TimelineGrid.stories.js +8 -0
  7. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte +39 -0
  8. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte.d.ts +11 -0
  9. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte +7 -0
  10. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte.d.ts +18 -0
  11. package/dist/components/molecules/ColorPicker/ColorPicker.svelte +45 -4
  12. package/dist/components/molecules/ColorPicker/ColorPicker.svelte.d.ts +2 -0
  13. package/dist/components/molecules/ContextMenu/ContextMenu.svelte +38 -11
  14. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte +17 -2
  15. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte.d.ts +5 -0
  16. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte +362 -52
  17. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte.d.ts +22 -2
  18. package/dist/components/molecules/PdfViewer/PdfViewer.svelte.d.ts +1 -1
  19. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte +751 -29
  20. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte.d.ts +14 -1
  21. package/dist/components/molecules/SplitPane/SplitPane.svelte +2 -2
  22. package/dist/components/molecules/TimelineClip/TimelineClip.svelte +95 -18
  23. package/dist/components/molecules/TimelineClip/TimelineClip.svelte.d.ts +3 -0
  24. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte +82 -12
  25. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte.d.ts +13 -2
  26. package/dist/components/molecules/TransportControls/TransportControls.svelte +84 -2
  27. package/dist/components/molecules/TransportControls/TransportControls.svelte.d.ts +13 -1
  28. package/dist/components/molecules/TransportControls/TransportControlsStory.svelte +33 -11
  29. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.stories.js +8 -0
  30. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte +213 -0
  31. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte.d.ts +22 -0
  32. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte +7 -0
  33. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte.d.ts +18 -0
  34. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.stories.js +8 -0
  35. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte +524 -0
  36. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte.d.ts +32 -0
  37. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte +7 -0
  38. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte.d.ts +18 -0
  39. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte +85 -32
  40. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte.d.ts +6 -0
  41. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.stories.js +8 -0
  42. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte +113 -0
  43. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte.d.ts +18 -0
  44. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte +7 -0
  45. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte.d.ts +18 -0
  46. package/dist/components/organisms/AudioEditor/AudioEditor.svelte +52 -1
  47. package/dist/components/organisms/EditorShell/EditorShell.svelte +102 -92
  48. package/dist/components/organisms/FileUploader/FileUploader.stories.d.ts +10 -0
  49. package/dist/components/organisms/FileUploader/FileUploader.stories.js +21 -0
  50. package/dist/components/organisms/FileUploader/FileUploader.svelte +61 -21
  51. package/dist/components/organisms/FileUploader/FileUploader.svelte.d.ts +9 -0
  52. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte +7 -1
  53. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte.d.ts +3 -0
  54. package/dist/components/organisms/VideoEditor/VideoEditor.svelte +1457 -78
  55. package/dist/components/organisms/VideoEditor/VideoEditor.svelte.d.ts +2 -1
  56. package/dist/components/organisms/VideoEditor/VideoEditorStory.svelte +3 -2
  57. package/dist/index.d.ts +11 -3
  58. package/dist/index.js +6 -2
  59. package/dist/styles.css +237 -0
  60. package/dist/utils/mediaTime.d.ts +14 -4
  61. package/dist/utils/mediaTime.js +27 -8
  62. package/dist/utils/mediaTracks.d.ts +154 -0
  63. package/dist/utils/mediaTracks.js +697 -1
  64. package/dist/utils/videoSequence.js +35 -4
  65. package/package.json +4 -2
@@ -1,11 +1,24 @@
1
- import type { MediaTrack } from '../../../utils/mediaTracks.js';
1
+ import type { MediaClip, MediaTrack } from '../../../utils/mediaTracks.js';
2
2
  interface ProgramMonitorProps {
3
3
  width?: number;
4
4
  height?: number;
5
5
  background?: string;
6
6
  currentTimeMs?: number;
7
+ playing?: boolean;
8
+ playbackRate?: number;
9
+ muted?: boolean;
10
+ volume?: number;
7
11
  tracks?: MediaTrack[];
12
+ selectedClipIds?: string[];
13
+ /** When true, selected overlays can be dragged / resized on the frame */
14
+ editable?: boolean;
8
15
  class?: string;
16
+ onselectclip?: (clipId: string, e: MouseEvent) => void;
17
+ onchangeclip?: (clip: MediaClip) => void;
18
+ /** Batch update (multi-drag). Falls back to repeated onchangeclip. */
19
+ onchangeclips?: (clips: MediaClip[]) => void;
20
+ onemptyclick?: () => void;
21
+ onselectionchange?: (clipIds: string[]) => void;
9
22
  }
10
23
  declare const ProgramMonitor: import("svelte").Component<ProgramMonitorProps, {}, "">;
11
24
  type ProgramMonitor = ReturnType<typeof ProgramMonitor>;
@@ -180,7 +180,7 @@
180
180
  className
181
181
  ]}
182
182
  >
183
- <div class="min-h-0 min-w-0 overflow-hidden" style={startStyle}>
183
+ <div class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" style={startStyle}>
184
184
  {#if start}
185
185
  {@render start()}
186
186
  {/if}
@@ -202,7 +202,7 @@
202
202
  />
203
203
  {/if}
204
204
 
205
- <div class="min-h-0 min-w-0 overflow-hidden" style={endStyle}>
205
+ <div class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" style={endStyle}>
206
206
  {#if end}
207
207
  {@render end()}
208
208
  {/if}
@@ -11,9 +11,12 @@
11
11
  locked?: boolean;
12
12
  class?: string;
13
13
  onclick?: (e: MouseEvent) => void;
14
+ oncontextmenu?: (e: MouseEvent) => void;
14
15
  onmove?: (startMs: number) => void;
15
16
  ontrimstart?: (startMs: number) => void;
16
17
  ontrimend?: (endMs: number) => void;
18
+ ondragoverclip?: (e: DragEvent) => void;
19
+ ondropclip?: (e: DragEvent) => void;
17
20
  }
18
21
 
19
22
  let {
@@ -23,39 +26,54 @@
23
26
  locked = false,
24
27
  class: className = '',
25
28
  onclick,
29
+ oncontextmenu,
26
30
  onmove,
27
31
  ontrimstart,
28
- ontrimend
32
+ ontrimend,
33
+ ondragoverclip,
34
+ ondropclip
29
35
  }: TimelineClipProps = $props();
30
36
 
31
37
  const left = $derived(msToPx(clip.startMs, pxPerMs));
32
38
  const width = $derived(Math.max(8, msToPx(clip.endMs - clip.startMs, pxPerMs)));
33
39
  const bg = $derived(clip.color ?? '#3b82f6');
40
+ const inW = $derived(
41
+ clip.animationIn ? Math.min(width * 0.45, msToPx(clip.animationIn.durationMs, pxPerMs)) : 0
42
+ );
43
+ const outW = $derived(
44
+ clip.animationOut ? Math.min(width * 0.45, msToPx(clip.animationOut.durationMs, pxPerMs)) : 0
45
+ );
46
+ const transW = $derived(
47
+ clip.transitionOut
48
+ ? Math.min(width * 0.5, Math.max(10, msToPx(clip.transitionOut.durationMs, pxPerMs)))
49
+ : 0
50
+ );
34
51
 
35
- let dragMode = $state<'move' | 'trimStart' | 'trimEnd' | null>(null);
52
+ let dragMode = $state<'move' | null>(null);
36
53
  let originX = $state(0);
37
54
  let originStart = $state(0);
38
- let originEnd = $state(0);
39
55
 
40
56
  function beginMove(e: PointerEvent) {
41
- if (locked) return;
57
+ if (locked || e.button !== 0) return;
58
+ e.stopPropagation();
59
+ e.preventDefault();
42
60
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
43
61
  dragMode = 'move';
44
62
  originX = e.clientX;
45
63
  originStart = clip.startMs;
46
- originEnd = clip.endMs;
47
64
  }
48
65
 
49
66
  function onPointerMove(e: PointerEvent) {
50
- if (!dragMode || locked) return;
67
+ if (locked) return;
68
+ if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return;
69
+ if (dragMode !== 'move') return;
51
70
  const deltaMs = (e.clientX - originX) / pxPerMs;
52
- if (dragMode === 'move') onmove?.(Math.max(0, originStart + deltaMs));
53
- if (dragMode === 'trimStart') ontrimstart?.(originStart + deltaMs);
54
- if (dragMode === 'trimEnd') ontrimend?.(originEnd + deltaMs);
71
+ onmove?.(Math.max(0, originStart + deltaMs));
55
72
  }
56
73
 
57
74
  function onPointerUp(e: PointerEvent) {
58
- (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
75
+ const el = e.currentTarget as HTMLElement;
76
+ if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
59
77
  dragMode = null;
60
78
  }
61
79
  </script>
@@ -67,27 +85,82 @@
67
85
  aria-label={clip.name}
68
86
  aria-pressed={selected}
69
87
  class={[
70
- 'absolute top-1 bottom-1 overflow-hidden rounded-md border text-left text-xs text-white shadow-sm',
71
- selected ? 'ring-2 ring-white/80 ring-offset-1 ring-offset-transparent' : 'border-black/20',
72
- locked ? 'cursor-not-allowed opacity-70' : 'cursor-grab active:cursor-grabbing',
88
+ 'absolute top-1 bottom-1 z-1 touch-none overflow-hidden rounded-md border text-left text-xs text-white shadow-sm',
89
+ selected
90
+ ? 'z-10 border-2 border-white shadow-md ring-2 ring-brand-500 ring-offset-1 ring-offset-surface'
91
+ : 'border-black/25',
92
+ locked ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing',
73
93
  className
74
94
  ]}
75
95
  style:left={`${left}px`}
76
96
  style:width={`${width}px`}
77
97
  style:background={bg}
98
+ style:opacity={locked ? Math.min(0.7, clip.opacity ?? 1) : (clip.opacity ?? 1)}
99
+ style:filter={selected ? 'brightness(1.12)' : undefined}
100
+ data-marquee-id={clip.id}
78
101
  onclick={(e) => {
79
102
  e.stopPropagation();
80
103
  onclick?.(e);
81
104
  }}
105
+ oncontextmenu={(e) => {
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+ oncontextmenu?.(e);
109
+ }}
82
110
  onpointerdown={beginMove}
83
111
  onpointermove={onPointerMove}
84
112
  onpointerup={onPointerUp}
113
+ ondragover={(e) => {
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ ondragoverclip?.(e);
117
+ }}
118
+ ondrop={(e) => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ ondropclip?.(e);
122
+ }}
85
123
  >
124
+ <!-- CapCut-style: In wedge -->
125
+ {#if clip.animationIn && inW > 2}
126
+ <div
127
+ class="pointer-events-none absolute inset-y-0 left-0 z-0 bg-gradient-to-r from-emerald-400/70 to-transparent"
128
+ style:width={`${inW}px`}
129
+ title={`In: ${clip.animationIn.type}`}
130
+ ></div>
131
+ {/if}
132
+ <!-- CapCut-style: Out wedge -->
133
+ {#if clip.animationOut && outW > 2}
134
+ <div
135
+ class="pointer-events-none absolute inset-y-0 right-0 z-0 bg-gradient-to-l from-amber-400/70 to-transparent"
136
+ style:width={`${outW}px`}
137
+ title={`Out: ${clip.animationOut.type}`}
138
+ ></div>
139
+ {/if}
140
+ <!-- Transition window at clip end -->
141
+ {#if clip.transitionOut && transW > 2}
142
+ <div
143
+ class="pointer-events-none absolute inset-y-0 right-0 z-[1] bg-gradient-to-l from-fuchsia-500/55 to-transparent"
144
+ style:width={`${transW}px`}
145
+ title={`Transition: ${clip.transitionOut.type} · ${(clip.transitionOut.durationMs / 1000).toFixed(1)}s`}
146
+ ></div>
147
+ {/if}
148
+ <!-- CapCut-style: transition diamond at clip end -->
149
+ {#if clip.transitionOut}
150
+ <div
151
+ class="pointer-events-none absolute top-1/2 right-0 z-20 flex h-4 w-4 -translate-y-1/2 translate-x-1/2 items-center justify-center"
152
+ title={`Transition: ${clip.transitionOut.type}`}
153
+ >
154
+ <span
155
+ class="block h-2.5 w-2.5 rotate-45 border border-white/80 bg-fuchsia-400 shadow-sm"
156
+ ></span>
157
+ </div>
158
+ {/if}
159
+
86
160
  <TrimHandle
87
161
  side="start"
88
162
  disabled={locked}
89
163
  ondragstart={(e) => {
90
- dragMode = 'trimStart';
91
164
  originX = e.clientX;
92
165
  originStart = clip.startMs;
93
166
  }}
@@ -96,21 +169,25 @@
96
169
  ontrimstart?.(originStart + deltaMs);
97
170
  }}
98
171
  />
99
- <div class="pointer-events-none flex h-full items-center gap-1.5 overflow-hidden px-2">
172
+ <div class="pointer-events-none relative z-10 flex h-full items-center gap-1.5 overflow-hidden px-2">
100
173
  <MediaKindIcon kind={clip.kind} size="sm" class="shrink-0 opacity-90" />
101
174
  <span class="truncate font-medium">{clip.name}</span>
175
+ {#if clip.animationIn || clip.animationOut}
176
+ <span class="ml-auto shrink-0 rounded bg-black/25 px-1 text-[9px] uppercase tracking-wide">
177
+ {clip.animationIn && clip.animationOut ? 'I/O' : clip.animationIn ? 'In' : 'Out'}
178
+ </span>
179
+ {/if}
102
180
  </div>
103
181
  <TrimHandle
104
182
  side="end"
105
183
  disabled={locked}
106
184
  ondragstart={(e) => {
107
- dragMode = 'trimEnd';
108
185
  originX = e.clientX;
109
- originEnd = clip.endMs;
186
+ originStart = clip.endMs;
110
187
  }}
111
188
  ondrag={(e) => {
112
189
  const deltaMs = (e.clientX - originX) / pxPerMs;
113
- ontrimend?.(originEnd + deltaMs);
190
+ ontrimend?.(originStart + deltaMs);
114
191
  }}
115
192
  />
116
193
  </div>
@@ -6,9 +6,12 @@ interface TimelineClipProps {
6
6
  locked?: boolean;
7
7
  class?: string;
8
8
  onclick?: (e: MouseEvent) => void;
9
+ oncontextmenu?: (e: MouseEvent) => void;
9
10
  onmove?: (startMs: number) => void;
10
11
  ontrimstart?: (startMs: number) => void;
11
12
  ontrimend?: (endMs: number) => void;
13
+ ondragoverclip?: (e: DragEvent) => void;
14
+ ondropclip?: (e: DragEvent) => void;
12
15
  }
13
16
  declare const TimelineClip: import("svelte").Component<TimelineClipProps, {}, "">;
14
17
  type TimelineClip = ReturnType<typeof TimelineClip>;
@@ -3,53 +3,123 @@
3
3
  import TimelineClip from '../TimelineClip/TimelineClip.svelte';
4
4
  import type { MediaClip, MediaTrack } from '../../../utils/mediaTracks.js';
5
5
  import { moveClip, trimClipEnd, trimClipStart } from '../../../utils/mediaTracks.js';
6
+ import { snapMs } from '../../../utils/mediaTime.js';
6
7
 
7
8
  interface TimelineTrackProps {
8
9
  track: MediaTrack;
9
10
  pxPerMs?: number;
10
11
  durationMs?: number;
11
- selectedClipId?: string | null;
12
+ selectedClipIds?: string[];
12
13
  height?: number;
14
+ snapToMs?: number;
15
+ headerWidth?: number;
13
16
  header?: Snippet;
14
17
  class?: string;
15
- onselectclip?: (clipId: string) => void;
18
+ onselectclip?: (clipId: string, e: MouseEvent) => void;
19
+ oncontextmenuclip?: (clipId: string, e: MouseEvent) => void;
16
20
  onchangeclip?: (clip: MediaClip) => void;
21
+ /** Move one or more clips by a shared delta (ms). Prefer over onchangeclip for moves. */
22
+ onmoveclips?: (clipIds: string[], deltaMs: number) => void;
23
+ ondropclip?: (clipId: string, e: DragEvent) => void;
24
+ oncontextmenutrack?: (trackId: string, e: MouseEvent) => void;
25
+ /** Empty lane click (deselect) */
26
+ onemptyclick?: (e: MouseEvent) => void;
27
+ /** Drop library item onto empty track area */
28
+ ondroptrack?: (trackId: string, e: DragEvent, timeMs: number) => void;
17
29
  }
18
30
 
19
31
  let {
20
32
  track,
21
33
  pxPerMs = 0.05,
22
34
  durationMs = 30_000,
23
- selectedClipId = null,
35
+ selectedClipIds = [],
24
36
  height = 44,
37
+ snapToMs = 0,
38
+ headerWidth = 144,
25
39
  header,
26
40
  class: className = '',
27
41
  onselectclip,
28
- onchangeclip
42
+ oncontextmenuclip,
43
+ onchangeclip,
44
+ onmoveclips,
45
+ ondropclip,
46
+ oncontextmenutrack,
47
+ onemptyclick,
48
+ ondroptrack
29
49
  }: TimelineTrackProps = $props();
30
50
 
31
51
  const width = $derived(Math.max(1, durationMs * pxPerMs));
52
+ const selectedSet = $derived(new Set(selectedClipIds));
53
+
54
+ function snap(ms: number) {
55
+ return snapToMs > 0 ? snapMs(ms, snapToMs) : ms;
56
+ }
57
+
58
+ function handleMove(clip: MediaClip, startMs: number) {
59
+ const nextStart = snap(startMs);
60
+ const delta = nextStart - clip.startMs;
61
+ if (delta === 0) return;
62
+ const ids =
63
+ selectedSet.has(clip.id) && selectedClipIds.length > 1 ? [...selectedClipIds] : [clip.id];
64
+ if (onmoveclips) {
65
+ onmoveclips(ids, delta);
66
+ return;
67
+ }
68
+ onchangeclip?.(moveClip(clip, nextStart));
69
+ }
32
70
  </script>
33
71
 
34
- <div class={['flex min-w-0 border-b border-border', className]} style:height={`${height}px`}>
72
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
73
+ <div
74
+ class={['relative z-[1] flex min-w-0 border-b border-border', className]}
75
+ style:height={`${height}px`}
76
+ oncontextmenu={(e) => {
77
+ const t = e.target as HTMLElement;
78
+ if (t.closest('[aria-label]') && t.getAttribute('role') === 'button') return;
79
+ oncontextmenutrack?.(track.id, e);
80
+ }}
81
+ >
35
82
  {#if header}
36
83
  <div
37
- class="sticky left-0 z-10 flex w-36 shrink-0 items-center gap-2 border-r border-border bg-surface-elevated px-2"
84
+ class="sticky left-0 z-10 flex shrink-0 items-center border-r border-border bg-surface-elevated px-1"
85
+ style:width={`${headerWidth}px`}
38
86
  >
39
87
  {@render header()}
40
88
  </div>
41
89
  {/if}
42
- <div class="relative min-w-0 flex-1 overflow-hidden bg-surface/60" style:width={`${width}px`}>
90
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
91
+ <div
92
+ class="relative min-w-0 flex-1 overflow-hidden bg-transparent"
93
+ style:width={`${width}px`}
94
+ role="presentation"
95
+ onclick={(e) => {
96
+ if (e.target === e.currentTarget) onemptyclick?.(e);
97
+ }}
98
+ ondragover={(e) => {
99
+ e.preventDefault();
100
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
101
+ }}
102
+ ondrop={(e) => {
103
+ if ((e.target as HTMLElement).closest('[data-marquee-id]')) return;
104
+ e.preventDefault();
105
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
106
+ const x = e.clientX - rect.left;
107
+ const timeMs = snap(Math.max(0, x / pxPerMs));
108
+ ondroptrack?.(track.id, e, timeMs);
109
+ }}
110
+ >
43
111
  {#each track.clips as clip (clip.id)}
44
112
  <TimelineClip
45
113
  {clip}
46
114
  {pxPerMs}
47
- selected={selectedClipId === clip.id}
115
+ selected={selectedSet.has(clip.id)}
48
116
  locked={track.locked}
49
- onclick={() => onselectclip?.(clip.id)}
50
- onmove={(startMs) => onchangeclip?.(moveClip(clip, startMs))}
51
- ontrimstart={(startMs) => onchangeclip?.(trimClipStart(clip, startMs))}
52
- ontrimend={(endMs) => onchangeclip?.(trimClipEnd(clip, endMs))}
117
+ onclick={(e) => onselectclip?.(clip.id, e)}
118
+ oncontextmenu={(e) => oncontextmenuclip?.(clip.id, e)}
119
+ onmove={(startMs) => handleMove(clip, startMs)}
120
+ ontrimstart={(startMs) => onchangeclip?.(trimClipStart(clip, snap(startMs)))}
121
+ ontrimend={(endMs) => onchangeclip?.(trimClipEnd(clip, snap(endMs)))}
122
+ ondropclip={(e) => ondropclip?.(clip.id, e)}
53
123
  />
54
124
  {/each}
55
125
  </div>
@@ -4,12 +4,23 @@ interface TimelineTrackProps {
4
4
  track: MediaTrack;
5
5
  pxPerMs?: number;
6
6
  durationMs?: number;
7
- selectedClipId?: string | null;
7
+ selectedClipIds?: string[];
8
8
  height?: number;
9
+ snapToMs?: number;
10
+ headerWidth?: number;
9
11
  header?: Snippet;
10
12
  class?: string;
11
- onselectclip?: (clipId: string) => void;
13
+ onselectclip?: (clipId: string, e: MouseEvent) => void;
14
+ oncontextmenuclip?: (clipId: string, e: MouseEvent) => void;
12
15
  onchangeclip?: (clip: MediaClip) => void;
16
+ /** Move one or more clips by a shared delta (ms). Prefer over onchangeclip for moves. */
17
+ onmoveclips?: (clipIds: string[], deltaMs: number) => void;
18
+ ondropclip?: (clipId: string, e: DragEvent) => void;
19
+ oncontextmenutrack?: (trackId: string, e: MouseEvent) => void;
20
+ /** Empty lane click (deselect) */
21
+ onemptyclick?: (e: MouseEvent) => void;
22
+ /** Drop library item onto empty track area */
23
+ ondroptrack?: (trackId: string, e: DragEvent, timeMs: number) => void;
13
24
  }
14
25
  declare const TimelineTrack: import("svelte").Component<TimelineTrackProps, {}, "">;
15
26
  type TimelineTrack = ReturnType<typeof TimelineTrack>;
@@ -1,34 +1,84 @@
1
1
  <script lang="ts">
2
2
  import IconButton from '../../atoms/IconButton/IconButton.svelte';
3
+ import VolumeControl from '../VolumeControl/VolumeControl.svelte';
3
4
  import Toolbar from '../Toolbar/Toolbar.svelte';
4
5
  import { formatTimecode } from '../../../utils/mediaTime.js';
5
6
  import Play from '@lucide/svelte/icons/play';
6
7
  import Pause from '@lucide/svelte/icons/pause';
7
8
  import Square from '@lucide/svelte/icons/square';
9
+ import SkipBack from '@lucide/svelte/icons/skip-back';
10
+ import SkipForward from '@lucide/svelte/icons/skip-forward';
11
+ import ArrowLeftToLine from '@lucide/svelte/icons/arrow-left-to-line';
12
+ import ArrowRightToLine from '@lucide/svelte/icons/arrow-right-to-line';
13
+
14
+ const RATE_OPTIONS = [0.5, 1, 1.5, 2] as const;
8
15
 
9
16
  interface TransportControlsProps {
10
17
  playing?: boolean;
11
18
  currentTimeMs?: number;
12
19
  durationMs?: number;
20
+ playbackRate?: number;
21
+ muted?: boolean;
22
+ volume?: number;
23
+ /** Skip step in ms (default 5s) */
24
+ skipMs?: number;
13
25
  class?: string;
14
26
  onplay?: () => void;
15
27
  onpause?: () => void;
16
28
  onstop?: () => void;
29
+ onseekstart?: () => void;
30
+ onseekend?: () => void;
31
+ onseekback?: () => void;
32
+ onseekforward?: () => void;
33
+ onratechange?: (rate: number) => void;
34
+ onmutechange?: (muted: boolean) => void;
35
+ onvolumechange?: (volume: number) => void;
17
36
  }
18
37
 
19
38
  let {
20
39
  playing = false,
21
40
  currentTimeMs = 0,
22
41
  durationMs = 0,
42
+ playbackRate = $bindable(1),
43
+ muted = $bindable(false),
44
+ volume = $bindable(1),
45
+ skipMs = 5000,
23
46
  class: className = '',
24
47
  onplay,
25
48
  onpause,
26
- onstop
49
+ onstop,
50
+ onseekstart,
51
+ onseekend,
52
+ onseekback,
53
+ onseekforward,
54
+ onratechange,
55
+ onmutechange,
56
+ onvolumechange
27
57
  }: TransportControlsProps = $props();
58
+
59
+ function cycleRate() {
60
+ const idx = RATE_OPTIONS.indexOf(playbackRate as (typeof RATE_OPTIONS)[number]);
61
+ const next = RATE_OPTIONS[(idx + 1) % RATE_OPTIONS.length] ?? 1;
62
+ playbackRate = next;
63
+ onratechange?.(next);
64
+ }
65
+
66
+ const rateLabel = $derived(`${playbackRate}×`);
28
67
  </script>
29
68
 
30
69
  <Toolbar label="Transport" dense class={className}>
31
70
  {#snippet center()}
71
+ <IconButton label="Go to start" size="sm" variant="ghost" onclick={() => onseekstart?.()}>
72
+ <ArrowLeftToLine class="h-4 w-4" />
73
+ </IconButton>
74
+ <IconButton
75
+ label={`Skip back ${skipMs / 1000}s`}
76
+ size="sm"
77
+ variant="ghost"
78
+ onclick={() => onseekback?.()}
79
+ >
80
+ <SkipBack class="h-4 w-4" />
81
+ </IconButton>
32
82
  <IconButton
33
83
  label={playing ? 'Pause' : 'Play'}
34
84
  size="sm"
@@ -41,13 +91,45 @@
41
91
  <Play class="h-4 w-4" />
42
92
  {/if}
43
93
  </IconButton>
94
+ <IconButton
95
+ label={`Skip forward ${skipMs / 1000}s`}
96
+ size="sm"
97
+ variant="ghost"
98
+ onclick={() => onseekforward?.()}
99
+ >
100
+ <SkipForward class="h-4 w-4" />
101
+ </IconButton>
102
+ <IconButton label="Go to end" size="sm" variant="ghost" onclick={() => onseekend?.()}>
103
+ <ArrowRightToLine class="h-4 w-4" />
104
+ </IconButton>
44
105
  <IconButton label="Stop" size="sm" variant="ghost" onclick={() => onstop?.()}>
45
106
  <Square class="h-3.5 w-3.5" />
46
107
  </IconButton>
108
+
109
+ <button
110
+ type="button"
111
+ class="ml-1 inline-flex h-7 min-w-9 items-center justify-center rounded-md px-1.5 font-mono text-[11px] font-semibold tabular-nums text-secondary transition-colors hover:bg-surface-overlay hover:text-primary"
112
+ aria-label={`Playback speed ${rateLabel}`}
113
+ title="Playback speed"
114
+ onclick={cycleRate}
115
+ >
116
+ {rateLabel}
117
+ </button>
118
+
47
119
  <span class="ml-2 font-mono text-xs text-secondary tabular-nums">
48
- {formatTimecode(currentTimeMs, { showMs: true })}
120
+ {formatTimecode(currentTimeMs)}
49
121
  <span class="text-muted"> / </span>
50
122
  {formatTimecode(durationMs)}
51
123
  </span>
52
124
  {/snippet}
125
+
126
+ {#snippet end()}
127
+ <VolumeControl
128
+ bind:value={volume}
129
+ bind:muted
130
+ variant="compact"
131
+ onchange={(v) => onvolumechange?.(v)}
132
+ onmutechange={(m) => onmutechange?.(m)}
133
+ />
134
+ {/snippet}
53
135
  </Toolbar>
@@ -2,11 +2,23 @@ interface TransportControlsProps {
2
2
  playing?: boolean;
3
3
  currentTimeMs?: number;
4
4
  durationMs?: number;
5
+ playbackRate?: number;
6
+ muted?: boolean;
7
+ volume?: number;
8
+ /** Skip step in ms (default 5s) */
9
+ skipMs?: number;
5
10
  class?: string;
6
11
  onplay?: () => void;
7
12
  onpause?: () => void;
8
13
  onstop?: () => void;
14
+ onseekstart?: () => void;
15
+ onseekend?: () => void;
16
+ onseekback?: () => void;
17
+ onseekforward?: () => void;
18
+ onratechange?: (rate: number) => void;
19
+ onmutechange?: (muted: boolean) => void;
20
+ onvolumechange?: (volume: number) => void;
9
21
  }
10
- declare const TransportControls: import("svelte").Component<TransportControlsProps, {}, "">;
22
+ declare const TransportControls: import("svelte").Component<TransportControlsProps, {}, "muted" | "volume" | "playbackRate">;
11
23
  type TransportControls = ReturnType<typeof TransportControls>;
12
24
  export default TransportControls;
@@ -3,16 +3,38 @@
3
3
 
4
4
  let playing = $state(false);
5
5
  let t = $state(1250);
6
+ let playbackRate = $state(1);
7
+ let muted = $state(false);
8
+ let volume = $state(1);
9
+ const durationMs = 30000;
10
+ const SKIP_MS = 5000;
11
+
12
+ function seekBy(delta: number) {
13
+ t = Math.max(0, Math.min(durationMs, t + delta));
14
+ }
6
15
  </script>
7
16
 
8
- <TransportControls
9
- {playing}
10
- currentTimeMs={t}
11
- durationMs={30000}
12
- onplay={() => (playing = true)}
13
- onpause={() => (playing = false)}
14
- onstop={() => {
15
- playing = false;
16
- t = 0;
17
- }}
18
- />
17
+ <div class="space-y-3 p-4">
18
+ <TransportControls
19
+ {playing}
20
+ currentTimeMs={t}
21
+ {durationMs}
22
+ bind:playbackRate
23
+ bind:muted
24
+ bind:volume
25
+ skipMs={SKIP_MS}
26
+ onplay={() => (playing = true)}
27
+ onpause={() => (playing = false)}
28
+ onstop={() => {
29
+ playing = false;
30
+ t = 0;
31
+ }}
32
+ onseekstart={() => (t = 0)}
33
+ onseekend={() => (t = durationMs)}
34
+ onseekback={() => seekBy(-SKIP_MS)}
35
+ onseekforward={() => seekBy(SKIP_MS)}
36
+ />
37
+ <p class="text-xs text-muted">
38
+ Rate {playbackRate}× · {muted ? 'Muted' : `Vol ${Math.round(volume * 100)}%`}
39
+ </p>
40
+ </div>
@@ -0,0 +1,8 @@
1
+ import VideoAnimationsPanelStory from './VideoAnimationsPanelStory.svelte';
2
+ const meta = {
3
+ title: 'Molecules/VideoAnimationsPanel',
4
+ component: VideoAnimationsPanelStory,
5
+ tags: ['autodocs']
6
+ };
7
+ export default meta;
8
+ export const Default = {};