mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
stuff
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PropertyGroup } from "../../properties-panel/property-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { Language, LanguageSelect } from "@/components/language-select";
|
||||
import { LanguageSelect } from "@/components/language-select";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/mediabunny-utils";
|
||||
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { LANGUAGES } from "@/constants/captions-constants";
|
||||
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,17 +19,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
|
||||
export const languages: Language[] = [
|
||||
{ code: "US", name: "English" },
|
||||
{ code: "ES", name: "Spanish" },
|
||||
{ code: "IT", name: "Italian" },
|
||||
{ code: "FR", name: "French" },
|
||||
{ code: "DE", name: "German" },
|
||||
{ code: "PT", name: "Portuguese" },
|
||||
{ code: "RU", name: "Russian" },
|
||||
{ code: "JP", name: "Japanese" },
|
||||
{ code: "CN", name: "Chinese" },
|
||||
];
|
||||
interface TranscriptionSegment {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
|
||||
|
||||
@@ -40,9 +35,8 @@ export function Captions() {
|
||||
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
|
||||
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { insertTrackAt, addElementToTrack } = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
|
||||
// Check if user has already accepted privacy on mount
|
||||
useEffect(() => {
|
||||
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
|
||||
setHasAcceptedPrivacy(hasAccepted);
|
||||
@@ -57,12 +51,8 @@ export function Captions() {
|
||||
const audioBlob = await extractTimelineAudio();
|
||||
|
||||
setProcessingStep("Encrypting audio...");
|
||||
|
||||
// Encrypt the audio with a random key (zero-knowledge)
|
||||
const audioBuffer = await audioBlob.arrayBuffer();
|
||||
const encryptionResult = await encryptWithRandomKey(audioBuffer);
|
||||
|
||||
// Convert encrypted data to blob for upload
|
||||
const encryptedBlob = new Blob([encryptionResult.encryptedData]);
|
||||
|
||||
setProcessingStep("Uploading...");
|
||||
@@ -79,7 +69,6 @@ export function Captions() {
|
||||
|
||||
const { uploadUrl, fileName } = await uploadResponse.json();
|
||||
|
||||
// Upload to R2
|
||||
await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
body: encryptedBlob,
|
||||
@@ -87,7 +76,6 @@ export function Captions() {
|
||||
|
||||
setProcessingStep("Transcribing...");
|
||||
|
||||
// Call Modal transcription API with encryption parameters
|
||||
const transcriptionResponse = await fetch("/api/transcribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -95,7 +83,6 @@ export function Captions() {
|
||||
filename: fileName,
|
||||
language:
|
||||
selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
|
||||
// Send the raw encryption key and IV (zero-knowledge)
|
||||
decryptionKey: arrayBufferToBase64(encryptionResult.key),
|
||||
iv: arrayBufferToBase64(encryptionResult.iv),
|
||||
}),
|
||||
@@ -116,32 +103,23 @@ export function Captions() {
|
||||
duration: number;
|
||||
}> = [];
|
||||
|
||||
let globalEndTime = 0; // Track the end time of the last caption globally
|
||||
let globalEndTime = 0;
|
||||
|
||||
segments.forEach((segment: any) => {
|
||||
segments.forEach((segment: TranscriptionSegment) => {
|
||||
const words = segment.text.trim().split(/\s+/);
|
||||
const segmentDuration = segment.end - segment.start;
|
||||
const wordsPerSecond = words.length / segmentDuration;
|
||||
|
||||
// Split into chunks of 2-4 words
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < words.length; i += 3) {
|
||||
chunks.push(words.slice(i, i + 3).join(" "));
|
||||
}
|
||||
|
||||
// Calculate timing for each chunk to place them sequentially
|
||||
let chunkStartTime = segment.start;
|
||||
chunks.forEach((chunk) => {
|
||||
const chunkWords = chunk.split(/\s+/).length;
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
|
||||
|
||||
let adjustedStartTime = chunkStartTime;
|
||||
|
||||
// Prevent overlapping: if this caption would start before the last one ends,
|
||||
// start it right after the last one ends
|
||||
if (adjustedStartTime < globalEndTime) {
|
||||
adjustedStartTime = globalEndTime;
|
||||
}
|
||||
const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond);
|
||||
const adjustedStartTime = Math.max(chunkStartTime, globalEndTime);
|
||||
|
||||
shortCaptions.push({
|
||||
text: chunk,
|
||||
@@ -149,28 +127,26 @@ export function Captions() {
|
||||
duration: chunkDuration,
|
||||
});
|
||||
|
||||
// Update global end time
|
||||
globalEndTime = adjustedStartTime + chunkDuration;
|
||||
|
||||
// Next chunk starts when this one ends (for within-segment timing)
|
||||
chunkStartTime += chunkDuration;
|
||||
});
|
||||
});
|
||||
|
||||
// Create a single track for all captions
|
||||
const captionTrackId = insertTrackAt("text", 0);
|
||||
const captionTrackId = editor.timeline.addTrack({ type: "text", index: 0 });
|
||||
|
||||
// Add all caption elements to the same track
|
||||
shortCaptions.forEach((caption, index) => {
|
||||
addElementToTrack(captionTrackId, {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
fontSize: 65, // Larger for captions
|
||||
fontWeight: "bold", // Bold for captions
|
||||
} as TextElement);
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: captionTrackId,
|
||||
element: {
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
name: `Caption ${index + 1}`,
|
||||
content: caption.text,
|
||||
duration: caption.duration,
|
||||
startTime: caption.startTime,
|
||||
fontSize: 65,
|
||||
fontWeight: "bold",
|
||||
} as TextElement,
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
@@ -194,7 +170,7 @@ export function Captions() {
|
||||
selectedCountry={selectedCountry}
|
||||
onSelect={setSelectedCountry}
|
||||
containerRef={containerRef}
|
||||
languages={languages}
|
||||
languages={LANGUAGES}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -216,7 +192,7 @@ export function Captions() {
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
|
||||
{isProcessing && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
|
||||
@@ -224,8 +200,8 @@ export function Captions() {
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Audio Processing Notice
|
||||
<Shield className="size-5" />
|
||||
Audio processing notice
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3">
|
||||
<p>
|
||||
@@ -235,7 +211,7 @@ export function Captions() {
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Zero-knowledge encryption - we cannot decrypt your files
|
||||
even if we wanted to
|
||||
@@ -243,7 +219,7 @@ export function Captions() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="h-4 w-4 flex-shrink-0" />
|
||||
<Shield className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Encryption keys generated randomly in your browser, never
|
||||
stored anywhere
|
||||
@@ -251,7 +227,7 @@ export function Captions() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="h-4 w-4 flex-shrink-0" />
|
||||
<Upload className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Audio encrypted before upload - raw audio never leaves
|
||||
your device
|
||||
@@ -259,7 +235,7 @@ export function Captions() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Trash2 className="h-4 w-4 flex-shrink-0" />
|
||||
<Trash2 className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Everything permanently deleted within seconds after
|
||||
transcription
|
||||
@@ -292,7 +268,7 @@ export function Captions() {
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Continue & Generate Captions
|
||||
Continue & generate captions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { processMediaFiles } from "@/lib/media-processing-utils";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { MediaFile } from "@/types/assets";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
ArrowDown01,
|
||||
CloudUpload,
|
||||
@@ -14,9 +11,16 @@ import {
|
||||
Music,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
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-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { canElementGoOnTrack } from "@/lib/timeline/track-utils";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { CreateTimelineElement, TrackType } from "@/types/timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaDragOverlay } from "@/components/editor/assets-panel/drag-overlay";
|
||||
import {
|
||||
@@ -31,9 +35,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -41,50 +43,28 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useAssetsPanelStore } from "../../../../stores/assets-panel-store";
|
||||
|
||||
function MediaItemWithContextMenu({
|
||||
item,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
item: MediaFile;
|
||||
children: React.ReactNode;
|
||||
onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => onRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const mediaFiles = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { mediaViewMode, setMediaViewMode } = usePanelStore();
|
||||
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 { highlightMediaId, clearHighlight } = useAssetsPanelStore();
|
||||
const { highlightedId, registerElement } = useRevealItem(
|
||||
highlightMediaId,
|
||||
clearHighlight,
|
||||
);
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
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");
|
||||
@@ -94,12 +74,16 @@ export function MediaView() {
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
const processedItems = await processMediaFiles({
|
||||
files: files as FileList,
|
||||
onProgress: (p: { progress: number }) => setProgress(p.progress),
|
||||
const processedAssets = await processMediaAssets({
|
||||
files,
|
||||
onProgress: (progress: { progress: number }) =>
|
||||
setProgress(progress.progress),
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaFile(activeProject.id, item);
|
||||
for (const asset of processedAssets) {
|
||||
await editor.media.addMediaAsset({
|
||||
projectId: activeProject.metadata.id,
|
||||
asset,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing files:", error);
|
||||
@@ -114,32 +98,76 @@ export function MediaView() {
|
||||
useFileUpload({
|
||||
accept: "image/*,video/*,audio/*",
|
||||
multiple: true,
|
||||
onFilesSelected: processFiles,
|
||||
onFilesSelected: (files) => processFiles({ files }),
|
||||
});
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const handleRemove = async ({
|
||||
event,
|
||||
id,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
id: string;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
await removeMediaFile(activeProject.id, id);
|
||||
await editor.media.removeMediaAsset({
|
||||
projectId: activeProject.metadata.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 addElementAtTime = ({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const element = createElementFromMedia({ asset, startTime });
|
||||
const trackType = getTrackTypeForMedia({ mediaType: asset.type });
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
const existingTrack = tracks.find((track) => {
|
||||
if (
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime,
|
||||
endTime: startTime + duration,
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTrack) {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: existingTrack.id,
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const newTrackId = editor.timeline.addTrack({ type: trackType });
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: newTrackId,
|
||||
element,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const filteredMediaItems = useMemo(() => {
|
||||
let filtered = mediaFiles.filter((item) => {
|
||||
if (item.ephemeral) return false;
|
||||
return true;
|
||||
});
|
||||
const filtered = mediaFiles.filter((item) => !item.ephemeral);
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let valueA: string | number;
|
||||
@@ -178,84 +206,16 @@ export function MediaView() {
|
||||
const previews = new Map<string, React.ReactNode>();
|
||||
|
||||
filteredMediaItems.forEach((item) => {
|
||||
let preview: React.ReactNode;
|
||||
|
||||
if (item.type === "image") {
|
||||
preview = (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
preview = (
|
||||
<div className="relative h-full w-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="h-full w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
|
||||
<Video className="mb-1 h-6 w-6" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (item.type === "audio") {
|
||||
preview = (
|
||||
<div className="bg-linear-to-br text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
|
||||
<Music className="mb-1 h-6 w-6" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<div className="bg-muted/30 text-muted-foreground flex h-full w-full flex-col items-center justify-center rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="mt-1 text-xs">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
previews.set(item.id, preview);
|
||||
previews.set(item.id, <MediaPreview item={item} />);
|
||||
});
|
||||
|
||||
return previews;
|
||||
}, [filteredMediaItems]);
|
||||
|
||||
const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
|
||||
const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* native file picker, visually hidden */}
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
@@ -265,16 +225,15 @@ export function MediaView() {
|
||||
<div className="bg-panel p-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
variant="foreground"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
className="!bg-background h-9 flex-1 items-center justify-center px-4 opacity-100 transition-opacity hover:opacity-75"
|
||||
className="w-full"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CloudUpload className="h-4 w-4" />
|
||||
<CloudUpload className="size-4" />
|
||||
)}
|
||||
<span>Upload</span>
|
||||
</Button>
|
||||
@@ -328,70 +287,70 @@ export function MediaView() {
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "name") {
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("name");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Name{" "}
|
||||
{sortBy === "name" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "type") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("type");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Type{" "}
|
||||
{sortBy === "type" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "duration") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("duration");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Duration{" "}
|
||||
{sortBy === "duration" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (sortBy === "size") {
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(
|
||||
sortOrder === "asc" ? "desc" : "asc",
|
||||
);
|
||||
} else {
|
||||
setSortBy("size");
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
>
|
||||
File Size{" "}
|
||||
{sortBy === "size" &&
|
||||
(sortOrder === "asc" ? "↑" : "↓")}
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
@@ -419,17 +378,19 @@ export function MediaView() {
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
filteredMediaItems={filteredMediaItems}
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
handleRemove={handleRemove}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
@@ -441,21 +402,52 @@ export function MediaView() {
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
filteredMediaItems,
|
||||
items,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
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;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
@@ -463,22 +455,23 @@ function GridView({
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{filteredMediaItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
rounded={false}
|
||||
isRounded={false}
|
||||
variant="card"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
/>
|
||||
@@ -490,36 +483,43 @@ function GridView({
|
||||
}
|
||||
|
||||
function ListView({
|
||||
filteredMediaItems,
|
||||
items,
|
||||
renderPreview,
|
||||
handleRemove,
|
||||
onRemove,
|
||||
onAddToTimeline,
|
||||
highlightedId,
|
||||
registerElement,
|
||||
}: {
|
||||
filteredMediaItems: MediaFile[];
|
||||
renderPreview: (item: MediaFile) => React.ReactNode;
|
||||
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
|
||||
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;
|
||||
}) {
|
||||
const { addElementAtTime } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{filteredMediaItems.map((item) => (
|
||||
{items.map((item) => (
|
||||
<div key={item.id} ref={(el) => registerElement(item.id, el)}>
|
||||
<MediaItemWithContextMenu item={item} onRemove={handleRemove}>
|
||||
<DraggableMediaItem
|
||||
<MediaItemWithContextMenu item={item} onRemove={onRemove}>
|
||||
<DraggableItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
type: "media",
|
||||
mediaType: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
addElementAtTime(item, currentTime)
|
||||
shouldShowPlusOnDrag={false}
|
||||
onAddToTimeline={({ currentTime }) =>
|
||||
onAddToTimeline({ asset: item, startTime: currentTime })
|
||||
}
|
||||
variant="compact"
|
||||
isHighlighted={highlightedId === item.id}
|
||||
@@ -530,3 +530,176 @@ function ListView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({ item }: { item: MediaAsset }) {
|
||||
const formatDuration = (duration: number) => {
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
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>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 rounded bg-black/70 px-1 text-xs text-white">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
|
||||
<Video className="mb-1 size-6" />
|
||||
<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="bg-linear-to-br text-muted-foreground flex size-full flex-col items-center justify-center rounded border border-green-500/20 from-green-500/20 to-emerald-500/20">
|
||||
<Music className="mb-1 size-6" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/30 text-muted-foreground flex size-full flex-col items-center justify-center rounded">
|
||||
<Image className="size-6" />
|
||||
<span className="mt-1 text-xs">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 getTrackTypeForMedia({
|
||||
mediaType,
|
||||
}: {
|
||||
mediaType: MediaAsset["type"];
|
||||
}): TrackType {
|
||||
switch (mediaType) {
|
||||
case "video":
|
||||
case "image":
|
||||
return "video";
|
||||
case "audio":
|
||||
return "audio";
|
||||
default:
|
||||
return "video";
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported media type: ${asset.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
PropertyGroup,
|
||||
} from "../../properties-panel/property-item";
|
||||
import {
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
FPS_PRESETS,
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
} from "@/constants/editor-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { dimensionToAspectRatio } from "@/lib/editor-utils";
|
||||
import Image from "next/image";
|
||||
@@ -31,6 +31,8 @@ import { useMemo, memo, useCallback } from "react";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TProject } from "@/types/project";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
@@ -66,7 +68,7 @@ function ProjectSettingsTabs() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Another UI, looks so beautiful I don't wanna remove it */}
|
||||
{/* another ui, looks so beautiful I don't wanna remove it */}
|
||||
{/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0">
|
||||
<Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full">
|
||||
<span className="text-sm">Custom</span>
|
||||
@@ -85,17 +87,19 @@ function ProjectSettingsTabs() {
|
||||
function getCurrentCanvasSize({
|
||||
activeProject,
|
||||
}: {
|
||||
activeProject: { canvasSize: { width: number; height: number } } | null;
|
||||
activeProject: TProject;
|
||||
}) {
|
||||
const { canvasSize } = activeProject.settings;
|
||||
|
||||
return {
|
||||
width: activeProject?.canvasSize.width || DEFAULT_CANVAS_SIZE.width,
|
||||
height: activeProject?.canvasSize.height || DEFAULT_CANVAS_SIZE.height,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const { activeProject, updateProjectFps, updateCanvasSize } =
|
||||
useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
@@ -131,15 +135,13 @@ function ProjectInfoView() {
|
||||
const index = parseInt(value, 10);
|
||||
const preset = canvasPresets[index];
|
||||
if (preset) {
|
||||
updateCanvasSize({
|
||||
size: preset,
|
||||
});
|
||||
editor.project.updateSettings({ settings: { canvasSize: preset } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
updateProjectFps(fps);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -147,7 +149,7 @@ function ProjectInfoView() {
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
{activeProject?.name || "Untitled project"}
|
||||
{activeProject.metadata.name}
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
@@ -182,7 +184,7 @@ function ProjectInfoView() {
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="bg-panel-accent">
|
||||
@@ -249,7 +251,7 @@ const BackgroundPreviews = memo(
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: (bg: string) => void;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
@@ -273,7 +275,7 @@ const BackgroundPreviews = memo(
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect(bg)}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
@@ -290,28 +292,40 @@ const BackgroundPreviews = memo(
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await updateBackgroundType("blur", { blurIntensity });
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[updateBackgroundType],
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async (color: string) => {
|
||||
await updateBackgroundType("color", { backgroundColor: color });
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[updateBackgroundType],
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity = activeProject?.blurIntensity || 8;
|
||||
const isBlurBackground = activeProject?.backgroundType === "blur";
|
||||
const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
|
||||
const isColorBackground = activeProject?.backgroundType === "color";
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
@@ -341,7 +355,7 @@ function BackgroundView() {
|
||||
backgrounds={colors}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -353,7 +367,7 @@ function BackgroundView() {
|
||||
backgrounds={patternCraftGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
@@ -364,7 +378,7 @@ function BackgroundView() {
|
||||
backgrounds={syntaxUIGradients}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={handleColorSelect}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -97,7 +97,7 @@ function SoundEffectsView() {
|
||||
loadMore,
|
||||
hasNextPage,
|
||||
isLoadingMore,
|
||||
} = useSoundSearch(searchQuery, showCommercialOnly);
|
||||
} = useSoundSearch({ query: searchQuery, commercialOnly: showCommercialOnly });
|
||||
|
||||
// Audio playback state
|
||||
const [playingId, setPlayingId] = useState<number | null>(null);
|
||||
@@ -120,8 +120,8 @@ function SoundEffectsView() {
|
||||
const fetchTopSounds = async () => {
|
||||
try {
|
||||
if (!ignore) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading({ loading: true });
|
||||
setError({ error: null });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
@@ -134,23 +134,24 @@ function SoundEffectsView() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopSoundEffects(data.results);
|
||||
setHasLoaded(true);
|
||||
setTopSoundEffects({ sounds: data.results });
|
||||
setHasLoaded({ loaded: true });
|
||||
|
||||
setCurrentPage(1);
|
||||
setHasNextPage(!!data.next);
|
||||
setTotalCount(data.count);
|
||||
setCurrentPage({ page: 1 });
|
||||
setHasNextPage({ hasNext: !!data.next });
|
||||
setTotalCount({ count: data.count });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
console.error("Failed to fetch top sounds:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to load sounds"
|
||||
);
|
||||
setError({
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to load sounds",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!ignore) {
|
||||
setLoading(false);
|
||||
setLoading({ loading: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -183,7 +184,7 @@ function SoundEffectsView() {
|
||||
|
||||
const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop } = event.currentTarget;
|
||||
setScrollPosition(scrollTop);
|
||||
setScrollPosition({ position: scrollTop });
|
||||
handleScroll(event);
|
||||
};
|
||||
|
||||
@@ -227,9 +228,9 @@ function SoundEffectsView() {
|
||||
className="bg-panel-accent w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => setSearchQuery({ query: e.target.value })}
|
||||
showClearIcon
|
||||
onClear={() => setSearchQuery("")}
|
||||
onClear={() => setSearchQuery({ query: "" })}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -244,7 +245,7 @@ function SoundEffectsView() {
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showCommercialOnly}
|
||||
onCheckedChange={toggleCommercialFilter}
|
||||
onCheckedChange={() => toggleCommercialFilter()}
|
||||
>
|
||||
Show only commercially licensed
|
||||
</DropdownMenuCheckboxItem>
|
||||
@@ -278,8 +279,8 @@ function SoundEffectsView() {
|
||||
sound={sound}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
onToggleSaved={() => toggleSavedSound(sound)}
|
||||
isSaved={isSoundSaved({ soundId: sound.id })}
|
||||
onToggleSaved={() => toggleSavedSound({ soundEffect: sound })}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !isSearching && displayedSounds.length === 0 && (
|
||||
@@ -464,9 +465,9 @@ function SavedSoundsView() {
|
||||
sound={convertToSoundEffect(sound)}
|
||||
isPlaying={playingId === sound.id}
|
||||
onPlay={() => playSound(sound)}
|
||||
isSaved={isSoundSaved(sound.id)}
|
||||
isSaved={isSoundSaved({ soundId: sound.id })}
|
||||
onToggleSaved={() =>
|
||||
toggleSavedSound(convertToSoundEffect(sound))
|
||||
toggleSavedSound({ soundEffect: convertToSoundEffect(sound) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -509,7 +510,7 @@ function AudioItem({
|
||||
|
||||
const handleAddToTimeline = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
await addSoundToTimeline(sound);
|
||||
await addSoundToTimeline({ sound });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,9 +32,10 @@ import {
|
||||
} from "@/lib/iconify-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { StickerCategory } from "@/stores/stickers-store";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
|
||||
export function StickersView() {
|
||||
@@ -44,8 +45,8 @@ export function StickersView() {
|
||||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
if (["all", "general", "brands", "emoji"].includes(v)) {
|
||||
setSelectedCategory(v as StickerCategory);
|
||||
if (STICKER_CATEGORIES.includes(v as StickerCategory)) {
|
||||
setSelectedCategory({ category: v as StickerCategory });
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
@@ -74,7 +75,7 @@ export function StickersView() {
|
||||
content: <StickersContentView category="emoji" />,
|
||||
},
|
||||
]}
|
||||
className="flex flex-col h-full p-0 overflow-hidden"
|
||||
className="flex h-full flex-col overflow-hidden p-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +125,7 @@ function CollectionGrid({
|
||||
total: number;
|
||||
category?: string;
|
||||
}>;
|
||||
onSelectCollection: (prefix: string) => void;
|
||||
onSelectCollection: ({ prefix }: { prefix: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
@@ -133,7 +134,7 @@ function CollectionGrid({
|
||||
key={collection.prefix}
|
||||
title={collection.name}
|
||||
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
|
||||
onClick={() => onSelectCollection(collection.prefix)}
|
||||
onClick={() => onSelectCollection({ prefix: collection.prefix })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -142,14 +143,14 @@ function CollectionGrid({
|
||||
|
||||
function EmptyView({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<StickerIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
className="text-muted-foreground h-10 w-10"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">No stickers found</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">{message}</p>
|
||||
<p className="text-muted-foreground text-balance text-sm">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -229,9 +230,9 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearchQuery !== searchQuery) {
|
||||
setSearchQuery(localSearchQuery);
|
||||
setSearchQuery({ query: localSearchQuery });
|
||||
if (localSearchQuery.trim()) {
|
||||
searchStickers(localSearchQuery);
|
||||
searchStickers({ query: localSearchQuery });
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -241,7 +242,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
|
||||
const handleAddSticker = async (iconName: string) => {
|
||||
try {
|
||||
await addStickerToTimeline(iconName);
|
||||
await addStickerToTimeline({ iconName });
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
@@ -259,8 +260,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
if (currentCollection.uncategorized) {
|
||||
icons.push(
|
||||
...currentCollection.uncategorized.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,8 +269,8 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
Object.values(currentCollection.categories).forEach((categoryIcons) => {
|
||||
icons.push(
|
||||
...categoryIcons.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`
|
||||
)
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -293,13 +294,13 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 mt-1 h-full p-4">
|
||||
<div className="mt-1 flex h-full flex-col gap-5 p-4">
|
||||
<div className="space-y-3">
|
||||
<InputWithBack
|
||||
isExpanded={isInCollection}
|
||||
setIsExpanded={(expanded) => {
|
||||
if (!expanded && isInCollection) {
|
||||
setSelectedCollection(null);
|
||||
setSelectedCollection({ collection: null });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
@@ -319,24 +320,24 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="flex-1 h-full"
|
||||
className="h-full flex-1"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-full">
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickers.length > 0 && viewMode === "browse" && (
|
||||
<div className="h-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Clock className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={clearRecentStickers}
|
||||
className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center"
|
||||
className="hover:bg-accent ml-auto flex h-5 w-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
<X className="text-muted-foreground h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -358,7 +359,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
<div className="h-full">
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : showCollectionItems ? (
|
||||
<StickerGrid
|
||||
@@ -368,7 +369,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -378,12 +379,12 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : searchResults?.icons.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
@@ -395,7 +396,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
/>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<EmptyView
|
||||
message={`No stickers found for "${searchQuery}"`}
|
||||
/>
|
||||
@@ -405,11 +406,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
onClick={() => {
|
||||
const q = localSearchQuery || searchQuery;
|
||||
if (q) {
|
||||
setSearchQuery(q);
|
||||
setSearchQuery({ query: q });
|
||||
}
|
||||
setSelectedCategory("all");
|
||||
setSelectedCategory({ category: "all" });
|
||||
if (q) {
|
||||
searchStickers(q);
|
||||
searchStickers({ query: q });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -422,26 +423,20 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
)}
|
||||
|
||||
{viewMode === "browse" && !selectedCollection && (
|
||||
<div className="space-y-4 h-full">
|
||||
<div className="h-full space-y-4">
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{category !== "all" && (
|
||||
<div className="h-full">
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Popular{" "}
|
||||
{category === "general"
|
||||
? "Icon Sets"
|
||||
: category === "brands"
|
||||
? "Brand Icons"
|
||||
: "Emoji Sets"}
|
||||
</h3>
|
||||
<CollectionGrid
|
||||
collections={filteredCollections}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -451,9 +446,11 @@ function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
<CollectionGrid
|
||||
collections={filteredCollections.slice(
|
||||
0,
|
||||
collectionsToShow
|
||||
collectionsToShow,
|
||||
)}
|
||||
onSelectCollection={setSelectedCollection}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -478,12 +475,12 @@ function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between h-auto py-2 "
|
||||
className="h-auto justify-between py-2 rounded-md"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -515,13 +512,13 @@ function StickerItem({
|
||||
const collectionPrefix = iconName.split(":")[0];
|
||||
|
||||
const preview = imageError ? (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground text-center break-all">
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-muted-foreground break-all text-center text-xs">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full p-4 flex items-center justify-center">
|
||||
<div className="flex h-full w-full items-center justify-center p-4">
|
||||
<Image
|
||||
src={
|
||||
hostIndex === 0
|
||||
@@ -529,13 +526,13 @@ function StickerItem({
|
||||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 }
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-contain"
|
||||
className="h-full w-full object-contain"
|
||||
style={
|
||||
capSize
|
||||
? {
|
||||
@@ -564,28 +561,28 @@ function StickerItem({
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isAdding && "opacity-50 pointer-events-none"
|
||||
isAdding && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<DraggableMediaItem
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: "sticker-placeholder",
|
||||
type: "image",
|
||||
id: iconName,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
iconName,
|
||||
}}
|
||||
onAddToTimeline={() => onAdd(iconName)}
|
||||
aspectRatio={1}
|
||||
showLabel={false}
|
||||
rounded={true}
|
||||
shouldShowLabel={false}
|
||||
isRounded={true}
|
||||
variant="card"
|
||||
className=""
|
||||
containerClassName="w-full"
|
||||
isDraggable={false}
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
@@ -594,7 +591,7 @@ function StickerItem({
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{collectionPrefix}</p>
|
||||
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { DraggableItem } from "@/components/ui/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
|
||||
export function TextView() {
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
if (!activeScene) return;
|
||||
|
||||
const element = buildTextElement({
|
||||
raw: DEFAULT_TEXT_ELEMENT,
|
||||
startTime: currentTime,
|
||||
});
|
||||
const textTrack = activeScene.tracks.find((t) => t.type === "text");
|
||||
if (textTrack) {
|
||||
editor.timeline.addElementToTrack({
|
||||
trackId: textTrack.id,
|
||||
element,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseView>
|
||||
<DraggableMediaItem
|
||||
<DraggableItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-panel-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
<div className="bg-panel-accent flex size-full items-center justify-center rounded">
|
||||
<span className="select-none text-xs">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
@@ -20,16 +40,8 @@ export function TextView() {
|
||||
content: DEFAULT_TEXT_ELEMENT.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addElementAtTime(
|
||||
{
|
||||
...DEFAULT_TEXT_ELEMENT,
|
||||
id: "temp-text-id",
|
||||
},
|
||||
currentTime
|
||||
)
|
||||
}
|
||||
showLabel={false}
|
||||
onAddToTimeline={handleAddToTimeline}
|
||||
shouldShowLabel={false}
|
||||
/>
|
||||
</BaseView>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Trash,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
@@ -28,6 +27,7 @@ import { ExportButton } from "./export-button";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function EditorHeader() {
|
||||
return (
|
||||
@@ -50,8 +50,8 @@ function ProjectDropdown() {
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const router = useRouter();
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
|
||||
const handleExit = async () => {
|
||||
if (isExiting) return;
|
||||
@@ -69,10 +69,14 @@ function ProjectDropdown() {
|
||||
};
|
||||
|
||||
const handleSaveProjectName = async (newName: string) => {
|
||||
if (activeProject && newName.trim() && newName !== activeProject.name) {
|
||||
if (
|
||||
activeProject &&
|
||||
newName.trim() &&
|
||||
newName !== activeProject.metadata.name
|
||||
) {
|
||||
try {
|
||||
await editor.project.renameProject({
|
||||
id: activeProject.id,
|
||||
id: activeProject.metadata.id,
|
||||
name: newName.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -89,7 +93,7 @@ function ProjectDropdown() {
|
||||
const handleDeleteProject = async () => {
|
||||
if (activeProject) {
|
||||
try {
|
||||
await editor.project.deleteProject({ id: activeProject.id });
|
||||
await editor.project.deleteProject({ id: activeProject.metadata.id });
|
||||
router.push("/projects");
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete project", {
|
||||
@@ -111,7 +115,9 @@ function ProjectDropdown() {
|
||||
className="flex h-auto items-center justify-center px-2.5 py-1.5"
|
||||
>
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
<span className="mr-2 text-[0.85rem]">{activeProject?.name}</span>
|
||||
<span className="mr-2 text-[0.85rem]">
|
||||
{activeProject?.metadata.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-40">
|
||||
@@ -160,13 +166,13 @@ function ProjectDropdown() {
|
||||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={(newName) => handleSaveProjectName(newName)}
|
||||
projectName={activeProject?.name || ""}
|
||||
projectName={activeProject?.metadata.name || ""}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteProject}
|
||||
projectName={activeProject?.name || ""}
|
||||
projectName={activeProject?.metadata.name || ""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
exportProject,
|
||||
getExportMimeType,
|
||||
getExportFileExtension,
|
||||
DEFAULT_EXPORT_OPTIONS,
|
||||
} from "@/lib/export-utils";
|
||||
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
|
||||
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||
import { PropertyGroup } from "./properties-panel/property-item";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
export function ExportButton() {
|
||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||
@@ -28,7 +28,7 @@ export function ExportButton() {
|
||||
setIsExportPopoverOpen(true);
|
||||
};
|
||||
|
||||
const hasProject = !!editor.project.activeProject;
|
||||
const hasProject = !!editor.project.getActive();
|
||||
|
||||
return (
|
||||
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
||||
@@ -70,7 +70,7 @@ function ExportPopover({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.activeProject;
|
||||
const activeProject = editor.project.getActive();
|
||||
const [format, setFormat] = useState<ExportFormat>(
|
||||
DEFAULT_EXPORT_OPTIONS.format,
|
||||
);
|
||||
@@ -94,10 +94,10 @@ function ExportPopover({
|
||||
const result = await exportProject({
|
||||
format,
|
||||
quality,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
includeAudio,
|
||||
onProgress: setProgress,
|
||||
onCancel: () => false, // TODO: Add cancel functionality
|
||||
onProgress: ({ progress }) => setProgress(progress),
|
||||
onCancel: () => false, // TODO: add cancel functionality
|
||||
});
|
||||
|
||||
setIsExporting(false);
|
||||
@@ -112,7 +112,7 @@ function ExportPopover({
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${activeProject.name}${extension}`;
|
||||
a.download = `${activeProject.metadata.name}${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
@@ -287,7 +287,7 @@ function ExportError({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium text-destructive">Export failed</p>
|
||||
<p className="text-destructive text-sm font-medium">Export failed</p>
|
||||
<p className="text-muted-foreground text-xs">{error}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function MigrationDialog() {
|
||||
const editor = useEditor();
|
||||
const migrationState = editor.project.getMigrationState();
|
||||
|
||||
if (!migrationState.isMigrating) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Updating project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upgrading "{migrationState.projectName}" from v
|
||||
{migrationState.fromVersion} to v{migrationState.toVersion}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function PanelBaseView({
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<div className="sticky top-0 z-10 bg-panel">
|
||||
<div className="px-3 pt-3.5 pb-0">
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
@@ -67,7 +67,7 @@ export function PanelBaseView({
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3.5" />
|
||||
<Separator className="mt-3" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
|
||||
@@ -5,60 +5,41 @@ import { useCallback, useMemo, useRef } from "react";
|
||||
import { useRafLoop } from "@/hooks/use-raf-loop";
|
||||
import { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useRendererStore } from "@/stores/renderer-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
function usePreviewSize() {
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
return {
|
||||
width: activeProject?.canvasSize?.width || 600,
|
||||
height: activeProject?.canvasSize?.height || 320,
|
||||
width: activeProject?.settings.canvasSize.width,
|
||||
height: activeProject?.settings.canvasSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
function RenderTreeController() {
|
||||
const setRenderTree = useRendererStore((s) => s.setRenderTree);
|
||||
const tracks = useTimelineStore((s) => s.tracks);
|
||||
const mediaFiles = useMediaStore((s) => s.mediaFiles);
|
||||
const getTotalDuration = useTimelineStore((s) => s.getTotalDuration);
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const { width, height } = usePreviewSize();
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (!activeProject) return;
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const renderTree = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration: getTotalDuration(),
|
||||
canvasSize: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
mediaAssets,
|
||||
duration,
|
||||
canvasSize: { width, height },
|
||||
background: activeProject.settings.background,
|
||||
});
|
||||
|
||||
setRenderTree(renderTree);
|
||||
}, [
|
||||
tracks,
|
||||
mediaFiles,
|
||||
getTotalDuration,
|
||||
activeProject?.backgroundColor,
|
||||
activeProject?.backgroundType,
|
||||
activeProject?.blurIntensity,
|
||||
width,
|
||||
height,
|
||||
]);
|
||||
editor.renderer.setRenderTree({ renderTree });
|
||||
}, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -80,21 +61,22 @@ function PreviewCanvas() {
|
||||
const lastSceneRef = useRef<RootNode | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const { width, height } = usePreviewSize();
|
||||
const { activeProject } = useProjectStore();
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
return new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
fps: activeProject?.fps || DEFAULT_FPS,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
}, [width, height, activeProject?.fps]);
|
||||
}, [width, height, activeProject.settings.fps]);
|
||||
|
||||
const renderTree = useRendererStore((s) => s.renderTree);
|
||||
const renderTree = editor.renderer.getRenderTree();
|
||||
|
||||
const render = useCallback(() => {
|
||||
if (ref.current && renderTree && !renderingRef.current) {
|
||||
const time = usePlaybackStore.getState().currentTime;
|
||||
const time = editor.playback.getCurrentTime();
|
||||
const frame = Math.floor(time * renderer.fps);
|
||||
|
||||
if (
|
||||
@@ -111,7 +93,7 @@ function PreviewCanvas() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [renderer, renderTree]);
|
||||
}, [renderer, renderTree, editor.playback]);
|
||||
|
||||
useRafLoop(render);
|
||||
|
||||
@@ -123,9 +105,9 @@ function PreviewCanvas() {
|
||||
className="block max-h-full max-w-full border"
|
||||
style={{
|
||||
background:
|
||||
activeProject?.backgroundType === "blur"
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,44 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { MediaProperties } from "./media-properties";
|
||||
import { VideoProperties } from "./video-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import { SquareSlashIcon } from "lucide-react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { selectedElements, tracks } = useTimelineStore();
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const { selectedElements } = useTimelineStore();
|
||||
|
||||
const editor = useEditor();
|
||||
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedElements.length > 0 ? (
|
||||
<ScrollArea className="h-full bg-panel rounded-sm">
|
||||
{selectedElements.map(({ trackId, elementId }) => {
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (element?.type === "text") {
|
||||
<ScrollArea className="bg-panel h-full rounded-sm">
|
||||
{elementsWithTracks.map(({ track, element }) => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<TextProperties element={element} trackId={trackId} />
|
||||
<div key={element.id}>
|
||||
<TextProperties element={element} trackId={track.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element?.type === "media") {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(file) => file.id === element.mediaId
|
||||
);
|
||||
|
||||
if (mediaFile?.type === "audio") {
|
||||
return <AudioProperties key={elementId} element={element} />;
|
||||
}
|
||||
|
||||
if (element.type === "audio") {
|
||||
return <AudioProperties key={element.id} element={element} />;
|
||||
}
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<MediaProperties element={element} />
|
||||
<div key={element.id}>
|
||||
<VideoProperties element={element} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,14 +51,14 @@ export function PropertiesPanel() {
|
||||
|
||||
function EmptyView() {
|
||||
return (
|
||||
<div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3">
|
||||
<div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<SquareSlashIcon
|
||||
className="w-10 h-10 text-muted-foreground"
|
||||
className="text-muted-foreground h-10 w-10"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<p className="text-lg font-medium">It’s empty here</p>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
<p className="text-muted-foreground text-balance text-sm">
|
||||
Click an element on the timeline to edit its properties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { Button } from "@/components/ui/button";
|
||||
@@ -19,13 +18,15 @@ import {
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { cn, uppercase } from "@/lib/utils";
|
||||
import { cn, capitalizeFirstLetter, clamp } from "@/lib/utils";
|
||||
import { Grid2x2 } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
@@ -34,88 +35,94 @@ export function TextProperties({
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
const editor = useEditor();
|
||||
const { activeTab, setActiveTab } = useTextPropertiesStore();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Local state for input values to allow temporary empty/invalid states
|
||||
const [fontSizeInput, setFontSizeInput] = useState(
|
||||
element.fontSize.toString()
|
||||
element.fontSize.toString(),
|
||||
);
|
||||
const [opacityInput, setOpacityInput] = useState(
|
||||
Math.round(element.opacity * 100).toString()
|
||||
Math.round(element.opacity * 100).toString(),
|
||||
);
|
||||
|
||||
// Track the last selected color for toggling
|
||||
const lastSelectedColor = useRef("#000000");
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
|
||||
const parseAndValidateNumber = (
|
||||
value: string,
|
||||
min: number,
|
||||
max: number,
|
||||
fallback: number
|
||||
): number => {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (isNaN(parsed)) return fallback;
|
||||
return Math.max(min, Math.min(max, parsed));
|
||||
};
|
||||
|
||||
const handleFontSizeChange = (value: string) => {
|
||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||
setFontSizeInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const fontSize = parseAndValidateNumber(value, 8, 300, element.fontSize);
|
||||
updateTextElement(trackId, element.id, { fontSize });
|
||||
const parsed = parseInt(value, 10);
|
||||
const fontSize = isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeBlur = () => {
|
||||
const fontSize = parseAndValidateNumber(
|
||||
fontSizeInput,
|
||||
8,
|
||||
300,
|
||||
element.fontSize
|
||||
);
|
||||
const parsed = parseInt(fontSizeInput, 10);
|
||||
const fontSize = isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: 8, max: 300 });
|
||||
setFontSizeInput(fontSize.toString());
|
||||
updateTextElement(trackId, element.id, { fontSize });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize },
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpacityChange = (value: string) => {
|
||||
const handleOpacityChange = ({ value }: { value: string }) => {
|
||||
setOpacityInput(value);
|
||||
|
||||
if (value.trim() !== "") {
|
||||
const opacityPercent = parseAndValidateNumber(
|
||||
value,
|
||||
0,
|
||||
100,
|
||||
Math.round(element.opacity * 100)
|
||||
);
|
||||
updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
|
||||
const parsed = parseInt(value, 10);
|
||||
const opacityPercent = isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
const opacityPercent = parseAndValidateNumber(
|
||||
opacityInput,
|
||||
0,
|
||||
100,
|
||||
Math.round(element.opacity * 100)
|
||||
);
|
||||
const parsed = parseInt(opacityInput, 10);
|
||||
const opacityPercent = isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
setOpacityInput(opacityPercent.toString());
|
||||
updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
});
|
||||
};
|
||||
|
||||
// Update last selected color when a new color is picked
|
||||
const handleColorChange = (color: string) => {
|
||||
const handleColorChange = ({ color }: { color: string }) => {
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = color;
|
||||
}
|
||||
updateTextElement(trackId, element.id, { backgroundColor: color });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle between transparent and last selected color
|
||||
const handleTransparentToggle = (isTransparent: boolean) => {
|
||||
const handleTransparentToggle = ({ isTransparent }: { isTransparent: boolean }) => {
|
||||
const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
|
||||
updateTextElement(trackId, element.id, { backgroundColor: newColor });
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: newColor },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -137,10 +144,12 @@ export function TextProperties({
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-18 resize-none bg-panel-accent"
|
||||
className="min-h-18 bg-panel-accent resize-none"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
content: e.target.value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -150,8 +159,10 @@ export function TextProperties({
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontFamily: value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -167,9 +178,13 @@ export function TextProperties({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold" ? "normal" : "bold",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
@@ -182,11 +197,15 @@ export function TextProperties({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
@@ -201,11 +220,15 @@ export function TextProperties({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
@@ -220,11 +243,15 @@ export function TextProperties({
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
@@ -244,8 +271,10 @@ export function TextProperties({
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: value,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
});
|
||||
setFontSizeInput(value.toString());
|
||||
}}
|
||||
@@ -256,12 +285,9 @@ export function TextProperties({
|
||||
value={fontSizeInput}
|
||||
min={8}
|
||||
max={300}
|
||||
onChange={(e) => handleFontSizeChange(e.target.value)}
|
||||
onChange={(e) => handleFontSizeChange({ value: e.target.value })}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
className="bg-panel-accent h-7 w-12 rounded-sm px-2 text-center !text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
@@ -270,12 +296,14 @@ export function TextProperties({
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase(
|
||||
value={capitalizeFirstLetter({ string:
|
||||
(element.color || "FFFFFF").replace("#", "")
|
||||
)}
|
||||
})}
|
||||
onChange={(color) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
color: `#${color}`,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
});
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
@@ -292,8 +320,10 @@ export function TextProperties({
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
updateTextElement(trackId, element.id, {
|
||||
opacity: value / 100,
|
||||
editor.timeline.updateTextElement({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
});
|
||||
setOpacityInput(value.toString());
|
||||
}}
|
||||
@@ -304,12 +334,9 @@ export function TextProperties({
|
||||
value={opacityInput}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onChange={(e) => handleOpacityChange({ value: e.target.value })}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
className="bg-panel-accent h-7 w-12 rounded-sm text-center !text-xs [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
@@ -319,19 +346,20 @@ export function TextProperties({
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={uppercase(
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: (element.backgroundColor || "#000000").replace(
|
||||
"#",
|
||||
""
|
||||
)
|
||||
)}
|
||||
onChange={(color) => handleColorChange(`#${color}`)}
|
||||
value={capitalizeFirstLetter({
|
||||
string:
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: (element.backgroundColor).replace(
|
||||
"#",
|
||||
"",
|
||||
),
|
||||
})}
|
||||
onChange={(color) => handleColorChange({ color: `#${color}` })}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "opacity-50 pointer-events-none"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
@@ -342,17 +370,17 @@ export function TextProperties({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleTransparentToggle(
|
||||
element.backgroundColor !== "transparent"
|
||||
)
|
||||
handleTransparentToggle({
|
||||
isTransparent: element.backgroundColor !== "transparent",
|
||||
})
|
||||
}
|
||||
className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden"
|
||||
className="bg-panel-accent size-9 overflow-hidden rounded-full p-0"
|
||||
>
|
||||
<Grid2x2
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
element.backgroundColor === "transparent" &&
|
||||
"text-primary"
|
||||
"text-primary",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { VideoElement, ImageElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({
|
||||
export function VideoProperties({
|
||||
element,
|
||||
}: {
|
||||
element: VideoElement | ImageElement;
|
||||
}) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
return <div className="space-y-4 p-5">Video properties</div>;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { Check, ListCheck, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
@@ -22,26 +21,31 @@ import {
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { canDeleteScene } from "@/lib/scene-utils";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
|
||||
const editor = useEditor();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSceneSwitch = async (sceneId: string) => {
|
||||
if (isSelectMode) {
|
||||
toggleSceneSelection(sceneId);
|
||||
toggleSceneSelection({ sceneId });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await switchToScene({ sceneId });
|
||||
await editor.scenes.switchToScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to switch scene:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSceneSelection = (sceneId: string) => {
|
||||
const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => {
|
||||
setSelectedScenes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(sceneId)) {
|
||||
@@ -60,13 +64,21 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
for (const sceneId of selectedScenes) {
|
||||
const scene = scenes.find((s) => s.id === sceneId);
|
||||
if (scene && !scene.isMain) {
|
||||
try {
|
||||
await deleteScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
}
|
||||
const scene = scenes.find((scene) => scene.id === sceneId);
|
||||
if (!scene) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { canDelete, reason } = canDeleteScene({ scene });
|
||||
if (!canDelete) {
|
||||
toast.error(reason || "Failed to delete scene");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await editor.scenes.deleteScene({ sceneId });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
}
|
||||
}
|
||||
setSelectedScenes(new Set());
|
||||
@@ -87,7 +99,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
: "Switch between scenes in your project"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="rounded-md"
|
||||
@@ -103,7 +115,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
count={selectedScenes.size}
|
||||
onDelete={handleDeleteSelected}
|
||||
disabled={Array.from(selectedScenes).some(
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain
|
||||
(id) => scenes.find((s) => s.id === id)?.isMain,
|
||||
)}
|
||||
>
|
||||
<Button className="rounded-md" variant="destructive" size="sm">
|
||||
@@ -114,7 +126,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
{scenes.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No scenes available
|
||||
</div>
|
||||
) : (
|
||||
@@ -130,7 +142,7 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||
"border-primary !text-primary",
|
||||
isSelectMode &&
|
||||
selectedScenes.has(scene.id) &&
|
||||
"bg-accent border-foreground/30"
|
||||
"bg-accent border-foreground/30",
|
||||
)}
|
||||
onClick={() => handleSceneSwitch(scene.id)}
|
||||
>
|
||||
|
||||
@@ -2,13 +2,46 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
audioUrl?: string;
|
||||
audioBuffer?: AudioBuffer;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function extractPeaks({
|
||||
buffer,
|
||||
length = 512,
|
||||
}: {
|
||||
buffer: AudioBuffer;
|
||||
length?: number;
|
||||
}): number[][] {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const peaks: number[][] = [];
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
const step = Math.floor(data.length / length);
|
||||
const channelPeaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const start = i * step;
|
||||
const end = Math.min(start + step, data.length);
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(data[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
channelPeaks.push(max);
|
||||
}
|
||||
peaks.push(channelPeaks);
|
||||
}
|
||||
|
||||
return peaks;
|
||||
}
|
||||
|
||||
const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
audioUrl,
|
||||
audioBuffer,
|
||||
height = 32,
|
||||
className = "",
|
||||
}) => {
|
||||
@@ -22,7 +55,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
let ws = wavesurfer.current;
|
||||
|
||||
const initWaveSurfer = async () => {
|
||||
if (!waveformRef.current || !audioUrl) return;
|
||||
if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
|
||||
|
||||
try {
|
||||
// Clear any existing instance safely
|
||||
@@ -74,7 +107,12 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
if (audioBuffer) {
|
||||
const peaks = extractPeaks({ buffer: audioBuffer });
|
||||
newWaveSurfer.load("", peaks, audioBuffer.duration);
|
||||
} else if (audioUrl) {
|
||||
await newWaveSurfer.load(audioUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
console.error("Failed to initialize WaveSurfer:", err);
|
||||
@@ -130,7 +168,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [audioUrl, height]);
|
||||
}, [audioUrl, audioBuffer, height]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Video, Music, TypeIcon, Eye, VolumeOff, Volume2 } from "lucide-react";
|
||||
import { Eye, VolumeOff, Volume2 } from "lucide-react";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -11,16 +11,16 @@ import {
|
||||
import { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
TimelinePlayhead,
|
||||
useTimelinePlayheadRuler,
|
||||
} from "./timeline-playhead";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
TRACK_ICONS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useElementInteraction } from "@/hooks/timeline/use-element-interaction";
|
||||
import {
|
||||
getTrackHeight,
|
||||
@@ -36,17 +36,16 @@ import { TimelineRuler } from "./timeline-ruler";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
||||
export function Timeline() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.sortedTracks;
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
const { snappingEnabled } = useTimelineStore();
|
||||
const { clearSelection, setSelection } = useElementSelection();
|
||||
const editor = useEditor();
|
||||
const timeline = editor.timeline;
|
||||
const seek = (time: number) => editor.playback.seek({ time });
|
||||
|
||||
// Refs
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
const tracksContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -56,6 +55,7 @@ export function Timeline() {
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null,
|
||||
@@ -83,18 +83,18 @@ export function Timeline() {
|
||||
});
|
||||
|
||||
const dynamicTimelineWidth = Math.max(
|
||||
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
(currentTime + TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
(timeline.getTotalDuration() || 0) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
(editor.playback.getCurrentTime() +
|
||||
TIMELINE_CONSTANTS.PLAYHEAD_LOOKAHEAD_SECONDS) *
|
||||
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
|
||||
zoomLevel,
|
||||
timelineRef.current?.clientWidth || 1000,
|
||||
);
|
||||
|
||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
const { handleRulerMouseDown } = useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
@@ -125,11 +125,10 @@ export function Timeline() {
|
||||
const { handleTimelineMouseDown, handleTimelineContentClick } =
|
||||
useTimelineInteractions({
|
||||
playheadRef,
|
||||
tracksContainerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
duration: timeline.getTotalDuration(),
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
clearSelectedElements: clearSelection,
|
||||
@@ -161,11 +160,7 @@ export function Timeline() {
|
||||
ref={timelineRef}
|
||||
>
|
||||
<TimelinePlayhead
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
seek={seek}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
@@ -179,7 +174,7 @@ export function Timeline() {
|
||||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
tracks={timeline.getTracks()}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
@@ -192,7 +187,6 @@ export function Timeline() {
|
||||
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
duration={duration}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
rulerRef={rulerRef}
|
||||
rulerScrollRef={rulerScrollRef}
|
||||
@@ -204,7 +198,7 @@ export function Timeline() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{tracks.length > 0 && (
|
||||
{timeline.getTracks().length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="z-100 bg-panel w-28 shrink-0 overflow-y-auto border-r"
|
||||
@@ -212,7 +206,7 @@ export function Timeline() {
|
||||
>
|
||||
<ScrollArea className="h-full w-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
{timeline.getTracks().map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="group flex items-center px-3"
|
||||
@@ -225,7 +219,7 @@ export function Timeline() {
|
||||
<VolumeOff
|
||||
className="text-destructive h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
@@ -234,7 +228,7 @@ export function Timeline() {
|
||||
<Volume2
|
||||
className="text-muted-foreground h-4 w-4 cursor-pointer"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
})
|
||||
}
|
||||
@@ -273,7 +267,7 @@ export function Timeline() {
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dropTarget}
|
||||
tracks={tracks}
|
||||
tracks={timeline.getTracks()}
|
||||
isVisible={isDragOver}
|
||||
/>
|
||||
<ScrollArea className="h-full w-full" ref={tracksScrollRef}>
|
||||
@@ -282,23 +276,26 @@ export function Timeline() {
|
||||
style={{
|
||||
height: `${Math.max(
|
||||
200,
|
||||
Math.min(800, getTotalTracksHeight({ tracks })),
|
||||
Math.min(
|
||||
800,
|
||||
getTotalTracksHeight({ tracks: timeline.getTracks() }),
|
||||
),
|
||||
)}px`,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
>
|
||||
{tracks.length === 0 ? (
|
||||
{timeline.getTracks().length === 0 ? (
|
||||
<div />
|
||||
) : (
|
||||
<>
|
||||
{tracks.map((track, index) => (
|
||||
{timeline.getTracks().map((track, index) => (
|
||||
<ContextMenu key={track.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${getCumulativeHeightBefore({
|
||||
tracks,
|
||||
tracks: timeline.getTracks(),
|
||||
trackIndex: index,
|
||||
})}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
@@ -329,7 +326,7 @@ export function Timeline() {
|
||||
<ContextMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editor.timeline.toggleTrackMute({
|
||||
timeline.toggleTrackMute({
|
||||
trackId: track.id,
|
||||
});
|
||||
}}
|
||||
@@ -354,17 +351,5 @@ export function Timeline() {
|
||||
}
|
||||
|
||||
function TrackIcon({ track }: { track: TimelineTrack }) {
|
||||
return (
|
||||
<>
|
||||
{track.type === "media" && (
|
||||
<Video className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{track.type === "text" && (
|
||||
<TypeIcon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{track.type === "audio" && (
|
||||
<Music className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <>{TRACK_ICONS[track.type]}</>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
@@ -24,50 +23,26 @@ export function SnapIndicator({
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
}: SnapIndicatorProps) {
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
// Track scroll position to lock snap indicator to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
if (!tracksViewport) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
tracksScrollRef,
|
||||
});
|
||||
|
||||
if (!isVisible || !snapPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Calculate position locked to timeline content (accounting for scroll)
|
||||
const timelinePosition =
|
||||
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="z-90 pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
top: topPosition,
|
||||
height: `${height}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,16 +12,18 @@ import {
|
||||
VolumeX,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/use-element-resize";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
getTrackColor,
|
||||
getTrackClasses,
|
||||
getTrackHeight,
|
||||
isMutableElement,
|
||||
canHaveAudio,
|
||||
canBeHidden,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -30,12 +32,14 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { useAssetsPanelStore } from "../../../stores/assets-panel-store";
|
||||
import {
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
ElementDragState,
|
||||
} from "@/types/timeline";
|
||||
import { ElementDragState } from "@/types/timeline";
|
||||
import { MediaAsset } from "@/types/assets";
|
||||
import { mediaSupportsAudio } from "@/lib/media-utils";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
|
||||
interface TimelineElementProps {
|
||||
element: TimelineElementType;
|
||||
@@ -59,25 +63,19 @@ export function TimelineElement({
|
||||
onElementClick,
|
||||
dragState,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaFiles } = useMediaStore();
|
||||
const editor = useEditor();
|
||||
const { selectedElements } = useTimelineStore();
|
||||
const { requestRevealMedia } = useAssetsPanelStore();
|
||||
const {
|
||||
copySelected,
|
||||
selectedElements,
|
||||
deleteSelected,
|
||||
splitSelected,
|
||||
toggleSelectedHidden,
|
||||
toggleSelectedMuted,
|
||||
duplicateElement,
|
||||
getContextMenuState,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaFiles.find((file) => file.id === element.mediaId)
|
||||
: null;
|
||||
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
let mediaAsset: MediaAsset | null = null;
|
||||
|
||||
if (hasMediaId(element)) {
|
||||
mediaAsset =
|
||||
mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
}
|
||||
|
||||
const hasAudio = mediaSupportsAudio({ media: mediaAsset });
|
||||
|
||||
const { handleResizeStart } = useTimelineElementResize({
|
||||
element,
|
||||
@@ -85,18 +83,14 @@ export function TimelineElement({
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const {
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
hasAudioElements,
|
||||
canSplitSelected,
|
||||
} = getContextMenuState(track.id, element.id);
|
||||
const isCurrentElementSelected = selectedElements.some(
|
||||
(selected) =>
|
||||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
element.duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
);
|
||||
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
@@ -104,116 +98,266 @@ export function TimelineElement({
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
|
||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
||||
|
||||
const handleElementSplitContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
splitSelected(
|
||||
currentTime,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
const handleAction = ({
|
||||
action,
|
||||
event,
|
||||
}: {
|
||||
action: TAction;
|
||||
event: React.MouseEvent;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
};
|
||||
|
||||
const handleElementDuplicateContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
duplicateElement(track.id, element.id);
|
||||
};
|
||||
|
||||
const handleElementCopyContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copySelected();
|
||||
};
|
||||
|
||||
const handleElementDeleteContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
deleteSelected(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleElementContext = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (hasAudio && element.type === "media") {
|
||||
toggleSelectedMuted(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
} else {
|
||||
toggleSelectedHidden(
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
|
||||
isMultipleSelected && isCurrentElementSelected ? undefined : element.id,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevealInMedia = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (element.type === "media") {
|
||||
const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
|
||||
event.stopPropagation();
|
||||
if (hasMediaId(element)) {
|
||||
requestRevealMedia(element.mediaId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderElementContent = () => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
const isMuted = canHaveAudio(element) && element.muted === true;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`timeline-element absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{ left: `${elementLeft}px`, width: `${elementWidth}px` }}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
>
|
||||
<ElementInner
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
onElementClick={onElementClick}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
handleResizeStart={handleResizeStart}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
mediaItem.type === "image" ||
|
||||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
|
||||
) {
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
|
||||
const imageUrl =
|
||||
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
className={`relative h-full w-full ${
|
||||
isSelected ? "bg-primary" : "bg-transparent"
|
||||
}`}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200">
|
||||
<ContextMenuItem
|
||||
onClick={(event) => handleAction({ action: "split-selected", event })}
|
||||
>
|
||||
<Scissors className="mr-2 size-4" />
|
||||
{selectedElements.length > 1 && isCurrentElementSelected
|
||||
? `Split ${selectedElements.length} elements at playhead`
|
||||
: "Split at playhead"}
|
||||
</ContextMenuItem>
|
||||
<CopyMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) => handleAction({ action: "copy-selected", event })}
|
||||
/>
|
||||
{canHaveAudio(element) && hasAudio && (
|
||||
<MuteMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
isMuted={isMuted}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-mute-selected", event })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{canBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-visibility-selected", event })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<ContextMenuItem
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]`}
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Copy className="mr-2 size-4" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 size-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
{selectedElements.length === 1 && hasMediaId(element) && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={(event) => handleRevealInMedia({ event })}
|
||||
>
|
||||
<Search className="mr-2 size-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<DeleteMenuItem
|
||||
isMultipleSelected={selectedElements.length > 1}
|
||||
isCurrentElementSelected={isCurrentElementSelected}
|
||||
elementType={element.type}
|
||||
selectedCount={selectedElements.length}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "audio") {
|
||||
function ElementInner({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
onElementClick,
|
||||
onElementMouseDown,
|
||||
handleResizeStart,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
|
||||
onElementMouseDown: (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackClasses(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-50" : "z-10"} ${canBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick(e, element)}
|
||||
onMouseDown={(e) => onElementMouseDown(e, element)}
|
||||
onContextMenu={(e) => onElementMouseDown(e, element)}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
<ElementContent
|
||||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
mediaAssets={mediaAssets}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : canBeHidden(element) && element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="size-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="size-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({ e, elementId: element.id, side: "left" })
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({ e, elementId: element.id, side: "right" })
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ElementContent({
|
||||
element,
|
||||
track,
|
||||
isSelected,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
}) {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="flex size-full items-center justify-start pl-2">
|
||||
<span className="truncate text-xs text-white">{element.content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
return (
|
||||
<div className="flex size-full items-center gap-2 pl-2">
|
||||
<img
|
||||
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
||||
alt={element.name}
|
||||
className="size-5 shrink-0"
|
||||
/>
|
||||
<span className="truncate text-xs text-white">{element.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "audio") {
|
||||
const audioBuffer =
|
||||
element.sourceType === "library" ? element.buffer : undefined;
|
||||
|
||||
const audioUrl =
|
||||
element.sourceType === "upload"
|
||||
? mediaAssets.find((asset) => asset.id === element.mediaId)?.url
|
||||
: undefined;
|
||||
|
||||
if (audioBuffer || audioUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-2">
|
||||
<div className="flex size-full items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url || ""}
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
@@ -227,180 +371,179 @@ export function TimelineElement({
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
||||
if (onElementMouseDown) {
|
||||
onElementMouseDown(e, element);
|
||||
}
|
||||
};
|
||||
const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
|
||||
if (!mediaAsset) {
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isMuted = isMutableElement(element) && element.muted;
|
||||
if (
|
||||
mediaAsset.type === "image" ||
|
||||
(mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
|
||||
) {
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const tileWidth = trackHeight * (16 / 9);
|
||||
const imageUrl =
|
||||
mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
return (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div
|
||||
className={`timeline-element absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
}}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
className={`relative size-full ${isSelected ? "bg-primary" : "bg-transparent"}`}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full cursor-pointer overflow-hidden rounded-[0.5rem] ${getTrackColor(
|
||||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isSelected ? "" : ""} ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
} ${element.hidden ? "opacity-50" : ""}`}
|
||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||
onMouseDown={handleElementMouseDown}
|
||||
onContextMenu={(e) =>
|
||||
onElementMouseDown && onElementMouseDown(e, element)
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full items-center">
|
||||
{renderElementContent()}
|
||||
</div>
|
||||
|
||||
{(hasAudio ? isMuted : element.hidden) && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
|
||||
{hasAudio ? (
|
||||
<VolumeX className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<EyeOff className="h-6 w-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 left-0 top-0 z-50 flex w-[0.6rem] cursor-w-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "left",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
<div
|
||||
className="bg-primary absolute bottom-0 right-0 top-0 z-50 flex w-[0.6rem] cursor-e-resize items-center justify-center"
|
||||
onMouseDown={(e) =>
|
||||
handleResizeStart({
|
||||
e,
|
||||
elementId: element.id,
|
||||
side: "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="bg-foreground/75 h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
className="absolute bottom-[0.25rem] left-0 right-0 top-[0.25rem]"
|
||||
style={{
|
||||
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${trackHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled ${mediaAsset.type === "image" ? "background" : "thumbnail"} of ${mediaAsset.name}`}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200">
|
||||
{(!isMultipleSelected ||
|
||||
(isMultipleSelected &&
|
||||
isCurrentElementSelected &&
|
||||
canSplitSelected)) && (
|
||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Split ${selectedElements.length} elements at playhead`
|
||||
: "Split at playhead"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<ContextMenuItem onClick={handleElementCopyContext}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Copy ${selectedElements.length} elements`
|
||||
: "Copy element"}
|
||||
</ContextMenuItem>
|
||||
return (
|
||||
<span className="text-foreground/80 truncate text-xs">{element.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||
{isMultipleSelected && isCurrentElementSelected ? (
|
||||
hasAudioElements ? (
|
||||
<VolumeX className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
)
|
||||
) : hasAudio ? (
|
||||
isMuted ? (
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<VolumeX className="mr-2 h-4 w-4" />
|
||||
)
|
||||
) : element.hidden ? (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? hasAudioElements
|
||||
? `Toggle mute ${selectedElements.length} elements`
|
||||
: `Toggle visibility ${selectedElements.length} elements`
|
||||
: hasAudio
|
||||
? isMuted
|
||||
? "Unmute"
|
||||
: "Mute"
|
||||
: element.hidden
|
||||
? "Show"
|
||||
: "Hide"}{" "}
|
||||
{!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
function CopyMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
<Copy className="mr-2 size-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Copy ${selectedCount} elements`
|
||||
: "Copy element"}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
{!isMultipleSelected && (
|
||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
function MuteMenuItem({
|
||||
element,
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
isMuted,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
isMuted: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <VolumeX className="mr-2 size-4" />;
|
||||
}
|
||||
return isMuted ? (
|
||||
<Volume2 className="mr-2 size-4" />
|
||||
) : (
|
||||
<VolumeX className="mr-2 size-4" />
|
||||
);
|
||||
};
|
||||
|
||||
<ContextMenuItem disabled>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4" />
|
||||
Move to track (Coming soon)
|
||||
</ContextMenuItem>
|
||||
const getLabel = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return `Toggle mute ${selectedCount} elements`;
|
||||
}
|
||||
const suffix = element.type === "text" ? "text" : "clip";
|
||||
return isMuted ? `Unmute ${suffix}` : `Mute ${suffix}`;
|
||||
};
|
||||
|
||||
{!isMultipleSelected && element.type === "media" && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleRevealInMedia}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Reveal in media
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Replace clip (Coming soon)
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
function VisibilityMenuItem({
|
||||
element,
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
element: TimelineElementType;
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const isHidden = canBeHidden(element) && element.hidden;
|
||||
|
||||
<ContextMenuItem
|
||||
onClick={handleElementDeleteContext}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedElements.length} elements`
|
||||
: `Delete ${element.type === "text" ? "text" : "clip"}`}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
const getIcon = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return <EyeOff className="mr-2 size-4" />;
|
||||
}
|
||||
return isHidden ? (
|
||||
<Eye className="mr-2 size-4" />
|
||||
) : (
|
||||
<EyeOff className="mr-2 size-4" />
|
||||
);
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
if (isMultipleSelected && isCurrentElementSelected) {
|
||||
return `Toggle visibility ${selectedCount} elements`;
|
||||
}
|
||||
const suffix = element.type === "text" ? "text" : "clip";
|
||||
return isHidden ? `Show ${suffix}` : `Hide ${suffix}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuItem onClick={onClick}>
|
||||
{getIcon()}
|
||||
<span>{getLabel()}</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteMenuItem({
|
||||
isMultipleSelected,
|
||||
isCurrentElementSelected,
|
||||
elementType,
|
||||
selectedCount,
|
||||
onClick,
|
||||
}: {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
elementType: TimelineElementType["type"];
|
||||
selectedCount: number;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuItem
|
||||
onClick={onClick}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
{isMultipleSelected && isCurrentElementSelected
|
||||
? `Delete ${selectedCount} elements`
|
||||
: `Delete ${elementType === "text" ? "text" : "clip"}`}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelineMarkerProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
isMainMarker: boolean;
|
||||
}
|
||||
|
||||
export function TimelineMarker({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
isMainMarker,
|
||||
}: TimelineMarkerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 h-4",
|
||||
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={cn(
|
||||
"absolute top-1 left-1 text-[0.6rem]",
|
||||
isMainMarker
|
||||
? "text-muted-foreground font-medium"
|
||||
: "text-muted-foreground/70"
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const formatTime = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${Math.floor(secs)
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
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);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
|
||||
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>;
|
||||
@@ -21,11 +17,7 @@ interface TimelinePlayheadProps {
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
@@ -34,22 +26,21 @@ export function TimelinePlayhead({
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Track scroll position to lock playhead to frame
|
||||
useEffect(() => {
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
|
||||
@@ -59,39 +50,33 @@ export function TimelinePlayhead({
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
};
|
||||
|
||||
// Set initial scroll position
|
||||
setScrollLeft(tracksViewport.scrollLeft);
|
||||
|
||||
tracksViewport.addEventListener("scroll", handleScroll);
|
||||
return () => tracksViewport.removeEventListener("scroll", handleScroll);
|
||||
}, [tracksScrollRef]);
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 4;
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate position locked to timeline content (accounting for scroll)
|
||||
const timelinePosition =
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
|
||||
|
||||
// Get the timeline content width and viewport width for right boundary
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
const viewportWidth = tracksViewport?.clientWidth || 1000;
|
||||
|
||||
// Constrain playhead to never appear outside the timeline area
|
||||
const leftBoundary = trackLabelsWidth;
|
||||
const rightBoundary = Math.min(
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
|
||||
trackLabelsWidth + viewportWidth, // Don't go beyond viewport
|
||||
trackLabelsWidth + timelineContentWidth - scrollLeft,
|
||||
trackLabelsWidth + viewportWidth,
|
||||
);
|
||||
|
||||
const leftPosition = Math.max(
|
||||
@@ -99,26 +84,6 @@ export function TimelinePlayhead({
|
||||
Math.min(rightBoundary, rawLeftPosition),
|
||||
);
|
||||
|
||||
// Debug logging when playhead might go outside
|
||||
if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
|
||||
console.log(
|
||||
"PLAYHEAD VISUAL DEBUG:",
|
||||
JSON.stringify({
|
||||
playheadPosition,
|
||||
timelinePosition,
|
||||
trackLabelsWidth,
|
||||
scrollLeft,
|
||||
rawLeftPosition,
|
||||
constrainedLeftPosition: leftPosition,
|
||||
leftBoundary,
|
||||
rightBoundary,
|
||||
timelineContentWidth,
|
||||
viewportWidth,
|
||||
zoomLevel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
@@ -127,46 +92,15 @@ export function TimelinePlayhead({
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
width: "2px",
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 h-full w-0.5 cursor-col-resize ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
|
||||
/>
|
||||
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
|
||||
|
||||
{/* Playhead dot indicator at the top (in ruler area) */}
|
||||
<div
|
||||
className={`shadow-xs absolute left-1/2 top-1 h-3 w-3 -translate-x-1/2 transform rounded-full border-2 ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
/>
|
||||
</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 };
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TimelineMarker } from "./timeline-marker";
|
||||
import { useSceneStore } from "@/stores/scene-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useCallback } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Bookmark } from "lucide-react";
|
||||
import { TimelineTick } from "./timeline-tick";
|
||||
|
||||
interface TimelineRulerProps {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
dynamicTimelineWidth: number;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -20,7 +17,6 @@ interface TimelineRulerProps {
|
||||
|
||||
export function TimelineRuler({
|
||||
zoomLevel,
|
||||
duration,
|
||||
dynamicTimelineWidth,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
@@ -29,18 +25,35 @@ export function TimelineRuler({
|
||||
handleTimelineContentClick,
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const { activeScene } = useSceneStore();
|
||||
const editor = useEditor();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const getOptimalTimeInterval = useCallback((zoom: number) => {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
}, []);
|
||||
const interval = getOptimalTimeInterval({ zoomLevel });
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const tickSpacingPixels = interval * pixelsPerSecond;
|
||||
const minLabelSpacingPixels = 120;
|
||||
const labelEvery = Math.max(
|
||||
1,
|
||||
Math.ceil(minLabelSpacingPixels / tickSpacingPixels),
|
||||
);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
|
||||
const timelineTicks: Array<JSX.Element> = [];
|
||||
for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) {
|
||||
const time = markerIndex * interval;
|
||||
if (time > duration) break;
|
||||
|
||||
timelineTicks.push(
|
||||
<TimelineTick
|
||||
key={markerIndex}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
shouldShowLabel={markerIndex % labelEvery === 0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,31 +72,11 @@ export function TimelineRuler({
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{(() => {
|
||||
const interval = getOptimalTimeInterval(zoomLevel);
|
||||
const markerCount = Math.ceil(duration / interval) + 1;
|
||||
{timelineTicks}
|
||||
|
||||
return Array.from({ length: markerCount }, (_, i) => {
|
||||
const time = i * interval;
|
||||
if (time > duration) return null;
|
||||
|
||||
const isMainMarker = time % Math.max(1, interval) === 0;
|
||||
|
||||
return (
|
||||
<TimelineMarker
|
||||
key={i}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
interval={interval}
|
||||
isMainMarker={isMainMarker}
|
||||
/>
|
||||
);
|
||||
}).filter(Boolean);
|
||||
})()}
|
||||
|
||||
{activeScene?.timeline?.bookmarks?.map((time, i) => (
|
||||
{activeScene.bookmarks.map((time: number, index: number) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-${i}`}
|
||||
key={`bookmark-${index}`}
|
||||
time={time}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
@@ -101,7 +94,16 @@ function TimelineBookmark({
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const seek = usePlaybackStore((state) => state.seek);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleBookmarkClick = ({
|
||||
event,
|
||||
}: {
|
||||
event: React.MouseEvent<HTMLDivElement>;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
editor.playback.seek({ time });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -109,14 +111,22 @@ function TimelineBookmark({
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seek(time);
|
||||
}}
|
||||
onClick={(event) => handleBookmarkClick({ event })}
|
||||
>
|
||||
<div className="text-primary absolute left-[-5px] top-[-1px]">
|
||||
<Bookmark className="fill-primary h-3 w-3" />
|
||||
<Bookmark className="fill-primary size-3" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getOptimalTimeInterval({ zoomLevel }: { zoomLevel: number }) {
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
if (pixelsPerSecond >= 12) return 5;
|
||||
if (pixelsPerSecond >= 6) return 10;
|
||||
return 30;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelineTickProps {
|
||||
time: number;
|
||||
zoomLevel: number;
|
||||
interval: number;
|
||||
shouldShowLabel: boolean;
|
||||
}
|
||||
|
||||
export function TimelineTick({
|
||||
time,
|
||||
zoomLevel,
|
||||
interval,
|
||||
shouldShowLabel,
|
||||
}: TimelineTickProps) {
|
||||
return (
|
||||
<div
|
||||
className="border-muted-foreground/20 absolute top-0 h-4 border-l"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowLabel ? (
|
||||
<span className="text-muted-foreground/70 absolute left-1 top-1 text-[0.6rem]">
|
||||
{formatTimelineTickLabel({ timeInSeconds: time, interval })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimelineTickLabel({
|
||||
timeInSeconds,
|
||||
interval,
|
||||
}: {
|
||||
timeInSeconds: number;
|
||||
interval: number;
|
||||
}): string {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const secondsRemainder = timeInSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${hours}:${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
const paddedSeconds = Math.floor(secondsRemainder)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
return `${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
if (interval >= 1) {
|
||||
return `${Math.floor(secondsRemainder)}s`;
|
||||
}
|
||||
|
||||
return `${secondsRemainder.toFixed(1)}s`;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/use-element-selection";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -33,11 +32,11 @@ import {
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
import { formatTimeCode } from "@/lib/time-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||
import { ScenesView } from "../scenes-view";
|
||||
import { type TAction, invokeAction } from "@/lib/actions";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
@@ -46,41 +45,6 @@ export function TimelineToolbar({
|
||||
zoomLevel: number;
|
||||
setZoomLevel: ({ zoom }: { zoom: number }) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { selectedElements, clearSelection } = useElementSelection();
|
||||
|
||||
const handleSplitSelected = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
return;
|
||||
}
|
||||
editor.timeline.duplicateElements({ elements: selectedElements });
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "left",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.currentTime,
|
||||
retainSide: "right",
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
@@ -95,17 +59,9 @@ export function TimelineToolbar({
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
const hasNoTracks = editor.timeline.getTracks().length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between border-b px-2 py-1">
|
||||
<ToolbarLeftSection
|
||||
hasNoTracks={hasNoTracks}
|
||||
onSplit={handleSplitSelected}
|
||||
onSplitLeft={handleSplitAndKeepLeft}
|
||||
onSplitRight={handleSplitAndKeepRight}
|
||||
onDuplicate={handleDuplicateSelected}
|
||||
/>
|
||||
<ToolbarLeftSection />
|
||||
|
||||
<SceneSelector />
|
||||
|
||||
@@ -118,28 +74,26 @@ export function TimelineToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarLeftSection({
|
||||
hasNoTracks,
|
||||
onSplit,
|
||||
onSplitLeft,
|
||||
onSplitRight,
|
||||
onDuplicate,
|
||||
}: {
|
||||
hasNoTracks: boolean;
|
||||
onSplit: () => void;
|
||||
onSplitLeft: () => void;
|
||||
onSplitRight: () => void;
|
||||
onDuplicate: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
function ToolbarLeftSection() {
|
||||
const { selectedElements } = useElementSelection();
|
||||
|
||||
const currentTime = editor.playback.currentTime;
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isPlaying = editor.playback.isPlaying;
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject?.fps ?? DEFAULT_FPS;
|
||||
const currentBookmarked = editor.scene.isBookmarked({ time: currentTime });
|
||||
const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
event,
|
||||
}: {
|
||||
action: TAction;
|
||||
event: React.MouseEvent;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
invokeAction(action);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -150,7 +104,9 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.toggle()}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-play", event })
|
||||
}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="size-4" />
|
||||
@@ -170,7 +126,7 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.playback.seek({ time: 0 })}
|
||||
onClick={(event) => handleAction({ action: "goto-start", event })}
|
||||
>
|
||||
<SkipBack className="size-4" />
|
||||
</Button>
|
||||
@@ -180,13 +136,27 @@ function ToolbarLeftSection({
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<TimeDisplay currentTime={currentTime} duration={duration} fps={fps} />
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
fps={activeProject.settings.fps}
|
||||
/>
|
||||
|
||||
<div className="bg-border mx-1 h-6 w-px" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" type="button" onClick={onSplit}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -199,7 +169,13 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitLeft}
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "left",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowLeftToLine className="size-4" />
|
||||
</Button>
|
||||
@@ -213,7 +189,13 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onSplitRight}
|
||||
onClick={() => {
|
||||
editor.timeline.splitElements({
|
||||
elements: selectedElements,
|
||||
splitTime: editor.playback.getCurrentTime(),
|
||||
retainSide: "right",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArrowRightToLine className="size-4" />
|
||||
</Button>
|
||||
@@ -236,7 +218,9 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onDuplicate}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "duplicate-selected", event })
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
@@ -246,18 +230,11 @@ function ToolbarLeftSection({
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toast.info("Freeze frame functionality coming soon!")
|
||||
}
|
||||
>
|
||||
<Button variant="text" size="icon" type="button" disabled>
|
||||
<Snowflake className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
<TooltipContent>Freeze frame (F) (Coming soon)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
@@ -266,8 +243,8 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor.timeline.deleteElements({ elements: selectedElements })
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "delete-selected", event })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
@@ -284,7 +261,9 @@ function ToolbarLeftSection({
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => editor.scene.toggleBookmark({ time: currentTime })}
|
||||
onClick={(event) =>
|
||||
handleAction({ action: "toggle-bookmark", event })
|
||||
}
|
||||
>
|
||||
<Bookmark
|
||||
className={`size-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
|
||||
@@ -323,10 +302,10 @@ function TimeDisplay({
|
||||
/>
|
||||
<div className="text-muted-foreground px-2 font-mono text-xs">/</div>
|
||||
<div className="text-muted-foreground text-center font-mono text-xs">
|
||||
{formatTimeCode({ timeInSeconds: duration })}
|
||||
{formatTimeCode({
|
||||
timeInSeconds: duration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,8 +314,8 @@ function TimeDisplay({
|
||||
|
||||
function SceneSelector() {
|
||||
const editor = useEditor();
|
||||
const currentScene = editor.scene.getCurrentScene();
|
||||
const scenesCount = editor.scene.getScenes().length;
|
||||
const currentScene = editor.scenes.getActiveScene();
|
||||
const scenesCount = editor.scenes.getScenes().length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -17,12 +17,12 @@ interface TimelineTrackContentProps {
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
onElementMouseDown: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
onElementClick: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
}) => void;
|
||||
@@ -71,11 +71,11 @@ export function TimelineTrackContent({
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isElementSelected}
|
||||
onElementMouseDown={(e, el) =>
|
||||
onElementMouseDown({ e, element: el, track })
|
||||
onElementMouseDown={(event, element) =>
|
||||
onElementMouseDown({ event, element, track })
|
||||
}
|
||||
onElementClick={(e, el) =>
|
||||
onElementClick({ e, element: el, track })
|
||||
onElementClick={(event, element) =>
|
||||
onElementClick({ event, element, track })
|
||||
}
|
||||
dragState={dragState}
|
||||
/>
|
||||
|
||||
@@ -47,8 +47,7 @@ export function Footer() {
|
||||
<span className="text-lg font-bold">OpenCut</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-5 text-sm md:text-left">
|
||||
The open source video editor that gets the job done. Simple,
|
||||
powerful, and works on any platform.
|
||||
The privacy-first video editor that feels simple to use.
|
||||
</p>
|
||||
<div className="flex justify-start gap-3">
|
||||
<Link
|
||||
@@ -81,15 +80,23 @@ export function Footer() {
|
||||
<div className="flex items-start justify-start gap-12 py-2">
|
||||
{(Object.keys(links) as Category[]).map((category) => (
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<h3 className="text-foreground font-semibold">{capitalizeFirstLetter({ string: category })}</h3>
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{capitalizeFirstLetter({ string: category })}
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{links[category].map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
target={
|
||||
link.href.startsWith("http") ? "_blank" : undefined
|
||||
}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@@ -104,7 +111,9 @@ export function Footer() {
|
||||
{/* Bottom Section */}
|
||||
<div className="flex flex-col items-start justify-between gap-4 pt-2 md:flex-row">
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span>© 2025 OpenCut, All Rights Reserved</span>
|
||||
<span>
|
||||
© {new Date().getFullYear()} OpenCut, All Rights Reserved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { GithubIcon, MenuIcon } from "./icons";
|
||||
import { GithubIcon, MenuIcon } from "@opencut/ui/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
|
||||
@@ -13,6 +13,24 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
const [leftHandle, setLeftHandle] = useState(0);
|
||||
const [rightHandle, setRightHandle] = useState(0);
|
||||
|
||||
const widthRef = useRef(0);
|
||||
const leftHandlePositionRef = useRef(0);
|
||||
const rightHandlePositionRef = useRef(0);
|
||||
|
||||
const dragRef = useRef<{
|
||||
isDragging: boolean;
|
||||
side: "left" | "right" | null;
|
||||
pointerId: number | null;
|
||||
startX: number;
|
||||
initialPosition: number;
|
||||
}>({
|
||||
isDragging: false,
|
||||
side: null,
|
||||
pointerId: null,
|
||||
startX: 0,
|
||||
initialPosition: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
@@ -31,85 +49,85 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const leftEl = leftHandleRef.current;
|
||||
const rightEl = rightHandleRef.current;
|
||||
if (!leftEl || !rightEl) return;
|
||||
|
||||
let isDraggingLeft = false;
|
||||
let isDraggingRight = false;
|
||||
let startX = 0;
|
||||
let initialPosition = 0;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent, isLeft: boolean) => {
|
||||
e.preventDefault();
|
||||
startX = e.clientX;
|
||||
|
||||
if (isLeft) {
|
||||
isDraggingLeft = true;
|
||||
initialPosition = leftHandle;
|
||||
} else {
|
||||
isDraggingRight = true;
|
||||
initialPosition = rightHandle;
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
|
||||
if (isDraggingLeft) {
|
||||
const newPosition = Math.max(0, Math.min(rightHandle - 60, initialPosition + deltaX));
|
||||
setLeftHandle(newPosition);
|
||||
if (leftEl) {
|
||||
leftEl.style.transform = `translateX(${newPosition}px)`;
|
||||
}
|
||||
} else if (isDraggingRight) {
|
||||
const newPosition = Math.max(leftHandle + 60, Math.min(width, initialPosition + deltaX));
|
||||
setRightHandle(newPosition);
|
||||
if (rightEl) {
|
||||
rightEl.style.transform = `translateX(${newPosition}px)`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingLeft = false;
|
||||
isDraggingRight = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const leftMouseDown = (e: MouseEvent) => handleMouseDown(e, true);
|
||||
const rightMouseDown = (e: MouseEvent) => handleMouseDown(e, false);
|
||||
|
||||
leftEl.addEventListener("mousedown", leftMouseDown);
|
||||
rightEl.addEventListener("mousedown", rightMouseDown);
|
||||
|
||||
return () => {
|
||||
leftEl.removeEventListener("mousedown", leftMouseDown);
|
||||
rightEl.removeEventListener("mousedown", rightMouseDown);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
widthRef.current = width;
|
||||
leftHandlePositionRef.current = leftHandle;
|
||||
rightHandlePositionRef.current = rightHandle;
|
||||
}, [leftHandle, rightHandle, width]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const { isDragging, side, pointerId, startX, initialPosition } =
|
||||
dragRef.current;
|
||||
|
||||
if (!isDragging) return;
|
||||
if (pointerId !== null && event.pointerId !== pointerId) return;
|
||||
if (!side) return;
|
||||
|
||||
const deltaX = event.clientX - startX;
|
||||
|
||||
if (side === "left") {
|
||||
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
|
||||
const nextLeftHandle = Math.max(
|
||||
0,
|
||||
Math.min(maxLeft, initialPosition + deltaX),
|
||||
);
|
||||
setLeftHandle(nextLeftHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
const minRight = Math.min(
|
||||
widthRef.current,
|
||||
leftHandlePositionRef.current + 60,
|
||||
);
|
||||
const nextRightHandle = Math.max(
|
||||
minRight,
|
||||
Math.min(widthRef.current, initialPosition + deltaX),
|
||||
);
|
||||
setRightHandle(nextRightHandle);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
const { pointerId } = dragRef.current;
|
||||
if (pointerId !== null && event.pointerId !== pointerId) return;
|
||||
|
||||
dragRef.current.isDragging = false;
|
||||
dragRef.current.side = null;
|
||||
dragRef.current.pointerId = null;
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerEnd);
|
||||
window.addEventListener("pointercancel", handlePointerEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
|
||||
const rightGradientPercent = width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
const rightGradientPercent =
|
||||
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="leading-16 -z-10 flex justify-center gap-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative -z-10 mt-0.5 -rotate-[2.76deg]"
|
||||
>
|
||||
<div className="absolute inset-0 flex h-full w-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div className="leading-16 flex justify-center gap-4">
|
||||
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
|
||||
<div className="absolute inset-0 z-10 flex h-full w-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div
|
||||
ref={leftHandleRef}
|
||||
className="bg-background absolute left-0 flex h-full w-7 select-none items-center justify-center rounded-full border border-yellow-500 cursor-grab hover:scale-105 transition-transform"
|
||||
className="bg-background absolute left-0 z-20 flex h-full w-7 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-yellow-500"
|
||||
style={{
|
||||
transform: `translateX(${leftHandle}px)`,
|
||||
translate: `${leftHandle}px 0`,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
leftHandleRef.current?.setPointerCapture(event.pointerId);
|
||||
dragRef.current.isDragging = true;
|
||||
dragRef.current.side = "left";
|
||||
dragRef.current.pointerId = event.pointerId;
|
||||
dragRef.current.startX = event.clientX;
|
||||
dragRef.current.initialPosition = leftHandlePositionRef.current;
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-2 rounded-full bg-yellow-500" />
|
||||
@@ -117,9 +135,18 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
|
||||
<div
|
||||
ref={rightHandleRef}
|
||||
className="bg-background absolute -left-[30px] flex h-full w-7 select-none items-center justify-center rounded-full border border-yellow-500 cursor-grab hover:scale-105 transition-transform]"
|
||||
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-yellow-500"
|
||||
style={{
|
||||
transform: `translateX(${rightHandle}px)`,
|
||||
translate: `${rightHandle}px 0`,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
rightHandleRef.current?.setPointerCapture(event.pointerId);
|
||||
dragRef.current.isDragging = true;
|
||||
dragRef.current.side = "right";
|
||||
dragRef.current.pointerId = event.pointerId;
|
||||
dragRef.current.startX = event.clientX;
|
||||
dragRef.current.initialPosition = rightHandlePositionRef.current;
|
||||
}}
|
||||
>
|
||||
<div className="h-8 w-2 rounded-full bg-yellow-500" />
|
||||
@@ -127,7 +154,7 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="relative inline-flex h-full w-full items-center justify-center rounded-2xl px-9 will-change-auto"
|
||||
className="relative z-0 inline-flex h-full w-full items-center justify-center rounded-2xl px-9 will-change-auto"
|
||||
style={{
|
||||
mask: `linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Hero() {
|
||||
/>
|
||||
<div className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center">
|
||||
<div className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]">
|
||||
<h1>The Open Source</h1>
|
||||
<h1>The open source</h1>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
useKeybindingsListener,
|
||||
useKeybindingDisabler,
|
||||
@@ -10,44 +11,113 @@ import {
|
||||
import { useEditorActions } from "@/hooks/use-editor-actions";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ children }: EditorProviderProps) {
|
||||
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const editor = useEditor();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
|
||||
// Set up action handlers
|
||||
useEditorActions();
|
||||
|
||||
// Set up keybinding listener
|
||||
useKeybindingsListener();
|
||||
|
||||
// Disable keybindings when initializing
|
||||
useEffect(() => {
|
||||
if (isInitializing || !isPanelsReady) {
|
||||
if (isLoading) {
|
||||
disableKeybindings();
|
||||
} else {
|
||||
enableKeybindings();
|
||||
}
|
||||
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
|
||||
}, [isLoading, disableKeybindings, enableKeybindings]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeApp();
|
||||
}, [initializeApp]);
|
||||
let cancelled = false;
|
||||
|
||||
// Show loading screen while initializing
|
||||
if (isInitializing || !isPanelsReady) {
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, editor, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-background">
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading editor...</p>
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// App is ready, render children
|
||||
return <>{children}</>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { TProject, TScene } from "@/types/project";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface MigrationProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
currentProjectName: string;
|
||||
}
|
||||
|
||||
export function ScenesMigrator({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [isMigrating, setIsMigrating] = useState(false);
|
||||
const [progress, setProgress] = useState<MigrationProgress>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentProjectName: "",
|
||||
});
|
||||
|
||||
const shouldCheckMigration =
|
||||
pathname.startsWith("/editor") || pathname.startsWith("/projects");
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldCheckMigration) return;
|
||||
|
||||
checkAndMigrateProjects();
|
||||
}, [shouldCheckMigration]);
|
||||
|
||||
const checkAndMigrateProjects = async () => {
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
const legacyProjects = projects.filter(
|
||||
(project) => !project.scenes || project.scenes.length === 0
|
||||
);
|
||||
|
||||
if (legacyProjects.length === 0) {
|
||||
// No migration needed
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMigrating(true);
|
||||
setProgress({
|
||||
current: 0,
|
||||
total: legacyProjects.length,
|
||||
currentProjectName: "",
|
||||
});
|
||||
|
||||
// Migrate each legacy project
|
||||
for (let i = 0; i < legacyProjects.length; i++) {
|
||||
const project = legacyProjects[i];
|
||||
|
||||
setProgress({
|
||||
current: i,
|
||||
total: legacyProjects.length,
|
||||
currentProjectName: project.name,
|
||||
});
|
||||
|
||||
await migrateLegacyProject(project);
|
||||
}
|
||||
|
||||
setProgress({
|
||||
current: legacyProjects.length,
|
||||
total: legacyProjects.length,
|
||||
currentProjectName: "Complete!",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
setIsMigrating(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
setIsMigrating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const migrateLegacyProject = async (project: TProject) => {
|
||||
try {
|
||||
const mainScene: TScene = {
|
||||
id: generateUUID(),
|
||||
name: "Main Scene",
|
||||
isMain: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const migratedProject: TProject = {
|
||||
...project,
|
||||
scenes: [mainScene],
|
||||
currentSceneId: mainScene.id,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Load existing timeline data (legacy format)
|
||||
const legacyTimeline = await storageService.loadTimeline({
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await storageService.saveProject({ project: migratedProject });
|
||||
|
||||
// If timeline data, migrate it to the main scene
|
||||
if (legacyTimeline && legacyTimeline.length > 0) {
|
||||
await storageService.saveTimeline({
|
||||
projectId: project.id,
|
||||
tracks: legacyTimeline,
|
||||
sceneId: mainScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up legacy timeline storage
|
||||
await storageService.deleteProjectTimeline({ projectId: project.id });
|
||||
} catch (error) {
|
||||
console.error(`Failed to migrate project ${project.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (!shouldCheckMigration) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (isMigrating) {
|
||||
const progressPercent =
|
||||
progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Updating Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
We're adding scene support to your projects. This will only take a
|
||||
moment.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{progress.current} of {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="w-full" />
|
||||
</div>
|
||||
|
||||
{progress.currentProjectName && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{progress.current < progress.total
|
||||
? `Updating: ${progress.currentProjectName}`
|
||||
: progress.currentProjectName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ export function RenameProjectDialog({
|
||||
}) {
|
||||
const [name, setName] = useState(projectName);
|
||||
|
||||
// Reset the name when dialog opens - this is better UX than syncing with every prop change
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(projectName);
|
||||
@@ -51,7 +50,7 @@ export function RenameProjectDialog({
|
||||
}
|
||||
}}
|
||||
placeholder="Enter a new name"
|
||||
className="mt-0 bg-background border-2 border-border"
|
||||
className="bg-background border-border mt-0 border-2"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -35,14 +34,17 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
||||
error: null,
|
||||
});
|
||||
|
||||
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
|
||||
const editor = useEditor();
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return;
|
||||
hasInitialized.current = true;
|
||||
|
||||
const initializeStorage = async () => {
|
||||
setStatus((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// Check browser support
|
||||
const hasSupport = storageService.isFullySupported();
|
||||
|
||||
if (!hasSupport) {
|
||||
@@ -51,8 +53,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Load saved projects (media will be loaded when a project is loaded)
|
||||
await loadAllProjects();
|
||||
await editor.project.loadAllProjects();
|
||||
|
||||
setStatus({
|
||||
isInitialized: true,
|
||||
@@ -72,7 +73,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
|
||||
};
|
||||
|
||||
initializeStorage();
|
||||
}, [loadAllProjects]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
@@ -23,7 +23,12 @@ export function AudioPlayer({
|
||||
trackMuted = false,
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const volume = editor.playback.getVolume();
|
||||
const muted = editor.playback.isMuted();
|
||||
const speed = editor.playback.getSpeed();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
@@ -42,8 +47,8 @@ export function AudioPlayer({
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
audio.currentTime = audioTime;
|
||||
};
|
||||
@@ -55,8 +60,8 @@ export function AudioPlayer({
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
timelineTime - clipStartTime + trimStart,
|
||||
),
|
||||
);
|
||||
|
||||
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
|
||||
@@ -71,22 +76,22 @@ export function AudioPlayer({
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
handleSeekEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
handleUpdateEvent as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
handleSpeed as EventListener,
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
@@ -11,6 +11,8 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default:
|
||||
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
|
||||
foreground:
|
||||
"bg-background text-foreground shadow-sm hover:bg-background/80",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||
"primary-gradient":
|
||||
@@ -35,7 +37,7 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -54,7 +56,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
||||
import { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Dialog, DialogContent } from "./dialog";
|
||||
|
||||
|
||||
@@ -11,28 +11,28 @@ import { ReactNode, useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { setAssetDragData } from "@/lib/asset-drag";
|
||||
import type { AssetDragData } from "@/types/assets";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { setDragData } from "@/lib/drag-data";
|
||||
import type { TimelineDragData } from "@/types/drag";
|
||||
|
||||
export interface DraggableMediaItemProps {
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: AssetDragData;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onAddToTimeline?: (currentTime: number) => void;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
showPlusOnDrag?: boolean;
|
||||
showLabel?: boolean;
|
||||
rounded?: boolean;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableMediaItem({
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
@@ -41,23 +41,21 @@ export function DraggableMediaItem({
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
showPlusOnDrag = true,
|
||||
showLabel = true,
|
||||
rounded = true,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
isHighlighted = false,
|
||||
}: DraggableMediaItemProps) {
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const currentTime = isDraggable
|
||||
? usePlaybackStore((state) => state.currentTime)
|
||||
: 0;
|
||||
const editor = useEditor();
|
||||
const highlightClassName = "ring-2 ring-primary rounded-sm bg-primary/10";
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.(currentTime);
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
@@ -81,13 +79,13 @@ export function DraggableMediaItem({
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
setAssetDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
setDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.(e);
|
||||
onDragStart?.({ e });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
@@ -99,7 +97,7 @@ export function DraggableMediaItem({
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "h-28 w-28")}
|
||||
className={cn("group relative", containerClassName ?? "size-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -112,8 +110,8 @@ export function DraggableMediaItem({
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-panel-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0", // Webkit-specific ghost hiding
|
||||
isRounded && "rounded-md",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
@@ -127,7 +125,7 @@ export function DraggableMediaItem({
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
aria-label={name}
|
||||
@@ -158,7 +156,7 @@ export function DraggableMediaItem({
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="h-6 w-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm">{name}</span>
|
||||
@@ -166,7 +164,6 @@ export function DraggableMediaItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom drag preview */}
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
@@ -174,8 +171,8 @@ export function DraggableMediaItem({
|
||||
<div
|
||||
className="z-9999 pointer-events-none fixed"
|
||||
style={{
|
||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
@@ -186,7 +183,7 @@ export function DraggableMediaItem({
|
||||
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{showPlusOnDrag && (
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode, parseTimeCode, TimeCode } from "@/lib/time-utils";
|
||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||
import { TTimeCode } from "@/types/time";
|
||||
import { formatTimeCode, parseTimeCode } from "@/lib/time-utils";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration?: number;
|
||||
format?: TimeCode;
|
||||
fps?: number;
|
||||
duration: number;
|
||||
format?: TTimeCode;
|
||||
fps: number;
|
||||
onTimeChange?: (time: number) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -19,7 +19,7 @@ export function EditableTimecode({
|
||||
time,
|
||||
duration,
|
||||
format = "HH:MM:SS:FF",
|
||||
fps = DEFAULT_FPS,
|
||||
fps,
|
||||
onTimeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
@@ -29,8 +29,7 @@ export function EditableTimecode({
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
|
||||
const formattedTime = formatTimeCode(time, format, fps);
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
@@ -48,7 +47,7 @@ export function EditableTimecode({
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimeCode(inputValue, format, fps);
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime === null) {
|
||||
setHasError(true);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
interface ImageTimelineTreatmentProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
targetAspectRatio?: number; // Default to 16:9 for video
|
||||
className?: string;
|
||||
backgroundType?: BackgroundType;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function ImageTimelineTreatment({
|
||||
src,
|
||||
alt,
|
||||
targetAspectRatio = 16 / 9,
|
||||
className,
|
||||
backgroundType = "blur",
|
||||
backgroundColor = "#000000",
|
||||
}: ImageTimelineTreatmentProps) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
setImageDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
setImageLoaded(true);
|
||||
};
|
||||
|
||||
const imageAspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
: 1;
|
||||
|
||||
const needsAspectRatioTreatment = imageAspectRatio !== targetAspectRatio;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
style={{ aspectRatio: targetAspectRatio }}
|
||||
>
|
||||
{/* Background Layer */}
|
||||
{needsAspectRatioTreatment && imageLoaded && (
|
||||
<>
|
||||
{backgroundType === "blur" && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover filter blur-xl scale-110 opacity-60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backgroundType === "mirror" && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-full h-full object-cover opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backgroundType === "color" && (
|
||||
<div className="absolute inset-0" style={{ backgroundColor }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main Image Layer */}
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{!imageLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
|
||||
<div className="animate-pulse text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-accent/50 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-background px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
paddingRight,
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-card data-[state=active]:text-foreground",
|
||||
"inline-flex items-center cursor-pointer justify-center whitespace-nowrap rounded-lg px-3 py-1 text-sm font-medium ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-panel-accent data-[state=active]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
trackMuted?: boolean;
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration,
|
||||
trackMuted = false,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange =
|
||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isInClipRange) return;
|
||||
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const videoTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
video.currentTime = videoTime;
|
||||
};
|
||||
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update video time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
|
||||
if (Math.abs(video.currentTime - targetTime) > 0.5) {
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying && isInClipRange) {
|
||||
video.play().catch(() => {});
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.volume = volume;
|
||||
video.muted = muted || trackMuted;
|
||||
video.playbackRate = speed;
|
||||
}, [volume, speed, muted, trackMuted]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
className={`max-w-full max-h-full object-contain ${className}`}
|
||||
playsInline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
style={{ pointerEvents: "none" }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user