@hyperframes/studio 0.8.5 → 0.8.7

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 (154) hide show
  1. package/dist/assets/{hyperframes-player-DcM3fVcp.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-DgJrvYGf.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Del6XWZj.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10337 -7200
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useExpandedTimelineElements.test.ts +64 -17
  142. package/src/player/hooks/useExpandedTimelineElements.ts +8 -11
  143. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  144. package/src/player/lib/timelineDOM.ts +51 -0
  145. package/src/player/store/audioSoloSlice.test.ts +113 -0
  146. package/src/player/store/audioSoloSlice.ts +43 -0
  147. package/src/player/store/editingModeSlice.ts +45 -0
  148. package/src/player/store/groupLevels.ts +33 -0
  149. package/src/player/store/keyframeSlice.ts +26 -0
  150. package/src/player/store/playerStore.ts +21 -22
  151. package/src/player/store/timelineElement.ts +10 -0
  152. package/src/telemetry/events.ts +12 -0
  153. package/dist/assets/index-Cx_UrhoW.js +0 -428
  154. 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 { type MediaCategory, getCategory, CATEGORY_LABELS, FILTER_ORDER } from "./assetHelpers";
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 &ldquo;{searchQuery}&rdquo;
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 [copiedPath, setCopiedPath] = useState<string | null>(null);
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) onImport?.(e.dataTransfer.files);
267
+ if (e.dataTransfer.files.length) void handleImport(e.dataTransfer.files);
152
268
  },
153
- [onImport],
269
+ [handleImport],
154
270
  );
271
+
155
272
  const handleCopyPath = useCallback(async (path: string) => {
156
273
  const copied = await copyTextToClipboard(path);
157
- if (copied) {
158
- setCopiedPath(path);
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 media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
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
- }, [assets, searchQuery, manifest, usageFilter, usedPaths]);
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
- <button
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
- onImport(e.target.files);
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
- {mediaAssets.length > 0 && (
282
- <div className="flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] mb-2">
283
- <svg width="12" height="12" viewBox="0 0 256 256" fill="none" className="flex-shrink-0">
284
- <circle
285
- cx="116"
286
- cy="116"
287
- r="76"
288
- stroke="currentColor"
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" && mediaAssets.length > 0 && (
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
- className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
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
- className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
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
- <div className="flex flex-col items-center justify-center h-full px-4 gap-2">
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
- isCopied={copiedPath === a}
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
- isCopied={copiedPath === a}
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
- isCopied={copiedPath === a}
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
- isCopied,
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
- isCopied: boolean;
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
- if (used) {
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
- audioRef.current?.pause();
130
- setPlaying(false);
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
- sourceRef.current = actxRef.current.createMediaElementSource(el);
150
- sourceRef.current.connect(analyserRef.current!);
151
- analyserRef.current!.connect(actxRef.current.destination);
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
- await audioRef.current.play();
158
- setPlaying(true);
159
- }, [serveUrl, playing]);
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-all cursor-pointer ${
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
- className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
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">