mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: implement mute video tracks
This commit is contained in:
@@ -235,7 +235,7 @@ export function PreviewPanel() {
|
|||||||
const activeElements: ActiveElement[] = [];
|
const activeElements: ActiveElement[] = [];
|
||||||
|
|
||||||
// Iterate tracks from bottom to top so topmost track renders last (on top)
|
// Iterate tracks from bottom to top so topmost track renders last (on top)
|
||||||
;[...tracks].reverse().forEach((track) => {
|
[...tracks].reverse().forEach((track) => {
|
||||||
track.elements.forEach((element) => {
|
track.elements.forEach((element) => {
|
||||||
if (element.hidden) return;
|
if (element.hidden) return;
|
||||||
const elementStart = element.startTime;
|
const elementStart = element.startTime;
|
||||||
@@ -436,7 +436,7 @@ export function PreviewPanel() {
|
|||||||
trimStart={element.trimStart}
|
trimStart={element.trimStart}
|
||||||
trimEnd={element.trimEnd}
|
trimEnd={element.trimEnd}
|
||||||
clipDuration={element.duration}
|
clipDuration={element.duration}
|
||||||
trackMuted={elementData.track.muted}
|
trackMuted={element.muted || elementData.track.muted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -462,14 +462,18 @@ export function PreviewPanel() {
|
|||||||
// Audio elements (no visual representation)
|
// Audio elements (no visual representation)
|
||||||
if (mediaItem.type === "audio") {
|
if (mediaItem.type === "audio") {
|
||||||
return (
|
return (
|
||||||
<div key={element.id} className="absolute inset-0" style={{ pointerEvents: "none" }}>
|
<div
|
||||||
|
key={element.id}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
|
>
|
||||||
<AudioPlayer
|
<AudioPlayer
|
||||||
src={mediaItem.url!}
|
src={mediaItem.url!}
|
||||||
clipStartTime={element.startTime}
|
clipStartTime={element.startTime}
|
||||||
trimStart={element.trimStart}
|
trimStart={element.trimStart}
|
||||||
trimEnd={element.trimEnd}
|
trimEnd={element.trimEnd}
|
||||||
clipDuration={element.duration}
|
clipDuration={element.duration}
|
||||||
trackMuted={elementData.track.muted}
|
trackMuted={element.muted || elementData.track.muted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function TimelineElement({
|
|||||||
replaceElementMedia,
|
replaceElementMedia,
|
||||||
rippleEditingEnabled,
|
rippleEditingEnabled,
|
||||||
toggleElementHidden,
|
toggleElementHidden,
|
||||||
|
toggleElementMuted,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ export function TimelineElement({
|
|||||||
element.type === "media"
|
element.type === "media"
|
||||||
? mediaItems.find((item) => item.id === element.mediaId)
|
? mediaItems.find((item) => item.id === element.mediaId)
|
||||||
: null;
|
: null;
|
||||||
const isAudio = mediaItem?.type === "audio";
|
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
|
||||||
|
|
||||||
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
|
||||||
useTimelineElementResize({
|
useTimelineElementResize({
|
||||||
@@ -124,8 +125,12 @@ export function TimelineElement({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleElementHidden = (e: React.MouseEvent) => {
|
const handleToggleElementContext = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (hasAudio && element.type === "media") {
|
||||||
|
toggleElementMuted(track.id, element.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toggleElementHidden(track.id, element.id);
|
toggleElementHidden(track.id, element.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,6 +251,8 @@ export function TimelineElement({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMuted = element.type === "media" ? element.muted === true : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
@@ -279,9 +286,9 @@ export function TimelineElement({
|
|||||||
{renderElementContent()}
|
{renderElementContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{element.hidden && (
|
{(hasAudio ? isMuted : element.hidden) && (
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
|
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
|
||||||
{isAudio ? (
|
{hasAudio ? (
|
||||||
<VolumeX className="h-6 w-6 text-white" />
|
<VolumeX className="h-6 w-6 text-white" />
|
||||||
) : (
|
) : (
|
||||||
<EyeOff className="h-6 w-6 text-white" />
|
<EyeOff className="h-6 w-6 text-white" />
|
||||||
@@ -309,9 +316,9 @@ export function TimelineElement({
|
|||||||
<Scissors className="h-4 w-4 mr-2" />
|
<Scissors className="h-4 w-4 mr-2" />
|
||||||
Split at playhead
|
Split at playhead
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={handleToggleElementHidden}>
|
<ContextMenuItem onClick={handleToggleElementContext}>
|
||||||
{isAudio ? (
|
{hasAudio ? (
|
||||||
element.hidden ? (
|
isMuted ? (
|
||||||
<Volume2 className="h-4 w-4 mr-2" />
|
<Volume2 className="h-4 w-4 mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<VolumeX className="h-4 w-4 mr-2" />
|
<VolumeX className="h-4 w-4 mr-2" />
|
||||||
@@ -322,8 +329,8 @@ export function TimelineElement({
|
|||||||
<EyeOff className="h-4 w-4 mr-2" />
|
<EyeOff className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{isAudio
|
{hasAudio
|
||||||
? element.hidden
|
? isMuted
|
||||||
? "Unmute"
|
? "Unmute"
|
||||||
: "Mute"
|
: "Mute"
|
||||||
: element.hidden
|
: element.hidden
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ const getElementNameWithSuffix = (
|
|||||||
): string => {
|
): string => {
|
||||||
// Remove existing suffixes to prevent accumulation
|
// Remove existing suffixes to prevent accumulation
|
||||||
const baseName = originalName
|
const baseName = originalName
|
||||||
.replace(/ \(left\)$/, "")
|
.replace(/ \(left\)$/i, "")
|
||||||
.replace(/ \(right\)$/, "")
|
.replace(/ \(right\)$/i, "")
|
||||||
.replace(/ \(audio\)$/, "")
|
.replace(/ \(audio\)$/i, "")
|
||||||
.replace(/ \(split \d+\)$/, "");
|
.replace(/ \(split \d+\)$/i, "");
|
||||||
|
|
||||||
return `${baseName} (${suffix})`;
|
return `${baseName} (${suffix})`;
|
||||||
};
|
};
|
||||||
@@ -126,6 +126,7 @@ interface TimelineStore {
|
|||||||
) => void;
|
) => void;
|
||||||
toggleTrackMute: (trackId: string) => void;
|
toggleTrackMute: (trackId: string) => void;
|
||||||
toggleElementHidden: (trackId: string, elementId: string) => void;
|
toggleElementHidden: (trackId: string, elementId: string) => void;
|
||||||
|
toggleElementMuted: (trackId: string, elementId: string) => void;
|
||||||
|
|
||||||
// Split operations for elements
|
// Split operations for elements
|
||||||
splitElement: (
|
splitElement: (
|
||||||
@@ -322,7 +323,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
addTrack: (type) => {
|
addTrack: (type) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// Generate proper track name based on type
|
|
||||||
const trackName =
|
const trackName =
|
||||||
type === "media"
|
type === "media"
|
||||||
? "Media Track"
|
? "Media Track"
|
||||||
@@ -347,7 +347,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
insertTrackAt: (type, index) => {
|
insertTrackAt: (type, index) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// Generate proper track name based on type
|
|
||||||
const trackName =
|
const trackName =
|
||||||
type === "media"
|
type === "media"
|
||||||
? "Media Track"
|
? "Media Track"
|
||||||
@@ -392,13 +391,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
|
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// If track has no elements, just remove it normally
|
|
||||||
if (trackToRemove.elements.length === 0) {
|
|
||||||
updateTracksAndSave(_tracks.filter((track) => track.id !== trackId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all the time ranges occupied by elements in the track being removed
|
|
||||||
const occupiedRanges = trackToRemove.elements.map((element) => ({
|
const occupiedRanges = trackToRemove.elements.map((element) => ({
|
||||||
startTime: element.startTime,
|
startTime: element.startTime,
|
||||||
endTime:
|
endTime:
|
||||||
@@ -406,10 +398,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
(element.duration - element.trimStart - element.trimEnd),
|
(element.duration - element.trimStart - element.trimEnd),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort ranges by start time
|
|
||||||
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
|
occupiedRanges.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
// Merge overlapping ranges to get consolidated gaps
|
|
||||||
const mergedRanges: Array<{
|
const mergedRanges: Array<{
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
@@ -426,11 +416,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
} else {
|
} else {
|
||||||
const lastRange = mergedRanges[mergedRanges.length - 1];
|
const lastRange = mergedRanges[mergedRanges.length - 1];
|
||||||
if (range.startTime <= lastRange.endTime) {
|
if (range.startTime <= lastRange.endTime) {
|
||||||
// Overlapping or adjacent ranges, merge them
|
|
||||||
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
|
lastRange.endTime = Math.max(lastRange.endTime, range.endTime);
|
||||||
lastRange.duration = lastRange.endTime - lastRange.startTime;
|
lastRange.duration = lastRange.endTime - lastRange.startTime;
|
||||||
} else {
|
} else {
|
||||||
// Non-overlapping range, add as new
|
|
||||||
mergedRanges.push({
|
mergedRanges.push({
|
||||||
startTime: range.startTime,
|
startTime: range.startTime,
|
||||||
endTime: range.endTime,
|
endTime: range.endTime,
|
||||||
@@ -440,17 +428,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the track and apply ripple effects to remaining tracks
|
|
||||||
const updatedTracks = _tracks
|
const updatedTracks = _tracks
|
||||||
.filter((track) => track.id !== trackId)
|
.filter((track) => track.id !== trackId)
|
||||||
.map((track) => {
|
.map((track) => {
|
||||||
const updatedElements = track.elements.map((element) => {
|
const updatedElements = track.elements.map((element) => {
|
||||||
let newStartTime = element.startTime;
|
let newStartTime = element.startTime;
|
||||||
|
|
||||||
// Process gaps from right to left (latest to earliest) to avoid cumulative shifts
|
|
||||||
for (let i = mergedRanges.length - 1; i >= 0; i--) {
|
for (let i = mergedRanges.length - 1; i >= 0; i--) {
|
||||||
const gap = mergedRanges[i];
|
const gap = mergedRanges[i];
|
||||||
// If this element starts after the gap, shift it left by the gap duration
|
|
||||||
if (newStartTime >= gap.endTime) {
|
if (newStartTime >= gap.endTime) {
|
||||||
newStartTime -= gap.duration;
|
newStartTime -= gap.duration;
|
||||||
}
|
}
|
||||||
@@ -462,7 +447,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for overlaps and resolve them if necessary
|
|
||||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
if (hasOverlaps) {
|
if (hasOverlaps) {
|
||||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
@@ -478,33 +462,28 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
addElementToTrack: (trackId, elementData) => {
|
addElementToTrack: (trackId, elementData) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use utility function for validation
|
|
||||||
const validation = validateElementTrackCompatibility(elementData, track);
|
const validation = validateElementTrackCompatibility(elementData, track);
|
||||||
if (!validation.isValid) {
|
if (!validation.isValid) {
|
||||||
console.error(validation.errorMessage);
|
console.error(validation.errorMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For media elements, validate mediaId exists
|
|
||||||
if (elementData.type === "media" && !elementData.mediaId) {
|
if (elementData.type === "media" && !elementData.mediaId) {
|
||||||
console.error("Media element must have mediaId");
|
console.error("Media element must have mediaId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For text elements, validate required text properties
|
|
||||||
if (elementData.type === "text" && !elementData.content) {
|
if (elementData.type === "text" && !elementData.content) {
|
||||||
console.error("Text element must have content");
|
console.error("Text element must have content");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -518,10 +497,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
startTime: elementData.startTime || 0,
|
startTime: elementData.startTime || 0,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
} as TimelineElement; // Type assertion since we trust the caller passes valid data
|
...(elementData.type === "media" ? { muted: false } : {}),
|
||||||
|
} as TimelineElement;
|
||||||
|
|
||||||
// If this is the first element and it's a media element, automatically set the project canvas size
|
|
||||||
// to match the media's aspect ratio and FPS (for videos)
|
|
||||||
if (isFirstElement && newElement.type === "media") {
|
if (isFirstElement && newElement.type === "media") {
|
||||||
const mediaStore = useMediaStore.getState();
|
const mediaStore = useMediaStore.getState();
|
||||||
const mediaItem = mediaStore.mediaItems.find(
|
const mediaItem = mediaStore.mediaItems.find(
|
||||||
@@ -539,7 +517,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set project FPS from the first video element
|
|
||||||
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
|
if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
|
||||||
const projectStore = useProjectStore.getState();
|
const projectStore = useProjectStore.getState();
|
||||||
if (projectStore.activeProject) {
|
if (projectStore.activeProject) {
|
||||||
@@ -591,7 +568,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
const { _tracks, rippleEditingEnabled } = get();
|
const { _tracks, rippleEditingEnabled } = get();
|
||||||
|
|
||||||
if (!rippleEditingEnabled) {
|
if (!rippleEditingEnabled) {
|
||||||
// If ripple editing is disabled, use regular removal
|
|
||||||
get().removeElementFromTrack(trackId, elementId, pushHistory);
|
get().removeElementFromTrack(trackId, elementId, pushHistory);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -608,15 +584,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
element.duration - element.trimStart - element.trimEnd;
|
element.duration - element.trimStart - element.trimEnd;
|
||||||
const elementEndTime = elementStartTime + elementDuration;
|
const elementEndTime = elementStartTime + elementDuration;
|
||||||
|
|
||||||
// Remove the element and shift all elements that come after it
|
|
||||||
const updatedTracks = _tracks
|
const updatedTracks = _tracks
|
||||||
.map((currentTrack) => {
|
.map((currentTrack) => {
|
||||||
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
|
||||||
const shouldApplyRipple = currentTrack.id === trackId;
|
const shouldApplyRipple = currentTrack.id === trackId;
|
||||||
|
|
||||||
const updatedElements = currentTrack.elements
|
const updatedElements = currentTrack.elements
|
||||||
.filter((currentElement) => {
|
.filter((currentElement) => {
|
||||||
// Remove the target element
|
|
||||||
if (
|
if (
|
||||||
currentElement.id === elementId &&
|
currentElement.id === elementId &&
|
||||||
currentTrack.id === trackId
|
currentTrack.id === trackId
|
||||||
@@ -626,12 +599,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((currentElement) => {
|
.map((currentElement) => {
|
||||||
// Only apply ripple effects if we should process this track
|
|
||||||
if (!shouldApplyRipple) {
|
if (!shouldApplyRipple) {
|
||||||
return currentElement;
|
return currentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift elements that start after the removed element
|
|
||||||
if (currentElement.startTime >= elementEndTime) {
|
if (currentElement.startTime >= elementEndTime) {
|
||||||
return {
|
return {
|
||||||
...currentElement,
|
...currentElement,
|
||||||
@@ -644,10 +615,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return currentElement;
|
return currentElement;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for overlaps and resolve them if necessary
|
|
||||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
if (hasOverlaps) {
|
if (hasOverlaps) {
|
||||||
// Resolve overlaps by adjusting element positions
|
|
||||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
return { ...currentTrack, elements: resolvedElements };
|
return { ...currentTrack, elements: resolvedElements };
|
||||||
}
|
}
|
||||||
@@ -670,7 +639,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
|
|
||||||
if (!elementToMove || !toTrack) return;
|
if (!elementToMove || !toTrack) return;
|
||||||
|
|
||||||
// Validate element type compatibility with target track
|
|
||||||
const validation = validateElementTrackCompatibility(
|
const validation = validateElementTrackCompatibility(
|
||||||
elementToMove,
|
elementToMove,
|
||||||
toTrack
|
toTrack
|
||||||
@@ -776,7 +744,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
const { _tracks, rippleEditingEnabled } = get();
|
const { _tracks, rippleEditingEnabled } = get();
|
||||||
|
|
||||||
if (!rippleEditingEnabled) {
|
if (!rippleEditingEnabled) {
|
||||||
// If ripple editing is disabled, use regular update
|
|
||||||
get().updateElementStartTime(trackId, elementId, newStartTime);
|
get().updateElementStartTime(trackId, elementId, newStartTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -796,9 +763,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
newStartTime + (element.duration - element.trimStart - element.trimEnd);
|
newStartTime + (element.duration - element.trimStart - element.trimEnd);
|
||||||
const timeDelta = newStartTime - oldStartTime;
|
const timeDelta = newStartTime - oldStartTime;
|
||||||
|
|
||||||
// Update tracks based on multi-track ripple setting
|
|
||||||
const updatedTracks = _tracks.map((currentTrack) => {
|
const updatedTracks = _tracks.map((currentTrack) => {
|
||||||
// Only apply ripple effects to the same track unless multi-track ripple is enabled
|
|
||||||
const shouldApplyRipple = currentTrack.id === trackId;
|
const shouldApplyRipple = currentTrack.id === trackId;
|
||||||
|
|
||||||
const updatedElements = currentTrack.elements.map((currentElement) => {
|
const updatedElements = currentTrack.elements.map((currentElement) => {
|
||||||
@@ -806,12 +771,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return { ...currentElement, startTime: Math.max(0, newStartTime) };
|
return { ...currentElement, startTime: Math.max(0, newStartTime) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only apply ripple effects if we should process this track
|
|
||||||
if (!shouldApplyRipple) {
|
if (!shouldApplyRipple) {
|
||||||
return currentElement;
|
return currentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For ripple editing, we need to move elements that come after the moved element
|
|
||||||
const currentElementStart = currentElement.startTime;
|
const currentElementStart = currentElement.startTime;
|
||||||
const currentElementEnd =
|
const currentElementEnd =
|
||||||
currentElement.startTime +
|
currentElement.startTime +
|
||||||
@@ -819,19 +782,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
currentElement.trimStart -
|
currentElement.trimStart -
|
||||||
currentElement.trimEnd);
|
currentElement.trimEnd);
|
||||||
|
|
||||||
// If moving element to the right (positive delta)
|
|
||||||
if (timeDelta > 0) {
|
if (timeDelta > 0) {
|
||||||
// Move elements that start after the original position of the moved element
|
|
||||||
if (currentElementStart >= oldEndTime) {
|
if (currentElementStart >= oldEndTime) {
|
||||||
return {
|
return {
|
||||||
...currentElement,
|
...currentElement,
|
||||||
startTime: currentElementStart + timeDelta,
|
startTime: currentElementStart + timeDelta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
} else if (timeDelta < 0) {
|
||||||
// If moving element to the left (negative delta)
|
|
||||||
else if (timeDelta < 0) {
|
|
||||||
// Move elements that start after the new position of the moved element
|
|
||||||
if (
|
if (
|
||||||
currentElementStart >= newEndTime &&
|
currentElementStart >= newEndTime &&
|
||||||
currentElementStart >= oldStartTime
|
currentElementStart >= oldStartTime
|
||||||
@@ -846,10 +804,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return currentElement;
|
return currentElement;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for overlaps and resolve them if necessary
|
|
||||||
const hasOverlaps = checkElementOverlaps(updatedElements);
|
const hasOverlaps = checkElementOverlaps(updatedElements);
|
||||||
if (hasOverlaps) {
|
if (hasOverlaps) {
|
||||||
// Resolve overlaps by adjusting element positions
|
|
||||||
const resolvedElements = resolveElementOverlaps(updatedElements);
|
const resolvedElements = resolveElementOverlaps(updatedElements);
|
||||||
return { ...currentTrack, elements: resolvedElements };
|
return { ...currentTrack, elements: resolvedElements };
|
||||||
}
|
}
|
||||||
@@ -887,6 +843,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleElementMuted: (trackId, elementId) => {
|
||||||
|
get().pushHistory();
|
||||||
|
updateTracksAndSave(
|
||||||
|
get()._tracks.map((track) =>
|
||||||
|
track.id === trackId
|
||||||
|
? {
|
||||||
|
...track,
|
||||||
|
elements: track.elements.map((element) =>
|
||||||
|
element.id === elementId && element.type === "media"
|
||||||
|
? { ...element, muted: !element.muted }
|
||||||
|
: element
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: track
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
updateTextElement: (trackId, elementId, updates) => {
|
updateTextElement: (trackId, elementId, updates) => {
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
updateTracksAndSave(
|
updateTracksAndSave(
|
||||||
@@ -1050,12 +1024,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
|
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// 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 = generateUUID();
|
const audioElementId = generateUUID();
|
||||||
|
|
||||||
if (existingAudioTrack) {
|
if (existingAudioTrack) {
|
||||||
// Add audio element to existing audio track
|
|
||||||
updateTracksAndSave(
|
updateTracksAndSave(
|
||||||
get()._tracks.map((track) =>
|
get()._tracks.map((track) =>
|
||||||
track.id === existingAudioTrack.id
|
track.id === existingAudioTrack.id
|
||||||
@@ -1074,7 +1046,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Create new audio track with the audio element in a single atomic update
|
|
||||||
const newAudioTrack: TimelineTrack = {
|
const newAudioTrack: TimelineTrack = {
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
name: "Audio Track",
|
name: "Audio Track",
|
||||||
@@ -1124,7 +1095,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return { success: false, error: "No active project found" };
|
return { success: false, error: "No active project found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import required media processing functions
|
|
||||||
const {
|
const {
|
||||||
getFileType,
|
getFileType,
|
||||||
getImageDimensions,
|
getImageDimensions,
|
||||||
@@ -1141,7 +1111,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the new media file
|
|
||||||
const mediaData: any = {
|
const mediaData: any = {
|
||||||
name: newFile.name,
|
name: newFile.name,
|
||||||
type: fileType,
|
type: fileType,
|
||||||
@@ -1150,7 +1119,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get media-specific metadata
|
|
||||||
if (fileType === "image") {
|
if (fileType === "image") {
|
||||||
const { width, height } = await getImageDimensions(newFile);
|
const { width, height } = await getImageDimensions(newFile);
|
||||||
mediaData.width = width;
|
mediaData.width = width;
|
||||||
@@ -1175,7 +1143,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new media item to store
|
|
||||||
try {
|
try {
|
||||||
await mediaStore.addMediaItem(
|
await mediaStore.addMediaItem(
|
||||||
projectStore.activeProject.id,
|
projectStore.activeProject.id,
|
||||||
@@ -1188,7 +1155,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the newly created media item
|
|
||||||
const newMediaItem = mediaStore.mediaItems.find(
|
const newMediaItem = mediaStore.mediaItems.find(
|
||||||
(item) => item.file === newFile
|
(item) => item.file === newFile
|
||||||
);
|
);
|
||||||
@@ -1202,7 +1168,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
|
|
||||||
get().pushHistory();
|
get().pushHistory();
|
||||||
|
|
||||||
// Update the timeline element to reference the new media
|
|
||||||
updateTracksAndSave(
|
updateTracksAndSave(
|
||||||
_tracks.map((track) =>
|
_tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
@@ -1214,7 +1179,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
...c,
|
...c,
|
||||||
mediaId: newMediaItem.id,
|
mediaId: newMediaItem.id,
|
||||||
name: newMediaItem.name,
|
name: newMediaItem.name,
|
||||||
// Update duration if the new media has a different duration
|
|
||||||
duration: newMediaItem.duration || c.duration,
|
duration: newMediaItem.duration || c.duration,
|
||||||
}
|
}
|
||||||
: c
|
: c
|
||||||
@@ -1355,22 +1319,18 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persistence methods
|
|
||||||
loadProjectTimeline: async (projectId) => {
|
loadProjectTimeline: async (projectId) => {
|
||||||
try {
|
try {
|
||||||
const tracks = await storageService.loadTimeline(projectId);
|
const tracks = await storageService.loadTimeline(projectId);
|
||||||
if (tracks) {
|
if (tracks) {
|
||||||
updateTracks(tracks);
|
updateTracks(tracks);
|
||||||
} else {
|
} else {
|
||||||
// No timeline saved yet, initialize with default
|
|
||||||
const defaultTracks = ensureMainTrack([]);
|
const defaultTracks = ensureMainTrack([]);
|
||||||
updateTracks(defaultTracks);
|
updateTracks(defaultTracks);
|
||||||
}
|
}
|
||||||
// Clear history when loading a project
|
|
||||||
set({ history: [], redoStack: [] });
|
set({ history: [], redoStack: [] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load timeline:", error);
|
console.error("Failed to load timeline:", error);
|
||||||
// Initialize with default on error
|
|
||||||
const defaultTracks = ensureMainTrack([]);
|
const defaultTracks = ensureMainTrack([]);
|
||||||
updateTracks(defaultTracks);
|
updateTracks(defaultTracks);
|
||||||
set({ history: [], redoStack: [] });
|
set({ history: [], redoStack: [] });
|
||||||
@@ -1429,8 +1389,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
findOrCreateTrack: (trackType) => {
|
findOrCreateTrack: (trackType) => {
|
||||||
// Always create new text track to allow multiple text elements
|
|
||||||
// Insert text tracks at the top
|
|
||||||
if (trackType === "text") {
|
if (trackType === "text") {
|
||||||
return get().insertTrackAt(trackType, 0);
|
return get().insertTrackAt(trackType, 0);
|
||||||
}
|
}
|
||||||
@@ -1448,10 +1406,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
const duration =
|
const duration =
|
||||||
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
||||||
|
|
||||||
// Get all tracks of the right type
|
|
||||||
const tracks = get()._tracks.filter((t) => t.type === trackType);
|
const tracks = get()._tracks.filter((t) => t.type === trackType);
|
||||||
|
|
||||||
// Try to find a track with no overlap
|
|
||||||
let targetTrackId = null;
|
let targetTrackId = null;
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
|
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
|
||||||
@@ -1460,7 +1416,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no free track found, create a new one
|
|
||||||
if (!targetTrackId) {
|
if (!targetTrackId) {
|
||||||
targetTrackId = get().addTrack(trackType);
|
targetTrackId = get().addTrack(trackType);
|
||||||
}
|
}
|
||||||
@@ -1473,12 +1428,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
startTime: currentTime,
|
startTime: currentTime,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
|
muted: false,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
addTextAtTime: (item, currentTime = 0) => {
|
addTextAtTime: (item, currentTime = 0) => {
|
||||||
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
|
const targetTrackId = get().insertTrackAt("text", 0);
|
||||||
|
|
||||||
get().addElementToTrack(targetTrackId, {
|
get().addElementToTrack(targetTrackId, {
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -1516,12 +1472,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
startTime: 0,
|
startTime: 0,
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: 0,
|
trimEnd: 0,
|
||||||
|
muted: false,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
addTextToNewTrack: (item) => {
|
addTextToNewTrack: (item) => {
|
||||||
const targetTrackId = get().insertTrackAt("text", 0); // Always create new text track at the top
|
const targetTrackId = get().insertTrackAt("text", 0);
|
||||||
|
|
||||||
get().addElementToTrack(targetTrackId, {
|
get().addElementToTrack(targetTrackId, {
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface BaseTimelineElement {
|
|||||||
export interface MediaElement extends BaseTimelineElement {
|
export interface MediaElement extends BaseTimelineElement {
|
||||||
type: "media";
|
type: "media";
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
|
muted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text element with embedded text data
|
// Text element with embedded text data
|
||||||
|
|||||||
Reference in New Issue
Block a user