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
+156
View File
@@ -0,0 +1,156 @@
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
/**
* Custom hook for searching sound effects with race condition protection.
* Uses global Zustand store to persist search state across tab switches.
* - Debounced search (300ms)
* - Race condition protection with cleanup
* - Proper error handling
*/
export function useSoundSearch(query: string, commercialOnly: boolean) {
const {
searchResults,
isSearching,
searchError,
lastSearchQuery,
currentPage,
hasNextPage,
isLoadingMore,
totalCount,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
setLoadingMore,
appendSearchResults,
appendTopSounds,
resetPagination,
} = useSoundsStore();
// Load more function for infinite scroll
const loadMore = async () => {
if (isLoadingMore || !hasNextPage) return;
try {
setLoadingMore(true);
const nextPage = currentPage + 1;
const searchParams = new URLSearchParams({
page: nextPage.toString(),
type: "effects",
});
if (query.trim()) {
searchParams.set("q", query);
}
searchParams.set("commercial_only", commercialOnly.toString());
const response = await fetch(
`/api/sounds/search?${searchParams.toString()}`
);
if (response.ok) {
const data = await response.json();
// Append to appropriate array based on whether we have a query
if (query.trim()) {
appendSearchResults(data.results);
} else {
appendTopSounds(data.results);
}
setCurrentPage(nextPage);
setHasNextPage(!!data.next);
setTotalCount(data.count);
} else {
setSearchError(`Load more failed: ${response.status}`);
}
} catch (err) {
setSearchError(err instanceof Error ? err.message : "Load more failed");
} finally {
setLoadingMore(false);
}
};
useEffect(() => {
if (!query.trim()) {
setSearchResults([]);
setSearchError(null);
setLastSearchQuery("");
// Don't reset pagination here - top sounds pagination is managed by prefetcher
return;
}
// If we already searched for this query and have results, don't search again
if (query === lastSearchQuery && searchResults.length > 0) {
return;
}
let ignore = false;
const timeoutId = setTimeout(async () => {
try {
setSearching(true);
setSearchError(null);
resetPagination();
const response = await fetch(
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`
);
if (!ignore) {
if (response.ok) {
const data = await response.json();
setSearchResults(data.results);
setLastSearchQuery(query);
setHasNextPage(!!data.next);
setTotalCount(data.count);
setCurrentPage(1);
} else {
setSearchError(`Search failed: ${response.status}`);
}
}
} catch (err) {
if (!ignore) {
setSearchError(err instanceof Error ? err.message : "Search failed");
}
} finally {
if (!ignore) {
setSearching(false);
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [
query,
lastSearchQuery,
searchResults.length,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
resetPagination,
]);
return {
results: searchResults,
isLoading: isSearching,
error: searchError,
loadMore,
hasNextPage,
isLoadingMore,
totalCount,
};
}
@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "@/stores/project-store";
import { snapTimeToFrame } from "@/constants/timeline-constants";
interface UseTimelineElementResizeProps {
element: TimelineElement;
@@ -109,6 +111,10 @@ export function useTimelineElementResize({
// Reasonable sensitivity for resize operations - similar to timeline scale
const deltaTime = deltaX / (50 * zoomLevel);
// Get project FPS for frame snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
if (resizing.side === "left") {
// Left resize - different behavior for media vs text/image elements
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
@@ -116,9 +122,15 @@ export function useTimelineElementResize({
if (calculated >= 0) {
// Normal trimming within available content
const newTrimStart = Math.min(maxAllowed, calculated);
const newTrimStart = snapTimeToFrame(
Math.min(maxAllowed, calculated),
projectFps
);
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = element.startTime + trimDelta;
const newStartTime = snapTimeToFrame(
element.startTime + trimDelta,
projectFps
);
updateElementTrim(
track.id,
@@ -135,8 +147,14 @@ export function useTimelineElementResize({
const extensionAmount = Math.abs(calculated);
const maxExtension = element.startTime;
const actualExtension = Math.min(extensionAmount, maxExtension);
const newStartTime = element.startTime - actualExtension;
const newDuration = element.duration + actualExtension;
const newStartTime = snapTimeToFrame(
element.startTime - actualExtension,
projectFps
);
const newDuration = snapTimeToFrame(
element.duration + actualExtension,
projectFps
);
// Keep trimStart at 0 and extend the element
updateElementTrim(
@@ -152,7 +170,10 @@ export function useTimelineElementResize({
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
const newTrimStart = 0;
const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = element.startTime + trimDelta;
const newStartTime = snapTimeToFrame(
element.startTime + trimDelta,
projectFps
);
updateElementTrim(
track.id,
@@ -173,7 +194,10 @@ export function useTimelineElementResize({
if (canExtendElementDuration()) {
// Extend the duration instead of reducing trimEnd further
const extensionNeeded = Math.abs(calculated);
const newDuration = element.duration + extensionNeeded;
const newDuration = snapTimeToFrame(
element.duration + extensionNeeded,
projectFps
);
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
// Update duration first, then trim
@@ -197,14 +221,34 @@ export function useTimelineElementResize({
}
} else {
// Normal trimming within original duration
const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
// Calculate the desired end time based on mouse movement
const currentEndTime =
element.startTime +
element.duration -
element.trimStart -
element.trimEnd;
const desiredEndTime = currentEndTime + deltaTime;
// Snap the desired end time to frame
const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps);
// Calculate what trimEnd should be to achieve this snapped end time
const newTrimEnd = Math.max(
0,
element.duration -
element.trimStart -
(snappedEndTime - element.startTime)
);
// Ensure we don't trim more than available content (leave at least 0.1s visible)
const maxTrimEnd = element.duration - element.trimStart - 0.1;
const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd);
updateElementTrim(
track.id,
element.id,
resizing.initialTrimStart,
newTrimEnd,
element.trimStart,
finalTrimEnd,
false
);
}