Source: gif/GifSprite.ts

import { Texture } from '../rendering/renderers/shared/texture/Texture';
import { Sprite, type SpriteOptions } from '../scene/sprite/Sprite';
import { UPDATE_PRIORITY } from '../ticker/const';
import { Ticker } from '../ticker/Ticker';
import { GifSource } from './GifSource';

import type { SCALE_MODE } from '../rendering/renderers/shared/texture/const';

/**
 * Optional module to import to decode and play animated GIFs.
 * @example
 * import { Assets } from 'pixi.js';
 * import { GifSprite } from 'pixi.js/gif';
 *
 * const source = await Assets.load('example.gif');
 * const gif = new GifSprite({ source });
 * @namespace gif
 */

/**
 * Default options for all GifSprite objects.
 * @memberof gif
 */
interface GifSpriteOptions extends Omit<SpriteOptions, 'texture'>
{
    /** Source to the GIF frame and animation data */
    source: GifSource;
    /** Whether to start playing right away */
    autoPlay?: boolean;
    /**
     * Scale Mode to use for the texture
     * @type {PIXI.SCALE_MODE}
     */
    scaleMode?: SCALE_MODE;
    /** To enable looping */
    loop?: boolean;
    /** Speed of the animation */
    animationSpeed?: number;
    /** Set to `false` to manage updates yourself */
    autoUpdate?: boolean;
    /** The completed callback, optional */
    onComplete?: null | (() => void);
    /** The loop callback, optional */
    onLoop?: null | (() => void);
    /** The frame callback, optional */
    onFrameChange?: null | ((currentFrame: number) => void);
    /** Fallback FPS if GIF contains no time information */
    fps?: number;
}

/**
 * Runtime object to play animated GIFs. This object is similar to an AnimatedSprite.
 * It support playback (seek, play, stop) as well as animation speed and looping.
 * @memberof gif
 * @see Thanks to gifuct-js
 */
class GifSprite extends Sprite
{
    /**
     * Default options for all GifSprite objects.
     * @property {PIXI.SCALE_MODE} [scaleMode='linear'] - Scale mode to use for the texture.
     * @property {boolean} [loop=true] - To enable looping.
     * @property {number} [animationSpeed=1] - Speed of the animation.
     * @property {boolean} [autoUpdate=true] - Set to `false` to manage updates yourself.
     * @property {boolean} [autoPlay=true] - To start playing right away.
     * @property {Function} [onComplete=null] - The completed callback, optional.
     * @property {Function} [onLoop=null] - The loop callback, optional.
     * @property {Function} [onFrameChange=null] - The frame callback, optional.
     * @property {number} [fps=30] - Fallback FPS if GIF contains no time information.
     */
    public static defaultOptions: Omit<GifSpriteOptions, 'source'> = {
        scaleMode: 'linear',
        fps: 30,
        loop: true,
        animationSpeed: 1,
        autoPlay: true,
        autoUpdate: true,
        onComplete: null,
        onFrameChange: null,
        onLoop: null,
    };

    /**
     * The speed that the animation will play at. Higher is faster, lower is slower.
     * @default 1
     */
    public animationSpeed = 1;

    /**
     * Whether or not the animate sprite repeats after playing.
     * @default true
     */
    public loop = true;

    /**
     * User-assigned function to call when animation finishes playing. This only happens
     * if loop is set to `false`.
     * @example
     * animation.onComplete = () => {
     *   // finished!
     * };
     */
    public onComplete?: () => void;

    /**
     * User-assigned function to call when animation changes which texture is being rendered.
     * @example
     * animation.onFrameChange = () => {
     *   // updated!
     * };
     */
    public onFrameChange?: (currentFrame: number) => void;

    /**
     * User-assigned function to call when `loop` is true, and animation is played and
     * loops around to start again. This only happens if loop is set to `true`.
     * @example
     * animation.onLoop = () => {
     *   // looped!
     * };
     */
    public onLoop?: () => void;

    /** The total duration of animation in milliseconds. */
    public readonly duration: number = 0;

    /** Whether to play the animation after constructing. */
    public readonly autoPlay: boolean = true;

    /** Collection of frame to render. */
    private _source: GifSource;

    /** Dirty means the image needs to be redrawn. Set to `true` to force redraw. */
    public dirty = false;

    /** The current frame number (zero-based index). */
    private _currentFrame = 0;

    /** `true` uses PIXI.Ticker.shared to auto update animation time.*/
    private _autoUpdate = false;

    /** `true` if the instance is currently connected to PIXI.Ticker.shared to auto update animation time. */
    private _isConnectedToTicker = false;

    /** If animation is currently playing. */
    private _playing = false;

    /** Current playback position in milliseconds. */
    private _currentTime = 0;

    /**
     * @param source - Source, default options will be used.
     */
    constructor(source: GifSource);

    /**
     * @param options - Options for the GifSprite
     */
    constructor(options: GifSpriteOptions);

    /** @ignore */
    constructor(...args: [GifSource] | [GifSpriteOptions])
    {
        const options = args[0] instanceof GifSource ? { source: args[0] } : args[0];

        // Get the options, apply defaults
        const {
            scaleMode,
            source,
            fps,
            loop,
            animationSpeed,
            autoPlay,
            autoUpdate,
            onComplete,
            onFrameChange,
            onLoop,
            ...rest
        } = Object.assign({},
            GifSprite.defaultOptions,
            options
        );

        super({ texture: Texture.EMPTY, ...rest });

        // Handle rerenders
        this.onRender = () => this._updateFrame();

        this.texture = source.textures[0];

        this.duration = source.frames[source.frames.length - 1].end;
        this._source = source;
        this._playing = false;
        this._currentTime = 0;
        this._isConnectedToTicker = false;
        Object.assign(this, {
            fps,
            loop,
            animationSpeed,
            autoPlay,
            autoUpdate,
            onComplete,
            onFrameChange,
            onLoop,
        });

        // Draw the first frame
        this.currentFrame = 0;
        if (autoPlay)
        {
            this.play();
        }
    }

    /** Stops the animation. */
    public stop(): void
    {
        if (!this._playing)
        {
            return;
        }

        this._playing = false;
        if (this._autoUpdate && this._isConnectedToTicker)
        {
            Ticker.shared.remove(this.update, this);
            this._isConnectedToTicker = false;
        }
    }

    /** Plays the animation. */
    public play(): void
    {
        if (this._playing)
        {
            return;
        }

        this._playing = true;
        if (this._autoUpdate && !this._isConnectedToTicker)
        {
            Ticker.shared.add(this.update, this, UPDATE_PRIORITY.HIGH);
            this._isConnectedToTicker = true;
        }

        // If were on the last frame and stopped, play should resume from beginning
        if (!this.loop && this.currentFrame === this._source.frames.length - 1)
        {
            this._currentTime = 0;
        }
    }

    /**
     * Get the current progress of the animation from 0 to 1.
     * @readonly
     */
    public get progress(): number
    {
        return this._currentTime / this.duration;
    }

    /** `true` if the current animation is playing */
    public get playing(): boolean
    {
        return this._playing;
    }

    /**
     * Updates the object transform for rendering. You only need to call this
     * if the `autoUpdate` property is set to `false`.
     * @param ticker - Ticker instance
     */
    public update(ticker: Ticker): void
    {
        if (!this._playing)
        {
            return;
        }

        const elapsed = this.animationSpeed * ticker.deltaTime / Ticker.targetFPMS;
        const currentTime = this._currentTime + elapsed;
        const localTime = currentTime % this.duration;

        const localFrame = this._source.frames.findIndex((frame) =>
            frame.start <= localTime && frame.end > localTime);

        if (currentTime >= this.duration)
        {
            if (this.loop)
            {
                this._currentTime = localTime;
                this._updateFrameIndex(localFrame);
                this.onLoop?.();
            }
            else
            {
                this._currentTime = this.duration;
                this._updateFrameIndex(this.totalFrames - 1);
                this.onComplete?.();
                this.stop();
            }
        }
        else
        {
            this._currentTime = localTime;
            this._updateFrameIndex(localFrame);
        }
    }

    /** Redraw the current frame, is necessary for the animation to work when */
    private _updateFrame(): void
    {
        if (!this.dirty)
        {
            return;
        }

        // Update the current frame
        this.texture = this._source.frames[this._currentFrame].texture;

        // Mark as clean
        this.dirty = false;
    }

    /**
     * Whether to use PIXI.Ticker.shared to auto update animation time.
     * @default true
     */
    get autoUpdate(): boolean
    {
        return this._autoUpdate;
    }

    set autoUpdate(value: boolean)
    {
        if (value !== this._autoUpdate)
        {
            this._autoUpdate = value;

            if (!this._autoUpdate && this._isConnectedToTicker)
            {
                Ticker.shared.remove(this.update, this);
                this._isConnectedToTicker = false;
            }
            else if (this._autoUpdate && !this._isConnectedToTicker && this._playing)
            {
                Ticker.shared.add(this.update, this);
                this._isConnectedToTicker = true;
            }
        }
    }

    /** Set the current frame number */
    get currentFrame(): number
    {
        return this._currentFrame;
    }
    set currentFrame(value: number)
    {
        this._updateFrameIndex(value);
        this._currentTime = this._source.frames[value].start;
    }

    /** Instance of the data, contains frame textures */
    get source(): GifSource
    {
        return this._source;
    }

    /**
     * Internally handle updating the frame index
     * @param value
     */
    private _updateFrameIndex(value: number): void
    {
        if (value < 0 || value >= this.totalFrames)
        {
            throw new Error(`Frame index out of range, expecting 0 to ${this.totalFrames}, got ${value}`);
        }
        if (this._currentFrame !== value)
        {
            this._currentFrame = value;
            this.dirty = true;
            this.onFrameChange?.(value);
        }
    }

    /** Get the total number of frame in the GIF. */
    get totalFrames(): number
    {
        return this._source.totalFrames;
    }

    /**
     * Destroy and don't use after this.
     * @param destroyData - Destroy the data, cannot be used again.
     */
    public destroy(destroyData: boolean = false): void
    {
        this.stop();
        super.destroy();

        if (destroyData)
        {
            this._source.destroy();
        }

        const forceClear = null as any;

        this._source = forceClear;
        this.onComplete = forceClear;
        this.onFrameChange = forceClear;
        this.onLoop = forceClear;
    }

    /**
     * Cloning the animation is a useful way to create a duplicate animation.
     * This maintains all the properties of the original animation but allows
     * you to control playback independent of the original animation.
     * If you want to create a simple copy, and not control independently,
     * then you can simply create a new Sprite, e.g. `const sprite = new Sprite(animation.texture)`.
     *
     * The clone will be flagged as `dirty` to immediatly trigger an update
     */
    public clone(): GifSprite
    {
        const clone = new GifSprite({
            source: this._source,
            autoUpdate: this._autoUpdate,
            loop: this.loop,
            autoPlay: this.autoPlay,
            scaleMode: this.texture.source.scaleMode,
            animationSpeed: this.animationSpeed,
            onComplete: this.onComplete,
            onFrameChange: this.onFrameChange,
            onLoop: this.onLoop,
        });

        clone.dirty = true;

        return clone;
    }
}

export { GifSprite };
export type { GifSpriteOptions };