@hyperframes/studio 0.8.6 → 0.8.8
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/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
2
|
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
|
|
3
|
+
import { SearchInput } from "../ui/SearchInput";
|
|
3
4
|
import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
4
5
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type MediaCategory,
|
|
9
|
+
type CopyFeedback,
|
|
10
|
+
getCategory,
|
|
11
|
+
basename,
|
|
12
|
+
CATEGORY_LABELS,
|
|
13
|
+
FILTER_ORDER,
|
|
14
|
+
} from "./assetHelpers";
|
|
7
15
|
import { AudioRow } from "./AudioRow";
|
|
8
16
|
import { GlobalAssetsView } from "./GlobalAssetsView";
|
|
9
17
|
import { AssetCard, FontRow } from "./AssetCard";
|
|
@@ -11,7 +19,7 @@ import { AssetCard, FontRow } from "./AssetCard";
|
|
|
11
19
|
interface AssetsTabProps {
|
|
12
20
|
projectId: string;
|
|
13
21
|
assets: string[];
|
|
14
|
-
onImport?: (files: FileList) => void
|
|
22
|
+
onImport?: (files: FileList) => void | Promise<void>;
|
|
15
23
|
onDelete?: (path: string) => void;
|
|
16
24
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
17
25
|
onAddAssetToTimeline?: (path: string) => void;
|
|
@@ -90,6 +98,99 @@ export function deriveUsedPaths(elements: Array<{ src?: string }>): Set<string>
|
|
|
90
98
|
return paths;
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
/** Import trigger. An import is an await, so the button owns the pending state
|
|
102
|
+
* instead of leaving the author clicking a control that looks idle. */
|
|
103
|
+
function ImportButton({ importing, onClick }: { importing: boolean; onClick: () => void }) {
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
onClick={onClick}
|
|
107
|
+
disabled={importing}
|
|
108
|
+
aria-busy={importing}
|
|
109
|
+
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-panel-input px-3 py-[7px] text-[11px] font-medium text-panel-text-3 enabled:hover:text-panel-text-1 enabled:active:scale-[0.98] disabled:opacity-60 transition-colors mb-2.5"
|
|
110
|
+
>
|
|
111
|
+
{importing ? (
|
|
112
|
+
<svg className="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none">
|
|
113
|
+
<circle
|
|
114
|
+
className="opacity-25"
|
|
115
|
+
cx="12"
|
|
116
|
+
cy="12"
|
|
117
|
+
r="10"
|
|
118
|
+
stroke="currentColor"
|
|
119
|
+
strokeWidth="4"
|
|
120
|
+
/>
|
|
121
|
+
<path
|
|
122
|
+
className="opacity-75"
|
|
123
|
+
fill="currentColor"
|
|
124
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
) : (
|
|
128
|
+
<svg
|
|
129
|
+
width="11"
|
|
130
|
+
height="11"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="2.5"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
>
|
|
137
|
+
<path d="M12 5v14M5 12h14" />
|
|
138
|
+
</svg>
|
|
139
|
+
)}
|
|
140
|
+
{importing ? "Importing…" : "Import media"}
|
|
141
|
+
</button>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Empty list body. A query that matched nothing says so and offers a way out;
|
|
146
|
+
* a genuinely empty project gets the drop hint. */
|
|
147
|
+
function EmptyState({
|
|
148
|
+
searchQuery,
|
|
149
|
+
onClearSearch,
|
|
150
|
+
}: {
|
|
151
|
+
searchQuery: string;
|
|
152
|
+
onClearSearch: () => void;
|
|
153
|
+
}) {
|
|
154
|
+
if (searchQuery) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
157
|
+
<p className="text-[11px] text-neutral-500 text-center">
|
|
158
|
+
No assets match “{searchQuery}”
|
|
159
|
+
</p>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={onClearSearch}
|
|
163
|
+
className="px-2.5 py-1 text-[11px] font-medium rounded-md bg-panel-input text-panel-text-3 hover:text-panel-text-1 active:scale-[0.98] transition-colors"
|
|
164
|
+
>
|
|
165
|
+
Clear search
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
172
|
+
<svg
|
|
173
|
+
width="24"
|
|
174
|
+
height="24"
|
|
175
|
+
viewBox="0 0 24 24"
|
|
176
|
+
fill="none"
|
|
177
|
+
stroke="currentColor"
|
|
178
|
+
strokeWidth="1.5"
|
|
179
|
+
className="text-neutral-700"
|
|
180
|
+
>
|
|
181
|
+
<path
|
|
182
|
+
d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"
|
|
183
|
+
strokeLinecap="round"
|
|
184
|
+
strokeLinejoin="round"
|
|
185
|
+
/>
|
|
186
|
+
<polyline points="17 8 12 3 7 8" strokeLinecap="round" strokeLinejoin="round" />
|
|
187
|
+
<line x1="12" y1="3" x2="12" y2="15" strokeLinecap="round" />
|
|
188
|
+
</svg>
|
|
189
|
+
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
93
194
|
export const AssetsTab = memo(function AssetsTab({
|
|
94
195
|
projectId,
|
|
95
196
|
assets,
|
|
@@ -100,7 +201,8 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
100
201
|
}: AssetsTabProps) {
|
|
101
202
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
102
203
|
const [dragOver, setDragOver] = useState(false);
|
|
103
|
-
const [
|
|
204
|
+
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>(null);
|
|
205
|
+
const [importing, setImporting] = useState(false);
|
|
104
206
|
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
|
105
207
|
const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all");
|
|
106
208
|
const [searchQuery, setSearchQuery] = useState("");
|
|
@@ -144,42 +246,55 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
144
246
|
cancelled = true;
|
|
145
247
|
};
|
|
146
248
|
}, [projectId, assetsKey]);
|
|
249
|
+
|
|
250
|
+
const handleImport = useCallback(
|
|
251
|
+
async (files: FileList) => {
|
|
252
|
+
if (!onImport) return;
|
|
253
|
+
setImporting(true);
|
|
254
|
+
try {
|
|
255
|
+
await onImport(files);
|
|
256
|
+
} finally {
|
|
257
|
+
setImporting(false);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[onImport],
|
|
261
|
+
);
|
|
262
|
+
|
|
147
263
|
const handleDrop = useCallback(
|
|
148
264
|
(e: React.DragEvent) => {
|
|
149
265
|
e.preventDefault();
|
|
150
266
|
setDragOver(false);
|
|
151
|
-
if (e.dataTransfer.files.length)
|
|
267
|
+
if (e.dataTransfer.files.length) void handleImport(e.dataTransfer.files);
|
|
152
268
|
},
|
|
153
|
-
[
|
|
269
|
+
[handleImport],
|
|
154
270
|
);
|
|
271
|
+
|
|
155
272
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
156
273
|
const copied = await copyTextToClipboard(path);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
setTimeout(() => setCopiedPath(null), 1500);
|
|
160
|
-
}
|
|
274
|
+
setCopyFeedback({ path, ok: copied });
|
|
275
|
+
setTimeout(() => setCopyFeedback(null), copied ? 1500 : 3000);
|
|
161
276
|
}, []);
|
|
162
277
|
const elements = usePlayerStore((s) => s.elements);
|
|
163
278
|
const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]);
|
|
279
|
+
|
|
280
|
+
// Unfiltered pool — header controls (search, chips) are gated on THIS, not
|
|
281
|
+
// the search-filtered list, so a no-match query can't unmount its own input.
|
|
282
|
+
const allMediaAssets = useMemo(
|
|
283
|
+
() => assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)),
|
|
284
|
+
[assets],
|
|
285
|
+
);
|
|
286
|
+
|
|
164
287
|
const mediaAssets = useMemo(() => {
|
|
165
|
-
const
|
|
166
|
-
const all = filterByUsage(media, usedPaths, usageFilter);
|
|
288
|
+
const all = filterByUsage(allMediaAssets, usedPaths, usageFilter);
|
|
167
289
|
if (!searchQuery) return all;
|
|
168
290
|
const q = searchQuery.toLowerCase();
|
|
169
291
|
return all.filter((a) => {
|
|
170
|
-
if (
|
|
171
|
-
a
|
|
172
|
-
.split("/")
|
|
173
|
-
.pop()
|
|
174
|
-
?.replace(/\.[^.]*$/, "")
|
|
175
|
-
.toLowerCase()
|
|
176
|
-
.includes(q)
|
|
177
|
-
)
|
|
178
|
-
return true;
|
|
292
|
+
if (basename(a).toLowerCase().includes(q)) return true;
|
|
179
293
|
const rec = manifest.get(a);
|
|
180
294
|
return rec?.description?.toLowerCase().includes(q);
|
|
181
295
|
});
|
|
182
|
-
}, [
|
|
296
|
+
}, [allMediaAssets, searchQuery, manifest, usageFilter, usedPaths]);
|
|
297
|
+
|
|
183
298
|
const categorized = useMemo(() => {
|
|
184
299
|
const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
|
|
185
300
|
for (const a of mediaAssets) {
|
|
@@ -244,23 +359,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
244
359
|
{/* Import */}
|
|
245
360
|
{onImport && (
|
|
246
361
|
<>
|
|
247
|
-
<
|
|
248
|
-
onClick={() => fileInputRef.current?.click()}
|
|
249
|
-
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-panel-input px-3 py-[7px] text-[11px] font-medium text-panel-text-3 hover:text-panel-text-1 transition-colors mb-2.5"
|
|
250
|
-
>
|
|
251
|
-
<svg
|
|
252
|
-
width="11"
|
|
253
|
-
height="11"
|
|
254
|
-
viewBox="0 0 24 24"
|
|
255
|
-
fill="none"
|
|
256
|
-
stroke="currentColor"
|
|
257
|
-
strokeWidth="2.5"
|
|
258
|
-
strokeLinecap="round"
|
|
259
|
-
>
|
|
260
|
-
<path d="M12 5v14M5 12h14" />
|
|
261
|
-
</svg>
|
|
262
|
-
Import media
|
|
263
|
-
</button>
|
|
362
|
+
<ImportButton importing={importing} onClick={() => fileInputRef.current?.click()} />
|
|
264
363
|
<input
|
|
265
364
|
ref={fileInputRef}
|
|
266
365
|
type="file"
|
|
@@ -269,7 +368,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
269
368
|
className="hidden"
|
|
270
369
|
onChange={(e) => {
|
|
271
370
|
if (e.target.files?.length) {
|
|
272
|
-
|
|
371
|
+
void handleImport(e.target.files);
|
|
273
372
|
e.target.value = "";
|
|
274
373
|
}
|
|
275
374
|
}}
|
|
@@ -277,45 +376,24 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
277
376
|
</>
|
|
278
377
|
)}
|
|
279
378
|
|
|
280
|
-
{/* Search */}
|
|
281
|
-
{
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
strokeWidth="22"
|
|
290
|
-
className="text-panel-text-5"
|
|
291
|
-
/>
|
|
292
|
-
<line
|
|
293
|
-
x1="170"
|
|
294
|
-
y1="170"
|
|
295
|
-
x2="232"
|
|
296
|
-
y2="232"
|
|
297
|
-
stroke="currentColor"
|
|
298
|
-
strokeWidth="22"
|
|
299
|
-
strokeLinecap="round"
|
|
300
|
-
className="text-panel-text-5"
|
|
301
|
-
/>
|
|
302
|
-
</svg>
|
|
303
|
-
<input
|
|
304
|
-
type="text"
|
|
305
|
-
value={searchQuery}
|
|
306
|
-
onChange={(e) => setSearchQuery(e.target.value)}
|
|
307
|
-
placeholder="Search assets..."
|
|
308
|
-
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
|
309
|
-
/>
|
|
310
|
-
</div>
|
|
379
|
+
{/* Search — gated on the UNFILTERED pool so it never unmounts itself */}
|
|
380
|
+
{allMediaAssets.length > 0 && (
|
|
381
|
+
<SearchInput
|
|
382
|
+
value={searchQuery}
|
|
383
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
384
|
+
placeholder="Search assets..."
|
|
385
|
+
aria-label="Search assets"
|
|
386
|
+
className="mb-2"
|
|
387
|
+
/>
|
|
311
388
|
)}
|
|
312
389
|
|
|
313
390
|
{/* Filter chips */}
|
|
314
|
-
{viewMode === "local" &&
|
|
391
|
+
{viewMode === "local" && allMediaAssets.length > 0 && (
|
|
315
392
|
<div className="flex gap-1.5 flex-wrap">
|
|
316
393
|
<button
|
|
317
394
|
onClick={() => setActiveFilter("all")}
|
|
318
|
-
|
|
395
|
+
aria-pressed={activeFilter === "all"}
|
|
396
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors active:scale-[0.98] ${
|
|
319
397
|
activeFilter === "all"
|
|
320
398
|
? "bg-panel-accent/15 text-panel-accent"
|
|
321
399
|
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
@@ -328,7 +406,8 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
328
406
|
<button
|
|
329
407
|
key={cat}
|
|
330
408
|
onClick={() => setActiveFilter(activeFilter === cat ? "all" : cat)}
|
|
331
|
-
|
|
409
|
+
aria-pressed={activeFilter === cat}
|
|
410
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors active:scale-[0.98] ${
|
|
332
411
|
activeFilter === cat
|
|
333
412
|
? "bg-panel-accent/15 text-panel-accent"
|
|
334
413
|
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
@@ -371,26 +450,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
371
450
|
{viewMode === "global" ? (
|
|
372
451
|
<GlobalAssetsView searchQuery={searchQuery} />
|
|
373
452
|
) : mediaAssets.length === 0 ? (
|
|
374
|
-
<
|
|
375
|
-
<svg
|
|
376
|
-
width="24"
|
|
377
|
-
height="24"
|
|
378
|
-
viewBox="0 0 24 24"
|
|
379
|
-
fill="none"
|
|
380
|
-
stroke="currentColor"
|
|
381
|
-
strokeWidth="1.5"
|
|
382
|
-
className="text-neutral-700"
|
|
383
|
-
>
|
|
384
|
-
<path
|
|
385
|
-
d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"
|
|
386
|
-
strokeLinecap="round"
|
|
387
|
-
strokeLinejoin="round"
|
|
388
|
-
/>
|
|
389
|
-
<polyline points="17 8 12 3 7 8" strokeLinecap="round" strokeLinejoin="round" />
|
|
390
|
-
<line x1="12" y1="3" x2="12" y2="15" strokeLinecap="round" />
|
|
391
|
-
</svg>
|
|
392
|
-
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
|
393
|
-
</div>
|
|
453
|
+
<EmptyState searchQuery={searchQuery} onClearSearch={() => setSearchQuery("")} />
|
|
394
454
|
) : (
|
|
395
455
|
visibleCategories.map((cat) => (
|
|
396
456
|
<div key={cat} className="mb-1">
|
|
@@ -411,7 +471,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
411
471
|
used={usedPaths.has(a)}
|
|
412
472
|
meta={manifest.get(a)}
|
|
413
473
|
onCopy={handleCopyPath}
|
|
414
|
-
|
|
474
|
+
copyFeedback={copyFeedback}
|
|
415
475
|
onDelete={onDelete}
|
|
416
476
|
onRename={onRename}
|
|
417
477
|
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
@@ -427,7 +487,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
427
487
|
used={usedPaths.has(a)}
|
|
428
488
|
duration={manifest.get(a)?.duration}
|
|
429
489
|
onCopy={handleCopyPath}
|
|
430
|
-
|
|
490
|
+
copyFeedback={copyFeedback}
|
|
431
491
|
onDelete={onDelete}
|
|
432
492
|
onRename={onRename}
|
|
433
493
|
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
@@ -442,7 +502,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
442
502
|
asset={a}
|
|
443
503
|
used={usedPaths.has(a)}
|
|
444
504
|
onCopy={handleCopyPath}
|
|
445
|
-
|
|
505
|
+
copyFeedback={copyFeedback}
|
|
446
506
|
onDelete={onDelete}
|
|
447
507
|
onRename={onRename}
|
|
448
508
|
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
2
|
import { ContextMenu } from "./AssetContextMenu";
|
|
3
|
-
import { basename, getAudioSubtype } from "./assetHelpers";
|
|
3
|
+
import { basename, getAudioSubtype, type CopyFeedback } from "./assetHelpers";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
5
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
6
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
@@ -8,13 +8,16 @@ import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior
|
|
|
8
8
|
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
9
9
|
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
10
10
|
|
|
11
|
+
// Only one preview should play at a time; starting a row stops the previous one.
|
|
12
|
+
let stopCurrentPreview: (() => void) | null = null;
|
|
13
|
+
|
|
11
14
|
export function AudioRow({
|
|
12
15
|
projectId,
|
|
13
16
|
asset,
|
|
14
17
|
used,
|
|
15
18
|
meta,
|
|
16
19
|
onCopy,
|
|
17
|
-
|
|
20
|
+
copyFeedback,
|
|
18
21
|
onDelete,
|
|
19
22
|
onRename,
|
|
20
23
|
onAddAssetToTimeline,
|
|
@@ -24,7 +27,7 @@ export function AudioRow({
|
|
|
24
27
|
used: boolean;
|
|
25
28
|
meta?: { description?: string; duration?: number };
|
|
26
29
|
onCopy: (path: string) => void;
|
|
27
|
-
|
|
30
|
+
copyFeedback: CopyFeedback;
|
|
28
31
|
onDelete?: (path: string) => void;
|
|
29
32
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
30
33
|
onAddAssetToTimeline?: (path: string) => void;
|
|
@@ -40,6 +43,14 @@ export function AudioRow({
|
|
|
40
43
|
const name = basename(asset);
|
|
41
44
|
const subtype = getAudioSubtype(asset);
|
|
42
45
|
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
|
|
46
|
+
const isCopied = copyFeedback?.path === asset && copyFeedback.ok;
|
|
47
|
+
const copyFailed = copyFeedback?.path === asset && !copyFeedback.ok;
|
|
48
|
+
|
|
49
|
+
const stopPlayback = useCallback(() => {
|
|
50
|
+
audioRef.current?.pause();
|
|
51
|
+
setPlaying(false);
|
|
52
|
+
cancelAnimationFrame(animRef.current);
|
|
53
|
+
}, []);
|
|
43
54
|
|
|
44
55
|
// CapCut-style click behavior: drag-threshold gate.
|
|
45
56
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
@@ -49,6 +60,36 @@ export function AudioRow({
|
|
|
49
60
|
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
50
61
|
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
51
62
|
|
|
63
|
+
// Reveal the clip when the asset is already on the timeline, otherwise open
|
|
64
|
+
// the preview overlay. Shared by pointer-up and keyboard activation so the
|
|
65
|
+
// row does the same thing however it is operated.
|
|
66
|
+
const activateRow = useCallback(() => {
|
|
67
|
+
if (used) {
|
|
68
|
+
const clip = findClipForAsset(elements, asset);
|
|
69
|
+
if (clip) {
|
|
70
|
+
// Dismiss any open preview overlay (from another asset) — the reveal
|
|
71
|
+
// must not leave a stale preview card floating over the canvas.
|
|
72
|
+
clearPreviewAsset();
|
|
73
|
+
const clipKey = clip.key ?? clip.id;
|
|
74
|
+
setSelectedElementId(clipKey);
|
|
75
|
+
// Scroll the timeline so the selected clip is actually visible.
|
|
76
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Not added → preview overlay (audio player)
|
|
81
|
+
setPreviewAsset(asset, projectId);
|
|
82
|
+
}, [
|
|
83
|
+
used,
|
|
84
|
+
elements,
|
|
85
|
+
asset,
|
|
86
|
+
projectId,
|
|
87
|
+
setSelectedElementId,
|
|
88
|
+
requestTimelineFocus,
|
|
89
|
+
setPreviewAsset,
|
|
90
|
+
clearPreviewAsset,
|
|
91
|
+
]);
|
|
92
|
+
|
|
52
93
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
53
94
|
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
|
54
95
|
}, []);
|
|
@@ -59,32 +100,9 @@ export function AudioRow({
|
|
|
59
100
|
pointerDownRef.current = null;
|
|
60
101
|
if (!origin) return;
|
|
61
102
|
if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
|
|
62
|
-
|
|
63
|
-
const clip = findClipForAsset(elements, asset);
|
|
64
|
-
if (clip) {
|
|
65
|
-
// Dismiss any open preview overlay (from another asset) — the reveal
|
|
66
|
-
// must not leave a stale preview card floating over the canvas.
|
|
67
|
-
clearPreviewAsset();
|
|
68
|
-
const clipKey = clip.key ?? clip.id;
|
|
69
|
-
setSelectedElementId(clipKey);
|
|
70
|
-
// Scroll the timeline so the selected clip is actually visible.
|
|
71
|
-
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
// Not added → preview overlay (audio player)
|
|
76
|
-
setPreviewAsset(asset, projectId);
|
|
103
|
+
activateRow();
|
|
77
104
|
},
|
|
78
|
-
[
|
|
79
|
-
used,
|
|
80
|
-
elements,
|
|
81
|
-
asset,
|
|
82
|
-
projectId,
|
|
83
|
-
setSelectedElementId,
|
|
84
|
-
requestTimelineFocus,
|
|
85
|
-
setPreviewAsset,
|
|
86
|
-
clearPreviewAsset,
|
|
87
|
-
],
|
|
105
|
+
[activateRow],
|
|
88
106
|
);
|
|
89
107
|
|
|
90
108
|
useEffect(() => {
|
|
@@ -92,8 +110,9 @@ export function AudioRow({
|
|
|
92
110
|
cancelAnimationFrame(animRef.current);
|
|
93
111
|
audioRef.current?.pause();
|
|
94
112
|
actxRef.current?.close();
|
|
113
|
+
if (stopCurrentPreview === stopPlayback) stopCurrentPreview = null;
|
|
95
114
|
};
|
|
96
|
-
}, []);
|
|
115
|
+
}, [stopPlayback]);
|
|
97
116
|
|
|
98
117
|
useEffect(() => {
|
|
99
118
|
if (playing) {
|
|
@@ -126,12 +145,15 @@ export function AudioRow({
|
|
|
126
145
|
|
|
127
146
|
const togglePlay = useCallback(async () => {
|
|
128
147
|
if (playing) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
cancelAnimationFrame(animRef.current);
|
|
148
|
+
stopPlayback();
|
|
149
|
+
if (stopCurrentPreview === stopPlayback) stopCurrentPreview = null;
|
|
132
150
|
return;
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
// Stop whichever other row is currently previewing.
|
|
154
|
+
if (stopCurrentPreview && stopCurrentPreview !== stopPlayback) stopCurrentPreview();
|
|
155
|
+
stopCurrentPreview = stopPlayback;
|
|
156
|
+
|
|
135
157
|
if (!actxRef.current) {
|
|
136
158
|
actxRef.current = new AudioContext();
|
|
137
159
|
analyserRef.current = actxRef.current.createAnalyser();
|
|
@@ -146,17 +168,26 @@ export function AudioRow({
|
|
|
146
168
|
cancelAnimationFrame(animRef.current);
|
|
147
169
|
};
|
|
148
170
|
audioRef.current = el;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
171
|
+
const analyser = analyserRef.current;
|
|
172
|
+
if (analyser) {
|
|
173
|
+
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
174
|
+
sourceRef.current.connect(analyser);
|
|
175
|
+
analyser.connect(actxRef.current.destination);
|
|
176
|
+
}
|
|
152
177
|
el.src = serveUrl;
|
|
153
178
|
}
|
|
154
179
|
|
|
155
180
|
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
156
181
|
audioRef.current.currentTime = 0;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
try {
|
|
183
|
+
await audioRef.current.play();
|
|
184
|
+
setPlaying(true);
|
|
185
|
+
} catch {
|
|
186
|
+
// Playback refused (e.g. decode failure) — reset instead of a stuck state.
|
|
187
|
+
setPlaying(false);
|
|
188
|
+
if (stopCurrentPreview === stopPlayback) stopCurrentPreview = null;
|
|
189
|
+
}
|
|
190
|
+
}, [serveUrl, playing, stopPlayback]);
|
|
160
191
|
|
|
161
192
|
return (
|
|
162
193
|
<>
|
|
@@ -164,6 +195,18 @@ export function AudioRow({
|
|
|
164
195
|
draggable
|
|
165
196
|
onPointerDown={handlePointerDown}
|
|
166
197
|
onPointerUp={handlePointerUp}
|
|
198
|
+
role="button"
|
|
199
|
+
tabIndex={0}
|
|
200
|
+
aria-label={`${name} — open, drag to timeline, right-click for actions`}
|
|
201
|
+
onKeyDown={(e) => {
|
|
202
|
+
// Only when the row itself is focused — keydowns bubbling from the
|
|
203
|
+
// inner controls (play button) must keep their native activation.
|
|
204
|
+
if (e.target !== e.currentTarget) return;
|
|
205
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
activateRow();
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
167
210
|
onDragStart={(e) => {
|
|
168
211
|
e.dataTransfer.effectAllowed = "copy";
|
|
169
212
|
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
@@ -173,7 +216,7 @@ export function AudioRow({
|
|
|
173
216
|
e.preventDefault();
|
|
174
217
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
175
218
|
}}
|
|
176
|
-
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-
|
|
219
|
+
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer outline-none focus-visible:bg-neutral-800/60 ${
|
|
177
220
|
playing
|
|
178
221
|
? "bg-panel-accent/[0.06]"
|
|
179
222
|
: isCopied
|
|
@@ -182,7 +225,9 @@ export function AudioRow({
|
|
|
182
225
|
}`}
|
|
183
226
|
>
|
|
184
227
|
<button
|
|
185
|
-
|
|
228
|
+
aria-label={playing ? `Pause preview of ${name}` : `Play preview of ${name}`}
|
|
229
|
+
aria-pressed={playing}
|
|
230
|
+
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-colors active:scale-[0.95] ${
|
|
186
231
|
playing
|
|
187
232
|
? "bg-panel-accent/15 text-panel-accent"
|
|
188
233
|
: "text-panel-text-5 group-hover:text-panel-text-3"
|
|
@@ -221,6 +266,16 @@ export function AudioRow({
|
|
|
221
266
|
in use
|
|
222
267
|
</span>
|
|
223
268
|
)}
|
|
269
|
+
{(isCopied || copyFailed) && (
|
|
270
|
+
<span
|
|
271
|
+
role="status"
|
|
272
|
+
className={`flex-shrink-0 text-[9px] font-medium px-1.5 py-px rounded ${
|
|
273
|
+
copyFailed ? "text-red-400 bg-red-500/10" : "text-panel-accent bg-panel-accent/10"
|
|
274
|
+
}`}
|
|
275
|
+
>
|
|
276
|
+
{copyFailed ? "Copy failed" : "Copied"}
|
|
277
|
+
</span>
|
|
278
|
+
)}
|
|
224
279
|
</div>
|
|
225
280
|
{bars.length > 0 && (
|
|
226
281
|
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|