diff options
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/audio-waveform.tsx | 115 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel.tsx | 26 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 52 |
3 files changed, 164 insertions, 29 deletions
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/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index f2b3f657..cdd67a86 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -17,27 +17,28 @@ export function MediaPanel() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const fileInputRef = useRef<HTMLInputElement>(null); const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); const processFiles = async (files: FileList | File[]) => { - // If no files, do nothing - if (!files?.length) return; - + if (!files || files.length === 0) return; setIsProcessing(true); + setProgress(0); try { // Process files (extract metadata, generate thumbnails, etc.) - const items = await processMediaFiles(files); + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); // Add each processed media item to the store - items.forEach((item) => { - addMediaItem(item); - }); + processedItems.forEach((item) => addMediaItem(item)); } catch (error) { - // Show error if processing fails - console.error("File processing failed:", error); + // Show error toast if processing fails + console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { setIsProcessing(false); + setProgress(0); } }; @@ -241,15 +242,12 @@ export function MediaPanel() { {isProcessing ? ( <> <Upload className="h-4 w-4 animate-spin" /> - <span className="hidden md:inline ml-2">Processing...</span> + <span className="hidden md:inline ml-2">{progress}%</span> </> ) : ( <> <Plus className="h-4 w-4" /> - <span - className="hidden sm:inline ml-2" - aria-label="Add file" - > + <span className="hidden sm:inline ml-2" aria-label="Add file"> Add </span> </> diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ce0ffa30..1ecbe545 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.
@@ -68,6 +69,7 @@ export function Timeline() { } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -333,8 +335,12 @@ export function Timeline() { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -362,6 +368,7 @@ export function Timeline() { toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -566,15 +573,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}>
@@ -602,10 +600,11 @@ export function Timeline() { <div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
@@ -953,6 +952,15 @@ export function Timeline() { )}
</>
)}
+ {isDragOver && (
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1626,7 +1634,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>
);
|
