summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/timeline-store.ts239
1 files changed, 230 insertions, 9 deletions
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 3b068d7c..73628497 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,4 +1,20 @@
import { create } from "zustand";
+import type { TrackType } from "@/types/timeline";
+
+// Helper function to manage clip naming with suffixes
+const getClipNameWithSuffix = (
+ originalName: string,
+ suffix: string
+): string => {
+ // Remove existing suffixes to prevent accumulation
+ const baseName = originalName
+ .replace(/ \(left\)$/, "")
+ .replace(/ \(right\)$/, "")
+ .replace(/ \(audio\)$/, "")
+ .replace(/ \(split \d+\)$/, "");
+
+ return `${baseName} (${suffix})`;
+};
export interface TimelineClip {
id: string;
@@ -13,7 +29,7 @@ export interface TimelineClip {
export interface TimelineTrack {
id: string;
name: string;
- type: "video" | "audio" | "effects";
+ type: TrackType;
clips: TimelineClip[];
muted?: boolean;
}
@@ -52,7 +68,7 @@ interface TimelineStore {
endDrag: () => void;
// Actions
- addTrack: (type: "video" | "audio" | "effects") => string;
+ addTrack: (type: TrackType) => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
@@ -74,10 +90,28 @@ interface TimelineStore {
) => void;
toggleTrackMute: (trackId: string) => void;
+ // Split operations for clips
+ splitClip: (
+ trackId: string,
+ clipId: string,
+ splitTime: number
+ ) => string | null;
+ splitAndKeepLeft: (
+ trackId: string,
+ clipId: string,
+ splitTime: number
+ ) => void;
+ splitAndKeepRight: (
+ trackId: string,
+ clipId: string,
+ splitTime: number
+ ) => void;
+ separateAudio: (trackId: string, clipId: string) => string | null;
+
// Computed values
getTotalDuration: () => number;
- // New actions
+ // History actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
@@ -91,10 +125,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
pushHistory: () => {
const { tracks, history, redoStack } = get();
- // Deep copy tracks
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
- redoStack: [], // Clear redo stack when new action is performed
+ redoStack: [],
});
},
@@ -105,7 +138,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
set({
tracks: prev,
history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
});
},
@@ -115,7 +148,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
(c) => c.trackId === trackId && c.clipId === clipId
);
if (multi) {
- // Toggle selection
return exists
? {
selectedClips: state.selectedClips.filter(
@@ -128,6 +160,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}
});
},
+
deselectClip: (trackId, clipId) => {
set((state) => ({
selectedClips: state.selectedClips.filter(
@@ -135,6 +168,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
),
}));
},
+
clearSelectedClips: () => {
set({ selectedClips: [] });
},
@@ -194,7 +228,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}
: track
)
- // Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
}));
},
@@ -223,7 +256,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}
return track;
})
- // Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
};
});
@@ -270,6 +302,195 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}));
},
+ splitClip: (trackId, clipId, splitTime) => {
+ const { tracks } = get();
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+
+ if (!clip) return null;
+
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+
+ get().pushHistory();
+
+ const relativeTime = splitTime - clip.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
+
+ const secondClipId = crypto.randomUUID();
+
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.flatMap((c) =>
+ c.id === clipId
+ ? [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getClipNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondClipId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getClipNameWithSuffix(c.name, "right"),
+ },
+ ]
+ : [c]
+ ),
+ }
+ : track
+ ),
+ }));
+
+ return secondClipId;
+ },
+
+ // Split clip and keep only the left portion
+ splitAndKeepLeft: (trackId, clipId, splitTime) => {
+ const { tracks } = get();
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+
+ if (!clip) return;
+
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+
+ get().pushHistory();
+
+ const relativeTime = splitTime - clip.startTime;
+ const durationToRemove =
+ clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
+
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((c) =>
+ c.id === clipId
+ ? {
+ ...c,
+ trimEnd: c.trimEnd + durationToRemove,
+ name: getClipNameWithSuffix(c.name, "left"),
+ }
+ : c
+ ),
+ }
+ : track
+ ),
+ }));
+ },
+
+ // Split clip and keep only the right portion
+ splitAndKeepRight: (trackId, clipId, splitTime) => {
+ const { tracks } = get();
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+
+ if (!clip) return;
+
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+
+ get().pushHistory();
+
+ const relativeTime = splitTime - clip.startTime;
+
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((c) =>
+ c.id === clipId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getClipNameWithSuffix(c.name, "right"),
+ }
+ : c
+ ),
+ }
+ : track
+ ),
+ }));
+ },
+
+ // Extract audio from video clip to an audio track
+ separateAudio: (trackId, clipId) => {
+ const { tracks } = get();
+ const track = tracks.find((t) => t.id === trackId);
+ const clip = track?.clips.find((c) => c.id === clipId);
+
+ if (!clip || track?.type !== "video") return null;
+
+ get().pushHistory();
+
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = tracks.find((t) => t.type === "audio");
+ const audioClipId = crypto.randomUUID();
+
+ if (existingAudioTrack) {
+ // Add audio clip to existing audio track
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ clips: [
+ ...track.clips,
+ {
+ ...clip,
+ id: audioClipId,
+ name: getClipNameWithSuffix(clip.name, "audio"),
+ },
+ ],
+ }
+ : track
+ ),
+ }));
+ } else {
+ // Create new audio track with the audio clip in a single atomic update
+ const newAudioTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: "Audio Track",
+ type: "audio",
+ clips: [
+ {
+ ...clip,
+ id: audioClipId,
+ name: getClipNameWithSuffix(clip.name, "audio"),
+ },
+ ],
+ muted: false,
+ };
+
+ set((state) => ({
+ tracks: [...state.tracks, newAudioTrack],
+ }));
+ }
+
+ return audioClipId;
+ },
+
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;