@r2digisolutions/components 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/atoms/Playhead/Playhead.svelte +44 -5
- package/dist/components/atoms/Playhead/Playhead.svelte.d.ts +8 -0
- package/dist/components/atoms/TimeRuler/TimeRuler.svelte +75 -16
- package/dist/components/atoms/TimeRuler/TimeRuler.svelte.d.ts +7 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.stories.js +8 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte +39 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte.d.ts +11 -0
- package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte +7 -0
- package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte.d.ts +18 -0
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte +45 -4
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte.d.ts +2 -0
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte +38 -11
- package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte +17 -2
- package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte.d.ts +5 -0
- package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte +362 -52
- package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte.d.ts +22 -2
- package/dist/components/molecules/PdfViewer/PdfViewer.svelte.d.ts +1 -1
- package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte +751 -29
- package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte.d.ts +14 -1
- package/dist/components/molecules/SplitPane/SplitPane.svelte +2 -2
- package/dist/components/molecules/TimelineClip/TimelineClip.svelte +95 -18
- package/dist/components/molecules/TimelineClip/TimelineClip.svelte.d.ts +3 -0
- package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte +82 -12
- package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte.d.ts +13 -2
- package/dist/components/molecules/TransportControls/TransportControls.svelte +84 -2
- package/dist/components/molecules/TransportControls/TransportControls.svelte.d.ts +13 -1
- package/dist/components/molecules/TransportControls/TransportControlsStory.svelte +33 -11
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte +213 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte.d.ts +22 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte +524 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte.d.ts +32 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte +85 -32
- package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte.d.ts +6 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte +113 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte.d.ts +18 -0
- package/dist/components/organisms/AudioEditor/AudioEditor.svelte +52 -1
- package/dist/components/organisms/EditorShell/EditorShell.svelte +102 -92
- package/dist/components/organisms/FileUploader/FileUploader.stories.d.ts +10 -0
- package/dist/components/organisms/FileUploader/FileUploader.stories.js +21 -0
- package/dist/components/organisms/FileUploader/FileUploader.svelte +61 -21
- package/dist/components/organisms/FileUploader/FileUploader.svelte.d.ts +9 -0
- package/dist/components/organisms/FileUploader/FileUploaderStory.svelte +7 -1
- package/dist/components/organisms/FileUploader/FileUploaderStory.svelte.d.ts +3 -0
- package/dist/components/organisms/VideoEditor/VideoEditor.svelte +1457 -78
- package/dist/components/organisms/VideoEditor/VideoEditor.svelte.d.ts +2 -1
- package/dist/components/organisms/VideoEditor/VideoEditorStory.svelte +3 -2
- package/dist/index.d.ts +11 -3
- package/dist/index.js +6 -2
- package/dist/utils/mediaTime.d.ts +14 -4
- package/dist/utils/mediaTime.js +27 -8
- package/dist/utils/mediaTracks.d.ts +154 -0
- package/dist/utils/mediaTracks.js +697 -1
- package/dist/utils/videoSequence.js +35 -4
- package/package.json +1 -1
|
@@ -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' |
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
186
|
+
originStart = clip.endMs;
|
|
110
187
|
}}
|
|
111
188
|
ondrag={(e) => {
|
|
112
189
|
const deltaMs = (e.clientX - originX) / pxPerMs;
|
|
113
|
-
ontrimend?.(
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
selectedClipIds = [],
|
|
24
36
|
height = 44,
|
|
37
|
+
snapToMs = 0,
|
|
38
|
+
headerWidth = 144,
|
|
25
39
|
header,
|
|
26
40
|
class: className = '',
|
|
27
41
|
onselectclip,
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
115
|
+
selected={selectedSet.has(clip.id)}
|
|
48
116
|
locked={track.locked}
|
|
49
|
-
onclick={() => onselectclip?.(clip.id)}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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>
|