This commit is contained in:
Maze Winther
2026-01-15 11:05:17 +01:00
parent c19f085e48
commit deef784b76
182 changed files with 7787 additions and 18046 deletions
@@ -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">Its 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>
@@ -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>;
}
+28 -16
View File
@@ -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}
/>
+15 -6
View File
@@ -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>
+1 -1
View File
@@ -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";
+101 -74
View File
@@ -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%,
+1 -1
View File
@@ -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>
+9 -8
View File
@@ -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>
+15 -10
View File
@@ -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]);
+4 -2
View File
@@ -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";
-1
View File
@@ -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";
+27 -30
View File
@@ -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>
);
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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}
-133
View File
@@ -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()}
/>
);
}