Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 65 additions & 20 deletions apps/web/src/components/editor/audio-waveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({

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)",
Expand All @@ -46,23 +45,36 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
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);
setIsLoading(false);
}
});

await wavesurfer.current.load(audioUrl);
await newWaveSurfer.load(audioUrl);
} catch (err) {
console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
Expand All @@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
}
};

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]);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/editor/preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/editor/timeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Link,
ZoomIn,
ZoomOut,
Bookmark,
} from "lucide-react";
import {
Tooltip,
Expand Down Expand Up @@ -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) => (
<div
key={`bookmark-${i}`}
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
style={{
left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onClick={(e) => {
e.stopPropagation();
usePlaybackStore.getState().seek(bookmarkTime);
}}
>
<div className="absolute top-[-1px] left-[-5px] text-primary">
<Bookmark className="h-3 w-3 fill-primary" />
</div>
</div>
));
})()}
</div>
</ScrollArea>
</div>
Expand Down Expand Up @@ -773,6 +798,23 @@ export function Timeline() {
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
Track settings (soon)
</ContextMenuItem>
{activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && (
<>
<ContextMenuItem disabled>Bookmarks</ContextMenuItem>
{activeProject.bookmarks.map((bookmarkTime, i) => (
<ContextMenuItem
key={`bookmark-menu-${i}`}
onClick={(e) => {
e.stopPropagation();
seek(bookmarkTime);
}}
>
<Bookmark className="h-3 w-3 mr-2 inline-block" />
{bookmarkTime.toFixed(1)}s
</ContextMenuItem>
))}
</>
)}
</ContextMenuContent>
</ContextMenu>
))}
Expand Down Expand Up @@ -828,6 +870,7 @@ function TimelineToolbar({
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
const { toggleBookmark, isBookmarked } = useProjectStore();

// Action handlers
const handleSplitSelected = () => {
Expand Down Expand Up @@ -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 (
<div className="border-b flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
Expand Down Expand Up @@ -1088,6 +1138,17 @@ function TimelineToolbar({
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
<Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} />
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
Expand Down Expand Up @@ -1121,6 +1182,8 @@ function TimelineToolbar({
</TooltipContent>
</Tooltip>
</TooltipProvider>

<div className="h-6 w-px bg-border mx-1" />
<div className="flex items-center gap-1">
<Button variant="text" size="icon" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
Expand Down
51 changes: 50 additions & 1 deletion apps/web/src/components/editor/timeline/timeline-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
Type,
Copy,
RefreshCw,
EyeOff,
Eye,
Volume2,
VolumeX,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
Expand Down Expand Up @@ -66,11 +70,18 @@ export function TimelineElement({
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();

const [elementMenuOpen, setElementMenuOpen] = useState(false);

const mediaItem =
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
const isAudio = mediaItem?.type === "audio";

const {
resizing,
isResizing,
Expand Down Expand Up @@ -141,6 +152,11 @@ export function TimelineElement({
}
};

const handleToggleElementHidden = (e: React.MouseEvent) => {
e.stopPropagation();
toggleElementHidden(track.id, element.id);
};

const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
Expand Down Expand Up @@ -332,7 +348,7 @@ export function TimelineElement({
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
} ${element.hidden ? "opacity-50" : ""}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
Expand All @@ -343,6 +359,16 @@ export function TimelineElement({
{renderElementContent()}
</div>

{element.hidden && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
{isAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
)}
</div>
)}

{isSelected && (
<>
<div
Expand All @@ -363,6 +389,29 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementHidden}>
{isAudio ? (
element.hidden ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
)
) : element.hidden ? (
<Eye className="h-4 w-4 mr-2" />
) : (
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{isAudio
? element.hidden
? "Unmute"
: "Mute"
: element.hidden
? "Show"
: "Hide"}{" "}
{element.type === "text" ? "text" : "clip"}
</span>
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/storage/storage-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ class StorageService {
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
bookmarks: project.bookmarks,
fps: project.fps,
};

await this.projectsAdapter.set(project.id, serializedProject);
Expand All @@ -83,6 +85,8 @@ class StorageService {
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
bookmarks: serializedProject.bookmarks,
fps: serializedProject.fps,
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface StorageConfig {
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
bookmarks?: number[];
};
Comment on lines 37 to 41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove redundant bookmarks override in SerializedProject.

bookmarks already comes from TProject via Omit<...>. Duplicating can cause drift.

 export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
   createdAt: string;
   updatedAt: string;
-  bookmarks?: number[];
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
bookmarks?: number[];
};
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
};
🤖 Prompt for AI Agents
In apps/web/src/lib/storage/types.ts around lines 37 to 41, the
SerializedProject type unnecessarily re-declares bookmarks?: number[] even
though bookmarks is already included via the Omit<TProject, "createdAt" |
"updatedAt">; remove the redundant bookmarks override from the intersection so
SerializedProject only overrides createdAt and updatedAt to string, keeping
bookmarks inherited from TProject to avoid drift.


// Extend FileSystemDirectoryHandle with missing async iterator methods
Expand Down
Loading