From 2cdf00789f7474dbe34f957900eb2c6b7b7b3765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Sat, 16 May 2026 19:47:38 -0400 Subject: [PATCH] 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. --- .../web/src/core/managers/playback-manager.ts | 20 +++++++++++-- apps/web/src/preview/components/toolbar.tsx | 28 ++++++++++++++++++- apps/web/src/project/types.ts | 5 ++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 9b9d26bca..5585aeb91 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -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; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 1a5ce1bc6..86ace8482 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -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 (
- +
+ + +
@@ -144,3 +150,23 @@ function PlayPauseButton() { ); } + +function LoopToggleButton() { + const loop = useEditor((e) => e.project.getActive()?.settings.loop === true); + + return ( + + ); +} diff --git a/apps/web/src/project/types.ts b/apps/web/src/project/types.ts index dca2d7854..2c201e7a3 100644 --- a/apps/web/src/project/types.ts +++ b/apps/web/src/project/types.ts @@ -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 {