summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/favicon.icobin4286 -> 0 bytes
-rw-r--r--apps/web/src/app/layout.tsx37
-rw-r--r--apps/web/src/app/metadata.ts66
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx115
-rw-r--r--apps/web/src/components/editor/timeline.tsx37
-rw-r--r--apps/web/src/components/header.tsx2
-rw-r--r--apps/web/src/stores/playback-store.ts6
7 files changed, 217 insertions, 46 deletions
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico
deleted file mode 100644
index cb20127b..00000000
--- a/apps/web/src/app/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 5d33100d..ee045647 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,4 +1,3 @@
-import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
@@ -6,46 +5,14 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
+import { baseMetaData } from "./metadata";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
-export const metadata: Metadata = {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- openGraph: {
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- url: "https://opencut.app",
- siteName: "OpenCut",
- locale: "en_US",
- type: "website",
- images: [
- {
- url: "https://opencut.app/opengraph-image.jpg",
- width: 1200,
- height: 630,
- alt: "OpenCut",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: "OpenCut",
- description:
- "A simple but powerful video editor that gets the job done. In your browser.",
- creator: "@opencutapp",
- images: ["/opengraph-image.jpg"],
- },
- robots: {
- index: true,
- follow: true,
- },
-};
+export const metadata = baseMetaData;
export default function RootLayout({
children,
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
new file mode 100644
index 00000000..4e387014
--- /dev/null
+++ b/apps/web/src/app/metadata.ts
@@ -0,0 +1,66 @@
+import { Metadata } from "next";
+
+const title = "OpenCut";
+const description = "A simple but powerful video editor that gets the job done. In your browser.";
+const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
+const twitterImageUrl = "/opengraph-image.jpg";
+
+export const baseMetaData: Metadata = {
+ title: title,
+ description: description,
+ openGraph: {
+ title: title,
+ description: description,
+ url: "https://opencut.app",
+ siteName: "OpenCut",
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: openGraphImageUrl,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: title,
+ description: description,
+ creator: "@opencutapp",
+ images: [twitterImageUrl],
+ },
+ robots: {
+ index: true,
+ follow: true,
+ },
+ icons: {
+ icon: [
+ { url: "/favicon.ico" },
+ { url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
+ { url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
+ { url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
+ ],
+ apple: [
+ { url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
+ { url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
+ { url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
+ { url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
+ { url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
+ { url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
+ { url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
+ { url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
+ { url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
+ ],
+ shortcut: ["/favicon.ico"]
+ },
+ appleWebApp: {
+ capable: true,
+ title: title,
+ },
+ manifest: "/manifest.json",
+ other: {
+ "msapplication-config": "/browserconfig.xml"
+ }
+};
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
new file mode 100644
index 00000000..75a5ae06
--- /dev/null
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+interface AudioWaveformProps {
+ audioUrl: string;
+ height?: number;
+ className?: string;
+}
+
+const AudioWaveform: React.FC<AudioWaveformProps> = ({
+ audioUrl,
+ height = 32,
+ className = ''
+}) => {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || !audioUrl) return;
+
+ try {
+ // Clean up any existing instance
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+
+ wavesurfer.current = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: 'rgba(255, 255, 255, 0.6)',
+ progressColor: 'rgba(255, 255, 255, 0.9)',
+ cursorColor: 'transparent',
+ barWidth: 2,
+ barGap: 1,
+ height: height,
+ normalize: true,
+ interact: false,
+ });
+
+ // Event listeners
+ wavesurfer.current.on('ready', () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ wavesurfer.current.on('error', (err) => {
+ console.error('WaveSurfer error:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ await wavesurfer.current.load(audioUrl);
+
+ } catch (err) {
+ console.error('Failed to initialize WaveSurfer:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initWaveSurfer();
+
+ return () => {
+ mounted = false;
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+ };
+ }, [audioUrl, height]);
+
+ if (error) {
+ return (
+ <div className={`flex items-center justify-center ${className}`} style={{ height }}>
+ <span className="text-xs text-foreground/60">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-xs text-foreground/60">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
+ style={{ height }}
+ />
+ </div>
+ );
+};
+
+export default AudioWaveform; \ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ce0ffa30..c1b8f3e8 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -566,15 +567,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Show overlay when dragging media over the timeline */}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- Drop media here to add to timeline
- </div>
- </div>
- )}
-
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@@ -953,6 +945,17 @@ export function Timeline() {
)}
</>
)}
+ {isDragOver && (
+ <div
+ className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground"
+ style={{
+ top: `${tracks.length * 60}px`,
+ height: "60px",
+ }}
+ >
+ <div>Drop media here to add a new track</div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1626,7 +1629,21 @@ function TimelineTrackContent({
);
}
- // Fallback for audio or videos without thumbnails
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index d99a4fc5..74ba9359 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -50,7 +50,7 @@ export function Header() {
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
- GitHub
+ GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index 0127edff..0cc01cc1 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -21,7 +21,13 @@ const startTimer = (store: any) => {
const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
+ // When video completes, pause and reset playhead to start
state.pause();
+ state.setCurrentTime(0);
+ // Notify video elements to sync with reset
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", { detail: { time: 0 } })
+ );
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync