diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx
new file mode 100644
index 0000000000..07b5417a80
--- /dev/null
+++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx
@@ -0,0 +1,97 @@
+export function ContextMenu({
+ x,
+ y,
+ asset,
+ onClose,
+ onCopy,
+ onDelete,
+ onRename,
+}: {
+ x: number;
+ y: number;
+ asset: string;
+ onClose: () => void;
+ onCopy: (path: string) => void;
+ onDelete?: (path: string) => void;
+ onRename?: (oldPath: string, newPath: string) => void;
+}) {
+ return (
+
{
+ e.preventDefault();
+ onClose();
+ }}
+ >
+
+
+ {onRename && (
+
+ )}
+ {onDelete && (
+
+ )}
+
+
+ );
+}
+
+export function DeleteConfirm({
+ name,
+ onConfirm,
+ onCancel,
+}: {
+ name: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ return (
+
+
Delete {name}?
+
+
+
+
+
+ );
+}
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx
index 4272c543dc..45b8843d2b 100644
--- a/packages/studio/src/components/sidebar/AssetsTab.tsx
+++ b/packages/studio/src/components/sidebar/AssetsTab.tsx
@@ -1,8 +1,19 @@
-import { memo, useState, useCallback, useRef } from "react";
+import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
-import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
+import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import { copyTextToClipboard } from "../../utils/clipboard";
+import { ContextMenu, DeleteConfirm } from "./AssetContextMenu";
+import { usePlayerStore } from "../../player/store/playerStore";
+import {
+ type MediaCategory,
+ getCategory,
+ getAudioSubtype,
+ basename,
+ ext,
+ CATEGORY_LABELS,
+ FILTER_ORDER,
+} from "./assetHelpers";
interface AssetsTabProps {
projectId: string;
@@ -12,98 +23,243 @@ interface AssetsTabProps {
onRename?: (oldPath: string, newPath: string) => void;
}
-/** Inline thumbnail content — rendered inside the container div in AssetCard. */
-function AssetThumbnail({
- serveUrl,
- name,
- isImage,
- isVideo,
- isAudio,
+function AudioRow({
+ projectId,
+ asset,
+ used,
+ meta,
+ onCopy,
+ isCopied,
+ onDelete,
+ onRename,
}: {
- serveUrl: string;
- name: string;
- isImage: boolean;
- isVideo: boolean;
- isAudio: boolean;
+ projectId: string;
+ asset: string;
+ used: boolean;
+ meta?: { description?: string; duration?: number };
+ onCopy: (path: string) => void;
+ isCopied: boolean;
+ onDelete?: (path: string) => void;
+ onRename?: (oldPath: string, newPath: string) => void;
}) {
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ const [bars, setBars] = useState([]);
+ const audioRef = useRef(null);
+ const actxRef = useRef(null);
+ const analyserRef = useRef(null);
+ const sourceRef = useRef(null);
+ const animRef = useRef(0);
+ const name = basename(asset);
+ const subtype = getAudioSubtype(asset);
+ const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
+
+ useEffect(() => {
+ return () => {
+ cancelAnimationFrame(animRef.current);
+ audioRef.current?.pause();
+ actxRef.current?.close();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (playing) {
+ const barCount = 24;
+ const loop = () => {
+ const analyser = analyserRef.current;
+ if (!analyser) {
+ animRef.current = requestAnimationFrame(loop);
+ return;
+ }
+ const data = new Uint8Array(analyser.frequencyBinCount);
+ analyser.getByteFrequencyData(data);
+ const step = Math.floor(data.length / barCount);
+ const next: number[] = [];
+ for (let i = 0; i < barCount; i++) {
+ let sum = 0;
+ for (let j = 0; j < step; j++) sum += data[i * step + j];
+ next.push(sum / step / 255);
+ }
+ setBars(next);
+ if (audioRef.current && !audioRef.current.paused)
+ animRef.current = requestAnimationFrame(loop);
+ };
+ animRef.current = requestAnimationFrame(loop);
+ } else {
+ setBars([]);
+ }
+ return () => cancelAnimationFrame(animRef.current);
+ }, [playing]);
+
+ const togglePlay = useCallback(async () => {
+ if (playing) {
+ audioRef.current?.pause();
+ setPlaying(false);
+ cancelAnimationFrame(animRef.current);
+ return;
+ }
+
+ if (!actxRef.current) {
+ actxRef.current = new AudioContext();
+ analyserRef.current = actxRef.current.createAnalyser();
+ analyserRef.current.fftSize = 256;
+ analyserRef.current.smoothingTimeConstant = 0.7;
+ }
+
+ if (!audioRef.current) {
+ const el = new Audio();
+ el.onended = () => {
+ setPlaying(false);
+ cancelAnimationFrame(animRef.current);
+ };
+ audioRef.current = el;
+ sourceRef.current = actxRef.current.createMediaElementSource(el);
+ sourceRef.current.connect(analyserRef.current!);
+ analyserRef.current!.connect(actxRef.current.destination);
+ el.src = serveUrl;
+ }
+
+ if (actxRef.current.state === "suspended") await actxRef.current.resume();
+ audioRef.current.currentTime = 0;
+ await audioRef.current.play();
+ setPlaying(true);
+ }, [serveUrl, playing]);
+
return (
<>
- {isImage && (
-
{
- (e.target as HTMLImageElement).style.display = "none";
+ onCopy(asset)}
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = "copy";
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
+ e.dataTransfer.setData("text/plain", asset);
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+ }}
+ className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
+ playing
+ ? "bg-panel-accent/[0.06]"
+ : isCopied
+ ? "bg-panel-accent/10"
+ : "hover:bg-panel-surface-hover"
+ }`}
+ >
+
- {/* Context menu */}
{contextMenu && (
- setContextMenu(null)}
- onContextMenu={(e) => {
- e.preventDefault();
- setContextMenu(null);
- }}
- >
-
-
- {onRename && (
-
- )}
- {onDelete && (
-
- )}
-
-
- )}
-
- {/* Delete confirmation */}
- {confirmDelete && (
-
-
Delete {name}?
-
-
-
-
-
+ setContextMenu(null)}
+ onCopy={onCopy}
+ onDelete={onDelete}
+ onRename={onRename}
+ />
)}
>
);
@@ -288,6 +389,26 @@ export const AssetsTab = memo(function AssetsTab({
const fileInputRef = useRef(null);
const [dragOver, setDragOver] = useState(false);
const [copiedPath, setCopiedPath] = useState(null);
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [searchQuery, setSearchQuery] = useState("");
+ const [manifest, setManifest] = useState