From f255ccb818358b90edd0c1bc1f3a690dc1e729ac Mon Sep 17 00:00:00 2001 From: melad Date: Thu, 31 Jul 2025 13:50:22 +0300 Subject: feat: add hide or show for media elements, mute or unmute for audio elements (#500) * feat: create external tools section (#493) * feat: create external tools section * fix: better wording * Feature, added hide or show for media elements, mute or unmute for audio elements both effective on timeline and preview panel, with overlay icon for indicating so and state storing, * Minor UI vertical separator between magnifier and other buttons * fixed the AbortError issue in the AudioWaveform component. The error was occurring because of a race condition during cleanup when the component unmounts. * feat: implement bookmarking functionality in the timeline and project storage - Added bookmark management methods in the project store for toggling and checking bookmarks. - Updated the timeline component to display bookmark markers and integrate bookmark actions in the context menu. - Enhanced the storage service to handle bookmarks in project serialization and deserialization. - Updated project types to include bookmarks as an array of numbers. --------- Co-authored-by: Dominik K. Co-authored-by: Maze Winther --- apps/web/src/components/editor/audio-waveform.tsx | 85 +++++++++++++++++----- apps/web/src/components/editor/preview-panel.tsx | 1 + apps/web/src/components/editor/timeline/index.tsx | 63 ++++++++++++++++ .../editor/timeline/timeline-element.tsx | 51 ++++++++++++- 4 files changed, 179 insertions(+), 21 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC = ({ useEffect(() => { let mounted = true; - + let ws = wavesurfer.current; + 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 - } + // Clear any existing instance safely + if (ws) { + // Instead of immediately destroying, just set to null + // We'll destroy it outside this function wavesurfer.current = null; } - wavesurfer.current = WaveSurfer.create({ + // Create a fresh instance + const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", progressColor: "rgba(255, 255, 255, 0.9)", @@ -46,15 +45,28 @@ const AudioWaveform: React.FC = ({ interact: false, }); + // Assign to ref only if component is still mounted + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + // Component unmounted during initialization, clean up + try { + newWaveSurfer.destroy(); + } catch (e) { + // Ignore destroy errors + } + return; + } + // Event listeners - wavesurfer.current.on("ready", () => { + newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on("error", (err) => { + newWaveSurfer.on("error", (err) => { console.error("WaveSurfer error:", err); if (mounted) { setError(true); @@ -62,7 +74,7 @@ const AudioWaveform: React.FC = ({ } }); - await wavesurfer.current.load(audioUrl); + await newWaveSurfer.load(audioUrl); } catch (err) { console.error("Failed to initialize WaveSurfer:", err); if (mounted) { @@ -72,17 +84,50 @@ const AudioWaveform: React.FC = ({ } }; - initWaveSurfer(); - - return () => { - mounted = false; - if (wavesurfer.current) { + // First safely destroy previous instance if it exists + if (ws) { + // Use this pattern to safely destroy the previous instance + const wsToDestroy = ws; + // Detach from ref immediately + wavesurfer.current = null; + + // Wait a tick to destroy so any pending operations can complete + requestAnimationFrame(() => { try { - wavesurfer.current.destroy(); + wsToDestroy.destroy(); } catch (e) { - // Silently ignore destroy errors + // Ignore errors during destroy + } + // Only initialize new instance after destroying the old one + if (mounted) { + initWaveSurfer(); } - wavesurfer.current = null; + }); + } else { + // No previous instance to clean up, initialize directly + initWaveSurfer(); + } + + 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 + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch (e) { + // Ignore destroy errors - they're expected + } + }); } }; }, [audioUrl, height]); diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 39c6795f..55d52bed 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -234,6 +234,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 078f1258..9833adad 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -19,6 +19,7 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, } from "lucide-react"; import { Tooltip, @@ -651,6 +652,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( +
{ + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > +
+ +
+
+ )); + })()} @@ -773,6 +798,23 @@ export function Timeline() { e.stopPropagation()}> Track settings (soon) + {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && ( + <> + Bookmarks + {activeProject.bookmarks.map((bookmarkTime, i) => ( + { + e.stopPropagation(); + seek(bookmarkTime); + }} + > + + {bookmarkTime.toFixed(1)}s + + ))} + + )} ))} @@ -828,6 +870,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -957,6 +1000,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + const currentBookmarked = isBookmarked(currentTime); return (
@@ -1088,6 +1138,17 @@ function TimelineToolbar({ Delete element (Delete) +
+ + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + +
@@ -1121,6 +1182,8 @@ function TimelineToolbar({ + +
+ {element.hidden && ( +
+ {isAudio ? ( + + ) : ( + + )} +
+ )} + {isSelected && ( <>
Split at playhead + + {isAudio ? ( + element.hidden ? ( + + ) : ( + + ) + ) : element.hidden ? ( + + ) : ( + + )} + + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + + Duplicate {element.type === "text" ? "text" : "clip"} -- cgit v1.3.1