Merge branch 'main' into feat/timeline-return-to-start

This commit is contained in:
ryu
2025-08-07 19:37:06 +05:00
committed by GitHub
105 changed files with 3702 additions and 1506 deletions
+6
View File
@@ -17,12 +17,15 @@ interface PanelState {
mainContent: number;
timeline: number;
mediaViewMode: "grid" | "list";
// Actions
setToolsPanel: (size: number) => void;
setPreviewPanel: (size: number) => void;
setPropertiesPanel: (size: number) => void;
setMainContent: (size: number) => void;
setTimeline: (size: number) => void;
setMediaViewMode: (mode: "grid" | "list") => void;
}
export const usePanelStore = create<PanelState>()(
@@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()(
// Default sizes - optimized for responsiveness
...DEFAULT_PANEL_SIZES,
mediaViewMode: "grid" as const,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),
setPreviewPanel: (size) => set({ previewPanel: size }),
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
setMainContent: (size) => set({ mainContent: size }),
setTimeline: (size) => set({ timeline: size }),
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
}),
{
name: "panel-sizes",
+27 -5
View File
@@ -1,5 +1,7 @@
import { create } from "zustand";
import type { PlaybackState, PlaybackControls } from "@/types/playback";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "./project-store";
interface PlaybackStore extends PlaybackState, PlaybackControls {
setDuration: (duration: number) => void;
@@ -20,13 +22,33 @@ const startTimer = (store: () => PlaybackStore) => {
lastUpdate = now;
const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start
// Get actual content duration from timeline store
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
// Stop at actual content end, not timeline duration (which has 10s minimum)
// It was either this or reducing default min timeline to 1 second
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (newTime >= effectiveDuration) {
// When content completes, pause just before the end so we can see the last frame
const projectFps = useProjectStore.getState().activeProject?.fps;
if (!projectFps)
console.error("Project FPS is not set, assuming 30fps");
const frameOffset = 1 / (projectFps ?? 30); // Stop 1 frame before end based on project FPS
const stopTime = Math.max(0, effectiveDuration - frameOffset);
state.pause();
state.setCurrentTime(0);
// Notify video elements to sync with reset
state.setCurrentTime(stopTime);
// Notify video elements to sync with end position
window.dispatchEvent(
new CustomEvent("playback-seek", { detail: { time: 0 } })
new CustomEvent("playback-seek", {
detail: { time: stopTime },
})
);
} else {
state.setCurrentTime(newTime);
+125 -1
View File
@@ -11,6 +11,7 @@ interface ProjectStore {
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
invalidProjectIds?: Set<string>;
// Actions
createNewProject: (name: string) => Promise<string>;
@@ -28,10 +29,20 @@ interface ProjectStore {
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
// Bookmark methods
toggleBookmark: (time: number) => Promise<void>;
isBookmarked: (time: number) => boolean;
removeBookmark: (time: number) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
) => TProject[];
// Global invalid project ID tracking
isInvalidProjectId: (id: string) => boolean;
markProjectIdAsInvalid: (id: string) => void;
clearInvalidProjectIds: () => void;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
@@ -39,6 +50,98 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
savedProjects: [],
isLoading: true,
isInitialized: false,
invalidProjectIds: new Set<string>(),
// Implementation of bookmark methods
toggleBookmark: async (time: number) => {
const { activeProject } = get();
if (!activeProject) return;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
const bookmarks = activeProject.bookmarks || [];
let updatedBookmarks: number[];
// Check if already bookmarked
const bookmarkIndex = bookmarks.findIndex(
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
);
if (bookmarkIndex !== -1) {
// Remove bookmark
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
} else {
// Add bookmark
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
}
const updatedProject = {
...activeProject,
bookmarks: updatedBookmarks,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
},
isBookmarked: (time: number) => {
const { activeProject } = get();
if (!activeProject || !activeProject.bookmarks) return false;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
return activeProject.bookmarks.some(
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
);
},
removeBookmark: async (time: number) => {
const { activeProject } = get();
if (!activeProject || !activeProject.bookmarks) return;
// Round time to the nearest frame
const fps = activeProject.fps || 30;
const frameTime = Math.round(time * fps) / fps;
const updatedBookmarks = activeProject.bookmarks.filter(
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001
);
if (updatedBookmarks.length === activeProject.bookmarks.length) {
// No bookmark found to remove
return;
}
const updatedProject = {
...activeProject,
bookmarks: updatedBookmarks,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
},
createNewProject: async (name: string) => {
const newProject: TProject = {
@@ -50,6 +153,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
bookmarks: [],
fps: 30,
};
set({ activeProject: newProject });
@@ -230,9 +335,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
...project, // Copy all properties from the original project
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
thumbnail: project.thumbnail,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -357,4 +462,23 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
return sortedProjects;
},
// Global invalid project ID tracking implementation
isInvalidProjectId: (id: string) => {
const invalidIds = get().invalidProjectIds || new Set();
return invalidIds.has(id);
},
markProjectIdAsInvalid: (id: string) => {
set((state) => ({
invalidProjectIds: new Set([
...(state.invalidProjectIds || new Set()),
id,
]),
}));
},
clearInvalidProjectIds: () => {
set({ invalidProjectIds: new Set() });
},
}));
+282
View File
@@ -0,0 +1,282 @@
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { useProjectStore } from "./project-store";
import { usePlaybackStore } from "./playback-store";
interface SoundsStore {
topSoundEffects: SoundEffect[];
isLoading: boolean;
error: string | null;
hasLoaded: boolean;
// Filter state
showCommercialOnly: boolean;
toggleCommercialFilter: () => void;
// Search state
searchQuery: string;
searchResults: SoundEffect[];
isSearching: boolean;
searchError: string | null;
lastSearchQuery: string;
scrollPosition: number;
// Pagination state
currentPage: number;
hasNextPage: boolean;
totalCount: number;
isLoadingMore: boolean;
// Saved sounds state
savedSounds: SavedSound[];
isSavedSoundsLoaded: boolean;
isLoadingSavedSounds: boolean;
savedSoundsError: string | null;
// Timeline integration
addSoundToTimeline: (sound: SoundEffect) => Promise<boolean>;
setTopSoundEffects: (sounds: SoundEffect[]) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setHasLoaded: (loaded: boolean) => void;
// Search actions
setSearchQuery: (query: string) => void;
setSearchResults: (results: SoundEffect[]) => void;
setSearching: (searching: boolean) => void;
setSearchError: (error: string | null) => void;
setLastSearchQuery: (query: string) => void;
setScrollPosition: (position: number) => void;
// Pagination actions
setCurrentPage: (page: number) => void;
setHasNextPage: (hasNext: boolean) => void;
setTotalCount: (count: number) => void;
setLoadingMore: (loading: boolean) => void;
appendSearchResults: (results: SoundEffect[]) => void;
appendTopSounds: (results: SoundEffect[]) => void;
resetPagination: () => void;
// Saved sounds actions
loadSavedSounds: () => Promise<void>;
saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
removeSavedSound: (soundId: number) => Promise<void>;
isSoundSaved: (soundId: number) => boolean;
toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
clearSavedSounds: () => Promise<void>;
}
export const useSoundsStore = create<SoundsStore>((set, get) => ({
topSoundEffects: [],
isLoading: false,
error: null,
hasLoaded: false,
showCommercialOnly: true,
toggleCommercialFilter: () => {
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
},
// Search state
searchQuery: "",
searchResults: [],
isSearching: false,
searchError: null,
lastSearchQuery: "",
scrollPosition: 0,
// Pagination state
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
// Saved sounds state
savedSounds: [],
isSavedSoundsLoaded: false,
isLoadingSavedSounds: false,
savedSoundsError: null,
setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
// Search actions
setSearchQuery: (query) => set({ searchQuery: query }),
setSearchResults: (results) =>
set({ searchResults: results, currentPage: 1 }),
setSearching: (searching) => set({ isSearching: searching }),
setSearchError: (error) => set({ searchError: error }),
setLastSearchQuery: (query) => set({ lastSearchQuery: query }),
setScrollPosition: (position) => set({ scrollPosition: position }),
// Pagination actions
setCurrentPage: (page) => set({ currentPage: page }),
setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }),
setTotalCount: (count) => set({ totalCount: count }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
appendSearchResults: (results) =>
set((state) => ({
searchResults: [...state.searchResults, ...results],
})),
appendTopSounds: (results) =>
set((state) => ({
topSoundEffects: [...state.topSoundEffects, ...results],
})),
resetPagination: () =>
set({
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
}),
// Saved sounds actions
loadSavedSounds: async () => {
if (get().isSavedSoundsLoaded) return;
try {
set({ isLoadingSavedSounds: true, savedSoundsError: null });
const savedSoundsData = await storageService.loadSavedSounds();
set({
savedSounds: savedSoundsData.sounds,
isSavedSoundsLoaded: true,
isLoadingSavedSounds: false,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to load saved sounds";
set({
savedSoundsError: errorMessage,
isLoadingSavedSounds: false,
});
console.error("Failed to load saved sounds:", error);
}
},
saveSoundEffect: async (soundEffect: SoundEffect) => {
try {
await storageService.saveSoundEffect(soundEffect);
// Refresh saved sounds
const savedSoundsData = await storageService.loadSavedSounds();
set({ savedSounds: savedSoundsData.sounds });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to save sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to save sound");
console.error("Failed to save sound:", error);
}
},
removeSavedSound: async (soundId: number) => {
try {
await storageService.removeSavedSound(soundId);
// Update local state immediately
set((state) => ({
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
}));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to remove sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to remove sound");
console.error("Failed to remove sound:", error);
}
},
isSoundSaved: (soundId: number) => {
const { savedSounds } = get();
return savedSounds.some((sound) => sound.id === soundId);
},
toggleSavedSound: async (soundEffect: SoundEffect) => {
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
if (isSoundSaved(soundEffect.id)) {
await removeSavedSound(soundEffect.id);
} else {
await saveSoundEffect(soundEffect);
}
},
clearSavedSounds: async () => {
try {
await storageService.clearSavedSounds();
set({
savedSounds: [],
savedSoundsError: null,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to clear saved sounds";
set({ savedSoundsError: errorMessage });
toast.error("Failed to clear saved sounds");
console.error("Failed to clear saved sounds:", error);
}
},
addSoundToTimeline: async (sound) => {
const activeProject = useProjectStore.getState().activeProject;
if (!activeProject) {
toast.error("No active project");
return false;
}
const audioUrl = sound.previewUrl;
if (!audioUrl) {
toast.error("Sound file not available");
return false;
}
try {
const response = await fetch(audioUrl);
if (!response.ok)
throw new Error(`Failed to download audio: ${response.statusText}`);
const blob = await response.blob();
const file = new File([blob], `${sound.name}.mp3`, {
type: "audio/mpeg",
});
await useMediaStore.getState().addMediaItem(activeProject.id, {
name: sound.name,
type: "audio",
file,
duration: sound.duration,
url: URL.createObjectURL(file),
});
const mediaItem = useMediaStore
.getState()
.mediaItems.find((item) => item.file === file);
if (!mediaItem) throw new Error("Failed to create media item");
const success = useTimelineStore
.getState()
.addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime);
if (success) {
return true;
}
throw new Error("Failed to add to timeline - check for overlaps");
} catch (error) {
console.error("Failed to add sound to timeline:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to add sound to timeline",
{ id: `sound-${sound.id}` }
);
return false;
}
},
}));
+34 -7
View File
@@ -126,6 +126,7 @@ interface TimelineStore {
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
toggleElementHidden: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
@@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
toggleElementHidden: (trackId, elementId) => {
get().pushHistory();
updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
...track,
elements: track.elements.map((element) =>
element.id === elementId
? { ...element, hidden: !element.hidden }
: element
),
}
: track
)
);
},
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
@@ -1426,16 +1445,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
addMediaAtTime: (item, currentTime = 0) => {
const trackType = item.type === "audio" ? "audio" : "media";
const targetTrackId = get().findOrCreateTrack(trackType);
const duration =
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
toast.error(
"Cannot place element here - it would overlap with existing elements"
);
return false;
// Get all tracks of the right type
const tracks = get()._tracks.filter((t) => t.type === trackType);
// Try to find a track with no overlap
let targetTrackId = null;
for (const track of tracks) {
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
targetTrackId = track.id;
break;
}
}
// If no free track found, create a new one
if (!targetTrackId) {
targetTrackId = get().addTrack(trackType);
}
get().addElementToTrack(targetTrackId, {