diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 46a850315..4962a1667 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -755,19 +755,6 @@ td.generalLabel { } } -.piecesbarWrapper { - position: relative; - width: 100%; -} - -.piecesbarCanvas { - height: 100%; - image-rendering: pixelated; - inset: 0; - position: absolute; - width: 100%; -} - #watched_folders_tab { border-collapse: collapse; } diff --git a/src/webui/www/private/scripts/piecesbar.js b/src/webui/www/private/scripts/piecesbar.js index 803087f24..07bee0424 100644 --- a/src/webui/www/private/scripts/piecesbar.js +++ b/src/webui/www/private/scripts/piecesbar.js @@ -36,229 +36,211 @@ window.qBittorrent.PiecesBar ??= (() => { }; }; - const STATUS_DOWNLOADING = 1; - const STATUS_DOWNLOADED = 2; + class PiecesBar extends HTMLElement { + static #STATUS_DOWNLOADING = 1; + static #STATUS_DOWNLOADED = 2; + // absolute max width of 4096 + // this is to support all browsers for size of canvas elements + // see https://github.com/jhildenbiddle/canvas-size#test-results + static #MAX_CANVAS_WIDTH = 4096; + static #piecesBarUniqueId = 0; - // absolute max width of 4096 - // this is to support all browsers for size of canvas elements - // see https://github.com/jhildenbiddle/canvas-size#test-results - const MAX_CANVAS_WIDTH = 4096; + #canvasEl; + #ctx; + #pieces; + #styles; + #id = ++PiecesBar.#piecesBarUniqueId; + #resizeObserver; - let piecesBarUniqueId = 0; - const PiecesBar = new Class({ - initialize: (pieces, parameters) => { - const vals = { - id: `piecesbar_${piecesBarUniqueId++}`, - width: 0, - height: 0, + constructor(pieces, styles = {}) { + super(); + this.setPieces(pieces); + this.#styles = { + height: 12, downloadingColor: "hsl(110deg 94% 27%)", // @TODO palette vars not supported for this value, apply average haveColor: "hsl(210deg 55% 55%)", // @TODO palette vars not supported for this value, apply average borderSize: 1, - borderColor: "var(--color-border-default)" + borderColor: "var(--color-border-default)", + ...styles }; - if (parameters && (typeOf(parameters) === "object")) - Object.append(vals, parameters); - vals.height = Math.max(vals.height, 12); + this.#canvasEl = document.createElement("canvas"); + this.#canvasEl.style.height = "100%"; + this.#canvasEl.style.imageRendering = "pixelated"; + this.#canvasEl.style.width = "100%"; + this.#ctx = this.#canvasEl.getContext("2d"); - const obj = document.createElement("div"); - obj.id = vals.id; - obj.className = "piecesbarWrapper"; - obj.style.border = `${vals.borderSize}px solid ${vals.borderColor}`; - obj.style.height = `${vals.height}px`; - obj.vals = vals; - obj.vals.pieces = [pieces, []].pick(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.host.id = `piecesbar_${this.#id}`; + this.shadowRoot.host.style.display = "block"; + this.shadowRoot.host.style.height = `${this.#styles.height}px`; + this.shadowRoot.host.style.border = `${this.#styles.borderSize}px solid ${this.#styles.borderColor}`; + this.shadowRoot.append(this.#canvasEl); - const canvas = document.createElement("canvas"); - canvas.id = `${vals.id}_canvas`; - canvas.className = "piecesbarCanvas"; - canvas.width = `${vals.width - (2 * vals.borderSize)}`; - canvas.height = "1"; // will stretch vertically to take up the height of the parent - obj.vals.canvas = canvas; - obj.appendChild(obj.vals.canvas); - - obj.setPieces = setPieces; - obj.refresh = refresh; - obj.clear = setPieces.bind(obj, []); - obj._drawStatus = drawStatus; - - if (vals.width > 0) - obj.setPieces(vals.pieces); - else - setTimeout(() => { checkForParent(obj.id); }); - - return obj; - } - }); - - function setPieces(pieces) { - if (!Array.isArray(pieces)) - pieces = []; - - this.vals.pieces = pieces; - this.refresh(true); - } - - function refresh(force) { - if (!this.parentNode) - return; - - const pieces = this.vals.pieces; - - // if the number of pieces is small, use that for the width, - // and have it stretch horizontally. - // this also limits the ratio below to >= 1 - const width = Math.min(this.offsetWidth, pieces.length, MAX_CANVAS_WIDTH); - if ((this.vals.width === width) && !force) - return; - - this.vals.width = width; - - // change canvas size to fit exactly in the space - this.vals.canvas.width = width - (2 * this.vals.borderSize); - - const canvas = this.vals.canvas; - const ctx = canvas.getContext("2d"); - ctx.clearRect(0, 0, canvas.width, canvas.height); - - const imageWidth = canvas.width; - - if (imageWidth.length === 0) - return; - - let minStatus = Infinity; - let maxStatus = 0; - - for (const status of pieces) { - if (status > maxStatus) - maxStatus = status; - if (status < minStatus) - minStatus = status; + this.#resizeObserver = new ResizeObserver(window.qBittorrent.Misc.createDebounceHandler(100, () => { + this.#refresh(); + })); } - // if no progress then don't do anything - if (maxStatus === 0) - return; - - // if all pieces are downloaded, fill entire image at once - if (minStatus === STATUS_DOWNLOADED) { - ctx.fillStyle = this.vals.haveColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - return; + connectedCallback() { + this.#resizeObserver.observe(this); + this.#refresh(); } - /* Linear transformation from pieces to pixels. - * - * The canvas size can vary in width so this figures out what to draw at each pixel. - * Inspired by the GUI code here https://github.com/qbittorrent/qBittorrent/blob/25b3f2d1a6b14f0fe098fb79a3d034607e52deae/src/gui/properties/downloadedpiecesbar.cpp#L54 - * - * example ratio > 1 (at least 2 pieces per pixel) - * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ - * pieces | 2 | 1 | 2 | 0 | 2 | 0 | 1 | 0 | 1 | 2 | - * +---------+---------+---------+---------+---------+---------+ - * pixels | | | | | | | - * +---------+---------+---------+---------+---------+---------+ - * - * example ratio < 1 (at most 2 pieces per pixel) - * This case shouldn't happen since the max pixels are limited to the number of pieces - * +---------+---------+---------+---------+----------+--------+ - * pieces | 2 | 1 | 1 | 0 | 2 | 2 | - * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ - * pixels | | | | | | | | | | | - * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ - */ + clear() { + this.setPieces([]); + } - const ratio = pieces.length / imageWidth; + setPieces(pieces) { + this.#pieces = !Array.isArray(pieces) ? [] : pieces; + this.#refresh(); + } - let lastValue = null; - let rectangleStart = 0; + #refresh() { + if (!this.isConnected) + return; - // for each pixel compute its status based on the pieces - for (let x = 0; x < imageWidth; ++x) { - // find positions in the pieces array - const piecesFrom = x * ratio; - const piecesTo = (x + 1) * ratio; - const piecesToInt = Math.ceil(piecesTo); + // if the number of pieces is small, use that for the width, + // and have it stretch horizontally. + // this also limits the ratio below to >= 1 + const width = Math.min(this.offsetWidth, this.#pieces.length, PiecesBar.#MAX_CANVAS_WIDTH); - const statusValues = { - [STATUS_DOWNLOADING]: 0, - [STATUS_DOWNLOADED]: 0 - }; + // change canvas size to fit exactly in the space + this.#canvasEl.width = width - (2 * this.#styles.borderSize); - // aggregate the status of each piece that contributes to this pixel - for (let p = piecesFrom; p < piecesToInt; ++p) { - const piece = Math.floor(p); - const pieceStart = Math.max(piecesFrom, piece); - const pieceEnd = Math.min(piece + 1, piecesTo); + this.#ctx.clearRect(0, 0, this.#canvasEl.width, this.#canvasEl.height); - const amount = pieceEnd - pieceStart; - const status = pieces[piece]; + const imageWidth = this.#canvasEl.width; - if (status in statusValues) - statusValues[status] += amount; + if (imageWidth.length === 0) + return; + + let minStatus = Infinity; + let maxStatus = 0; + + for (const status of this.#pieces) { + if (status > maxStatus) + maxStatus = status; + if (status < minStatus) + minStatus = status; } - // normalize to interval [0, 1] - statusValues[STATUS_DOWNLOADING] /= ratio; - statusValues[STATUS_DOWNLOADED] /= ratio; + // if no progress then don't do anything + if (maxStatus === 0) + return; - // floats accumulate small errors, so smooth it out by rounding to hundredths place - // this effectively limits each status to a value 1 in 100 - statusValues[STATUS_DOWNLOADING] = Math.round(statusValues[STATUS_DOWNLOADING] * 100) / 100; - statusValues[STATUS_DOWNLOADED] = Math.round(statusValues[STATUS_DOWNLOADED] * 100) / 100; + // if all pieces are downloaded, fill entire image at once + if (minStatus === PiecesBar.#STATUS_DOWNLOADED) { + this.#ctx.fillStyle = this.#styles.haveColor; + this.#ctx.fillRect(0, 0, this.#canvasEl.width, this.#canvasEl.height); + return; + } - // float precision sometimes _still_ gives > 1 - statusValues[STATUS_DOWNLOADING] = Math.min(statusValues[STATUS_DOWNLOADING], 1); - statusValues[STATUS_DOWNLOADED] = Math.min(statusValues[STATUS_DOWNLOADED], 1); + /* Linear transformation from pieces to pixels. + * + * The canvas size can vary in width so this figures out what to draw at each pixel. + * Inspired by the GUI code here https://github.com/qbittorrent/qBittorrent/blob/25b3f2d1a6b14f0fe098fb79a3d034607e52deae/src/gui/properties/downloadedpiecesbar.cpp#L54 + * + * example ratio > 1 (at least 2 pieces per pixel) + * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ + * pieces | 2 | 1 | 2 | 0 | 2 | 0 | 1 | 0 | 1 | 2 | + * +---------+---------+---------+---------+---------+---------+ + * pixels | | | | | | | + * +---------+---------+---------+---------+---------+---------+ + * + * example ratio < 1 (at most 2 pieces per pixel) + * This case shouldn't happen since the max pixels are limited to the number of pieces + * +---------+---------+---------+---------+----------+--------+ + * pieces | 2 | 1 | 1 | 0 | 2 | 2 | + * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ + * pixels | | | | | | | | | | | + * +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+ + */ + + const ratio = this.#pieces.length / imageWidth; + + let lastValue = null; + let rectangleStart = 0; + + // for each pixel compute its status based on the pieces + for (let x = 0; x < imageWidth; ++x) { + // find positions in the pieces array + const piecesFrom = x * ratio; + const piecesTo = (x + 1) * ratio; + const piecesToInt = Math.ceil(piecesTo); + + const statusValues = { + [PiecesBar.#STATUS_DOWNLOADING]: 0, + [PiecesBar.#STATUS_DOWNLOADED]: 0 + }; + + // aggregate the status of each piece that contributes to this pixel + for (let p = piecesFrom; p < piecesToInt; ++p) { + const piece = Math.floor(p); + const pieceStart = Math.max(piecesFrom, piece); + const pieceEnd = Math.min(piece + 1, piecesTo); + + const amount = pieceEnd - pieceStart; + const status = this.#pieces[piece]; + + if (status in statusValues) + statusValues[status] += amount; + } + + // normalize to interval [0, 1] + statusValues[PiecesBar.#STATUS_DOWNLOADING] /= ratio; + statusValues[PiecesBar.#STATUS_DOWNLOADED] /= ratio; + + // floats accumulate small errors, so smooth it out by rounding to hundredths place + // this effectively limits each status to a value 1 in 100 + statusValues[PiecesBar.#STATUS_DOWNLOADING] = Math.round(statusValues[PiecesBar.#STATUS_DOWNLOADING] * 100) / 100; + statusValues[PiecesBar.#STATUS_DOWNLOADED] = Math.round(statusValues[PiecesBar.#STATUS_DOWNLOADED] * 100) / 100; + + // float precision sometimes _still_ gives > 1 + statusValues[PiecesBar.#STATUS_DOWNLOADING] = Math.min(statusValues[PiecesBar.#STATUS_DOWNLOADING], 1); + statusValues[PiecesBar.#STATUS_DOWNLOADED] = Math.min(statusValues[PiecesBar.#STATUS_DOWNLOADED], 1); + + if (!lastValue) + lastValue = statusValues; + + // group contiguous colors together and draw as a single rectangle + if ((lastValue[PiecesBar.#STATUS_DOWNLOADING] === statusValues[PiecesBar.#STATUS_DOWNLOADING]) + && (lastValue[PiecesBar.#STATUS_DOWNLOADED] === statusValues[PiecesBar.#STATUS_DOWNLOADED])) + continue; + + const rectangleWidth = x - rectangleStart; + this.#drawStatus(rectangleStart, rectangleWidth, lastValue); - if (!lastValue) lastValue = statusValues; + rectangleStart = x; + } - // group contiguous colors together and draw as a single rectangle - if ((lastValue[STATUS_DOWNLOADING] === statusValues[STATUS_DOWNLOADING]) - && (lastValue[STATUS_DOWNLOADED] === statusValues[STATUS_DOWNLOADED])) - continue; - - const rectangleWidth = x - rectangleStart; - this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue); - - lastValue = statusValues; - rectangleStart = x; + // fill a rect at the end of the canvas + if (rectangleStart < imageWidth) { + const rectangleWidth = imageWidth - rectangleStart; + this.#drawStatus(rectangleStart, rectangleWidth, lastValue); + } } - // fill a rect at the end of the canvas - if (rectangleStart < imageWidth) { - const rectangleWidth = imageWidth - rectangleStart; - this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue); + #drawStatus(start, width, statusValues) { + // mix the colors by using transparency and a composite mode + this.#ctx.globalCompositeOperation = "lighten"; + + if (statusValues[PiecesBar.#STATUS_DOWNLOADING]) { + this.#ctx.globalAlpha = statusValues[PiecesBar.#STATUS_DOWNLOADING]; + this.#ctx.fillStyle = this.#styles.downloadingColor; + this.#ctx.fillRect(start, 0, width, this.#canvasEl.height); + } + + if (statusValues[PiecesBar.#STATUS_DOWNLOADED]) { + this.#ctx.globalAlpha = statusValues[PiecesBar.#STATUS_DOWNLOADED]; + this.#ctx.fillStyle = this.#styles.haveColor; + this.#ctx.fillRect(start, 0, width, this.#canvasEl.height); + } } } - function drawStatus(ctx, start, width, statusValues) { - // mix the colors by using transparency and a composite mode - ctx.globalCompositeOperation = "lighten"; - - if (statusValues[STATUS_DOWNLOADING]) { - ctx.globalAlpha = statusValues[STATUS_DOWNLOADING]; - ctx.fillStyle = this.vals.downloadingColor; - ctx.fillRect(start, 0, width, ctx.canvas.height); - } - - if (statusValues[STATUS_DOWNLOADED]) { - ctx.globalAlpha = statusValues[STATUS_DOWNLOADED]; - ctx.fillStyle = this.vals.haveColor; - ctx.fillRect(start, 0, width, ctx.canvas.height); - } - } - - const checkForParent = (id) => { - const obj = document.getElementById(id); - if (!obj) - return; - if (!obj.parentNode) - return setTimeout(() => { checkForParent(id); }, 100); - - obj.refresh(); - }; + customElements.define("pieces-bar", PiecesBar); return exports(); })();