2021-07-07 14:59:29 +02:00
|
|
|
/**
|
2021-07-29 21:19:48 +02:00
|
|
|
* @copyright Shachaf Ben-Kiki, JC Brand
|
2021-07-07 14:59:29 +02:00
|
|
|
* @description
|
|
|
|
* Started as a fork of Shachaf Ben-Kiki's jsgif library
|
|
|
|
* https://github.com/shachaf/jsgif
|
|
|
|
* @license MIT License
|
|
|
|
*/
|
|
|
|
import Stream from './stream.js';
|
|
|
|
import { getOpenPromise } from '@converse/openpromise';
|
|
|
|
import { parseGIF } from './utils.js';
|
|
|
|
|
2021-07-29 12:21:24 +02:00
|
|
|
const DELAY_FACTOR = 10;
|
|
|
|
|
2021-07-07 14:59:29 +02:00
|
|
|
|
|
|
|
export default class ConverseGif {
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a new ConverseGif instance
|
|
|
|
* @param { HTMLElement } 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) {
|
2022-10-22 18:20:07 +02:00
|
|
|
this.options = Object.assign({
|
2021-07-07 14:59:29 +02:00
|
|
|
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
|
|
|
|
);
|
|
|
|
|
2021-08-02 18:30:59 +02:00
|
|
|
this.el = el;
|
2021-07-07 14:59:29 +02:00
|
|
|
this.gif_el = el.querySelector('img');
|
|
|
|
this.canvas = el.querySelector('canvas');
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
|
|
// It's good practice to pre-render to an offscreen canvas
|
|
|
|
this.offscreenCanvas = document.createElement('canvas');
|
|
|
|
|
|
|
|
this.ctx_scaled = false;
|
|
|
|
this.disposal_method = null;
|
|
|
|
this.disposal_restore_from_idx = null;
|
|
|
|
this.frame = null;
|
|
|
|
this.frame_offsets = []; // elements have .x and .y properties
|
|
|
|
this.frames = [];
|
|
|
|
this.last_disposal_method = null;
|
|
|
|
this.last_img = null;
|
|
|
|
this.load_error = null;
|
|
|
|
this.playing = this.options.autoplay;
|
|
|
|
this.transparency = null;
|
2022-10-23 05:00:21 +02:00
|
|
|
this.frame_delay = null;
|
2021-07-07 14:59:29 +02:00
|
|
|
|
2021-07-29 12:21:24 +02:00
|
|
|
this.frame_idx = 0;
|
2021-07-07 14:59:29 +02:00
|
|
|
this.iteration_count = 0;
|
|
|
|
this.start = 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.startParsing(data));
|
|
|
|
}
|
|
|
|
|
|
|
|
initPlayer () {
|
|
|
|
if (this.load_error) return;
|
|
|
|
|
|
|
|
if (!(this.options.width && this.options.height)) {
|
|
|
|
this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
|
|
|
|
}
|
|
|
|
|
2021-07-29 12:21:24 +02:00
|
|
|
// Show the first frame
|
|
|
|
this.frame_idx = 0;
|
|
|
|
this.putFrame(this.frame_idx);
|
|
|
|
|
2021-07-07 14:59:29 +02:00
|
|
|
if (this.options.autoplay) {
|
2021-07-29 12:21:24 +02:00
|
|
|
const delay = (this.frames[this.frame_idx]?.delay ?? 0) * DELAY_FACTOR;
|
|
|
|
setTimeout(() => this.play(), delay);
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2021-07-29 12:21:24 +02:00
|
|
|
* Gets the index of the frame "up next"
|
2021-07-07 14:59:29 +02:00
|
|
|
* @returns {number}
|
|
|
|
*/
|
|
|
|
getNextFrameNo () {
|
2022-10-22 18:20:07 +02:00
|
|
|
if (this.frames.length === 0) {
|
|
|
|
return 0;
|
|
|
|
}
|
2021-07-07 14:59:29 +02:00
|
|
|
return (this.frame_idx + 1 + this.frames.length) % this.frames.length;
|
|
|
|
}
|
|
|
|
|
2021-07-29 12:21:24 +02:00
|
|
|
/**
|
|
|
|
* 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 () {
|
2021-07-07 14:59:29 +02:00
|
|
|
this.iteration_count++;
|
2021-07-29 12:21:24 +02:00
|
|
|
this.options.onIterationEnd?.(this);
|
|
|
|
if (!this.options.loop) {
|
2021-07-07 14:59:29 +02:00
|
|
|
this.pause();
|
2021-07-29 12:21:24 +02:00
|
|
|
return true;
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
2021-07-29 12:21:24 +02:00
|
|
|
return false;
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
|
2021-07-29 12:21:24 +02:00
|
|
|
/**
|
|
|
|
* 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 `putFrame` 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.putFrame`, so
|
|
|
|
* `putFrame(0)` needs to be called *before* this method, otherwise the
|
|
|
|
* animation will incorrectly start from frame #1 (this is done in `initPlayer`).
|
|
|
|
*
|
|
|
|
* @param { DOMHighRestTimestamp } timestamp - The timestamp as returned by `requestAnimationFrame`
|
|
|
|
* @param { DOMHighRestTimestamp } 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) {
|
2021-07-07 14:59:29 +02:00
|
|
|
if (!this.playing) {
|
|
|
|
return;
|
|
|
|
}
|
2021-07-29 12:21:24 +02:00
|
|
|
if ((timestamp - previous_timestamp) < frame_delay) {
|
2021-07-29 21:19:48 +02:00
|
|
|
this.hovering ? this.drawPauseIcon() : this.putFrame(this.frame_idx);
|
2021-07-29 12:21:24 +02:00
|
|
|
// We need to wait longer
|
|
|
|
requestAnimationFrame(ts => this.onAnimationFrame(ts, previous_timestamp, frame_delay));
|
|
|
|
return;
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
2021-07-29 12:21:24 +02:00
|
|
|
const next_frame = this.getNextFrameNo();
|
|
|
|
if (next_frame === 0 && this.onIterationEnd()) {
|
|
|
|
return;
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
2021-07-29 12:21:24 +02:00
|
|
|
this.frame_idx = next_frame;
|
|
|
|
this.putFrame(this.frame_idx);
|
|
|
|
const delay = (this.frames[this.frame_idx]?.delay || 8) * DELAY_FACTOR;
|
|
|
|
requestAnimationFrame(ts => this.onAnimationFrame(ts, timestamp, delay));
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
setFrameOffset (frame, offset) {
|
|
|
|
if (!this.frame_offsets[frame]) {
|
|
|
|
this.frame_offsets[frame] = offset;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (typeof offset.x !== 'undefined') {
|
|
|
|
this.frame_offsets[frame].x = offset.x;
|
|
|
|
}
|
|
|
|
if (typeof offset.y !== 'undefined') {
|
|
|
|
this.frame_offsets[frame].y = offset.y;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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 { String } data - The GIF file data, as returned by the server
|
|
|
|
*/
|
|
|
|
startParsing (data) {
|
|
|
|
const stream = new Stream(data);
|
|
|
|
/**
|
|
|
|
* @typedef { Object } GIFParserHandlers
|
|
|
|
* A map of callback functions passed `parseGIF`. These functions are
|
|
|
|
* called as various parts of the GIF file format are parsed.
|
|
|
|
* @property { Function } hdr - Callback to handle the GIF header data
|
|
|
|
* @property { Function } gce - Callback to handle the GIF Graphic Control Extension data
|
|
|
|
* @property { Function } com - Callback to handle the comment extension block
|
|
|
|
* @property { Function } img - Callback to handle image data
|
|
|
|
* @property { Function } eof - Callback once the end of file has been reached
|
|
|
|
*/
|
|
|
|
const handler = {
|
|
|
|
'hdr': this.withProgress(stream, header => this.handleHeader(header)),
|
|
|
|
'gce': this.withProgress(stream, gce => this.handleGCE(gce)),
|
|
|
|
'com': this.withProgress(stream, ),
|
|
|
|
'img': this.withProgress(stream, img => this.doImg(img), true),
|
|
|
|
'eof': () => this.handleEOF(stream)
|
|
|
|
};
|
|
|
|
try {
|
|
|
|
parseGIF(stream, handler);
|
|
|
|
} catch (err) {
|
|
|
|
this.showError('parse');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
drawError () {
|
|
|
|
this.ctx.fillStyle = 'black';
|
|
|
|
this.ctx.fillRect(
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
this.options.width ? this.options.width : this.hdr.width,
|
|
|
|
this.options.height ? this.options.height : this.hdr.height
|
|
|
|
);
|
|
|
|
this.ctx.strokeStyle = 'red';
|
|
|
|
this.ctx.lineWidth = 3;
|
|
|
|
this.ctx.moveTo(0, 0);
|
|
|
|
this.ctx.lineTo(
|
|
|
|
this.options.width ? this.options.width : this.hdr.width,
|
|
|
|
this.options.height ? this.options.height : this.hdr.height
|
|
|
|
);
|
|
|
|
this.ctx.moveTo(0, this.options.height ? this.options.height : this.hdr.height);
|
|
|
|
this.ctx.lineTo(this.options.width ? this.options.width : this.hdr.width, 0);
|
|
|
|
this.ctx.stroke();
|
|
|
|
}
|
|
|
|
|
|
|
|
showError (errtype) {
|
|
|
|
this.load_error = errtype;
|
|
|
|
this.hdr = {
|
|
|
|
width: this.gif_el.width,
|
|
|
|
height: this.gif_el.height,
|
|
|
|
}; // Fake header.
|
|
|
|
this.frames = [];
|
|
|
|
this.drawError();
|
2021-08-02 18:30:59 +02:00
|
|
|
this.el.requestUpdate();
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
handleHeader (header) {
|
|
|
|
this.hdr = header;
|
|
|
|
this.setSizes(
|
|
|
|
this.options.width ?? this.hdr.width,
|
|
|
|
this.options.height ?? this.hdr.height
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handler for GIF Graphic Control Extension (GCE) data
|
|
|
|
*/
|
|
|
|
handleGCE (gce) {
|
2022-10-23 05:00:21 +02:00
|
|
|
this.pushFrame();
|
2021-07-07 14:59:29 +02:00
|
|
|
this.clear();
|
2022-10-23 05:00:21 +02:00
|
|
|
this.frame_delay = gce.delayTime;
|
2021-07-07 14:59:29 +02:00
|
|
|
this.transparency = gce.transparencyGiven ? gce.transparencyIndex : null;
|
|
|
|
this.disposal_method = gce.disposalMethod;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handler for when the end of the GIF's file has been reached
|
|
|
|
*/
|
|
|
|
handleEOF (stream) {
|
2022-10-23 05:00:21 +02:00
|
|
|
this.pushFrame();
|
2021-07-07 14:59:29 +02:00
|
|
|
this.doDecodeProgress(stream, false);
|
|
|
|
this.initPlayer();
|
|
|
|
!this.options.autoplay && this.drawPlayIcon();
|
|
|
|
}
|
|
|
|
|
2022-10-23 05:00:21 +02:00
|
|
|
pushFrame () {
|
2021-07-07 14:59:29 +02:00
|
|
|
if (!this.frame) return;
|
|
|
|
this.frames.push({
|
|
|
|
data: this.frame.getImageData(0, 0, this.hdr.width, this.hdr.height),
|
2022-10-23 05:00:21 +02:00
|
|
|
delay: this.frame_delay
|
2021-07-07 14:59:29 +02:00
|
|
|
});
|
|
|
|
this.frame_offsets.push({ x: 0, y: 0 });
|
|
|
|
}
|
|
|
|
|
|
|
|
doImg (img) {
|
|
|
|
this.frame = this.frame || this.offscreenCanvas.getContext('2d');
|
|
|
|
const currIdx = this.frames.length;
|
|
|
|
|
|
|
|
//ct = color table, gct = global color table
|
|
|
|
const ct = img.lctFlag ? img.lct : this.hdr.gct; // TODO: What if neither exists?
|
|
|
|
|
|
|
|
/*
|
|
|
|
* 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 (currIdx > 0) {
|
|
|
|
if (this.last_disposal_method === 3) {
|
|
|
|
// Restore to previous
|
|
|
|
// If we disposed every frame including first frame up to this point, then we have
|
|
|
|
// no composited frame to restore to. In this case, restore to background instead.
|
|
|
|
if (this.disposal_restore_from_idx !== null) {
|
|
|
|
this.frame.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0);
|
|
|
|
} else {
|
|
|
|
this.frame.clearRect(
|
|
|
|
this.last_img.leftPos,
|
|
|
|
this.last_img.topPos,
|
|
|
|
this.last_img.width,
|
|
|
|
this.last_img.height
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
this.disposal_restore_from_idx = currIdx - 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.last_disposal_method === 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
|
|
|
|
this.frame.clearRect(
|
|
|
|
this.last_img.leftPos,
|
|
|
|
this.last_img.topPos,
|
|
|
|
this.last_img.width,
|
|
|
|
this.last_img.height
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// else, Undefined/Do not dispose.
|
|
|
|
// frame contains final pixel data from the last frame; do nothing
|
|
|
|
|
|
|
|
//Get existing pixels for img region after applying disposal method
|
|
|
|
const imgData = this.frame.getImageData(img.leftPos, img.topPos, img.width, img.height);
|
|
|
|
|
|
|
|
//apply color table colors
|
|
|
|
img.pixels.forEach((pixel, i) => {
|
|
|
|
// imgData.data === [R,G,B,A,R,G,B,A,...]
|
|
|
|
if (pixel !== this.transparency) {
|
|
|
|
imgData.data[i * 4 + 0] = ct[pixel][0];
|
|
|
|
imgData.data[i * 4 + 1] = ct[pixel][1];
|
|
|
|
imgData.data[i * 4 + 2] = ct[pixel][2];
|
|
|
|
imgData.data[i * 4 + 3] = 255; // Opaque.
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.frame.putImageData(imgData, img.leftPos, img.topPos);
|
|
|
|
|
|
|
|
if (!this.ctx_scaled) {
|
|
|
|
this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
|
|
|
|
this.ctx_scaled = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.last_img) {
|
2022-10-23 05:00:21 +02:00
|
|
|
// This is the first received image, so we draw it
|
2021-07-07 14:59:29 +02:00
|
|
|
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
|
|
|
}
|
|
|
|
this.last_img = img;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Draws a gif frame at a specific index inside the canvas.
|
|
|
|
* @param { Number } i - The frame index
|
|
|
|
*/
|
|
|
|
putFrame (i, show_pause_on_hover=true) {
|
2022-10-22 18:20:07 +02:00
|
|
|
if (!this.frames.length) return
|
|
|
|
|
2021-07-07 14:59:29 +02:00
|
|
|
i = parseInt(i, 10);
|
2022-10-22 18:20:07 +02:00
|
|
|
if (i > this.frames.length - 1 || i < 0) {
|
2021-07-07 14:59:29 +02:00
|
|
|
i = 0;
|
|
|
|
}
|
|
|
|
const offset = this.frame_offsets[i];
|
|
|
|
this.offscreenCanvas.getContext('2d').putImageData(this.frames[i].data, offset.x, offset.y);
|
|
|
|
this.ctx.globalCompositeOperation = 'copy';
|
|
|
|
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
|
|
|
|
2021-07-29 21:19:48 +02:00
|
|
|
if (show_pause_on_hover && this.hovering) {
|
|
|
|
this.drawPauseIcon();
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
clear () {
|
|
|
|
this.transparency = null;
|
|
|
|
this.last_disposal_method = this.disposal_method;
|
|
|
|
this.disposal_method = null;
|
|
|
|
this.frame = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Start playing the gif
|
|
|
|
*/
|
|
|
|
play () {
|
|
|
|
this.playing = true;
|
2021-07-29 12:21:24 +02:00
|
|
|
requestAnimationFrame(ts => this.onAnimationFrame(ts, 0, 0));
|
2021-07-07 14:59:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
2021-07-29 12:21:24 +02:00
|
|
|
this.putFrame(this.frame_idx, false);
|
2021-07-07 14:59:29 +02:00
|
|
|
|
|
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
|
|
|
|
|
|
// 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
|
2021-07-29 12:21:24 +02:00
|
|
|
this.putFrame(this.frame_idx, false);
|
2021-07-07 14:59:29 +02:00
|
|
|
|
|
|
|
this.ctx.globalCompositeOperation = 'source-over';
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
}
|
|
|
|
|
|
|
|
doDecodeProgress (stream, draw) {
|
|
|
|
this.doShowProgress(stream.pos, stream.data.length, draw);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param{boolean=} draw Whether to draw progress bar or not;
|
|
|
|
* this is not idempotent because of translucency.
|
|
|
|
* Note that this means that the text will be unsynchronized
|
|
|
|
* with the progress bar on non-frames;
|
|
|
|
* but those are typically so small (GCE etc.) that it doesn't really matter
|
|
|
|
*/
|
|
|
|
withProgress (stream, fn, draw) {
|
|
|
|
return block => {
|
|
|
|
fn?.(block);
|
|
|
|
this.doDecodeProgress(stream, draw);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
getCanvasScale () {
|
|
|
|
let scale;
|
|
|
|
if (this.options.max_width && this.hdr && this.hdr.width > this.options.max_width) {
|
|
|
|
scale = this.options.max_width / this.hdr.width;
|
|
|
|
} else {
|
|
|
|
scale = 1;
|
|
|
|
}
|
|
|
|
return scale;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Makes an HTTP request to fetch a GIF
|
|
|
|
* @param { String } url
|
|
|
|
* @returns { Promise<String> } Returns a promise which resolves with the response data.
|
|
|
|
*/
|
|
|
|
fetchGIF (url) {
|
|
|
|
const promise = getOpenPromise();
|
|
|
|
const h = new XMLHttpRequest();
|
|
|
|
h.open('GET', url, true);
|
|
|
|
h?.overrideMimeType('text/plain; charset=x-user-defined');
|
|
|
|
h.onload = () => {
|
|
|
|
if (h.status != 200) {
|
|
|
|
this.showError('xhr - response');
|
|
|
|
return promise.reject();
|
|
|
|
}
|
|
|
|
promise.resolve(h.response);
|
|
|
|
};
|
|
|
|
h.onprogress = (e) => (e.lengthComputable && this.doShowProgress(e.loaded, e.total, true));
|
|
|
|
h.onerror = () => this.showError('xhr');
|
|
|
|
h.send();
|
|
|
|
return promise;
|
|
|
|
}
|
|
|
|
}
|