mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add ultracite (#452)
* Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
@@ -7,10 +7,10 @@ interface AudioWaveformProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
height = 32,
|
||||
className = ''
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
height = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurfer = useRef<WaveSurfer | null>(null);
|
||||
@@ -36,26 +36,26 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
|
||||
wavesurfer.current = WaveSurfer.create({
|
||||
container: waveformRef.current,
|
||||
waveColor: 'rgba(255, 255, 255, 0.6)',
|
||||
progressColor: 'rgba(255, 255, 255, 0.9)',
|
||||
cursorColor: 'transparent',
|
||||
waveColor: "rgba(255, 255, 255, 0.6)",
|
||||
progressColor: "rgba(255, 255, 255, 0.9)",
|
||||
cursorColor: "transparent",
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
height: height,
|
||||
height,
|
||||
normalize: true,
|
||||
interact: false,
|
||||
});
|
||||
|
||||
// Event listeners
|
||||
wavesurfer.current.on('ready', () => {
|
||||
wavesurfer.current.on("ready", () => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
setError(false);
|
||||
}
|
||||
});
|
||||
|
||||
wavesurfer.current.on('error', (err) => {
|
||||
console.error('WaveSurfer error:', err);
|
||||
wavesurfer.current.on("error", (err) => {
|
||||
console.error("WaveSurfer error:", err);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
@@ -63,9 +63,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
});
|
||||
|
||||
await wavesurfer.current.load(audioUrl);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize WaveSurfer:', err);
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setIsLoading(false);
|
||||
@@ -90,7 +89,10 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
|
||||
<div
|
||||
className={`flex items-center justify-center ${className}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<span className="text-xs text-foreground/60">Audio unavailable</span>
|
||||
</div>
|
||||
);
|
||||
@@ -103,13 +105,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
<span className="text-xs text-foreground/60">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={waveformRef}
|
||||
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
|
||||
<div
|
||||
ref={waveformRef}
|
||||
className={`w-full transition-opacity duration-200 ${isLoading ? "opacity-0" : "opacity-100"}`}
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioWaveform;
|
||||
export default AudioWaveform;
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { Upload, Plus, Image } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MediaDragOverlayProps {
|
||||
isVisible: boolean;
|
||||
isProcessing?: boolean;
|
||||
progress?: number;
|
||||
onClick?: () => void;
|
||||
isEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function MediaDragOverlay({
|
||||
isVisible,
|
||||
isProcessing = false,
|
||||
progress = 0,
|
||||
onClick,
|
||||
isEmptyState = false,
|
||||
}: MediaDragOverlayProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isProcessing || !onClick) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Upload className="h-10 w-10 text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{isProcessing
|
||||
? `Processing your files (${progress}%)`
|
||||
: "Drag and drop videos, photos, and audio files here"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="w-full bg-muted/50 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Upload, Plus, Image } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MediaDragOverlayProps {
|
||||
isVisible: boolean;
|
||||
isProcessing?: boolean;
|
||||
progress?: number;
|
||||
onClick?: () => void;
|
||||
isEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function MediaDragOverlay({
|
||||
isVisible,
|
||||
isProcessing = false,
|
||||
progress = 0,
|
||||
onClick,
|
||||
isEmptyState = false,
|
||||
}: MediaDragOverlayProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isProcessing || !onClick) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Upload className="h-10 w-10 text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{isProcessing
|
||||
? `Processing your files (${progress}%)`
|
||||
: "Drag and drop videos, photos, and audio files here"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="w-full bg-muted/50 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { TextView } from "./views/text";
|
||||
import { AudioView } from "./views/audio";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
audio: <AudioView />,
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Stickers view coming soon...
|
||||
</div>
|
||||
),
|
||||
effects: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
transitions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Captions view coming soon...
|
||||
</div>
|
||||
),
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Filters view coming soon...
|
||||
</div>
|
||||
),
|
||||
adjustment: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
|
||||
<TabBar />
|
||||
<div className="flex-1">{viewMap[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { TextView } from "./views/text";
|
||||
import { AudioView } from "./views/audio";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
audio: <AudioView />,
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Stickers view coming soon...
|
||||
</div>
|
||||
),
|
||||
effects: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
transitions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Captions view coming soon...
|
||||
</div>
|
||||
),
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Filters view coming soon...
|
||||
</div>
|
||||
),
|
||||
adjustment: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
|
||||
<TabBar />
|
||||
<div className="flex-1">{viewMap[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import {
|
||||
CaptionsIcon,
|
||||
ArrowLeftRightIcon,
|
||||
SparklesIcon,
|
||||
StickerIcon,
|
||||
MusicIcon,
|
||||
VideoIcon,
|
||||
BlendIcon,
|
||||
SlidersHorizontalIcon,
|
||||
LucideIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "audio"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
audio: {
|
||||
icon: MusicIcon,
|
||||
label: "Audio",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
label: "Text",
|
||||
},
|
||||
stickers: {
|
||||
icon: StickerIcon,
|
||||
label: "Stickers",
|
||||
},
|
||||
effects: {
|
||||
icon: SparklesIcon,
|
||||
label: "Effects",
|
||||
},
|
||||
transitions: {
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transitions",
|
||||
},
|
||||
captions: {
|
||||
icon: CaptionsIcon,
|
||||
label: "Captions",
|
||||
},
|
||||
filters: {
|
||||
icon: BlendIcon,
|
||||
label: "Filters",
|
||||
},
|
||||
adjustment: {
|
||||
icon: SlidersHorizontalIcon,
|
||||
label: "Adjustment",
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}));
|
||||
import {
|
||||
CaptionsIcon,
|
||||
ArrowLeftRightIcon,
|
||||
SparklesIcon,
|
||||
StickerIcon,
|
||||
MusicIcon,
|
||||
VideoIcon,
|
||||
BlendIcon,
|
||||
SlidersHorizontalIcon,
|
||||
LucideIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "audio"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
audio: {
|
||||
icon: MusicIcon,
|
||||
label: "Audio",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
label: "Text",
|
||||
},
|
||||
stickers: {
|
||||
icon: StickerIcon,
|
||||
label: "Stickers",
|
||||
},
|
||||
effects: {
|
||||
icon: SparklesIcon,
|
||||
label: "Effects",
|
||||
},
|
||||
transitions: {
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transitions",
|
||||
},
|
||||
captions: {
|
||||
icon: CaptionsIcon,
|
||||
label: "Captions",
|
||||
},
|
||||
filters: {
|
||||
icon: BlendIcon,
|
||||
label: "Filters",
|
||||
},
|
||||
adjustment: {
|
||||
icon: SlidersHorizontalIcon,
|
||||
label: "Adjustment",
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}));
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||
const [isAtStart, setIsAtStart] = useState(true);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
});
|
||||
setIsAtEnd(true);
|
||||
setIsAtStart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStart = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
});
|
||||
setIsAtStart(true);
|
||||
setIsAtEnd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
scrollContainerRef.current;
|
||||
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
const isAtStartNow = scrollLeft <= 1;
|
||||
setIsAtEnd(isAtEndNow);
|
||||
setIsAtStart(isAtStartNow);
|
||||
}
|
||||
};
|
||||
|
||||
// We're using useEffect because we need to sync with external DOM scroll events
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ScrollButton
|
||||
direction="left"
|
||||
onClick={scrollToStart}
|
||||
isVisible={!isAtStart}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<tab.icon className="!size-[1.1rem]" />
|
||||
<span className="text-[0.65rem]">{tab.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollButton
|
||||
direction="right"
|
||||
onClick={scrollToEnd}
|
||||
isVisible={!isAtEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
isVisible,
|
||||
}: {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="!size-4 text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||
const [isAtStart, setIsAtStart] = useState(true);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
});
|
||||
setIsAtEnd(true);
|
||||
setIsAtStart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStart = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
});
|
||||
setIsAtStart(true);
|
||||
setIsAtEnd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
scrollContainerRef.current;
|
||||
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
const isAtStartNow = scrollLeft <= 1;
|
||||
setIsAtEnd(isAtEndNow);
|
||||
setIsAtStart(isAtStartNow);
|
||||
}
|
||||
};
|
||||
|
||||
// We're using useEffect because we need to sync with external DOM scroll events
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ScrollButton
|
||||
direction="left"
|
||||
onClick={scrollToStart}
|
||||
isVisible={!isAtStart}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<tab.icon className="!size-[1.1rem]" />
|
||||
<span className="text-[0.65rem]">{tab.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollButton
|
||||
direction="right"
|
||||
onClick={scrollToEnd}
|
||||
isVisible={!isAtEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
isVisible,
|
||||
}: {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="!size-4 text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AudioView() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-4">
|
||||
<Input
|
||||
placeholder="Search songs and artists"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AudioView() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-4">
|
||||
<Input
|
||||
placeholder="Search songs and artists"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,298 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
for (const item of processedItems) {
|
||||
await addMediaItem(activeProject.id, item);
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// When files are selected via file picker, process them
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
e.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Media store now handles cascade deletion automatically
|
||||
await removeMediaItem(activeProject.id, id);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
// Format seconds as mm:ss
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="p-3 pb-2">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex gap-2">
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
for (const item of processedItems) {
|
||||
await addMediaItem(activeProject.id, item);
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// When files are selected via file picker, process them
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
e.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Media store now handles cascade deletion automatically
|
||||
await removeMediaItem(activeProject.id, id);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
// Format seconds as mm:ss
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="p-3 pb-2">
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex gap-2">
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { type TextElement } from "@/types/timeline";
|
||||
|
||||
let textData: TextElement = {
|
||||
id: "default-text",
|
||||
type: "text",
|
||||
name: "Default text",
|
||||
content: "Default text",
|
||||
fontSize: 48,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
textAlign: "center" as const,
|
||||
fontWeight: "normal" as const,
|
||||
fontStyle: "normal" as const,
|
||||
textDecoration: "none" as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
id: textData.id,
|
||||
type: textData.type,
|
||||
name: textData.name,
|
||||
content: textData.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addTextAtTime(textData, currentTime)
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { type TextElement } from "@/types/timeline";
|
||||
|
||||
const textData: TextElement = {
|
||||
id: "default-text",
|
||||
type: "text",
|
||||
name: "Default text",
|
||||
content: "Default text",
|
||||
fontSize: 48,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
textAlign: "center" as const,
|
||||
fontWeight: "normal" as const,
|
||||
fontStyle: "normal" as const,
|
||||
textDecoration: "none" as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
id: textData.id,
|
||||
type: textData.type,
|
||||
name: textData.name,
|
||||
content: textData.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addTextAtTime(textData, currentTime)
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,7 +368,7 @@ export function PreviewPanel() {
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
|
||||
>
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex-1" />
|
||||
{hasAnyElements ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
@@ -402,7 +402,7 @@ export function PreviewPanel() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
<div className="flex-1" />
|
||||
|
||||
<PreviewToolbar
|
||||
hasAnyElements={hasAnyElements}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
}
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyItem({
|
||||
direction = "row",
|
||||
children,
|
||||
className,
|
||||
}: PropertyItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyItem({
|
||||
direction = "row",
|
||||
children,
|
||||
className,
|
||||
}: PropertyItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import { FontFamily } from "@/constants/font-constants";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-[4.5rem] resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
defaultValue={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
updateTextElement(trackId, element.id, { fontSize: value })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={element.fontSize}
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import { FontFamily } from "@/constants/font-constants";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-[4.5rem] resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
defaultValue={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
updateTextElement(trackId, element.id, { fontSize: value })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={element.fontSize}
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SelectionBoxProps {
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentPos: { x: number; y: number } | null;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function SelectionBox({
|
||||
startPos,
|
||||
currentPos,
|
||||
containerRef,
|
||||
isActive,
|
||||
}: SelectionBoxProps) {
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate relative positions within the container
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const currentX = currentPos.x - containerRect.left;
|
||||
const currentY = currentPos.y - containerRect.top;
|
||||
|
||||
// Calculate the selection rectangle bounds
|
||||
const left = Math.min(startX, currentX);
|
||||
const top = Math.min(startY, currentY);
|
||||
const width = Math.abs(currentX - startX);
|
||||
const height = Math.abs(currentY - startY);
|
||||
|
||||
// Update the selection box position and size
|
||||
if (selectionBoxRef.current) {
|
||||
selectionBoxRef.current.style.left = `${left}px`;
|
||||
selectionBoxRef.current.style.top = `${top}px`;
|
||||
selectionBoxRef.current.style.width = `${width}px`;
|
||||
selectionBoxRef.current.style.height = `${height}px`;
|
||||
}
|
||||
}, [startPos, currentPos, isActive, containerRef]);
|
||||
|
||||
if (!isActive || !startPos || !currentPos) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={selectionBoxRef}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--foreground) / 0.1)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SelectionBoxProps {
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentPos: { x: number; y: number } | null;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function SelectionBox({
|
||||
startPos,
|
||||
currentPos,
|
||||
containerRef,
|
||||
isActive,
|
||||
}: SelectionBoxProps) {
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate relative positions within the container
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const currentX = currentPos.x - containerRect.left;
|
||||
const currentY = currentPos.y - containerRect.top;
|
||||
|
||||
// Calculate the selection rectangle bounds
|
||||
const left = Math.min(startX, currentX);
|
||||
const top = Math.min(startY, currentY);
|
||||
const width = Math.abs(currentX - startX);
|
||||
const height = Math.abs(currentY - startY);
|
||||
|
||||
// Update the selection box position and size
|
||||
if (selectionBoxRef.current) {
|
||||
selectionBoxRef.current.style.left = `${left}px`;
|
||||
selectionBoxRef.current.style.top = `${top}px`;
|
||||
selectionBoxRef.current.style.width = `${width}px`;
|
||||
selectionBoxRef.current.style.height = `${height}px`;
|
||||
}
|
||||
}, [startPos, currentPos, isActive, containerRef]);
|
||||
|
||||
if (!isActive || !startPos || !currentPos) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={selectionBoxRef}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--foreground) / 0.1)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function SnapIndicator({
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
|
||||
<div className={"w-0.5 h-full bg-primary/40 opacity-80"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,4 +43,4 @@ export function SpeedControl() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ export function Timeline() {
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -501,7 +501,9 @@ export function Timeline() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
|
||||
className={
|
||||
"h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden"
|
||||
}
|
||||
{...dragProps}
|
||||
onMouseEnter={() => setIsInTimeline(true)}
|
||||
onMouseLeave={() => setIsInTimeline(false)}
|
||||
@@ -598,19 +600,21 @@ export function Timeline() {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute top-0 bottom-0 ${isMainMarker
|
||||
className={`absolute top-0 bottom-0 ${
|
||||
isMainMarker
|
||||
? "border-l border-muted-foreground/40"
|
||||
: "border-l border-muted-foreground/20"
|
||||
}`}
|
||||
}`}
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 text-[0.6rem] ${isMainMarker
|
||||
className={`absolute top-1 left-1 text-[0.6rem] ${
|
||||
isMainMarker
|
||||
? "text-muted-foreground font-medium"
|
||||
: "text-muted-foreground/70"
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{(() => {
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -620,13 +624,14 @@ export function Timeline() {
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
|
||||
} else if (interval >= 1) {
|
||||
return `${Math.floor(secs)}s`;
|
||||
} else {
|
||||
return `${secs.toFixed(1)}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
|
||||
}
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secs)}s`;
|
||||
}
|
||||
return `${secs.toFixed(1)}s`;
|
||||
};
|
||||
return formatTime(time);
|
||||
})()}
|
||||
@@ -709,7 +714,7 @@ export function Timeline() {
|
||||
}}
|
||||
>
|
||||
{tracks.length === 0 ? (
|
||||
<div></div>
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
{tracks.map((track, index) => (
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
getTotalTracksHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
const leftPosition =
|
||||
trackLabelsWidth +
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-[150]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export a hook for getting ruler handlers
|
||||
export function useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
return { handleRulerMouseDown, isDraggingRuler };
|
||||
}
|
||||
|
||||
export { TimelinePlayhead as default };
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
getTotalTracksHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
const leftPosition =
|
||||
trackLabelsWidth +
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-[150]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export a hook for getting ruler handlers
|
||||
export function useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
return { handleRulerMouseDown, isDraggingRuler };
|
||||
}
|
||||
|
||||
export { TimelinePlayhead as default };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user