diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 28ff3e910..fd363cd90 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -508,19 +508,16 @@ td.generalLabel { padding: 2px; } -#progress { - border: 1px solid #999; - padding: 0; +.piecesbarWrapper { position: relative; width: 100%; } -#progress canvas { +.piecesbarCanvas { height: 100%; image-rendering: pixelated; - left: 0; + inset: 0; position: absolute; - top: 0; width: 100%; } diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index 67095b6c0..964ae7dd2 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -27,6 +27,7 @@ + diff --git a/src/webui/www/private/scripts/piecesbar.js b/src/webui/www/private/scripts/piecesbar.js new file mode 100644 index 000000000..4a7fc83c7 --- /dev/null +++ b/src/webui/www/private/scripts/piecesbar.js @@ -0,0 +1,278 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2022 Jesse Smick + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +'use strict'; + +if (window.qBittorrent === undefined) { + window.qBittorrent = {}; +} + +window.qBittorrent.PiecesBar = (() => { + const exports = () => { + return { + PiecesBar: PiecesBar + }; + }; + + const STATUS_DOWNLOADING = 1; + const 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 + const MAX_CANVAS_WIDTH = 4096; + + let piecesBarUniqueId = 0; + const PiecesBar = new Class({ + initialize(pieces, parameters) { + const vals = { + 'id': 'piecesbar_' + (piecesBarUniqueId++), + 'width': 0, + 'height': 0, + 'downloadingColor': 'green', + 'haveColor': 'blue', + 'borderSize': 1, + 'borderColor': '#999' + }; + + if (parameters && ($type(parameters) === 'object')) + $extend(vals, parameters); + vals.height = Math.max(vals.height, 12); + + const obj = new Element('div', { + 'id': vals.id, + 'class': 'piecesbarWrapper', + 'styles': { + 'border': vals.borderSize.toString() + 'px solid ' + vals.borderColor, + 'height': vals.height.toString() + 'px', + } + }); + obj.vals = vals; + obj.vals.pieces = $pick(pieces, []); + + obj.vals.canvas = new Element('canvas', { + 'id': vals.id + '_canvas', + 'class': 'piecesbarCanvas', + 'width': (vals.width - (2 * vals.borderSize)).toString(), + 'height': '1' // will stretch vertically to take up the height of the parent + }); + 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); }, 1); + + return obj; + } + }); + + function setPieces(pieces) { + if (!Array.isArray(pieces)) + pieces = []; + + this.vals.pieces = pieces; + this.refresh(true); + } + + function refresh(force) { + const start = Date.now(); + 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; + } + } + + // 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; + } + + /* 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 = 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 = { + [STATUS_DOWNLOADING]: 0, + [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 = pieces[piece]; + + if (status in statusValues) + statusValues[status] += amount; + } + + // normalize to interval [0, 1] + statusValues[STATUS_DOWNLOADING] /= ratio; + statusValues[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[STATUS_DOWNLOADING] = Math.round(statusValues[STATUS_DOWNLOADING] * 100) / 100; + statusValues[STATUS_DOWNLOADED] = Math.round(statusValues[STATUS_DOWNLOADED] * 100) / 100; + + // float precision sometimes _still_ gives > 1 + statusValues[STATUS_DOWNLOADING] = Math.min(statusValues[STATUS_DOWNLOADING], 1); + statusValues[STATUS_DOWNLOADED] = Math.min(statusValues[STATUS_DOWNLOADED], 1); + + if (!lastValue) { + lastValue = statusValues; + } + + // 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(ctx, rectangleStart, rectangleWidth, lastValue); + } + } + + 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); + } + } + + function checkForParent(id) { + const obj = $(id); + if (!obj) + return; + if (!obj.parentNode) + return setTimeout(function() { checkForParent(id); }, 1); + + obj.refresh(); + } + + return exports(); +})(); + +Object.freeze(window.qBittorrent.PiecesBar); diff --git a/src/webui/www/private/scripts/prop-general.js b/src/webui/www/private/scripts/prop-general.js index 5e1d604fb..d98a41165 100644 --- a/src/webui/www/private/scripts/prop-general.js +++ b/src/webui/www/private/scripts/prop-general.js @@ -39,6 +39,11 @@ window.qBittorrent.PropGeneral = (function() { }; }; + const piecesBar = new window.qBittorrent.PiecesBar.PiecesBar([], { + height: 16 + }); + $('progress').appendChild(piecesBar); + const clearData = function() { $('time_elapsed').set('html', ''); $('eta').set('html', ''); @@ -65,9 +70,7 @@ window.qBittorrent.PropGeneral = (function() { $('torrent_hash_v2').set('html', ''); $('save_path').set('html', ''); $('comment').set('html', ''); - - const canvas = $('progress').getFirst('canvas'); - canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + piecesBar.clear(); }; let loadTorrentDataTimer; @@ -231,43 +234,7 @@ window.qBittorrent.PropGeneral = (function() { $('error_div').set('html', ''); if (data) { - const canvas = $('progress').getFirst('canvas'); - canvas.width = data.length; - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Group contiguous colors together and draw as a single rectangle - let color = ''; - let rectWidth = 1; - - for (let i = 0; i < data.length; ++i) { - const status = data[i]; - let newColor = ''; - - if (status === 1) - newColor = 'green'; - else if (status === 2) - newColor = 'blue'; - - if (newColor === color) { - ++rectWidth; - continue; - } - - if (color !== '') { - ctx.fillStyle = color; - ctx.fillRect((i - rectWidth), 0, rectWidth, canvas.height); - } - - rectWidth = 1; - color = newColor; - } - - // Fill a rect at the end of the canvas if one is needed - if (color !== '') { - ctx.fillStyle = color; - ctx.fillRect((data.length - rectWidth), 0, rectWidth, canvas.height); - } + piecesBar.setPieces(data); } else { clearData(); diff --git a/src/webui/www/private/views/properties.html b/src/webui/www/private/views/properties.html index 171e54196..bd505f318 100644 --- a/src/webui/www/private/views/properties.html +++ b/src/webui/www/private/views/properties.html @@ -2,9 +2,7 @@ - +
QBT_TR(Progress:)QBT_TR[CONTEXT=PropertiesWidget] - -

diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index 78a0cd083..6621dccd6 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -56,6 +56,7 @@ private/scripts/lib/MooTools-More-1.6.0-compat-compressed.js private/scripts/misc.js private/scripts/mocha-init.js + private/scripts/piecesbar.js private/scripts/preferences.js private/scripts/progressbar.js private/scripts/prop-files.js