Skip to content

Commit 5a898fa

Browse files
committed
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.
1 parent 52b995e commit 5a898fa

File tree

6 files changed

+229
-30
lines changed

6 files changed

+229
-30
lines changed

apps/web/src/components/editor/audio-waveform.tsx

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
1919

2020
useEffect(() => {
2121
let mounted = true;
22-
22+
let ws = wavesurfer.current;
23+
2324
const initWaveSurfer = async () => {
2425
if (!waveformRef.current || !audioUrl) return;
2526

2627
try {
27-
// Clean up any existing instance
28-
if (wavesurfer.current) {
29-
try {
30-
wavesurfer.current.destroy();
31-
} catch (e) {
32-
// Silently ignore destroy errors
33-
}
28+
// Clear any existing instance safely
29+
if (ws) {
30+
// Instead of immediately destroying, just set to null
31+
// We'll destroy it outside this function
3432
wavesurfer.current = null;
3533
}
3634

37-
wavesurfer.current = WaveSurfer.create({
35+
// Create a fresh instance
36+
const newWaveSurfer = WaveSurfer.create({
3837
container: waveformRef.current,
3938
waveColor: "rgba(255, 255, 255, 0.6)",
4039
progressColor: "rgba(255, 255, 255, 0.9)",
@@ -46,23 +45,36 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
4645
interact: false,
4746
});
4847

48+
// Assign to ref only if component is still mounted
49+
if (mounted) {
50+
wavesurfer.current = newWaveSurfer;
51+
} else {
52+
// Component unmounted during initialization, clean up
53+
try {
54+
newWaveSurfer.destroy();
55+
} catch (e) {
56+
// Ignore destroy errors
57+
}
58+
return;
59+
}
60+
4961
// Event listeners
50-
wavesurfer.current.on("ready", () => {
62+
newWaveSurfer.on("ready", () => {
5163
if (mounted) {
5264
setIsLoading(false);
5365
setError(false);
5466
}
5567
});
5668

57-
wavesurfer.current.on("error", (err) => {
69+
newWaveSurfer.on("error", (err) => {
5870
console.error("WaveSurfer error:", err);
5971
if (mounted) {
6072
setError(true);
6173
setIsLoading(false);
6274
}
6375
});
6476

65-
await wavesurfer.current.load(audioUrl);
77+
await newWaveSurfer.load(audioUrl);
6678
} catch (err) {
6779
console.error("Failed to initialize WaveSurfer:", err);
6880
if (mounted) {
@@ -72,27 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
7284
}
7385
};
7486

75-
initWaveSurfer();
76-
77-
return () => {
78-
mounted = false;
79-
// Use a safer cleanup approach to avoid AbortError
80-
if (wavesurfer.current) {
87+
// First safely destroy previous instance if it exists
88+
if (ws) {
89+
// Use this pattern to safely destroy the previous instance
90+
const wsToDestroy = ws;
91+
// Detach from ref immediately
92+
wavesurfer.current = null;
93+
94+
// Wait a tick to destroy so any pending operations can complete
95+
requestAnimationFrame(() => {
8196
try {
82-
// Wrap in setTimeout to avoid race conditions with AbortController
83-
setTimeout(() => {
84-
try {
85-
if (wavesurfer.current) {
86-
wavesurfer.current.destroy();
87-
wavesurfer.current = null;
88-
}
89-
} catch (e) {
90-
console.warn("Error during delayed WaveSurfer cleanup:", e);
91-
}
92-
}, 0);
97+
wsToDestroy.destroy();
9398
} catch (e) {
94-
console.warn("Error during WaveSurfer cleanup:", e);
99+
// Ignore errors during destroy
100+
}
101+
// Only initialize new instance after destroying the old one
102+
if (mounted) {
103+
initWaveSurfer();
95104
}
105+
});
106+
} else {
107+
// No previous instance to clean up, initialize directly
108+
initWaveSurfer();
109+
}
110+
111+
return () => {
112+
// Mark component as unmounted
113+
mounted = false;
114+
115+
// Store reference to current wavesurfer instance
116+
const wsToDestroy = wavesurfer.current;
117+
118+
// Immediately clear the ref to prevent accessing it after unmount
119+
wavesurfer.current = null;
120+
121+
// If we have an instance to clean up, do it safely
122+
if (wsToDestroy) {
123+
// Delay destruction to avoid race conditions
124+
requestAnimationFrame(() => {
125+
try {
126+
wsToDestroy.destroy();
127+
} catch (e) {
128+
// Ignore destroy errors - they're expected
129+
}
130+
});
96131
}
97132
};
98133
}, [audioUrl, height]);

apps/web/src/components/editor/timeline/index.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Link,
2020
ZoomIn,
2121
ZoomOut,
22+
Bookmark,
2223
} from "lucide-react";
2324
import {
2425
Tooltip,
@@ -651,6 +652,30 @@ export function Timeline() {
651652
);
652653
}).filter(Boolean);
653654
})()}
655+
656+
{/* Bookmark markers */}
657+
{(() => {
658+
const { activeProject } = useProjectStore.getState();
659+
if (!activeProject?.bookmarks?.length) return null;
660+
661+
return activeProject.bookmarks.map((bookmarkTime, i) => (
662+
<div
663+
key={`bookmark-${i}`}
664+
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
665+
style={{
666+
left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
667+
}}
668+
onClick={(e) => {
669+
e.stopPropagation();
670+
usePlaybackStore.getState().seek(bookmarkTime);
671+
}}
672+
>
673+
<div className="absolute top-[-1px] left-[-5px] text-primary">
674+
<Bookmark className="h-3 w-3 fill-primary" />
675+
</div>
676+
</div>
677+
));
678+
})()}
654679
</div>
655680
</ScrollArea>
656681
</div>
@@ -773,6 +798,23 @@ export function Timeline() {
773798
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
774799
Track settings (soon)
775800
</ContextMenuItem>
801+
{activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && (
802+
<>
803+
<ContextMenuItem disabled>Bookmarks</ContextMenuItem>
804+
{activeProject.bookmarks.map((bookmarkTime, i) => (
805+
<ContextMenuItem
806+
key={`bookmark-menu-${i}`}
807+
onClick={(e) => {
808+
e.stopPropagation();
809+
seek(bookmarkTime);
810+
}}
811+
>
812+
<Bookmark className="h-3 w-3 mr-2 inline-block" />
813+
{bookmarkTime.toFixed(1)}s
814+
</ContextMenuItem>
815+
))}
816+
</>
817+
)}
776818
</ContextMenuContent>
777819
</ContextMenu>
778820
))}
@@ -828,6 +870,7 @@ function TimelineToolbar({
828870
toggleRippleEditing,
829871
} = useTimelineStore();
830872
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
873+
const { toggleBookmark, isBookmarked } = useProjectStore();
831874

832875
// Action handlers
833876
const handleSplitSelected = () => {
@@ -957,6 +1000,13 @@ function TimelineToolbar({
9571000
const handleZoomSliderChange = (values: number[]) => {
9581001
setZoomLevel(values[0]);
9591002
};
1003+
1004+
const handleToggleBookmark = async () => {
1005+
await toggleBookmark(currentTime);
1006+
};
1007+
1008+
// Check if the current time is bookmarked
1009+
const currentBookmarked = isBookmarked(currentTime);
9601010
return (
9611011
<div className="border-b flex items-center justify-between px-2 py-1">
9621012
<div className="flex items-center gap-1 w-full">
@@ -1088,6 +1138,17 @@ function TimelineToolbar({
10881138
</TooltipTrigger>
10891139
<TooltipContent>Delete element (Delete)</TooltipContent>
10901140
</Tooltip>
1141+
<div className="w-px h-6 bg-border mx-1" />
1142+
<Tooltip>
1143+
<TooltipTrigger asChild>
1144+
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
1145+
<Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} />
1146+
</Button>
1147+
</TooltipTrigger>
1148+
<TooltipContent>
1149+
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
1150+
</TooltipContent>
1151+
</Tooltip>
10911152
</TooltipProvider>
10921153
</div>
10931154
<div className="flex items-center gap-1">

apps/web/src/lib/storage/storage-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ class StorageService {
6363
backgroundColor: project.backgroundColor,
6464
backgroundType: project.backgroundType,
6565
blurIntensity: project.blurIntensity,
66+
bookmarks: project.bookmarks,
67+
fps: project.fps,
6668
};
6769

6870
await this.projectsAdapter.set(project.id, serializedProject);
@@ -83,6 +85,8 @@ class StorageService {
8385
backgroundColor: serializedProject.backgroundColor,
8486
backgroundType: serializedProject.backgroundType,
8587
blurIntensity: serializedProject.blurIntensity,
88+
bookmarks: serializedProject.bookmarks,
89+
fps: serializedProject.fps,
8690
};
8791
}
8892

apps/web/src/lib/storage/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface StorageConfig {
3737
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
3838
createdAt: string;
3939
updatedAt: string;
40+
bookmarks?: number[];
4041
};
4142

4243
// Extend FileSystemDirectoryHandle with missing async iterator methods

apps/web/src/stores/project-store.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ interface ProjectStore {
2727
options?: { backgroundColor?: string; blurIntensity?: number }
2828
) => Promise<void>;
2929
updateProjectFps: (fps: number) => Promise<void>;
30+
31+
// Bookmark methods
32+
toggleBookmark: (time: number) => Promise<void>;
33+
isBookmarked: (time: number) => boolean;
34+
removeBookmark: (time: number) => Promise<void>;
3035

3136
getFilteredAndSortedProjects: (
3237
searchQuery: string,
@@ -39,6 +44,97 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
3944
savedProjects: [],
4045
isLoading: true,
4146
isInitialized: false,
47+
48+
// Implementation of bookmark methods
49+
toggleBookmark: async (time: number) => {
50+
const { activeProject } = get();
51+
if (!activeProject) return;
52+
53+
// Round time to the nearest frame
54+
const fps = activeProject.fps || 30;
55+
const frameTime = Math.round(time * fps) / fps;
56+
57+
const bookmarks = activeProject.bookmarks || [];
58+
let updatedBookmarks: number[];
59+
60+
// Check if already bookmarked
61+
const bookmarkIndex = bookmarks.findIndex(
62+
bookmark => Math.abs(bookmark - frameTime) < 0.001
63+
);
64+
65+
if (bookmarkIndex !== -1) {
66+
// Remove bookmark
67+
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
68+
} else {
69+
// Add bookmark
70+
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
71+
}
72+
73+
const updatedProject = {
74+
...activeProject,
75+
bookmarks: updatedBookmarks,
76+
updatedAt: new Date(),
77+
};
78+
79+
try {
80+
await storageService.saveProject(updatedProject);
81+
set({ activeProject: updatedProject });
82+
await get().loadAllProjects(); // Refresh the list
83+
} catch (error) {
84+
console.error("Failed to update project bookmarks:", error);
85+
toast.error("Failed to update bookmarks", {
86+
description: "Please try again",
87+
});
88+
}
89+
},
90+
91+
isBookmarked: (time: number) => {
92+
const { activeProject } = get();
93+
if (!activeProject || !activeProject.bookmarks) return false;
94+
95+
// Round time to the nearest frame
96+
const fps = activeProject.fps || 30;
97+
const frameTime = Math.round(time * fps) / fps;
98+
99+
return activeProject.bookmarks.some(
100+
bookmark => Math.abs(bookmark - frameTime) < 0.001
101+
);
102+
},
103+
104+
removeBookmark: async (time: number) => {
105+
const { activeProject } = get();
106+
if (!activeProject || !activeProject.bookmarks) return;
107+
108+
// Round time to the nearest frame
109+
const fps = activeProject.fps || 30;
110+
const frameTime = Math.round(time * fps) / fps;
111+
112+
const updatedBookmarks = activeProject.bookmarks.filter(
113+
bookmark => Math.abs(bookmark - frameTime) >= 0.001
114+
);
115+
116+
if (updatedBookmarks.length === activeProject.bookmarks.length) {
117+
// No bookmark found to remove
118+
return;
119+
}
120+
121+
const updatedProject = {
122+
...activeProject,
123+
bookmarks: updatedBookmarks,
124+
updatedAt: new Date(),
125+
};
126+
127+
try {
128+
await storageService.saveProject(updatedProject);
129+
set({ activeProject: updatedProject });
130+
await get().loadAllProjects(); // Refresh the list
131+
} catch (error) {
132+
console.error("Failed to update project bookmarks:", error);
133+
toast.error("Failed to remove bookmark", {
134+
description: "Please try again",
135+
});
136+
}
137+
},
42138

43139
createNewProject: async (name: string) => {
44140
const newProject: TProject = {
@@ -50,6 +146,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
50146
backgroundColor: "#000000",
51147
backgroundType: "color",
52148
blurIntensity: 8,
149+
bookmarks: [],
53150
};
54151

55152
set({ activeProject: newProject });

apps/web/src/types/project.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export interface TProject {
99
backgroundType?: "color" | "blur";
1010
blurIntensity?: number; // in pixels (4, 8, 18)
1111
fps?: number;
12+
bookmarks?: number[];
1213
}

0 commit comments

Comments
 (0)