feat(preview): add loop toggle for timeline playback

Adds a loop button next to the play/pause control in the preview
toolbar. When enabled, preview playback wraps back to the start of
the timeline instead of pausing on reaching the end. The state is
persisted as a per-project setting and defaults to off.

- Add optional \`loop: boolean\` field to \`TProjectSettings\`
- Add \`LoopToggleButton\` in the preview toolbar, wired through
  \`UpdateProjectSettingsCommand\` so toggling is undoable
- In \`PlaybackManager.updateTime\`, when the playhead reaches the
  end and the project's \`loop\` setting is on, reset to time zero
  and continue scheduling animation frames instead of pausing.
  The audio manager picks up the seek-to-zero through the existing
  \`onSeek\` listener, so audio restarts in sync with video.
This commit is contained in:
Andrés Villagrán 2026-05-16 19:47:38 -04:00
parent fbe3db74d7
commit 2cdf00789f
3 changed files with 49 additions and 4 deletions

View File

@ -224,12 +224,26 @@ export class PlaybackManager {
const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) {
const shouldLoop =
this.editor.project.getActive()?.settings.loop === true &&
maxTime > ZERO_MEDIA_TIME;
if (shouldLoop) {
this.playbackStartWallTime = performance.now();
this.playbackStartTime = ZERO_MEDIA_TIME;
this.currentTime = ZERO_MEDIA_TIME;
this.notifySeek(ZERO_MEDIA_TIME);
this.dispatchSeekEvent(ZERO_MEDIA_TIME);
this.playbackTimer = requestAnimationFrame(this.updateTime);
return;
}
this.pause();
this.currentTime = maxTime;
this.notify();
this.notifySeek(maxTime);
this.dispatchSeekEvent(maxTime);
return;
this.notifySeek(maxTime);
this.dispatchSeekEvent(maxTime);
return;
}
this.currentTime = newTime;

View File

@ -10,7 +10,10 @@ import {
FullScreenIcon,
PauseIcon,
PlayIcon,
RepeatIcon,
RepeatOffIcon,
} from "@hugeicons/core-free-icons";
import { UpdateProjectSettingsCommand } from "@/commands/project";
import { HugeiconsIcon } from "@hugeicons/react";
import { Separator } from "@/components/ui/separator";
import {
@ -34,7 +37,10 @@ export function PreviewToolbar({
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
<TimecodeDisplay />
<PlayPauseButton />
<div className="justify-self-center flex items-center gap-1">
<PlayPauseButton />
<LoopToggleButton />
</div>
<div className="justify-self-end flex items-center gap-2.5">
<ZoomSelect />
<Separator orientation="vertical" className="h-4" />
@ -144,3 +150,23 @@ function PlayPauseButton() {
</Button>
);
}
function LoopToggleButton() {
const loop = useEditor((e) => e.project.getActive()?.settings.loop === true);
return (
<Button
type="button"
variant={loop ? "secondary" : "text"}
size="icon"
aria-pressed={loop}
aria-label={loop ? "Disable loop playback" : "Enable loop playback"}
title={loop ? "Loop is on" : "Loop is off"}
onClick={() => {
new UpdateProjectSettingsCommand({ loop: !loop }).execute();
}}
>
<HugeiconsIcon icon={loop ? RepeatIcon : RepeatOffIcon} />
</Button>
);
}

View File

@ -33,6 +33,11 @@ export interface TProjectSettings {
lastCustomCanvasSize?: TCanvasSize | null;
originalCanvasSize?: TCanvasSize | null;
background: TBackground;
/**
* When true, preview playback wraps back to the start of the timeline
* instead of pausing once the playhead reaches the end. Defaults to false.
*/
loop?: boolean;
}
export interface TTimelineViewState {