Fixed BUG Editor cursor does not stop at the end of an audio file

Get duration of content on the timeline to make sure the playhead moves until the end of the timeline content when the content is less than 10 seconds.
Originally it would go all the way to the 10 seconds when timeline content was less than that.
I checked how clipchamp does it and they have a min 10 sec timeline but video timeline plays at length of content, so we are doing it like the pros. timeline is jumpy though will probably work on that.
This commit is contained in:
Vlad
2025-07-30 10:25:35 -04:00
parent 1c20027d33
commit 281ec63d7e
5 changed files with 236 additions and 122 deletions
@@ -211,7 +211,7 @@ export function PreviewPanel() {
setDragState({ setDragState({
isDragging: true, isDragging: true,
elementId: element.id, elementId: element.id,
trackId: trackId, trackId,
startX: e.clientX, startX: e.clientX,
startY: e.clientY, startY: e.clientY,
initialElementX: element.x, initialElementX: element.x,
@@ -355,8 +355,24 @@ export function PreviewPanel() {
handleTextMouseDown(e, element, elementData.track.id) handleTextMouseDown(e, element, elementData.track.id)
} }
style={{ style={{
left: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentX : element.x) / canvasSize.width) * 100}%`, left: `${
top: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentY : element.y) / canvasSize.height) * 100}%`, 50 +
(
(dragState.isDragging && dragState.elementId === element.id
? dragState.currentX
: element.x) / canvasSize.width
) *
100
}%`,
top: `${
50 +
(
(dragState.isDragging && dragState.elementId === element.id
? dragState.currentY
: element.y) / canvasSize.height
) *
100
}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
opacity: element.opacity, opacity: element.opacity,
zIndex: 100 + index, // Text elements on top zIndex: 100 + index, // Text elements on top
@@ -607,7 +607,11 @@ export function Timeline() {
: "border-l border-muted-foreground/20" : "border-l border-muted-foreground/20"
}`} }`}
style={{ style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`, left: `${
time *
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
zoomLevel
}px`,
}} }}
> >
<span <span
@@ -624,10 +628,16 @@ export function Timeline() {
const secs = seconds % 60; const secs = seconds % 60;
if (hours > 0) { if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`; return `${hours}:${minutes
.toString()
.padStart(2, "0")}:${Math.floor(secs)
.toString()
.padStart(2, "0")}`;
} }
if (minutes > 0) { if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`; return `${minutes}:${Math.floor(secs)
.toString()
.padStart(2, "0")}`;
} }
if (interval >= 1) { if (interval >= 1) {
return `${Math.floor(secs)}s`; return `${Math.floor(secs)}s`;
@@ -710,7 +720,10 @@ export function Timeline() {
<div <div
className="relative flex-1" className="relative flex-1"
style={{ style={{
height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`, height: `${Math.max(
200,
Math.min(800, getTotalTracksHeight(tracks))
)}px`,
width: `${dynamicTimelineWidth}px`, width: `${dynamicTimelineWidth}px`,
}} }}
> >
@@ -724,7 +737,10 @@ export function Timeline() {
<div <div
className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]" className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
style={{ style={{
top: `${getCumulativeHeightBefore(tracks, index)}px`, top: `${getCumulativeHeightBefore(
tracks,
index
)}px`,
height: `${getTrackHeight(track.type)}px`, height: `${getTrackHeight(track.type)}px`,
}} }}
onClick={(e) => { onClick={(e) => {
@@ -1092,7 +1108,9 @@ function TimelineToolbar({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleRippleEditing}> <Button variant="text" size="icon" onClick={toggleRippleEditing}>
<Link <Link
className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`} className={`h-4 w-4 ${
rippleEditingEnabled ? "text-primary" : ""
}`}
/> />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@@ -62,7 +62,7 @@ export function Handlebars({ children }: HandlebarsProps) {
whileDrag={{ scale: 1.1, cursor: "grabbing" }} whileDrag={{ scale: 1.1, cursor: "grabbing" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}
> >
<div className="w-2 h-8 rounded-full bg-yellow-500"></div> <div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div> </motion.div>
<motion.div <motion.div
@@ -81,7 +81,7 @@ export function Handlebars({ children }: HandlebarsProps) {
whileDrag={{ scale: 1.1, cursor: "grabbing" }} whileDrag={{ scale: 1.1, cursor: "grabbing" }}
transition={{ type: "spring", stiffness: 400, damping: 30 }} transition={{ type: "spring", stiffness: 400, damping: 30 }}
> >
<div className="w-2 h-8 rounded-full bg-yellow-500"></div> <div className="w-2 h-8 rounded-full bg-yellow-500" />
</motion.div> </motion.div>
</div> </div>
+177 -109
View File
@@ -26,136 +26,204 @@ export function useEditorActions() {
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
// Playback actions // Playback actions
useActionHandler("toggle-play", () => { useActionHandler(
toggle(); "toggle-play",
}, undefined); () => {
useActionHandler("stop-playback", () => {
if (isPlaying) {
toggle(); toggle();
} },
seek(0); undefined
}, undefined); );
useActionHandler("seek-forward", (args) => { useActionHandler(
const seconds = args?.seconds ?? 1; "stop-playback",
seek(Math.min(duration, currentTime + seconds)); () => {
}, undefined); if (isPlaying) {
toggle();
}
seek(0);
},
undefined
);
useActionHandler("seek-backward", (args) => { useActionHandler(
const seconds = args?.seconds ?? 1; "seek-forward",
seek(Math.max(0, currentTime - seconds)); (args) => {
}, undefined); const seconds = args?.seconds ?? 1;
seek(Math.min(duration, currentTime + seconds));
},
undefined
);
useActionHandler("frame-step-forward", () => { useActionHandler(
const projectFps = activeProject?.fps || 30; "seek-backward",
seek(Math.min(duration, currentTime + 1 / projectFps)); (args) => {
}, undefined); const seconds = args?.seconds ?? 1;
seek(Math.max(0, currentTime - seconds));
},
undefined
);
useActionHandler("frame-step-backward", () => { useActionHandler(
const projectFps = activeProject?.fps || 30; "frame-step-forward",
seek(Math.max(0, currentTime - 1 / projectFps)); () => {
}, undefined); const projectFps = activeProject?.fps || 30;
seek(Math.min(duration, currentTime + 1 / projectFps));
},
undefined
);
useActionHandler("jump-forward", (args) => { useActionHandler(
const seconds = args?.seconds ?? 5; "frame-step-backward",
seek(Math.min(duration, currentTime + seconds)); () => {
}, undefined); const projectFps = activeProject?.fps || 30;
seek(Math.max(0, currentTime - 1 / projectFps));
},
undefined
);
useActionHandler("jump-backward", (args) => { useActionHandler(
const seconds = args?.seconds ?? 5; "jump-forward",
seek(Math.max(0, currentTime - seconds)); (args) => {
}, undefined); const seconds = args?.seconds ?? 5;
seek(Math.min(duration, currentTime + seconds));
},
undefined
);
useActionHandler("goto-start", () => { useActionHandler(
seek(0); "jump-backward",
}, undefined); (args) => {
const seconds = args?.seconds ?? 5;
seek(Math.max(0, currentTime - seconds));
},
undefined
);
useActionHandler("goto-end", () => { useActionHandler(
seek(duration); "goto-start",
}, undefined); () => {
seek(0);
},
undefined
);
useActionHandler(
"goto-end",
() => {
seek(duration);
},
undefined
);
// Timeline editing actions // Timeline editing actions
useActionHandler("split-element", () => { useActionHandler(
if (selectedElements.length !== 1) { "split-element",
toast.error("Select exactly one element to split"); () => {
return; if (selectedElements.length !== 1) {
} toast.error("Select exactly one element to split");
return;
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t: any) => t.id === trackId);
const element = track?.elements.find((el: any) => el.id === elementId);
if (element) {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitElement(trackId, elementId, currentTime);
} else {
toast.error("Playhead must be within selected element");
} }
}
}, undefined);
useActionHandler("delete-selected", () => { const { trackId, elementId } = selectedElements[0];
if (selectedElements.length === 0) { const track = tracks.find((t: any) => t.id === trackId);
return; const element = track?.elements.find((el: any) => el.id === elementId);
}
selectedElements.forEach( if (element) {
({ trackId, elementId }: { trackId: string; elementId: string }) => { const effectiveStart = element.startTime;
removeElementFromTrack(trackId, elementId); const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
splitElement(trackId, elementId, currentTime);
} else {
toast.error("Playhead must be within selected element");
}
} }
); },
clearSelectedElements(); undefined
}, undefined); );
useActionHandler("select-all", () => { useActionHandler(
const allElements = tracks.flatMap((track: any) => "delete-selected",
track.elements.map((element: any) => ({ () => {
trackId: track.id, if (selectedElements.length === 0) {
elementId: element.id, return;
})) }
); selectedElements.forEach(
setSelectedElements(allElements); ({ trackId, elementId }: { trackId: string; elementId: string }) => {
}, undefined); removeElementFromTrack(trackId, elementId);
}
);
clearSelectedElements();
},
undefined
);
useActionHandler("duplicate-selected", () => { useActionHandler(
if (selectedElements.length !== 1) { "select-all",
toast.error("Select exactly one element to duplicate"); () => {
return; const allElements = tracks.flatMap((track: any) =>
} track.elements.map((element: any) => ({
trackId: track.id,
elementId: element.id,
}))
);
setSelectedElements(allElements);
},
undefined
);
const { trackId, elementId } = selectedElements[0]; useActionHandler(
const track = tracks.find((t: any) => t.id === trackId); "duplicate-selected",
const element = track?.elements.find((el: any) => el.id === elementId); () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element to duplicate");
return;
}
if (element) { const { trackId, elementId } = selectedElements[0];
const newStartTime = const track = tracks.find((t: any) => t.id === trackId);
element.startTime + const element = track?.elements.find((el: any) => el.id === elementId);
(element.duration - element.trimStart - element.trimEnd) +
0.1;
const { id, ...elementWithoutId } = element;
addElementToTrack(trackId, { if (element) {
...elementWithoutId, const newStartTime =
startTime: newStartTime, element.startTime +
}); (element.duration - element.trimStart - element.trimEnd) +
} 0.1;
}, undefined); const { id, ...elementWithoutId } = element;
useActionHandler("toggle-snapping", () => { addElementToTrack(trackId, {
toggleSnapping(); ...elementWithoutId,
}, undefined); startTime: newStartTime,
});
}
},
undefined
);
useActionHandler(
"toggle-snapping",
() => {
toggleSnapping();
},
undefined
);
// History actions // History actions
useActionHandler("undo", () => { useActionHandler(
undo(); "undo",
}, undefined); () => {
undo();
},
undefined
);
useActionHandler("redo", () => { useActionHandler(
redo(); "redo",
}, undefined); () => {
redo();
},
undefined
);
} }
+14 -2
View File
@@ -20,8 +20,20 @@ const startTimer = (store: () => PlaybackStore) => {
lastUpdate = now; lastUpdate = now;
const newTime = state.currentTime + delta * state.speed; const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start // Get actual content duration from timeline store
const { useTimelineStore } = require("@/stores/timeline-store");
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
// Stop at actual content end, not timeline duration (which has 10s minimum)
// It was either this or reducing default min timeline to 1 second
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (newTime >= effectiveDuration) {
// When content completes, pause and reset playhead to start
state.pause(); state.pause();
state.setCurrentTime(0); state.setCurrentTime(0);
// Notify video elements to sync with reset // Notify video elements to sync with reset