mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'main' into feat/timeline-return-to-start
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user