Skip to content

Commit 2cdf007

Browse files
committed
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.
1 parent fbe3db7 commit 2cdf007

3 files changed

Lines changed: 49 additions & 4 deletions

File tree

apps/web/src/core/managers/playback-manager.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,26 @@ export class PlaybackManager {
224224
const maxTime = this.editor.timeline.getTotalDuration();
225225

226226
if (newTime >= maxTime) {
227+
const shouldLoop =
228+
this.editor.project.getActive()?.settings.loop === true &&
229+
maxTime > ZERO_MEDIA_TIME;
230+
231+
if (shouldLoop) {
232+
this.playbackStartWallTime = performance.now();
233+
this.playbackStartTime = ZERO_MEDIA_TIME;
234+
this.currentTime = ZERO_MEDIA_TIME;
235+
this.notifySeek(ZERO_MEDIA_TIME);
236+
this.dispatchSeekEvent(ZERO_MEDIA_TIME);
237+
this.playbackTimer = requestAnimationFrame(this.updateTime);
238+
return;
239+
}
240+
227241
this.pause();
228242
this.currentTime = maxTime;
229243
this.notify();
230-
this.notifySeek(maxTime);
231-
this.dispatchSeekEvent(maxTime);
232-
return;
244+
this.notifySeek(maxTime);
245+
this.dispatchSeekEvent(maxTime);
246+
return;
233247
}
234248

235249
this.currentTime = newTime;

apps/web/src/preview/components/toolbar.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
FullScreenIcon,
1111
PauseIcon,
1212
PlayIcon,
13+
RepeatIcon,
14+
RepeatOffIcon,
1315
} from "@hugeicons/core-free-icons";
16+
import { UpdateProjectSettingsCommand } from "@/commands/project";
1417
import { HugeiconsIcon } from "@hugeicons/react";
1518
import { Separator } from "@/components/ui/separator";
1619
import {
@@ -34,7 +37,10 @@ export function PreviewToolbar({
3437
return (
3538
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
3639
<TimecodeDisplay />
37-
<PlayPauseButton />
40+
<div className="justify-self-center flex items-center gap-1">
41+
<PlayPauseButton />
42+
<LoopToggleButton />
43+
</div>
3844
<div className="justify-self-end flex items-center gap-2.5">
3945
<ZoomSelect />
4046
<Separator orientation="vertical" className="h-4" />
@@ -144,3 +150,23 @@ function PlayPauseButton() {
144150
</Button>
145151
);
146152
}
153+
154+
function LoopToggleButton() {
155+
const loop = useEditor((e) => e.project.getActive()?.settings.loop === true);
156+
157+
return (
158+
<Button
159+
type="button"
160+
variant={loop ? "secondary" : "text"}
161+
size="icon"
162+
aria-pressed={loop}
163+
aria-label={loop ? "Disable loop playback" : "Enable loop playback"}
164+
title={loop ? "Loop is on" : "Loop is off"}
165+
onClick={() => {
166+
new UpdateProjectSettingsCommand({ loop: !loop }).execute();
167+
}}
168+
>
169+
<HugeiconsIcon icon={loop ? RepeatIcon : RepeatOffIcon} />
170+
</Button>
171+
);
172+
}

apps/web/src/project/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export interface TProjectSettings {
3333
lastCustomCanvasSize?: TCanvasSize | null;
3434
originalCanvasSize?: TCanvasSize | null;
3535
background: TBackground;
36+
/**
37+
* When true, preview playback wraps back to the start of the timeline
38+
* instead of pausing once the playhead reaches the end. Defaults to false.
39+
*/
40+
loop?: boolean;
3641
}
3742

3843
export interface TTimelineViewState {

0 commit comments

Comments
 (0)