Merge branch 'staging' into feat/timeline-return-to-start

This commit is contained in:
ryu
2025-08-13 14:01:07 +05:00
committed by GitHub
64 changed files with 2860 additions and 792 deletions
+29 -16
View File
@@ -29,6 +29,8 @@ import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { useTheme } from "next-themes";
import { usePlaybackStore } from "@/stores/playback-store";
import { TransitionUpIcon } from "./icons";
import { PanelPresetSelector } from "./panel-preset-selector";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
@@ -39,13 +41,6 @@ export function EditorHeader() {
const router = useRouter();
const { theme, setTheme } = useTheme();
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
};
const handleNameSave = async (newName: string) => {
console.log("handleNameSave", newName);
if (activeProject && newName.trim() && newName !== activeProject.name) {
@@ -78,7 +73,7 @@ export function EditorHeader() {
<span className="text-[0.85rem] mr-2">{activeProject?.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuContent align="start" className="w-40 z-100">
<Link href="/projects">
<DropdownMenuItem className="flex items-center gap-1.5">
<ArrowLeft className="h-4 w-4" />
@@ -147,15 +142,9 @@ export function EditorHeader() {
const rightContent = (
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<Button
size="sm"
className="h-8 text-xs !bg-linear-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm pr-1">Export</span>
</Button>
<ExportButton />
<Button
size="icon"
variant="text"
@@ -177,3 +166,27 @@ export function EditorHeader() {
/>
);
}
function ExportButton() {
const handleExport = () => {
// TODO: Implement export functionality
// NOTE: This is already being worked on
console.log("Export project");
window.open("https://youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
};
return (
<button
className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.1rem] py-[0.1rem] cursor-pointer hover:brightness-95 transition-all duration-200"
onClick={handleExport}
>
<div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.45)]">
<TransitionUpIcon className="z-50" />
<span className="text-[0.875rem] z-50">Export</span>
<div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center">
<div className="absolute w-[calc(100%-4px)] h-[calc(100%-4px)] top-[0.12rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-lg"></div>
</div>
</div>
</button>
);
}
@@ -20,7 +20,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
useEffect(() => {
let mounted = true;
let ws = wavesurfer.current;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
@@ -90,7 +90,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
const wsToDestroy = ws;
// Detach from ref immediately
wavesurfer.current = null;
// Wait a tick to destroy so any pending operations can complete
requestAnimationFrame(() => {
try {
@@ -111,13 +111,13 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
return () => {
// Mark component as unmounted
mounted = false;
// Store reference to current wavesurfer instance
const wsToDestroy = wavesurfer.current;
// Immediately clear the ref to prevent accessing it after unmount
wavesurfer.current = null;
// If we have an instance to clean up, do it safely
if (wsToDestroy) {
// Delay destruction to avoid race conditions
@@ -0,0 +1,27 @@
"use client";
import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="absolute inset-0 pointer-events-none">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 w-full h-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}
@@ -7,6 +7,7 @@ import { TextView } from "./views/text";
import { SoundsView } from "./views/sounds";
import { Separator } from "@/components/ui/separator";
import { SettingsView } from "./views/settings";
import { Captions } from "./views/captions";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
@@ -30,11 +31,7 @@ export function MediaPanel() {
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
captions: <Captions />,
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
@@ -2,122 +2,50 @@
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4"
>
<div className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full py-4">
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer opacity-100 hover:opacity-75",
activeTab === tabKey ? "text-primary !opacity-100" : "text-muted-foreground"
"flex z-[100] flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey
? "text-primary !opacity-100"
: "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="size-[1.1rem]!" />
<Tooltip delayDuration={10}>
<TooltipTrigger asChild>
<tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" />
</TooltipTrigger>
<TooltipContent
side="right"
align="center"
variant="sidebar"
sideOffset={8}
>
<div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white">
{tab.label}
</div>
</TooltipContent>
</Tooltip>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 bg-foreground/10!"
onClick={onClick}
>
<Icon className="size-4! text-foreground" />
</Button>
</div>
);
}
@@ -0,0 +1,67 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface BaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
tabs?: {
value: string;
label: string;
content: React.ReactNode;
}[];
className?: string;
ref?: React.RefObject<HTMLDivElement>;
}
function ViewContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<ScrollArea className="flex-1">
<div className={`p-5 h-full ${className}`}>{children}</div>
</ScrollArea>
);
}
export function BaseView({
children,
defaultTab,
tabs,
className = "",
ref,
}: BaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
<Tabs defaultValue={defaultTab} className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex-1 flex flex-col min-h-0"
>
<ViewContent>{tab.content}</ViewContent>
</TabsContent>
))}
</Tabs>
)}
</div>
);
}
@@ -0,0 +1,313 @@
import { Button } from "@/components/ui/button";
import { PropertyGroup } from "../../properties-panel/property-item";
import { BaseView } from "./base-view";
import { Language, LanguageSelect } from "@/components/language-select";
import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/ffmpeg-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store";
import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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" },
];
const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
export function Captions() {
const [selectedCountry, setSelectedCountry] = useState("auto");
const [isProcessing, setIsProcessing] = useState(false);
const [processingStep, setProcessingStep] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { insertTrackAt, addElementToTrack } = useTimelineStore();
// Check if user has already accepted privacy on mount
useEffect(() => {
const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
setHasAcceptedPrivacy(hasAccepted);
}, []);
const handleGenerateTranscript = async () => {
try {
setIsProcessing(true);
setError(null);
setProcessingStep("Extracting audio...");
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...");
const uploadResponse = await fetch("/api/get-upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileExtension: "wav" }),
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
throw new Error(error.message || "Failed to get upload URL");
}
const { uploadUrl, fileName } = await uploadResponse.json();
// Upload to R2
await fetch(uploadUrl, {
method: "PUT",
body: encryptedBlob,
});
setProcessingStep("Transcribing...");
// Call Modal transcription API with encryption parameters
const transcriptionResponse = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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),
}),
});
if (!transcriptionResponse.ok) {
const error = await transcriptionResponse.json();
throw new Error(error.message || "Transcription failed");
}
const { text, segments } = await transcriptionResponse.json();
console.log("Transcription completed:", { text, segments });
const shortCaptions: Array<{
text: string;
startTime: number;
duration: number;
}> = [];
let globalEndTime = 0; // Track the end time of the last caption globally
segments.forEach((segment: any) => {
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;
}
shortCaptions.push({
text: chunk,
startTime: adjustedStartTime,
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);
// Add all caption elements to the same track
shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, {
type: "text",
name: `Caption ${index + 1}`,
content: caption.text,
duration: caption.duration,
startTime: caption.startTime,
trimStart: 0,
trimEnd: 0,
fontSize: 65,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
} as TextElement);
});
console.log(
`${shortCaptions.length} short-form caption chunks added to timeline!`
);
} catch (error) {
console.error("Transcription failed:", error);
setError(
error instanceof Error ? error.message : "An unexpected error occurred"
);
} finally {
setIsProcessing(false);
setProcessingStep("");
}
};
return (
<BaseView ref={containerRef} className="flex flex-col justify-between">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
onSelect={setSelectedCountry}
containerRef={containerRef}
languages={languages}
/>
</PropertyGroup>
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<Button
className="w-full"
onClick={() => {
if (hasAcceptedPrivacy) {
handleGenerateTranscript();
} else {
setShowPrivacyDialog(true);
}
}}
disabled={isProcessing}
>
{isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
{isProcessing ? processingStep : "Generate transcript"}
</Button>
<Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Audio Processing Notice
</DialogTitle>
<DialogDescription className="space-y-3">
<p>
To generate captions, we need to process your timeline audio
using speech-to-text technology.
</p>
<div className="space-y-2 pt-2">
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Zero-knowledge encryption - we cannot decrypt your files
even if we wanted to
</span>
</div>
<div className="flex items-start gap-2">
<Shield className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Encryption keys generated randomly in your browser, never
stored anywhere
</span>
</div>
<div className="flex items-start gap-2">
<Upload className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Audio encrypted before upload - raw audio never leaves
your device
</span>
</div>
<div className="flex items-start gap-2">
<Trash2 className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Everything permanently deleted within seconds after
transcription
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
<strong>True zero-knowledge privacy:</strong> Encryption keys
are generated randomly in your browser and never stored
anywhere. It's cryptographically impossible for us, our cloud
providers, or anyone else to decrypt your audio files.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowPrivacyDialog(false)}
disabled={isProcessing}
>
Cancel
</Button>
<Button
onClick={() => {
localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
setHasAcceptedPrivacy(true);
setShowPrivacyDialog(false);
handleGenerateTranscript();
}}
disabled={isProcessing}
>
Continue & Generate Captions
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</BaseView>
);
}
@@ -7,9 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BaseView } from "./base-view";
import {
PropertyItem,
PropertyItemLabel,
@@ -34,37 +32,37 @@ export function SettingsView() {
function ProjectSettingsTabs() {
return (
<div className="h-full flex flex-col">
<Tabs defaultValue="project-info" className="flex flex-col h-full">
<div className="px-3 pt-4 pb-0">
<TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger>
</TabsList>
</div>
<Separator className="my-4" />
<ScrollArea className="flex-1">
<TabsContent value="project-info" className="p-5 pt-0 mt-0">
<ProjectInfoView />
</TabsContent>
<TabsContent value="background" className="p-4 pt-0">
<BackgroundView />
</TabsContent>
</ScrollArea>
</Tabs>
</div>
<BaseView
defaultTab="project-info"
tabs={[
{
value: "project-info",
label: "Project info",
content: <ProjectInfoView />,
},
{
value: "background",
label: "Background",
content: <BackgroundView />,
},
]}
/>
);
}
function ProjectInfoView() {
const { activeProject, updateProjectFps } = useProjectStore();
const { canvasPresets, setCanvasSize } = useEditorStore();
const { activeProject, updateProjectFps, updateCanvasSize } =
useProjectStore();
const { canvasPresets } = useEditorStore();
const { getDisplayName } = useAspectRatio();
const handleAspectRatioChange = (value: string) => {
const preset = canvasPresets.find((p) => p.name === value);
if (preset) {
setCanvasSize({ width: preset.width, height: preset.height });
updateCanvasSize(
{ width: preset.width, height: preset.height },
"preset"
);
}
};
@@ -259,7 +257,7 @@ function BackgroundView() {
);
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4">
<PropertyGroup title="Blur" defaultExpanded={false}>
<div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div>
</PropertyGroup>
@@ -1,4 +1,5 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { BaseView } from "./base-view";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline";
@@ -28,7 +29,7 @@ const textData: TextElement = {
export function TextView() {
return (
<div className="p-4">
<BaseView>
<DraggableMediaItem
name="Default text"
preview={
@@ -48,6 +49,6 @@ export function TextView() {
}
showLabel={false}
/>
</div>
</BaseView>
);
}
@@ -14,8 +14,18 @@ import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { FONT_CLASS_MAP } from "@/lib/font-config";
import { useProjectStore } from "@/stores/project-store";
import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
import { TextElementDragState } from "@/types/editor";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { LayoutGuideOverlay } from "./layout-guide-overlay";
import { Label } from "../ui/label";
import { SocialsIcon } from "../icons";
import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
interface ActiveElement {
element: TimelineElement;
@@ -26,8 +36,8 @@ interface ActiveElement {
export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, toggle, setCurrentTime, isPlaying } = usePlaybackStore();
const { canvasSize } = useEditorStore();
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { activeProject } = useProjectStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
@@ -35,7 +45,8 @@ export function PreviewPanel() {
height: 0,
});
const [isExpanded, setIsExpanded] = useState(false);
const { activeProject } = useProjectStore();
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
const [dragState, setDragState] = useState<TextElementDragState>({
isDragging: false,
elementId: null,
@@ -223,7 +234,8 @@ export function PreviewPanel() {
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
// Iterate tracks from bottom to top so topmost track renders last (on top)
[...tracks].reverse().forEach((track) => {
track.elements.forEach((element) => {
if (element.hidden) return;
const elementStart = element.startTime;
@@ -300,6 +312,7 @@ export function PreviewPanel() {
trimEnd={element.trimEnd}
clipDuration={element.duration}
className="w-full h-full object-cover"
trackMuted={true}
/>
</div>
);
@@ -343,7 +356,7 @@ export function PreviewPanel() {
return (
<div
key={element.id}
className="absolute flex items-center justify-center cursor-grab"
className="absolute cursor-grab"
onMouseDown={(e) =>
handleTextMouseDown(e, element, elementData.track.id)
}
@@ -364,7 +377,7 @@ export function PreviewPanel() {
canvasSize.height) *
100
}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
@@ -372,16 +385,16 @@ export function PreviewPanel() {
<div
className={fontClassName}
style={{
fontSize: `${element.fontSize}px`,
fontSize: `${element.fontSize * scaleRatio}px`,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
whiteSpace: "pre-wrap",
padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`,
borderRadius: `${2 * scaleRatio}px`,
whiteSpace: "nowrap",
// Fallback for system fonts that don't have classes
...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
@@ -423,6 +436,7 @@ export function PreviewPanel() {
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={element.muted || elementData.track.muted}
/>
</div>
);
@@ -448,14 +462,18 @@ export function PreviewPanel() {
// Audio elements (no visual representation)
if (mediaItem.type === "audio") {
return (
<div key={element.id} className="absolute inset-0">
<div
key={element.id}
className="absolute inset-0"
style={{ pointerEvents: "none" }}
>
<AudioPlayer
src={mediaItem.url!}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={elementData.track.muted}
trackMuted={element.muted || elementData.track.muted}
/>
</div>
);
@@ -496,13 +514,7 @@ export function PreviewPanel() {
renderElement(elementData, index)
)
)}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
<LayoutGuideOverlay />
</div>
) : null}
@@ -758,13 +770,7 @@ function FullscreenPreview({
renderElement(elementData, index)
)
)}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
<LayoutGuideOverlay />
</div>
</div>
<div className="p-4 bg-background">
@@ -799,6 +805,7 @@ function PreviewToolbar({
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
const { layoutGuide, toggleLayoutGuide } = useEditorStore();
if (isExpanded) {
return (
@@ -818,7 +825,7 @@ function PreviewToolbar({
return (
<div
data-toolbar
className="flex justify-between gap-2 px-1.5 pr-4 py-1.5 border border-border/50 w-auto absolute bottom-4 right-4 bg-black/20 rounded-full backdrop-blur-l text-white"
className="flex justify-end gap-2 h-auto pb-5 pr-5 pt-4 w-full"
>
<div className="flex items-center gap-2">
<Button
@@ -834,6 +841,54 @@ function PreviewToolbar({
<Play className="h-3 w-3" />
)}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="h-auto p-0 mr-1"
title="Toggle layout guide"
>
<SocialsIcon className="!size-6" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Layout guide</h4>
<p className="text-sm text-muted-foreground">
Show platform-specific layout guides to help align your
content with interface elements like profile pictures,
usernames, and interaction buttons.
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="none"
checked={layoutGuide.platform === null}
onCheckedChange={() =>
toggleLayoutGuide(layoutGuide.platform || "tiktok")
}
/>
<Label htmlFor="none">None</Label>
</div>
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
<div key={platform} className="flex items-center space-x-2">
<Checkbox
id={platform}
checked={layoutGuide.platform === platform}
onCheckedChange={() =>
toggleLayoutGuide(platform as PlatformLayout)
}
/>
<Label htmlFor={platform}>{label}</Label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
<Button
variant="text"
size="icon"
@@ -28,9 +28,7 @@ export function SnapIndicator({
// Track scroll position to lock snap indicator to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;
+121 -26
View File
@@ -55,7 +55,7 @@ import { SelectionBox } from "../selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapIndicator } from "../snap-indicator";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
import {
getTrackHeight,
getCumulativeHeightBefore,
@@ -169,11 +169,32 @@ export function Timeline() {
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
// Only track mouse down on timeline background areas (not elements)
const target = e.target as HTMLElement;
console.log(
JSON.stringify({
debug_mousedown: "START",
target_class: target.className,
target_parent_class: target.parentElement?.className,
clientX: e.clientX,
clientY: e.clientY,
timeStamp: e.timeStamp,
})
);
const isTimelineBackground =
!target.closest(".timeline-element") &&
!playheadRef.current?.contains(target) &&
!target.closest("[data-track-labels]");
console.log(
JSON.stringify({
debug_mousedown: "CHECK",
isTimelineBackground,
hasTimelineElement: !!target.closest(".timeline-element"),
hasPlayhead: !!playheadRef.current?.contains(target),
hasTrackLabels: !!target.closest("[data-track-labels]"),
})
);
if (isTimelineBackground) {
mouseTrackingRef.current = {
isMouseDown: true,
@@ -181,12 +202,38 @@ export function Timeline() {
downY: e.clientY,
downTime: e.timeStamp,
};
console.log(
JSON.stringify({
debug_mousedown: "TRACKED",
mouseTracking: mouseTrackingRef.current,
})
);
} else {
console.log(
JSON.stringify({
debug_mousedown: "IGNORED - not timeline background",
})
);
}
}, []);
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
console.log(
JSON.stringify({
debug_click: "START",
target: (e.target as HTMLElement).className,
target_parent: (e.target as HTMLElement).parentElement?.className,
mouseTracking: mouseTrackingRef.current,
isSelecting,
justFinishedSelecting,
clickX: e.clientX,
clickY: e.clientY,
timeStamp: e.timeStamp,
})
);
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
// Reset mouse tracking
@@ -201,8 +248,8 @@ export function Timeline() {
if (!isMouseDown) {
console.log(
JSON.stringify({
ignoredClickWithoutMouseDown: true,
timeStamp: e.timeStamp,
debug_click: "REJECTED - no mousedown",
mouseTracking: mouseTrackingRef.current,
})
);
return;
@@ -216,11 +263,10 @@ export function Timeline() {
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
console.log(
JSON.stringify({
ignoredDragNotClick: true,
debug_click: "REJECTED - movement too large",
deltaX,
deltaY,
deltaTime,
timeStamp: e.timeStamp,
})
);
return;
@@ -228,27 +274,54 @@ export function Timeline() {
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
console.log(
JSON.stringify({
debug_click: "REJECTED - selection operation",
isSelecting,
justFinishedSelecting,
})
);
return;
}
// Don't seek if clicking on timeline elements, but still deselect
if ((e.target as HTMLElement).closest(".timeline-element")) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked timeline element",
})
);
return;
}
// Don't seek if clicking on playhead
if (playheadRef.current?.contains(e.target as Node)) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked playhead",
})
);
return;
}
// Don't seek if clicking on track labels
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
console.log(
JSON.stringify({
debug_click: "REJECTED - clicked track labels",
})
);
clearSelectedElements();
return;
}
// Clear selected elements when clicking empty timeline area
console.log(JSON.stringify({ clearingSelectedElements: true }));
console.log(
JSON.stringify({
debug_click: "PROCEEDING - clearing elements",
clearingSelectedElements: true,
})
);
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
@@ -256,24 +329,39 @@ export function Timeline() {
"[data-ruler-area]"
);
console.log(
JSON.stringify({
debug_click: "CALCULATING POSITION",
isRulerClick,
clientX: e.clientX,
clientY: e.clientY,
target_element: (e.target as HTMLElement).tagName,
target_class: (e.target as HTMLElement).className,
})
);
let mouseX: number;
let scrollLeft = 0;
if (isRulerClick) {
// Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerContent) return;
const rulerContent = rulerScrollRef.current;
if (!rulerContent) {
console.log(
JSON.stringify({
debug_click: "ERROR - no ruler container found",
})
);
return;
}
const rect = rulerContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
// Calculate based on tracks content position
const tracksContent = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!tracksContent) return;
const tracksContent = tracksScrollRef.current;
if (!tracksContent) {
return;
}
const rect = tracksContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = tracksContent.scrollLeft;
@@ -291,7 +379,6 @@ export function Timeline() {
// Use frame snapping for timeline clicking
const projectFps = activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
seek(time);
},
[
@@ -405,7 +492,21 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
useTimelineStore.getState().addMediaToNewTrack(addedItem);
const trackType: TrackType =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = useTimelineStore
.getState()
.insertTrackAt(trackType, 0);
useTimelineStore.getState().addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
} catch (error) {
@@ -428,15 +529,9 @@ export function Timeline() {
// --- Scroll synchronization effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const rulerViewport = rulerScrollRef.current;
const tracksViewport = tracksScrollRef.current;
const trackLabelsViewport = trackLabelsScrollRef.current;
if (!rulerViewport || !tracksViewport) return;
@@ -50,6 +50,7 @@ export function TimelineElement({
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
toggleElementMuted,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -57,7 +58,7 @@ export function TimelineElement({
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
const isAudio = mediaItem?.type === "audio";
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
@@ -124,8 +125,12 @@ export function TimelineElement({
}
};
const handleToggleElementHidden = (e: React.MouseEvent) => {
const handleToggleElementContext = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasAudio && element.type === "media") {
toggleElementMuted(track.id, element.id);
return;
}
toggleElementHidden(track.id, element.id);
};
@@ -246,6 +251,8 @@ export function TimelineElement({
}
};
const isMuted = element.type === "media" ? element.muted === true : false;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
@@ -279,9 +286,9 @@ export function TimelineElement({
{renderElementContent()}
</div>
{element.hidden && (
{(hasAudio ? isMuted : element.hidden) && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
{isAudio ? (
{hasAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
@@ -309,9 +316,9 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementHidden}>
{isAudio ? (
element.hidden ? (
<ContextMenuItem onClick={handleToggleElementContext}>
{hasAudio ? (
isMuted ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
@@ -322,8 +329,8 @@ export function TimelineElement({
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{isAudio
? element.hidden
{hasAudio
? isMuted
? "Unmute"
: "Mute"
: element.hidden
@@ -51,9 +51,7 @@ export function TimelinePlayhead({
// Track scroll position to lock playhead to frame
useEffect(() => {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
if (!tracksViewport) return;
@@ -86,9 +84,7 @@ export function TimelinePlayhead({
// Get the timeline content width and viewport width for right boundary
const timelineContentWidth =
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current;
const viewportWidth = tracksViewport?.clientWidth || 1000;
// Constrain playhead to never appear outside the timeline area
@@ -126,7 +122,7 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-150"
className="absolute pointer-events-auto z-40"
style={{
left: `${leftPosition}px`,
top: 0,
@@ -4,11 +4,10 @@ import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
import { processMediaFiles } from "@/lib/media-processing";
import { TimelineElement } from "./timeline-element";
import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
getMainTrack,
canElementGoOnTrack,
} from "@/types/timeline";
@@ -16,6 +15,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
import type {
TimelineElement as TimelineElementType,
DragData,
TrackType,
} from "@/types/timeline";
import {
snapTimeToFrame,
@@ -689,8 +689,9 @@ export function TimelineTrackContent({
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
const hasFiles = e.dataTransfer.files?.length > 0;
if (!hasTimelineElement && !hasMediaItem) return;
if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
const trackContainer = e.currentTarget.querySelector(
".track-elements-container"
@@ -1050,6 +1051,50 @@ export function TimelineTrackContent({
trimEnd: 0,
});
}
} else if (hasFiles) {
// External file drops
const { activeProject } = useProjectStore.getState();
const { addMediaItem } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) {
toast.error("No active project");
return;
}
// Process and add files to new timeline tracks at playhead position
processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => {
for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
(item) =>
item.name === processedItem.name &&
item.url === processedItem.url
);
if (addedItem) {
const trackType: TrackType =
addedItem.type === "audio" ? "audio" : "media";
const targetTrackId = insertTrackAt(trackType, 0);
addElementToTrack(targetTrackId, {
type: "media",
mediaId: addedItem.id,
name: addedItem.name,
duration: addedItem.duration || 5,
startTime: currentTime,
trimStart: 0,
trimEnd: 0,
});
}
}
})
.catch((error) => {
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
});
}
} catch (error) {
console.error("Error handling drop:", error);
+2 -2
View File
@@ -9,7 +9,7 @@ import Image from "next/image";
export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<Image src="/logo.svg" alt="OpenCut Logo" className="invert dark:invert-0" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
@@ -40,7 +40,7 @@ export function Header() {
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
+68
View File
@@ -163,3 +163,71 @@ export function DataBuddyIcon({
</svg>
);
}
export function SocialsIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 345 243"
fill="none"
className={className}
>
<g opacity="0.5">
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" fill="#FFFC00"/>
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" fill="white"/>
<path d="M203.75 4H39.25C19.782 4 4 19.782 4 39.25V203.75C4 223.218 19.782 239 39.25 239H203.75C223.218 239 239 223.218 239 203.75V39.25C239 19.782 223.218 4 203.75 4Z" stroke="black" strokeWidth="7"/>
<path d="M97.1738 194.02C88.9121 188.053 82.4863 184.84 66.8809 187.594C64.5859 188.053 60.4551 189.43 59.9961 185.299C59.0781 182.086 59.0781 177.037 56.7832 176.578C42.5547 174.742 37.5059 171.988 35.2109 169.234C34.293 168.316 33.834 166.021 35.6699 165.562C59.9961 160.973 71.4707 137.564 73.7656 132.975C76.5195 126.09 71.9297 121.959 63.209 119.205C59.0781 117.828 52.1934 115.992 52.1934 111.402C52.1934 109.107 54.4883 107.73 56.7832 106.812C58.6191 106.354 60.4551 105.895 62.291 106.812C67.7988 109.107 72.8477 110.025 75.6016 107.73C75.6016 95.3379 72.3887 79.7324 77.4375 66.8809C83.4043 52.6523 98.0918 39.8008 121.5 39.8008C144.908 39.8008 159.596 52.6523 165.562 66.8809C170.611 79.7324 167.398 95.3379 167.398 107.73C170.152 110.025 175.201 109.107 180.709 106.812C182.545 105.895 184.381 106.354 186.217 106.812C188.512 107.73 190.807 109.107 190.807 111.402C190.807 115.992 183.922 117.828 179.791 119.205C171.07 121.959 166.48 126.09 169.234 132.975C171.529 137.564 183.004 160.973 207.33 165.562C209.166 166.021 208.707 168.316 207.789 169.234C205.494 171.988 200.445 174.742 186.217 176.578C183.922 177.037 183.922 182.086 183.004 185.299C182.545 189.43 178.414 188.053 176.119 187.594C160.514 184.84 154.088 188.053 145.826 194.02C139.065 199.872 130.442 203.126 121.5 203.199C111.861 203.658 104.059 199.527 97.1738 194.02Z" stroke="black" strokeWidth="7"/>
</g>
<path fillRule="evenodd" clipRule="evenodd" d="M133.5 4H321.5C334.48 4 345 14.5205 345 27.5V215.5C345 228.48 334.48 239 321.5 239H133.5C120.52 239 110 228.48 110 215.5V27.5C110 14.5205 120.52 4 133.5 4Z" fill="#010101"/>
<path fillRule="evenodd" clipRule="evenodd" d="M261.497 99.4086C271.784 106.758 284.388 111.083 297.999 111.083V84.9035C295.423 84.9045 292.854 84.6357 290.333 84.1016V104.709C276.723 104.709 264.121 100.384 253.831 93.0345V146.459C253.831 173.185 232.155 194.85 205.417 194.85C195.44 194.85 186.167 191.835 178.464 186.665C187.256 195.65 199.516 201.224 213.08 201.224C239.821 201.224 261.498 179.56 261.498 152.833L261.497 99.4086ZM270.953 72.9965C265.696 67.2559 262.244 59.8365 261.497 51.634V48.267H254.233C256.061 58.6916 262.298 67.5981 270.953 72.9965ZM195.376 166.157C192.438 162.308 190.851 157.598 190.858 152.756C190.858 140.533 200.773 130.622 213.005 130.622C215.285 130.621 217.551 130.97 219.723 131.659V104.894C217.185 104.547 214.622 104.399 212.061 104.454V125.286C209.887 124.597 207.62 124.247 205.34 124.249C193.107 124.249 183.193 134.159 183.193 146.384C183.193 155.027 188.149 162.512 195.376 166.157Z" fill="#EE1D52"/>
<path fillRule="evenodd" clipRule="evenodd" d="M253.831 93.0345C264.121 100.384 276.723 104.709 290.333 104.709V84.1016C282.736 82.4848 276.011 78.5162 270.953 72.9965C262.298 67.5981 256.061 58.6916 254.233 48.267H235.152V152.832C235.108 165.022 225.21 174.892 213.004 174.892C205.811 174.892 199.422 171.465 195.376 166.157C188.149 162.512 183.193 155.027 183.193 146.384C183.193 134.159 193.107 124.249 205.34 124.249C207.683 124.249 209.942 124.614 212.061 125.286V104.454C185.792 104.996 164.665 126.449 164.665 152.833C164.665 166.003 169.926 177.942 178.464 186.665C186.167 191.835 195.44 194.85 205.417 194.85C232.155 194.85 253.831 173.185 253.831 146.459V93.0345Z" fill="white"/>
<path fillRule="evenodd" clipRule="evenodd" d="M290.333 84.1016V78.5293C283.482 78.5396 276.766 76.6229 270.953 72.9965C276.099 78.6269 282.874 82.5087 290.333 84.1016ZM254.233 48.267C254.058 47.2708 253.924 46.2679 253.831 45.2608V41.8938H227.485V146.459C227.443 158.648 217.545 168.518 205.339 168.518C201.754 168.518 198.372 167.669 195.376 166.157C199.422 171.465 205.811 174.892 213.004 174.892C225.21 174.892 235.108 165.022 235.152 152.832V48.267H254.233ZM212.061 104.454L212.061 98.5212C209.859 98.2202 207.64 98.0698 205.418 98.071C178.676 98.071 157 119.736 157 146.459C157 163.214 165.518 177.979 178.464 186.665C169.926 177.942 164.666 166.002 164.666 152.832C164.666 126.449 185.791 104.996 212.061 104.454Z" fill="#69C9D0"/>
</svg>
);
}
export function TransitionUpIcon({
className = "",
size = 16,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 12.6667C2.5 13.4951 3.17157 14.1667 4 14.1667H12C12.8284 14.1667 13.5 13.4951 13.5 12.6667V11.3333C13.5 10.5049 12.8284 9.83333 12 9.83333H4C3.17157 9.83333 2.5 10.5049 2.5 11.3333V12.6667ZM4 15.1667C2.61929 15.1667 1.5 14.0474 1.5 12.6667V11.3333C1.5 9.95262 2.61929 8.83333 4 8.83333H12C13.3807 8.83333 14.5 9.95262 14.5 11.3333V12.6667C14.5 14.0474 13.3807 15.1667 12 15.1667H4Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 5.83333C1.72386 5.83333 1.5 5.60947 1.5 5.33333L1.5 4C1.5 2.2511 2.91777 0.833332 4.66667 0.833332L11.3333 0.833332C13.0822 0.833332 14.5 2.2511 14.5 4V5.33333C14.5 5.60947 14.2761 5.83333 14 5.83333C13.7239 5.83333 13.5 5.60947 13.5 5.33333V4C13.5 2.80338 12.53 1.83333 11.3333 1.83333L4.66667 1.83333C3.47005 1.83333 2.5 2.80338 2.5 4V5.33333C2.5 5.60947 2.27614 5.83333 2 5.83333Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.35355 3.64645C8.15829 3.45118 7.84171 3.45118 7.64645 3.64645L5.64645 5.64645C5.45118 5.84171 5.45118 6.15829 5.64645 6.35355C5.84171 6.54882 6.15829 6.54882 6.35355 6.35355L7.5 5.20711L7.5 9.33333C7.5 9.60948 7.72386 9.83333 8 9.83333C8.27614 9.83333 8.5 9.60948 8.5 9.33333V5.20711L9.64645 6.35355C9.84171 6.54882 10.1583 6.54882 10.3536 6.35355C10.5488 6.15829 10.5488 5.84171 10.3536 5.64645L8.35355 3.64645Z"
fill="white"
/>
</svg>
);
}
@@ -50,7 +50,7 @@ export function Handlebars({ children }: HandlebarsProps) {
<div ref={containerRef} className="relative -rotate-[2.76deg] mt-0.5">
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between z-1">
<motion.div
className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
className="absolute z-10 left-0 h-full border border-yellow-500 w-7 rounded-full bg-background flex items-center justify-center select-none"
style={{
x: leftHandleX,
}}
@@ -66,7 +66,7 @@ export function Handlebars({ children }: HandlebarsProps) {
</motion.div>
<motion.div
className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center select-none"
className="absolute z-10 -left-[30px] h-full border border-yellow-500 w-7 rounded-full bg-background flex items-center justify-center select-none"
style={{
x: rightHandleX,
}}
+2 -2
View File
@@ -14,8 +14,8 @@ export function Hero() {
return (
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
<Image
className="absolute top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
className="absolute top-0 left-0 -z-50 size-full object-cover invert dark:invert-0 opacity-85"
src="/landing-page-dark.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
+204
View File
@@ -0,0 +1,204 @@
import { useState, useRef, useEffect } from "react";
import { ChevronDown, Globe } from "lucide-react";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import ReactCountryFlag from "react-country-flag";
export interface Language {
code: string;
name: string;
flag?: string;
}
interface LanguageSelectProps {
selectedCountry: string;
onSelect: (country: string) => void;
containerRef: React.RefObject<HTMLDivElement>;
languages: Language[];
}
function FlagPreloader({ languages }: { languages: Language[] }) {
return (
<div className="absolute -top-[9999px] left-0 pointer-events-none">
{languages.map((language) => (
<ReactCountryFlag
key={language.code}
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
))}
</div>
);
}
export function LanguageSelect({
selectedCountry,
onSelect,
containerRef,
languages,
}: LanguageSelectProps) {
const [expanded, setExpanded] = useState(false);
const [isTapping, setIsTapping] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const collapsedHeight = "2.5rem";
const expandHeight = "12rem";
const buttonRef = useRef<HTMLButtonElement>(null);
const expand = () => {
setIsTapping(true);
setTimeout(() => setIsTapping(false), 600);
setExpanded(true);
buttonRef.current?.focus();
};
useEffect(() => {
if (!expanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsClosing(true);
setTimeout(() => setIsClosing(false), 600);
setExpanded(false);
buttonRef.current?.blur();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [expanded]);
const selectedLanguage = languages.find(
(lang) => lang.code === selectedCountry
);
const handleSelect = ({
code,
e,
}: {
code: string;
e: React.MouseEvent<HTMLButtonElement>;
}) => {
e.stopPropagation();
e.preventDefault();
onSelect(code);
setExpanded(false);
};
return (
<div className="relative w-full h-9">
<FlagPreloader languages={languages} />
<motion.button
type="button"
className={cn(
"absolute w-full h-full flex flex-col overflow-hidden items-start justify-between z-10 rounded-lg px-3 cursor-pointer",
"!bg-foreground/10 backdrop-blur-md text-foreground py-0",
"transition-[color,box-shadow] focus:border-ring focus:ring-ring/50 focus:ring-[1px]"
)}
initial={{
height: collapsedHeight,
scale: 1,
}}
animate={{
height: expanded ? expandHeight : collapsedHeight,
scale: isTapping ? [1, 0.985, 1] : 1,
}}
transition={{
height: { duration: 0.25, ease: [0.4, 0, 0.2, 1] },
scale: { duration: 0.6, ease: "easeOut" },
}}
onClick={expand}
ref={buttonRef}
>
{!expanded ? (
<div
className="flex items-center justify-between w-full"
style={{
height: collapsedHeight,
}}
>
<div className="flex items-center gap-2">
{selectedCountry === "auto" ? (
<Globe className="!size-[1.05rem]" />
) : (
<ReactCountryFlag
countryCode={selectedCountry}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">
{selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
</span>
</div>
</div>
) : (
<div className="flex flex-col gap-2 my-2.5 w-full overflow-y-auto scrollbar-hidden">
<LanguageButton
language={{ code: "auto", name: "Auto" }}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
{languages.map((language) => (
<LanguageButton
key={language.code}
language={language}
onSelect={handleSelect}
selectedCountry={selectedCountry}
/>
))}
</div>
)}
</motion.button>
<motion.div
className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none z-20 mt-0.5"
initial={{ opacity: 1 }}
animate={{ opacity: expanded ? 0 : 1 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<ChevronDown className="text-muted-foreground size-4" />
</motion.div>
</div>
);
}
function LanguageButton({
language,
onSelect,
selectedCountry,
}: {
language: Language;
onSelect: ({
code,
e,
}: {
code: string;
e: React.MouseEvent<HTMLButtonElement>;
}) => void;
selectedCountry: string;
}) {
return (
<button
type="button"
className="flex items-center gap-2 cursor-pointer text-foreground hover:text-foreground/75"
onClick={(e) => onSelect({ code: language.code, e })}
>
{language.code === "auto" ? (
<Globe className="!size-[1.0rem]" />
) : (
<ReactCountryFlag
countryCode={language.code}
svg
style={{ width: "1.05rem", height: "1.05rem" }}
/>
)}
<span className="pt-[0.05rem]">{language.name}</span>
</button>
);
}
+1 -1
View File
@@ -79,7 +79,7 @@ export function Onboarding() {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px] outline-hidden!">
<DialogContent className="sm:max-w-[425px] !outline-none">
<DialogTitle>
<span className="sr-only">{getStepTitle()}</span>
</DialogTitle>
@@ -0,0 +1,91 @@
"use client";
import { Button } from "./ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
const PRESET_LABELS: Record<PanelPreset, string> = {
default: "Default",
media: "Media",
inspector: "Inspector",
"vertical-preview": "Vertical Preview",
};
const PRESET_DESCRIPTIONS: Record<PanelPreset, string> = {
default: "Media, preview, and inspector on top row, timeline on bottom",
media: "Full height media on left, preview and inspector on top row",
inspector: "Full height inspector on right, media and preview on top row",
"vertical-preview": "Full height preview on right for vertical videos",
};
export function PanelPresetSelector() {
const { activePreset, setActivePreset, resetPreset } = usePanelStore();
const handlePresetChange = (preset: PanelPreset) => {
setActivePreset(preset);
};
const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
event.stopPropagation();
resetPreset(preset);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 px-2 flex items-center gap-1 text-xs"
title="Panel Presets"
>
<LayoutPanelTop className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
{(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
<DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
className="flex items-start justify-between gap-2 py-2 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PRESET_LABELS[preset]}
</span>
{activePreset === preset && (
<div className="h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight">
{PRESET_DESCRIPTIONS[preset]}
</p>
</div>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 shrink-0 opacity-60 hover:opacity-100"
onClick={(e) => handleResetPreset(preset, e)}
title={`Reset ${PRESET_LABELS[preset]} preset`}
>
<RotateCcw className="h-3 w-3" />
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -51,7 +51,7 @@ export function RenameProjectDialog({
}
}}
placeholder="Enter a new name"
className="mt-4 bg-background/50"
className="mt-4"
/>
<DialogFooter>
+2
View File
@@ -13,6 +13,8 @@ const buttonVariants = cva(
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
primary:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline:
+1 -1
View File
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-100 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-150 bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
+1 -1
View File
@@ -51,7 +51,7 @@ const Input = React.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-transparent 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 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",
"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,
@@ -19,18 +19,18 @@ export function SponsorButton({
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-xs hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-background/5 backdrop-blur-xs hover:bg-background/10 transition-all duration-200 group shadow-lg ${className}`}
>
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors">
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground transition-colors">
Sponsored by
</span>
<div className="flex items-center gap-1.5">
<div className="text-zinc-100 group-hover:text-white transition-colors">
<div className="text-foreground/90 group-hover:text-foreground transition-colors">
<Logo className="w-4 h-4" />
</div>
<span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors">
<span className="text-xs font-medium text-foreground/90 group-hover:text-foreground transition-colors">
{companyName}
</span>
</div>
+59 -19
View File
@@ -1,9 +1,8 @@
"use client";
import { cva, type VariantProps } from 'class-variance-authority';
import { Tooltip as TooltipPrimitive } from 'radix-ui';
import * as React from 'react';
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "../../lib/utils";
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
@@ -11,22 +10,63 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const tooltipVariants = cva(
'z-50 overflow-visible rounded-sm text-sm shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
{
variants: {
variant: {
default: 'bg-popover text-popover-foreground border px-3 py-1.5',
destructive:
'bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]',
outline: 'border-border',
important:
'bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]',
promotions:
'bg-red-100/90 text-red-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]',
personal:
'bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]',
updates:
'bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]',
forums:
'bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]',
sidebar: 'bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2',
},
},
defaultVariants: {
variant: 'default',
},
},
);
interface TooltipContentProps
extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
VariantProps<typeof tooltipVariants> {}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
TooltipContentProps
>(({ className, sideOffset = 4, variant, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
{variant === 'sidebar' && (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute left-[-6px] top-1/2 -translate-y-1/2"
>
<path d="M6 0L0 5L6 10V0Z" className="fill-white/80 dark:fill-[#413F3E]" />
</svg>
)}
{props.children}
</TooltipPrimitive.Content>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+4 -2
View File
@@ -11,6 +11,7 @@ interface VideoPlayerProps {
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function VideoPlayer({
@@ -21,6 +22,7 @@ export function VideoPlayer({
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
@@ -109,9 +111,9 @@ export function VideoPlayer({
if (!video) return;
video.volume = volume;
video.muted = muted;
video.muted = muted || trackMuted;
video.playbackRate = speed;
}, [volume, speed, muted]);
}, [volume, speed, muted, trackMuted]);
return (
<video