import log from '@converse/headless/log.js';
import { getOpenPromise } from '@converse/openpromise';
import { parseGIF, decompressFrames } from 'gifuct-js';
export default class ConverseGif {
/**
* Creates a new ConverseGif instance
* @param { import('lit').LitElement } el
* @param { Object } [options]
* @param { Number } [options.width] - The width, in pixels, of the canvas
* @param { Number } [options.height] - The height, in pixels, of the canvas
* @param { Boolean } [options.loop=true] - Setting this to `true` will enable looping of the gif
* @param { Boolean } [options.autoplay=true] - Same as the rel:autoplay attribute above, this arg overrides the img tag info.
* @param { Number } [options.max_width] - Scale images over max_width down to max_width. Helpful with mobile.
* @param { Function } [options.onIterationEnd] - Add a callback for when the gif reaches the end of a single loop (one iteration). The first argument passed will be the gif HTMLElement.
* @param { Boolean } [options.show_progress_bar=true]
* @param { String } [options.progress_bg_color='rgba(0,0,0,0.4)']
* @param { String } [options.progress_color='rgba(255,0,22,.8)']
* @param { Number } [options.progress_bar_height=5]
*/
constructor (el, opts) {
this.options = Object.assign(
{
width: null,
height: null,
autoplay: true,
loop: true,
show_progress_bar: true,
progress_bg_color: 'rgba(0,0,0,0.4)',
progress_color: 'rgba(255,0,22,.8)',
progress_bar_height: 5,
},
opts
);
this.el = el;
this.gif_el = el.querySelector('img');
this.canvas = el.querySelector('canvas');
this.ctx = this.canvas.getContext('2d');
// Offscreen canvas with full gif
this.offscreenCanvas = document.createElement('canvas');
// Offscreen canvas for patches
this.patchCanvas = document.createElement('canvas');
this.ctx_scaled = false;
this.frames = [];
this.load_error = null;
this.playing = this.options.autoplay;
this.frame_idx = 0;
this.iteration_count = 0;
this.start = null;
this.hovering = null;
this.frameImageData = null;
this.disposal_restore_from_idx = null;
this.initialize();
}
async initialize () {
if (this.options.width && this.options.height) {
this.setSizes(this.options.width, this.options.height);
}
const data = await this.fetchGIF(this.gif_el.src);
requestAnimationFrame(() => this.handleGIFResponse(data));
}
initPlayer () {
if (this.load_error) return;
if (!(this.options.width && this.options.height)) {
this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
}
// Show the first frame
this.frame_idx = 0;
this.renderImage();
if (this.options.autoplay) {
const delay = this.frames[this.frame_idx]?.delay ?? 0;
setTimeout(() => this.play(), delay);
}
}
/**
* Gets the index of the frame "up next"
* @returns {number}
*/
getNextFrameNo () {
if (this.frames.length === 0) {
return 0;
}
return (this.frame_idx + 1 + this.frames.length) % this.frames.length;
}
/**
* Called once we've looped through all frames in the GIF
* @returns { Boolean } - Returns `true` if the GIF is now paused (i.e. further iterations are not desired)
*/
onIterationEnd () {
this.iteration_count++;
this.options.onIterationEnd?.(this);
if (!this.options.loop) {
this.pause();
return true;
}
return false;
}
/**
* Inner callback for the `requestAnimationFrame` function.
*
* This method gets wrapped by an arrow function so that the `previous_timestamp` and
* `frame_delay` parameters can also be passed in. The `timestamp`
* parameter comes from `requestAnimationFrame`.
*
* The purpose of this method is to call `renderImage` with the right delay
* in order to render the GIF animation.
*
* Note, this method will cause the *next* upcoming frame to be rendered,
* not the current one.
*
* This means `this.frame_idx` will be incremented before calling `this.renderImage`, so
* `renderImage(0)` needs to be called *before* this method, otherwise the
* animation will incorrectly start from frame #1 (this is done in `initPlayer`).
*
* @param { DOMHighResTimeStamp } timestamp - The timestamp as returned by `requestAnimationFrame`
* @param { DOMHighResTimeStamp } previous_timestamp - The timestamp from the previous iteration of this method.
* We need this in order to calculate whether we have waited long enough to
* show the next frame.
* @param { Number } frame_delay - The delay (in 1/100th of a second)
* before the currently being shown frame should be replaced by a new one.
*/
onAnimationFrame (timestamp, previous_timestamp, frame_delay) {
if (!this.playing) {
return;
}
if (timestamp - previous_timestamp < frame_delay) {
this.hovering ? this.drawPauseIcon() : this.renderImage();
// We need to wait longer
requestAnimationFrame((ts) => this.onAnimationFrame(ts, previous_timestamp, frame_delay));
return;
}
const next_frame = this.getNextFrameNo();
if (next_frame === 0 && this.onIterationEnd()) {
return;
}
this.frame_idx = next_frame;
this.renderImage();
const delay = this.frames[this.frame_idx]?.delay || 8;
requestAnimationFrame((ts) => this.onAnimationFrame(ts, timestamp, delay));
}
setSizes (w, h) {
this.canvas.width = w * this.getCanvasScale();
this.canvas.height = h * this.getCanvasScale();
this.offscreenCanvas.width = w;
this.offscreenCanvas.height = h;
this.offscreenCanvas.style.width = w + 'px';
this.offscreenCanvas.style.height = h + 'px';
this.offscreenCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
}
doShowProgress (pos, length, draw) {
if (draw && this.options.show_progress_bar) {
let height = this.options.progress_bar_height;
const top = (this.canvas.height - height) / (this.ctx_scaled ? this.getCanvasScale() : 1);
const mid = ((pos / length) * this.canvas.width) / (this.ctx_scaled ? this.getCanvasScale() : 1);
const width = this.canvas.width / (this.ctx_scaled ? this.getCanvasScale() : 1);
height /= this.ctx_scaled ? this.getCanvasScale() : 1;
this.ctx.fillStyle = this.options.progress_bg_color;
this.ctx.fillRect(mid, top, width - mid, height);
this.ctx.fillStyle = this.options.progress_color;
this.ctx.fillRect(0, top, mid, height);
}
}
/**
* Starts parsing the GIF stream data by calling `parseGIF` and passing in
* a map of handler functions.
* @param {ArrayBuffer} data - The GIF file data, as returned by the server
*/
handleGIFResponse (data) {
try {
const gif = parseGIF(data);
this.hdr = gif.header;
this.lsd = gif.lsd;
this.setSizes(this.options.width ?? this.lsd.width, this.options.height ?? this.lsd.height);
this.frames = decompressFrames(gif, true);
} catch (err) {
this.showError();
}
this.initPlayer();
!this.options.autoplay && this.drawPlayIcon();
}
drawError () {
this.ctx.fillStyle = 'black';
this.ctx.fillRect(0, 0, this.options.width, this.options.height);
this.ctx.strokeStyle = 'red';
this.ctx.lineWidth = 3;
this.ctx.moveTo(0, 0);
this.ctx.lineTo(this.options.width, this.options.height);
this.ctx.moveTo(0, this.options.height);
this.ctx.lineTo(this.options.width, 0);
this.ctx.stroke();
}
showError () {
this.load_error = true;
this.hdr = {
width: this.gif_el.width,
height: this.gif_el.height,
}; // Fake header.
this.frames = [];
this.drawError();
this.el.requestUpdate();
}
manageDisposal (i) {
if (i <= 0) return;
const offscreenContext = this.offscreenCanvas.getContext('2d');
const disposal = this.frames[i - 1].disposalType;
/*
* Disposal method indicates the way in which the graphic is to
* be treated after being displayed.
*
* Values : 0 - No disposal specified. The decoder is
* not required to take any action.
* 1 - Do not dispose. The graphic is to be left
* in place.
* 2 - Restore to background color. The area used by the
* graphic must be restored to the background color.
* 3 - Restore to previous. The decoder is required to
* restore the area overwritten by the graphic with
* what was there prior to rendering the graphic.
*
* Importantly, "previous" means the frame state
* after the last disposal of method 0, 1, or 2.
*/
if (i > 1) {
if (disposal === 3) {
// eslint-disable-next-line no-eq-null
if (this.disposal_restore_from_idx != null) {
offscreenContext.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0);
}
} else {
this.disposal_restore_from_idx = i - 1;
}
}
if (disposal === 2) {
// Restore to background color
// Browser implementations historically restore to transparent; we do the same.
// http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079
offscreenContext.clearRect(
this.last_frame.dims.left,
this.last_frame.dims.top,
this.last_frame.dims.width,
this.last_frame.dims.height
);
}
}
/**
* Draws a gif frame at a specific index inside the canvas.
* @param {boolean} show_pause_on_hover - The frame index
*/
renderImage (show_pause_on_hover = true) {
if (!this.frames.length) return;
let i = this.frame_idx;
i = parseInt(i.toString(), 10);
if (i > this.frames.length - 1 || i < 0) {
i = 0;
}
this.manageDisposal(i);
const frame = this.frames[i];
const patchContext = this.patchCanvas.getContext('2d');
const offscreenContext = this.offscreenCanvas.getContext('2d');
const dims = frame.dims;
if (
!this.frameImageData ||
dims.width != this.frameImageData.width ||
dims.height != this.frameImageData.height
) {
this.patchCanvas.width = dims.width;
this.patchCanvas.height = dims.height;
this.frameImageData = patchContext.createImageData(dims.width, dims.height);
}
// set the patch data as an override
this.frameImageData.data.set(frame.patch);
// draw the patch back over the canvas
patchContext.putImageData(this.frameImageData, 0, 0);
offscreenContext.drawImage(this.patchCanvas, dims.left, dims.top);
const imageData = offscreenContext.getImageData(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
this.ctx.putImageData(imageData, 0, 0);
this.ctx.drawImage(this.canvas, 0, 0, this.canvas.width, this.canvas.height);
if (show_pause_on_hover && this.hovering) {
this.drawPauseIcon();
}
this.last_frame = frame;
}
/**
* Start playing the gif
*/
play () {
this.playing = true;
requestAnimationFrame((ts) => this.onAnimationFrame(ts, 0, 0));
}
/**
* Pause the gif
*/
pause () {
this.playing = false;
requestAnimationFrame(() => this.drawPlayIcon());
}
drawPauseIcon () {
if (!this.playing) return;
// Clear the potential play button by re-rendering the current frame
this.renderImage(false);
// Draw dark overlay
this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const icon_size = this.canvas.height * 0.1;
// Draw bars
this.ctx.lineWidth = this.canvas.height * 0.04;
this.ctx.beginPath();
this.ctx.moveTo(this.canvas.width / 2 - icon_size / 2, this.canvas.height / 2 - icon_size);
this.ctx.lineTo(this.canvas.width / 2 - icon_size / 2, this.canvas.height / 2 + icon_size);
this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(this.canvas.width / 2 + icon_size / 2, this.canvas.height / 2 - icon_size);
this.ctx.lineTo(this.canvas.width / 2 + icon_size / 2, this.canvas.height / 2 + icon_size);
this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
this.ctx.stroke();
// Draw circle
this.ctx.lineWidth = this.canvas.height * 0.02;
this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
this.ctx.beginPath();
this.ctx.arc(this.canvas.width / 2, this.canvas.height / 2, icon_size * 1.5, 0, 2 * Math.PI);
this.ctx.stroke();
}
drawPlayIcon () {
if (this.playing) return;
// Clear the potential pause button by re-rendering the current frame
this.renderImage(false);
// Draw dark overlay
this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw triangle
const triangle_size = this.canvas.height * 0.1;
const region = new Path2D();
region.moveTo(this.canvas.width / 2 + triangle_size, this.canvas.height / 2); // start at the pointy end
region.lineTo(this.canvas.width / 2 - triangle_size / 2, this.canvas.height / 2 + triangle_size);
region.lineTo(this.canvas.width / 2 - triangle_size / 2, this.canvas.height / 2 - triangle_size);
region.closePath();
this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
this.ctx.fill(region);
// Draw circle
const circle_size = triangle_size * 1.5;
this.ctx.lineWidth = this.canvas.height * 0.02;
this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
this.ctx.beginPath();
this.ctx.arc(this.canvas.width / 2, this.canvas.height / 2, circle_size, 0, 2 * Math.PI);
this.ctx.stroke();
}
getCanvasScale () {
let scale;
if (this.options.max_width && this.hdr && this.lsd.width > this.options.max_width) {
scale = this.options.max_width / this.lsd.width;
} else {
scale = 1;
}
return scale;
}
/**
* Makes an HTTP request to fetch a GIF
* @param { String } url
* @returns { Promise<ArrayBuffer> } Returns a promise which resolves with the response data.
*/
fetchGIF (url) {
const promise = getOpenPromise();
const h = new XMLHttpRequest();
h.open('GET', url, true);
h.responseType = 'arraybuffer';
h?.overrideMimeType('text/plain; charset=x-user-defined');
h.onload = () => {
if (h.status != 200) {
this.showError();
return promise.reject();
}
promise.resolve(h.response);
};
h.onprogress = (e) => e.lengthComputable && this.doShowProgress(e.loaded, e.total, true);
h.onerror = (e) => {
log.error(e);
this.showError();
};
h.send();
return promise;
}
}