mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
688 lines
20 KiB
TypeScript
688 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import {
|
|
ArrowDown01,
|
|
CloudUpload,
|
|
Grid2X2,
|
|
Image,
|
|
List,
|
|
Loader2,
|
|
Music,
|
|
Video,
|
|
} from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useEditor } from "@/hooks/use-editor";
|
|
import { useFileUpload } from "@/hooks/use-file-upload";
|
|
import { useRevealItem } from "@/hooks/use-reveal-item";
|
|
import { processMediaAssets } from "@/lib/media/processing";
|
|
import { cn } from "@/utils/ui";
|
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|
import type { MediaAsset } from "@/types/assets";
|
|
import type { CreateTimelineElement } from "@/types/timeline";
|
|
import { Button } from "@/components/ui/button";
|
|
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { DraggableItem } from "@/components/editor/draggable-item";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
|
|
|
export function MediaView() {
|
|
const editor = useEditor();
|
|
const mediaFiles = editor.media.getAssets();
|
|
const activeProject = editor.project.getActive();
|
|
|
|
const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
|
|
useAssetsPanelStore();
|
|
const { highlightedId, registerElement } = useRevealItem(
|
|
highlightMediaId,
|
|
clearHighlight,
|
|
);
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
|
|
"name",
|
|
);
|
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
|
|
|
const processFiles = async ({ files }: { files: FileList }) => {
|
|
if (!files || files.length === 0) return;
|
|
if (!activeProject) {
|
|
toast.error("No active project");
|
|
return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
setProgress(0);
|
|
try {
|
|
const processedAssets = await processMediaAssets({
|
|
files,
|
|
onProgress: (progress: { progress: number }) =>
|
|
setProgress(progress.progress),
|
|
});
|
|
for (const asset of processedAssets) {
|
|
await editor.media.addMediaAsset({
|
|
projectId: activeProject.metadata.id,
|
|
asset,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing files:", error);
|
|
toast.error("Failed to process files");
|
|
} finally {
|
|
setIsProcessing(false);
|
|
setProgress(0);
|
|
}
|
|
};
|
|
|
|
const { isDragOver, dragProps, openFilePicker, fileInputProps } =
|
|
useFileUpload({
|
|
accept: "image/*,video/*,audio/*",
|
|
multiple: true,
|
|
onFilesSelected: (files) => processFiles({ files }),
|
|
});
|
|
|
|
const handleRemove = async ({
|
|
event,
|
|
id,
|
|
}: {
|
|
event: React.MouseEvent;
|
|
id: string;
|
|
}) => {
|
|
event.stopPropagation();
|
|
|
|
if (!activeProject) {
|
|
toast.error("No active project");
|
|
return;
|
|
}
|
|
|
|
await editor.media.removeMediaAsset({
|
|
projectId: activeProject.metadata.id,
|
|
id,
|
|
});
|
|
};
|
|
|
|
const addElementAtTime = ({
|
|
asset,
|
|
startTime,
|
|
}: {
|
|
asset: MediaAsset;
|
|
startTime: number;
|
|
}): boolean => {
|
|
const element = createElementFromMedia({ asset, startTime });
|
|
editor.timeline.insertElement({
|
|
element,
|
|
placement: { mode: "auto" },
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const filteredMediaItems = useMemo(() => {
|
|
const filtered = mediaFiles.filter((item) => !item.ephemeral);
|
|
|
|
filtered.sort((a, b) => {
|
|
let valueA: string | number;
|
|
let valueB: string | number;
|
|
|
|
switch (sortBy) {
|
|
case "name":
|
|
valueA = a.name.toLowerCase();
|
|
valueB = b.name.toLowerCase();
|
|
break;
|
|
case "type":
|
|
valueA = a.type;
|
|
valueB = b.type;
|
|
break;
|
|
case "duration":
|
|
valueA = a.duration || 0;
|
|
valueB = b.duration || 0;
|
|
break;
|
|
case "size":
|
|
valueA = a.file.size;
|
|
valueB = b.file.size;
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
|
|
if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
return filtered;
|
|
}, [mediaFiles, sortBy, sortOrder]);
|
|
|
|
const previewComponents = useMemo(() => {
|
|
const previews = new Map<string, React.ReactNode>();
|
|
|
|
filteredMediaItems.forEach((item) => {
|
|
previews.set(item.id, <MediaPreview item={item} />);
|
|
});
|
|
|
|
return previews;
|
|
}, [filteredMediaItems]);
|
|
|
|
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
|
|
|
|
return (
|
|
<>
|
|
<input {...fileInputProps} />
|
|
|
|
<div
|
|
className={`relative flex h-full flex-col gap-1 transition-colors ${isDragOver ? "bg-accent/30" : ""}`}
|
|
{...dragProps}
|
|
>
|
|
<div className="bg-panel p-3 pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="foreground"
|
|
onClick={openFilePicker}
|
|
disabled={isProcessing}
|
|
className="w-full"
|
|
>
|
|
{isProcessing ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
<CloudUpload />
|
|
)}
|
|
<span>Upload</span>
|
|
</Button>
|
|
<div className="flex items-center gap-0">
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="text"
|
|
onClick={() =>
|
|
setMediaViewMode(
|
|
mediaViewMode === "grid" ? "list" : "grid",
|
|
)
|
|
}
|
|
disabled={isProcessing}
|
|
className="items-center justify-center"
|
|
>
|
|
{mediaViewMode === "grid" ? (
|
|
<List strokeWidth={1.5} className="!size-[1.05rem]" />
|
|
) : (
|
|
<Grid2X2
|
|
strokeWidth={1.5}
|
|
className="!size-[1.05rem]"
|
|
/>
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
{mediaViewMode === "grid"
|
|
? "Switch to list view"
|
|
: "Switch to grid view"}
|
|
</p>
|
|
</TooltipContent>
|
|
<Tooltip>
|
|
<DropdownMenu>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="text"
|
|
disabled={isProcessing}
|
|
className="items-center justify-center"
|
|
>
|
|
<ArrowDown01
|
|
strokeWidth={1.5}
|
|
className="!size-[1.05rem]"
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<SortMenuItem
|
|
label="Name"
|
|
sortKey="name"
|
|
currentSortBy={sortBy}
|
|
currentSortOrder={sortOrder}
|
|
onSort={({ key }) => {
|
|
if (sortBy === key) {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortBy(key);
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
/>
|
|
<SortMenuItem
|
|
label="Type"
|
|
sortKey="type"
|
|
currentSortBy={sortBy}
|
|
currentSortOrder={sortOrder}
|
|
onSort={({ key }) => {
|
|
if (sortBy === key) {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortBy(key);
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
/>
|
|
<SortMenuItem
|
|
label="Duration"
|
|
sortKey="duration"
|
|
currentSortBy={sortBy}
|
|
currentSortOrder={sortOrder}
|
|
onSort={({ key }) => {
|
|
if (sortBy === key) {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortBy(key);
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
/>
|
|
<SortMenuItem
|
|
label="File size"
|
|
sortKey="size"
|
|
currentSortBy={sortBy}
|
|
currentSortOrder={sortOrder}
|
|
onSort={({ key }) => {
|
|
if (sortBy === key) {
|
|
setSortOrder(
|
|
sortOrder === "asc" ? "desc" : "asc",
|
|
);
|
|
} else {
|
|
setSortBy(key);
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<TooltipContent>
|
|
<p>
|
|
Sort by {sortBy} (
|
|
{sortOrder === "asc" ? "ascending" : "descending"})
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="scrollbar-thin h-full w-full overflow-y-auto pt-1">
|
|
<div className="w-full flex-1 p-3 pt-0">
|
|
{isDragOver || filteredMediaItems.length === 0 ? (
|
|
<MediaDragOverlay
|
|
isVisible={true}
|
|
isProcessing={isProcessing}
|
|
progress={progress}
|
|
onClick={openFilePicker}
|
|
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
|
/>
|
|
) : mediaViewMode === "grid" ? (
|
|
<GridView
|
|
items={filteredMediaItems}
|
|
renderPreview={renderPreview}
|
|
onRemove={handleRemove}
|
|
onAddToTimeline={addElementAtTime}
|
|
highlightedId={highlightedId}
|
|
registerElement={registerElement}
|
|
/>
|
|
) : (
|
|
<ListView
|
|
items={filteredMediaItems}
|
|
renderPreview={renderPreview}
|
|
onRemove={handleRemove}
|
|
onAddToTimeline={addElementAtTime}
|
|
highlightedId={highlightedId}
|
|
registerElement={registerElement}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MediaItemWithContextMenu({
|
|
item,
|
|
children,
|
|
onRemove,
|
|
}: {
|
|
item: MediaAsset;
|
|
children: React.ReactNode;
|
|
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
|
}) {
|
|
return (
|
|
<ContextMenu>
|
|
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem>Export clips</ContextMenuItem>
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={(event) => onRemove({ event, id: item.id })}
|
|
>
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
function GridView({
|
|
items,
|
|
renderPreview,
|
|
onRemove,
|
|
onAddToTimeline,
|
|
highlightedId,
|
|
registerElement,
|
|
}: {
|
|
items: MediaAsset[];
|
|
renderPreview: (item: MediaAsset) => React.ReactNode;
|
|
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
|
onAddToTimeline: ({
|
|
asset,
|
|
startTime,
|
|
}: {
|
|
asset: MediaAsset;
|
|
startTime: number;
|
|
}) => boolean;
|
|
highlightedId: string | null;
|
|
registerElement: (id: string, element: HTMLElement | null) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="grid gap-2"
|
|
style={{
|
|
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
|
}}
|
|
>
|
|
{items.map((item) => (
|
|
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
|
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
|
<DraggableItem
|
|
name={item.name}
|
|
preview={renderPreview(item)}
|
|
dragData={{
|
|
id: item.id,
|
|
type: "media",
|
|
mediaType: item.type,
|
|
name: item.name,
|
|
}}
|
|
shouldShowPlusOnDrag={false}
|
|
onAddToTimeline={({ currentTime }) =>
|
|
onAddToTimeline({ asset: item, startTime: currentTime })
|
|
}
|
|
isRounded={false}
|
|
variant="card"
|
|
isHighlighted={highlightedId === item.id}
|
|
/>
|
|
</MediaItemWithContextMenu>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ListView({
|
|
items,
|
|
renderPreview,
|
|
onRemove,
|
|
onAddToTimeline,
|
|
highlightedId,
|
|
registerElement,
|
|
}: {
|
|
items: MediaAsset[];
|
|
renderPreview: (item: MediaAsset) => React.ReactNode;
|
|
onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
|
|
onAddToTimeline: ({
|
|
asset,
|
|
startTime,
|
|
}: {
|
|
asset: MediaAsset;
|
|
startTime: number;
|
|
}) => boolean;
|
|
highlightedId: string | null;
|
|
registerElement: (id: string, element: HTMLElement | null) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1">
|
|
{items.map((item) => (
|
|
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
|
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
|
<DraggableItem
|
|
name={item.name}
|
|
preview={renderPreview(item)}
|
|
dragData={{
|
|
id: item.id,
|
|
type: "media",
|
|
mediaType: item.type,
|
|
name: item.name,
|
|
}}
|
|
shouldShowPlusOnDrag={false}
|
|
onAddToTimeline={({ currentTime }) =>
|
|
onAddToTimeline({ asset: item, startTime: currentTime })
|
|
}
|
|
variant="compact"
|
|
isHighlighted={highlightedId === item.id}
|
|
/>
|
|
</MediaItemWithContextMenu>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const formatDuration = ({ duration }: { duration: number }) => {
|
|
const min = Math.floor(duration / 60);
|
|
const sec = Math.floor(duration % 60);
|
|
return `${min}:${sec.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
function MediaDurationBadge({ duration }: { duration?: number }) {
|
|
if (!duration) return null;
|
|
|
|
return (
|
|
<div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white">
|
|
{formatDuration({ duration })}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MediaDurationLabel({ duration }: { duration?: number }) {
|
|
if (!duration) return null;
|
|
|
|
return (
|
|
<span className="text-xs opacity-70">{formatDuration({ duration })}</span>
|
|
);
|
|
}
|
|
|
|
function MediaTypePlaceholder({
|
|
icon: Icon,
|
|
label,
|
|
duration,
|
|
variant,
|
|
}: {
|
|
icon: LucideIcon;
|
|
label: string;
|
|
duration?: number;
|
|
variant: "muted" | "bordered";
|
|
}) {
|
|
const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"text-muted-foreground flex size-full flex-col items-center justify-center rounded",
|
|
variant === "muted" ? "bg-muted/30" : "border",
|
|
)}
|
|
>
|
|
<Icon className={iconClassName} aria-hidden="true" />
|
|
<span className="text-xs">{label}</span>
|
|
<MediaDurationLabel duration={duration} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MediaPreview({ item }: { item: MediaAsset }) {
|
|
if (item.type === "image") {
|
|
return (
|
|
<div className="flex size-full items-center justify-center">
|
|
<img
|
|
src={item.url}
|
|
alt={item.name}
|
|
className="max-h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (item.type === "video") {
|
|
if (item.thumbnailUrl) {
|
|
return (
|
|
<div className="relative size-full">
|
|
<img
|
|
src={item.thumbnailUrl}
|
|
alt={item.name}
|
|
className="size-full rounded object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
|
|
<Video className="size-6 text-white drop-shadow-md" />
|
|
</div>
|
|
<MediaDurationBadge duration={item.duration} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MediaTypePlaceholder
|
|
icon={Video}
|
|
label="Video"
|
|
duration={item.duration}
|
|
variant="muted"
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (item.type === "audio") {
|
|
return (
|
|
<MediaTypePlaceholder
|
|
icon={Music}
|
|
label="Audio"
|
|
duration={item.duration}
|
|
variant="bordered"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <MediaTypePlaceholder icon={Image} label="Unknown" variant="muted" />;
|
|
}
|
|
|
|
function SortMenuItem({
|
|
label,
|
|
sortKey,
|
|
currentSortBy,
|
|
currentSortOrder,
|
|
onSort,
|
|
}: {
|
|
label: string;
|
|
sortKey: "name" | "type" | "duration" | "size";
|
|
currentSortBy: string;
|
|
currentSortOrder: "asc" | "desc";
|
|
onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
|
|
}) {
|
|
const isActive = currentSortBy === sortKey;
|
|
const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
|
|
|
|
return (
|
|
<DropdownMenuItem onClick={() => onSort({ key: sortKey })}>
|
|
{label} {arrow}
|
|
</DropdownMenuItem>
|
|
);
|
|
}
|
|
|
|
function createElementFromMedia({
|
|
asset,
|
|
startTime,
|
|
}: {
|
|
asset: MediaAsset;
|
|
startTime: number;
|
|
}): CreateTimelineElement {
|
|
const duration =
|
|
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
|
|
|
switch (asset.type) {
|
|
case "video":
|
|
return {
|
|
type: "video",
|
|
name: asset.name,
|
|
mediaId: asset.id,
|
|
startTime,
|
|
duration,
|
|
trimStart: 0,
|
|
trimEnd: 0,
|
|
muted: false,
|
|
hidden: false,
|
|
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
|
|
opacity: 1,
|
|
};
|
|
case "image":
|
|
return {
|
|
type: "image",
|
|
name: asset.name,
|
|
mediaId: asset.id,
|
|
startTime,
|
|
duration,
|
|
trimStart: 0,
|
|
trimEnd: 0,
|
|
hidden: false,
|
|
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
|
|
opacity: 1,
|
|
};
|
|
case "audio":
|
|
return {
|
|
type: "audio",
|
|
sourceType: "upload",
|
|
name: asset.name,
|
|
mediaId: asset.id,
|
|
startTime,
|
|
duration,
|
|
trimStart: 0,
|
|
trimEnd: 0,
|
|
volume: 1,
|
|
muted: false,
|
|
};
|
|
default:
|
|
throw new Error(`Unsupported media type: ${asset.type}`);
|
|
}
|
|
}
|