feat: main track and ordering tracks

This commit is contained in:
Maze Winther
2025-07-06 22:51:11 +02:00
parent 40c7fbb4f8
commit 6edd5b36cf
5 changed files with 709 additions and 597 deletions

View File

@ -1,7 +1,8 @@
"use client"; "use client";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { TimelineTrack } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState } from "react"; import { useState } from "react";

View File

@ -1,10 +1,7 @@
"use client"; "use client";
import { import { useTimelineStore } from "@/stores/timeline-store";
useTimelineStore, import { TimelineElement, TimelineTrack } from "@/types/timeline";
type TimelineTrack,
} from "@/stores/timeline-store";
import { TimelineElement } from "@/types/timeline";
import { import {
useMediaStore, useMediaStore,
type MediaItem, type MediaItem,
@ -112,16 +109,19 @@ export function PreviewPanel() {
track.elements.forEach((element) => { track.elements.forEach((element) => {
const elementStart = element.startTime; const elementStart = element.startTime;
const elementEnd = const elementEnd =
element.startTime + (element.duration - element.trimStart - element.trimEnd); element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= elementStart && currentTime < elementEnd) { if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null; let mediaItem = null;
// Only get media item for media elements // Only get media item for media elements
if (element.type === "media") { if (element.type === "media") {
mediaItem = element.mediaId === "test" mediaItem =
element.mediaId === "test"
? null // Test elements don't have a real media item ? null // Test elements don't have a real media item
: mediaItems.find((item) => item.id === element.mediaId) || null; : mediaItems.find((item) => item.id === element.mediaId) ||
null;
} }
activeElements.push({ element, track, mediaItem }); activeElements.push({ element, track, mediaItem });
@ -165,9 +165,9 @@ export function PreviewPanel() {
fontWeight: element.fontWeight, fontWeight: element.fontWeight,
fontStyle: element.fontStyle, fontStyle: element.fontStyle,
textDecoration: element.textDecoration, textDecoration: element.textDecoration,
padding: '4px 8px', padding: "4px 8px",
borderRadius: '2px', borderRadius: "2px",
whiteSpace: 'pre-wrap', whiteSpace: "pre-wrap",
}} }}
> >
{element.content} {element.content}
@ -262,7 +262,9 @@ export function PreviewPanel() {
No elements at current time No elements at current time
</div> </div>
) : ( ) : (
activeElements.map((elementData, index) => renderElement(elementData, index)) activeElements.map((elementData, index) =>
renderElement(elementData, index)
)
)} )}
</div> </div>
) : ( ) : (
@ -305,7 +307,9 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
for (const track of tracks) { for (const track of tracks) {
for (const element of track.elements) { for (const element of track.elements) {
if (element.type === "media") { if (element.type === "media") {
const mediaItem = mediaItems.find((item) => item.id === element.mediaId); const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if ( if (
mediaItem && mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image") (mediaItem.type === "video" || mediaItem.type === "image")

View File

@ -13,7 +13,12 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from "../ui/context-menu"; } from "../ui/context-menu";
import { TimelineTrack } from "@/stores/timeline-store"; import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
getMainTrack
} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import type { import type {
TimelineElement as TimelineElementType, TimelineElement as TimelineElementType,
@ -555,15 +560,23 @@ export function TimelineTrackContent({
// Handle position-aware track creation for text // Handle position-aware track creation for text
if (track.type !== "text" || dropPosition !== "on") { if (track.type !== "text" || dropPosition !== "on") {
// Determine where to insert the new text track // Text tracks should go above the main track
const mainTrack = getMainTrack(tracks);
let insertIndex: number; let insertIndex: number;
if (dropPosition === "above") { if (dropPosition === "above") {
insertIndex = currentTrackIndex; insertIndex = currentTrackIndex;
} else if (dropPosition === "below") { } else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1; insertIndex = currentTrackIndex + 1;
} else { } else {
// dropPosition === "on" but track is not text type // dropPosition === "on" but track is not text type
insertIndex = currentTrackIndex + 1; // Insert above main track if main track exists, otherwise at top
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
insertIndex = mainTrackIndex;
} else {
insertIndex = 0; // Top of timeline
}
} }
targetTrackId = insertTrackAt("text", insertIndex); targetTrackId = insertTrackAt("text", insertIndex);
@ -646,42 +659,72 @@ export function TimelineTrackContent({
const needsNewTrack = !isCompatible || dropPosition !== "on"; const needsNewTrack = !isCompatible || dropPosition !== "on";
if (needsNewTrack) { if (needsNewTrack) {
// Determine where to insert the new track if (isVideoOrImage) {
// For video/image, check if we need a main track or additional media track
const mainTrack = getMainTrack(tracks);
if (!mainTrack) {
// No main track exists, create it
const updatedTracks = ensureMainTrack(tracks);
const newMainTrack = getMainTrack(updatedTracks);
if (newMainTrack && newMainTrack.elements.length === 0) {
targetTrackId = newMainTrack.id;
targetTrack = newMainTrack;
} else {
// Main track was created but somehow has elements, create new media track
const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id);
targetTrackId = insertTrackAt("media", mainTrackIndex);
const updatedTracksAfterInsert = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
} else if (mainTrack.elements.length === 0 && dropPosition === "on") {
// Main track exists and is empty, use it
targetTrackId = mainTrack.id;
targetTrack = mainTrack;
} else {
// Create new media track above main track
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
let insertIndex: number; let insertIndex: number;
if (dropPosition === "above") { if (dropPosition === "above") {
insertIndex = currentTrackIndex; insertIndex = currentTrackIndex;
} else if (dropPosition === "below") { } else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1; insertIndex = currentTrackIndex + 1;
} else { } else {
// dropPosition === "on" but track is incompatible // Insert above main track
insertIndex = currentTrackIndex + 1; insertIndex = mainTrackIndex;
} }
if (isVideoOrImage) {
// For video/image, check if main media track is empty and at the right position
const mainMediaTrack = tracks.find((t) => t.type === "media");
if (
mainMediaTrack &&
mainMediaTrack.elements.length === 0 &&
dropPosition === "on"
) {
targetTrackId = mainMediaTrack.id;
targetTrack = mainMediaTrack;
} else {
targetTrackId = insertTrackAt("media", insertIndex); targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks; const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find( const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return; if (!newTargetTrack) return;
targetTrack = newTargetTrack; targetTrack = newTargetTrack;
} }
} else if (isAudio) { } else if (isAudio) {
// Audio tracks go at the bottom
const mainTrack = getMainTrack(tracks);
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// Insert after main track (bottom area)
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
insertIndex = mainTrackIndex + 1;
} else {
insertIndex = tracks.length; // Bottom of timeline
}
}
targetTrackId = insertTrackAt("audio", insertIndex); targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks; const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find( const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return; if (!newTargetTrack) return;
targetTrack = newTargetTrack; targetTrack = newTargetTrack;
} }

View File

@ -1,8 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import type { import {
TrackType, TrackType,
TimelineElement, TimelineElement,
CreateTimelineElement, CreateTimelineElement,
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
} from "@/types/timeline"; } from "@/types/timeline";
import { useEditorStore } from "./editor-store"; import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store";
@ -22,19 +25,18 @@ const getElementNameWithSuffix = (
return `${baseName} (${suffix})`; return `${baseName} (${suffix})`;
}; };
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
elements: TimelineElement[];
muted?: boolean;
}
interface TimelineStore { interface TimelineStore {
tracks: TimelineTrack[]; // Private track storage
_tracks: TimelineTrack[];
history: TimelineTrack[][]; history: TimelineTrack[][];
redoStack: TimelineTrack[][]; redoStack: TimelineTrack[][];
// Always returns properly ordered tracks with main track ensured
tracks: TimelineTrack[];
// Manual method if you need to force recomputation
getSortedTracks: () => TimelineTrack[];
// Multi-selection // Multi-selection
selectedElements: { trackId: string; elementId: string }[]; selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void; selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@ -116,28 +118,50 @@ interface TimelineStore {
pushHistory: () => void; pushHistory: () => void;
} }
export const useTimelineStore = create<TimelineStore>((set, get) => ({ export const useTimelineStore = create<TimelineStore>((set, get) => {
tracks: [], // Helper to update tracks and maintain ordering
const updateTracks = (newTracks: TimelineTrack[]) => {
const tracksWithMain = ensureMainTrack(newTracks);
const sortedTracks = sortTracksByOrder(tracksWithMain);
set({
_tracks: tracksWithMain,
tracks: sortedTracks,
});
};
// Initialize with proper track ordering
const initialTracks = ensureMainTrack([]);
const sortedInitialTracks = sortTracksByOrder(initialTracks);
return {
_tracks: initialTracks,
tracks: sortedInitialTracks,
history: [], history: [],
redoStack: [], redoStack: [],
selectedElements: [], selectedElements: [],
getSortedTracks: () => {
const { _tracks } = get();
const tracksWithMain = ensureMainTrack(_tracks);
return sortTracksByOrder(tracksWithMain);
},
pushHistory: () => { pushHistory: () => {
const { tracks, history } = get(); const { _tracks, history } = get();
set({ set({
history: [...history, JSON.parse(JSON.stringify(tracks))], history: [...history, JSON.parse(JSON.stringify(_tracks))],
redoStack: [], redoStack: [],
}); });
}, },
undo: () => { undo: () => {
const { history, redoStack, tracks } = get(); const { history, redoStack, _tracks } = get();
if (history.length === 0) return; if (history.length === 0) return;
const prev = history[history.length - 1]; const prev = history[history.length - 1];
updateTracks(prev);
set({ set({
tracks: prev,
history: history.slice(0, -1), history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
}); });
}, },
@ -199,9 +223,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
elements: [], elements: [],
muted: false, muted: false,
}; };
set((state) => ({
tracks: [...state.tracks, newTrack], updateTracks([...get()._tracks, newTrack]);
}));
return newTrack.id; return newTrack.id;
}, },
@ -226,26 +249,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
muted: false, muted: false,
}; };
set((state) => { const newTracks = [...get()._tracks];
const newTracks = [...state.tracks];
newTracks.splice(index, 0, newTrack); newTracks.splice(index, 0, newTrack);
return { tracks: newTracks }; updateTracks(newTracks);
});
return newTrack.id; return newTrack.id;
}, },
removeTrack: (trackId) => { removeTrack: (trackId) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ updateTracks(get()._tracks.filter((track) => track.id !== trackId));
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
}, },
addElementToTrack: (trackId, elementData) => { addElementToTrack: (trackId, elementData) => {
get().pushHistory(); get().pushHistory();
// Validate element type matches track type // Validate element type matches track type
const track = get().tracks.find((t) => t.id === trackId); const track = get()._tracks.find((t) => t.id === trackId);
if (!track) { if (!track) {
console.error("Track not found:", trackId); console.error("Track not found:", trackId);
return; return;
@ -279,7 +298,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
// Check if this is the first element being added to the timeline // Check if this is the first element being added to the timeline
const currentState = get(); const currentState = get();
const totalElementsInTimeline = currentState.tracks.reduce( const totalElementsInTimeline = currentState._tracks.reduce(
(total, track) => total + track.elements.length, (total, track) => total + track.elements.length,
0 0
); );
@ -312,20 +331,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
} }
} }
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ...track, elements: [...track.elements, newElement] } ? { ...track, elements: [...track.elements, newElement] }
: track : track
), )
})); );
}, },
removeElementFromTrack: (trackId, elementId) => { removeElementFromTrack: (trackId, elementId) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ updateTracks(
tracks: state.tracks get()
.map((track) => ._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -335,23 +354,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
} }
: track : track
) )
.filter((track) => track.elements.length > 0), .filter((track) => track.elements.length > 0)
})); );
}, },
moveElementToTrack: (fromTrackId, toTrackId, elementId) => { moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
get().pushHistory(); get().pushHistory();
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId); const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
const elementToMove = fromTrack?.elements.find( const elementToMove = fromTrack?.elements.find(
(element) => element.id === elementId (element) => element.id === elementId
); );
if (!elementToMove) return state; if (!elementToMove) return;
return { const newTracks = get()
tracks: state.tracks ._tracks.map((track) => {
.map((track) => {
if (track.id === fromTrackId) { if (track.id === fromTrackId) {
return { return {
...track, ...track,
@ -367,15 +385,15 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
} }
return track; return track;
}) })
.filter((track) => track.elements.length > 0), .filter((track) => track.elements.length > 0);
};
}); updateTracks(newTracks);
}, },
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -386,14 +404,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
), ),
} }
: track : track
), )
})); );
}, },
updateElementStartTime: (trackId, elementId, startTime) => { updateElementStartTime: (trackId, elementId, startTime) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -402,22 +420,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
), ),
} }
: track : track
), )
})); );
}, },
toggleTrackMute: (trackId) => { toggleTrackMute: (trackId) => {
get().pushHistory(); get().pushHistory();
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track track.id === trackId ? { ...track, muted: !track.muted } : track
), )
})); );
}, },
splitElement: (trackId, elementId, splitTime) => { splitElement: (trackId, elementId, splitTime) => {
const { tracks } = get(); const { _tracks } = get();
const track = tracks.find((t) => t.id === trackId); const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId); const element = track?.elements.find((c) => c.id === elementId);
if (!element) return null; if (!element) return null;
@ -438,8 +456,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const secondElementId = crypto.randomUUID(); const secondElementId = crypto.randomUUID();
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -463,16 +481,16 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
), ),
} }
: track : track
), )
})); );
return secondElementId; return secondElementId;
}, },
// Split element and keep only the left portion // Split element and keep only the left portion
splitAndKeepLeft: (trackId, elementId, splitTime) => { splitAndKeepLeft: (trackId, elementId, splitTime) => {
const { tracks } = get(); const { _tracks } = get();
const track = tracks.find((t) => t.id === trackId); const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId); const element = track?.elements.find((c) => c.id === elementId);
if (!element) return; if (!element) return;
@ -490,8 +508,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const durationToRemove = const durationToRemove =
element.duration - element.trimStart - element.trimEnd - relativeTime; element.duration - element.trimStart - element.trimEnd - relativeTime;
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -506,14 +524,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
), ),
} }
: track : track
), )
})); );
}, },
// Split element and keep only the right portion // Split element and keep only the right portion
splitAndKeepRight: (trackId, elementId, splitTime) => { splitAndKeepRight: (trackId, elementId, splitTime) => {
const { tracks } = get(); const { _tracks } = get();
const track = tracks.find((t) => t.id === trackId); const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId); const element = track?.elements.find((c) => c.id === elementId);
if (!element) return; if (!element) return;
@ -529,8 +547,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const relativeTime = splitTime - element.startTime; const relativeTime = splitTime - element.startTime;
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === trackId track.id === trackId
? { ? {
...track, ...track,
@ -546,14 +564,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
), ),
} }
: track : track
), )
})); );
}, },
// Extract audio from video element to an audio track // Extract audio from video element to an audio track
separateAudio: (trackId, elementId) => { separateAudio: (trackId, elementId) => {
const { tracks } = get(); const { _tracks } = get();
const track = tracks.find((t) => t.id === trackId); const track = _tracks.find((t) => t.id === trackId);
const element = track?.elements.find((c) => c.id === elementId); const element = track?.elements.find((c) => c.id === elementId);
if (!element || track?.type !== "media") return null; if (!element || track?.type !== "media") return null;
@ -561,13 +579,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
get().pushHistory(); get().pushHistory();
// Find existing audio track or prepare to create one // Find existing audio track or prepare to create one
const existingAudioTrack = tracks.find((t) => t.type === "audio"); const existingAudioTrack = _tracks.find((t) => t.type === "audio");
const audioElementId = crypto.randomUUID(); const audioElementId = crypto.randomUUID();
if (existingAudioTrack) { if (existingAudioTrack) {
// Add audio element to existing audio track // Add audio element to existing audio track
set((state) => ({ updateTracks(
tracks: state.tracks.map((track) => get()._tracks.map((track) =>
track.id === existingAudioTrack.id track.id === existingAudioTrack.id
? { ? {
...track, ...track,
@ -581,8 +599,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
], ],
} }
: track : track
), )
})); );
} else { } else {
// Create new audio track with the audio element in a single atomic update // Create new audio track with the audio element in a single atomic update
const newAudioTrack: TimelineTrack = { const newAudioTrack: TimelineTrack = {
@ -599,19 +617,17 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
muted: false, muted: false,
}; };
set((state) => ({ updateTracks([...get()._tracks, newAudioTrack]);
tracks: [...state.tracks, newAudioTrack],
}));
} }
return audioElementId; return audioElementId;
}, },
getTotalDuration: () => { getTotalDuration: () => {
const { tracks } = get(); const { _tracks } = get();
if (tracks.length === 0) return 0; if (_tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) => const trackEndTimes = _tracks.map((track) =>
track.elements.reduce((maxEnd, element) => { track.elements.reduce((maxEnd, element) => {
const elementEnd = const elementEnd =
element.startTime + element.startTime +
@ -629,7 +645,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
const { redoStack } = get(); const { redoStack } = get();
if (redoStack.length === 0) return; if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1]; const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) }); updateTracks(next);
set({ redoStack: redoStack.slice(0, -1) });
}, },
dragState: { dragState: {
@ -689,4 +706,5 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
}); });
}, },
})); };
});

View File

@ -1,5 +1,4 @@
import { MediaType } from "@/stores/media-store"; import { MediaType } from "@/stores/media-store";
import { TimelineTrack } from "@/stores/timeline-store";
export type TrackType = "media" | "text" | "audio"; export type TrackType = "media" | "text" | "audio";
@ -77,3 +76,50 @@ export interface TextItemDragData {
} }
export type DragData = MediaItemDragData | TextItemDragData; export type DragData = MediaItemDragData | TextItemDragData;
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
elements: TimelineElement[];
muted?: boolean;
isMain?: boolean;
}
export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
return [...tracks].sort((a, b) => {
// Audio tracks always go to bottom
if (a.type === "audio" && b.type !== "audio") return 1;
if (b.type === "audio" && a.type !== "audio") return -1;
// Main track goes above audio but below other tracks
if (a.isMain && !b.isMain && b.type !== "audio") return 1;
if (b.isMain && !a.isMain && a.type !== "audio") return -1;
// Within same category, maintain creation order
return 0;
});
}
export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null {
return tracks.find((track) => track.isMain) || null;
}
export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
const hasMainTrack = tracks.some((track) => track.isMain);
if (!hasMainTrack) {
// Create main track if it doesn't exist
const mainTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: "Main Track",
type: "media",
elements: [],
muted: false,
isMain: true,
};
return [mainTrack, ...tracks];
}
return tracks;
}