diff --git a/.changeset/three-horses-dream.md b/.changeset/three-horses-dream.md new file mode 100644 index 00000000..2aa027de --- /dev/null +++ b/.changeset/three-horses-dream.md @@ -0,0 +1,5 @@ +--- +'@lottiefiles/dotlottie-web': minor +--- + +feat: 🎸 add play mode diff --git a/apps/dotlottie-web-example/src/main.ts b/apps/dotlottie-web-example/src/main.ts index c496f0b0..8defccaf 100644 --- a/apps/dotlottie-web-example/src/main.ts +++ b/apps/dotlottie-web-example/src/main.ts @@ -13,7 +13,7 @@ const app = document.getElementById('app') as HTMLDivElement; app.innerHTML = `
- + @@ -83,6 +83,7 @@ fetch('/hamster.lottie') data, loop: true, autoplay: true, + mode: 'bounce', }); const playPauseButton = document.getElementById('playPause') as HTMLButtonElement; @@ -114,11 +115,17 @@ fetch('/hamster.lottie') frameSlider.value = '0'; }); + frameSlider.addEventListener('mousedown', () => { + dotLottie.pause(); + }); + frameSlider.addEventListener('input', () => { const frame = frameSlider.valueAsNumber; - dotLottie.pause(); dotLottie.setFrame(frame); + }); + + frameSlider.addEventListener('mouseup', () => { dotLottie.play(); }); diff --git a/packages/web/README.md b/packages/web/README.md index c7bfd600..0ca8aafa 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -117,18 +117,20 @@ const dotLottie = new DotLottie({ | `src` | string | | undefined | URL to the animation data (`.json` or `.lottie`). | | `speed` | number | | 1 | Animation playback speed. 1 is regular speed. | | `data` | string \| ArrayBuffer | | undefined | Animation data provided either as a Lottie JSON string or as an ArrayBuffer for .lottie animations. | +| `mode` | string | | "normal" | Animation play mode. Accepts "normal", "reverse", "bounce", "bounce-reverse". | ### Properties -| Property | Type | Description | -| -------------- | ------- | ---------------------------------------------------------------------- | -| `currentFrame` | number | Represents the animation's currently displayed frame number. | -| `duration` | number | Specifies the animation's total playback time in milliseconds. | -| `totalFrames` | number | Denotes the total count of individual frames within the animation. | -| `loop` | boolean | Indicates if the animation is set to play in a continuous loop. | -| `speed` | number | Represents the playback speed factor; e.g., 2 would mean double speed. | -| `loopCount` | number | Tracks how many times the animation has completed its loop. | -| `playing` | boolean | Reflects whether the animation is in active playback or not | +| Property | Type | Description | +| -------------- | ------- | ------------------------------------------------------------------------------------------- | +| `currentFrame` | number | Represents the animation's currently displayed frame number. | +| `duration` | number | Specifies the animation's total playback time in milliseconds. | +| `totalFrames` | number | Denotes the total count of individual frames within the animation. | +| `loop` | boolean | Indicates if the animation is set to play in a continuous loop. | +| `speed` | number | Represents the playback speed factor; e.g., 2 would mean double speed. | +| `loopCount` | number | Tracks how many times the animation has completed its loop. | +| `playing` | boolean | Reflects whether the animation is in active playback or not | +| `direction` | string | Reflects the current playback direction; e.g., 1 would mean forward, -1 would mean reverse. | ### Methods diff --git a/packages/web/src/dotlottie.ts b/packages/web/src/dotlottie.ts index d6e1a13d..f55b51db 100644 --- a/packages/web/src/dotlottie.ts +++ b/packages/web/src/dotlottie.ts @@ -14,6 +14,8 @@ import { getAnimationJSONFromDotLottie, loadAnimationJSONFromURL } from './utils const MS_TO_SEC_FACTOR = 1000; +export type Mode = 'normal' | 'reverse' | 'bounce' | 'bounce-reverse'; + export interface Options { /** * Boolean indicating if the animation should start playing automatically. @@ -33,6 +35,10 @@ export interface Options { * Boolean indicating if the animation should loop. */ loop?: boolean; + /** + * The playback mode of the animation. + */ + mode?: Mode; /** * The speed of the animation. */ @@ -70,14 +76,27 @@ export class DotLottie { private _autoplay = false; + private _mode: Mode = 'normal'; + + private _direction = 1; + + private _bounceCount = 0; + + private _animationFrameId?: number; + public constructor(config: Options) { this._animationLoop = this._animationLoop.bind(this); this._canvas = config.canvas; this._context = this._canvas.getContext('2d'); + if (!this._context) { + throw new Error('2D context not supported or canvas already initialized with another context type.'); + } + this._loop = config.loop ?? false; this._speed = config.speed ?? 1; this._autoplay = config.autoplay ?? false; + this._mode = config.mode ?? 'normal'; WasmLoader.load() .then((module) => { @@ -98,6 +117,16 @@ export class DotLottie { } // #region Getters and Setters + + /** + * Gets the current direction of the animation. + * + * @returns The current direction of the animation. + */ + public get direction(): number { + return this._direction; + } + /** * Gets the current frame number. * @@ -160,8 +189,9 @@ export class DotLottie { public get playing(): boolean { return this._playing; } - // #endregion + // #endregion Getters and Setters + // #region Private Methods /** * Loads and initializes the animation from a given URL. * @@ -221,6 +251,8 @@ export class DotLottie { error: error as Error, }); }); + } else { + console.error('Unsupported data type for animation data. Expected a string or ArrayBuffer.'); } } @@ -259,36 +291,69 @@ export class DotLottie { * @returns Boolean indicating if update was successful. */ private _update(): boolean { - if (!this._playing) return false; + // animation is not loaded yet + if (this._duration === 0) return false; + + const timeElapsed = (performance.now() / MS_TO_SEC_FACTOR - this._beginTime) * this._speed; + let frameProgress = (timeElapsed / this._duration) * this._totalFrames; + + if (this._mode === 'normal') { + this._currentFrame = frameProgress; + } else if (this._mode === 'reverse') { + this._currentFrame = this._totalFrames - frameProgress - 1; + } else if (this._mode === 'bounce') { + if (this._direction === -1) { + frameProgress = this._totalFrames - frameProgress - 1; + } + this._currentFrame = frameProgress; + } else { + // bounce-reverse mode + if (this._direction === -1) { + frameProgress = this._totalFrames - frameProgress - 1; + } + this._currentFrame = frameProgress; + if (this._bounceCount === 0) { + this._direction = -1; + } + } - this._currentFrame = - (((performance.now() / MS_TO_SEC_FACTOR - this._beginTime) * this._speed) / this._duration) * this._totalFrames; + // ensure the frame is within the valid range + this._currentFrame = Math.max(0, Math.min(this._currentFrame, this._totalFrames - 1)); - if (this._currentFrame >= this._totalFrames) { - if (this._loop) { - this._currentFrame = 0; + // handle animation looping or completion + if (this._currentFrame >= this._totalFrames - 1 || this._currentFrame <= 0) { + if (this._loop || this._mode === 'bounce' || this._mode === 'bounce-reverse') { this._beginTime = performance.now() / MS_TO_SEC_FACTOR; - this._loopCount += 1; - this._eventManager.dispatch({ - type: 'loop', - loopCount: this._loopCount, - }); - - return true; + if (this._mode === 'bounce' || this._mode === 'bounce-reverse') { + this._direction *= -1; + this._bounceCount += 1; + + if (this._bounceCount >= 2) { + this._bounceCount = 0; + if (!this._loop) { + this._playing = false; + this._bounceCount = 0; + this._direction = 1; + this._eventManager.dispatch({ type: 'complete' }); + + return false; + } + this._loopCount += 1; + this._eventManager.dispatch({ type: 'loop', loopCount: this._loopCount }); + } + } else { + this._loopCount += 1; + this._eventManager.dispatch({ type: 'loop', loopCount: this._loopCount }); + } } else { this._playing = false; - - this._eventManager.dispatch({ - type: 'complete', - }); + this._eventManager.dispatch({ type: 'complete' }); return false; } } - this._currentFrame = Math.max(0, Math.min(this._currentFrame, this._totalFrames - 1)); - if (this._renderer?.frame(this._currentFrame)) { this._eventManager.dispatch({ type: 'frame', @@ -305,10 +370,33 @@ export class DotLottie { * Loop that handles the animation playback. */ private _animationLoop(): void { - if (this._update()) { + if (this._playing && this._update()) { this._render(); - window.requestAnimationFrame(this._animationLoop); + this._startAnimationLoop(); + } + } + + /** + * Stops the animation loop. + * + * This is used to ensure that the animation loop is only stopped once. + */ + public _stopAnimationLoop(): void { + if (this._animationFrameId) { + window.cancelAnimationFrame(this._animationFrameId); + } + } + + /** + * Starts the animation loop. + * + * This is used to ensure that the animation loop is only started once. + */ + public _startAnimationLoop(): void { + if (this._animationFrameId) { + window.cancelAnimationFrame(this._animationFrameId); } + this._animationFrameId = window.requestAnimationFrame(this._animationLoop); } // #endregion @@ -327,16 +415,21 @@ export class DotLottie { return; } - const progress = this._currentFrame / this._totalFrames; + const currentProgress = this._currentFrame / this._totalFrames; + + if (this._direction === -1) { + this._beginTime = performance.now() / MS_TO_SEC_FACTOR - this._duration * (1 - currentProgress); + } else { + this._beginTime = performance.now() / MS_TO_SEC_FACTOR - this._duration * currentProgress; + } - this._beginTime = performance.now() / 1000 - progress * this._duration; if (!this._playing) { this._playing = true; - this._animationLoop(); - this._eventManager.dispatch({ type: 'play', }); + + this._startAnimationLoop(); } } @@ -344,9 +437,11 @@ export class DotLottie { * Stops the animation playback and resets the current frame. */ public stop(): void { - if (!this._playing && this._currentFrame === 0) return; - - this._playing = false; + this._loopCount = 0; + this._direction = 1; + this._currentFrame = 0; + this._bounceCount = 0; + this._beginTime = 0; this.setFrame(0); this._eventManager.dispatch({ type: 'stop', @@ -371,6 +466,11 @@ export class DotLottie { * @param speed - Speed multiplier for playback. */ public setSpeed(speed: number): void { + if (speed <= 0) { + console.error('Speed must be a positive number.'); + + return; + } this._speed = speed; } @@ -388,7 +488,6 @@ export class DotLottie { */ public setFrame(frame: number): void { if (frame < 0 || frame >= this._totalFrames) { - // eslint-disable-next-line no-console console.error(`Invalid frame number provided: ${frame}. Valid range is between 0 and ${this._totalFrames - 1}.`); return; @@ -437,6 +536,7 @@ export class DotLottie { * */ public destroy(): void { + this._stopAnimationLoop(); this._eventManager.removeAllEventListeners(); this._context = null; this._renderer = null;