mirror of
https://github.com/qbittorrent/qBittorrent.git
synced 2026-02-04 02:32:16 -06:00
WebUI: Add torrent availability bar
* WebAPI: Add endpoint for retrieving piece availability * WebUI: Add torrent availability bar PR #23741.
This commit is contained in:
parent
1a471bea9b
commit
b5825ee01f
@ -5,6 +5,9 @@
|
||||
* Add `app/processInfo` endpoint returning `launch_time` (process launch time as UTC epoch seconds)
|
||||
* [#23708](https://github.com/qbittorrent/qBittorrent/pull/23708)
|
||||
* `sync/torrentPeers` endpoint now includes peer `host_name` when peer host name resolution is enabled
|
||||
* [#23741](https://github.com/qbittorrent/qBittorrent/pull/23741)
|
||||
* Add `torrents/pieceAvailability` endpoint for retrieving availability of each torrent piece
|
||||
* `torrents/properties` endpoint now includes the number of distributed copies of the torrent's selected files via `availability` field
|
||||
|
||||
## 2.15.0
|
||||
* [#23585](https://github.com/qbittorrent/qBittorrent/pull/23585)
|
||||
|
||||
@ -719,6 +719,7 @@ void TorrentsController::infoAction()
|
||||
// - "peers_total": Torrent total number of peers
|
||||
// - "share_ratio": Torrent share ratio
|
||||
// - "popularity": Torrent popularity
|
||||
// - "availability": Torrent distributed copies
|
||||
// - "reannounce": Torrent next reannounce time
|
||||
// - "total_size": Torrent total size
|
||||
// - "pieces_num": Torrent pieces count
|
||||
@ -785,6 +786,7 @@ void TorrentsController::propertiesAction()
|
||||
{KEY_PROP_PEERS_TOTAL, torrent->totalLeechersCount()},
|
||||
{KEY_PROP_RATIO, ((ratio >= BitTorrent::Torrent::MAX_RATIO) ? -1 : ratio)},
|
||||
{KEY_PROP_POPULARITY, ((popularity >= BitTorrent::Torrent::MAX_RATIO) ? -1 : popularity)},
|
||||
{KEY_TORRENT_AVAILABILITY, torrent->distributedCopies()},
|
||||
{KEY_PROP_REANNOUNCE, torrent->nextAnnounce()},
|
||||
{KEY_PROP_TOTAL_SIZE, torrent->totalSize()},
|
||||
{KEY_PROP_PIECES_NUM, torrent->piecesCount()},
|
||||
@ -1048,6 +1050,25 @@ void TorrentsController::pieceStatesAction()
|
||||
setResult(pieceStates);
|
||||
}
|
||||
|
||||
// Returns an array of availability counts for each piece of a torrent in JSON format.
|
||||
// Each value represents the number of peers that have that piece.
|
||||
void TorrentsController::pieceAvailabilityAction()
|
||||
{
|
||||
requireParams({u"hash"_s});
|
||||
|
||||
const auto id = BitTorrent::TorrentID::fromString(params()[u"hash"_s]);
|
||||
const BitTorrent::Torrent *torrent = BitTorrent::Session::instance()->getTorrent(id);
|
||||
if (!torrent)
|
||||
throw APIError(APIErrorType::NotFound);
|
||||
|
||||
const QList<int> avail = torrent->fetchPieceAvailability().takeResult();
|
||||
QJsonArray pieceAvailability;
|
||||
for (const int count : avail)
|
||||
pieceAvailability.append(count);
|
||||
|
||||
setResult(pieceAvailability);
|
||||
}
|
||||
|
||||
void TorrentsController::addAction()
|
||||
{
|
||||
const QStringList urls = params()[u"urls"_s].split(u'\n', Qt::SkipEmptyParts);
|
||||
|
||||
@ -68,6 +68,7 @@ private slots:
|
||||
void filesAction();
|
||||
void pieceHashesAction();
|
||||
void pieceStatesAction();
|
||||
void pieceAvailabilityAction();
|
||||
void startAction();
|
||||
void stopAction();
|
||||
void recheckAction();
|
||||
|
||||
@ -715,20 +715,21 @@ td.noWrap {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#propProgressWrapper {
|
||||
#propBarsWrapper {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
display: grid;
|
||||
gap: 4px 8px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: auto;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
& > span:not(:first-child, :empty) {
|
||||
margin-left: 6px;
|
||||
min-width: 3.5em;
|
||||
}
|
||||
#propAvailabilityWrapper {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
& #progress {
|
||||
flex: 1;
|
||||
}
|
||||
.propBarLabel {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#watched_folders_tab {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/*
|
||||
* Bittorrent Client using Qt and libtorrent.
|
||||
* Copyright (C) 2022 Jesse Smick <jesse.smick@gmail.com>
|
||||
* Copyright (C) 2026 Thomas Piccirello <thomas@piccirello.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
@ -32,89 +33,164 @@ window.qBittorrent ??= {};
|
||||
window.qBittorrent.PiecesBar ??= (() => {
|
||||
const exports = () => {
|
||||
return {
|
||||
PiecesBar: PiecesBar
|
||||
PiecesBar: PiecesBar,
|
||||
AvailabilityBar: AvailabilityBar
|
||||
};
|
||||
};
|
||||
|
||||
class PiecesBar extends HTMLElement {
|
||||
static #STATUS_DOWNLOADING = 1;
|
||||
static #STATUS_DOWNLOADED = 2;
|
||||
/**
|
||||
* Base class for piece-based progress bars.
|
||||
* Uses template method pattern - subclasses implement abstract methods.
|
||||
*/
|
||||
class Base extends HTMLElement {
|
||||
// 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;
|
||||
|
||||
#canvasEl;
|
||||
#ctx;
|
||||
#pieces;
|
||||
#styles;
|
||||
#id = ++PiecesBar.#piecesBarUniqueId;
|
||||
_canvasEl;
|
||||
_ctx;
|
||||
_styles;
|
||||
#id = ++Base.#piecesBarUniqueId;
|
||||
#resizeObserver;
|
||||
|
||||
constructor(pieces, styles = {}) {
|
||||
constructor(styles = {}) {
|
||||
super();
|
||||
this.setPieces(pieces);
|
||||
this.#styles = {
|
||||
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)",
|
||||
...styles
|
||||
};
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(window.qBittorrent.Misc.createDebounceHandler(100, () => {
|
||||
this.#refresh();
|
||||
this._refresh();
|
||||
}));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#resizeObserver.observe(this);
|
||||
this.#refresh();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.setPieces([]);
|
||||
}
|
||||
|
||||
setPieces(pieces) {
|
||||
this.#pieces = !Array.isArray(pieces) ? [] : pieces;
|
||||
this.#refresh();
|
||||
}
|
||||
|
||||
#refresh() {
|
||||
// Template method - orchestrates the rendering
|
||||
_refresh() {
|
||||
if (!this.isConnected)
|
||||
return;
|
||||
|
||||
// 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 data = this._getData();
|
||||
const width = Math.min(this.offsetWidth, data.length, Base.#MAX_CANVAS_WIDTH);
|
||||
|
||||
// change canvas size to fit exactly in the space
|
||||
this.#canvasEl.width = width - (2 * this.#styles.borderSize);
|
||||
this._canvasEl.width = width - (2 * this._styles.borderSize);
|
||||
this._ctx.clearRect(0, 0, this._canvasEl.width, this._canvasEl.height);
|
||||
|
||||
this.#ctx.clearRect(0, 0, this.#canvasEl.width, this.#canvasEl.height);
|
||||
|
||||
const imageWidth = this.#canvasEl.width;
|
||||
|
||||
if (imageWidth.length === 0)
|
||||
const imageWidth = this._canvasEl.width;
|
||||
if (imageWidth <= 0)
|
||||
return;
|
||||
|
||||
// Subclass-specific early exit or fill
|
||||
if (!this._shouldRender(imageWidth))
|
||||
return;
|
||||
|
||||
const ratio = data.length / imageWidth;
|
||||
|
||||
let lastValue = null;
|
||||
let rectangleStart = 0;
|
||||
|
||||
// for each pixel compute its value based on the pieces
|
||||
for (let x = 0; x < imageWidth; ++x) {
|
||||
const piecesFrom = x * ratio;
|
||||
const piecesTo = (x + 1) * ratio;
|
||||
|
||||
const value = this._computePixelValue(piecesFrom, piecesTo, ratio);
|
||||
|
||||
if (lastValue === null)
|
||||
lastValue = value;
|
||||
|
||||
// group contiguous colors together and draw as a single rectangle
|
||||
if (this._valuesEqual(lastValue, value))
|
||||
continue;
|
||||
|
||||
const rectangleWidth = x - rectangleStart;
|
||||
this._drawRectangle(rectangleStart, rectangleWidth, lastValue);
|
||||
|
||||
lastValue = value;
|
||||
rectangleStart = x;
|
||||
}
|
||||
|
||||
// fill a rect at the end of the canvas
|
||||
if (rectangleStart < imageWidth) {
|
||||
const rectangleWidth = imageWidth - rectangleStart;
|
||||
this._drawRectangle(rectangleStart, rectangleWidth, lastValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Abstract methods - must be implemented by subclasses
|
||||
_getData() {
|
||||
throw new Error("_getData must be implemented");
|
||||
}
|
||||
_shouldRender(imageWidth) {
|
||||
throw new Error("_shouldRender must be implemented");
|
||||
}
|
||||
_computePixelValue(piecesFrom, piecesTo, ratio) {
|
||||
throw new Error("_computePixelValue must be implemented");
|
||||
}
|
||||
_valuesEqual(left, right) {
|
||||
throw new Error("_valuesEqual must be implemented");
|
||||
}
|
||||
_drawRectangle(start, width, value) {
|
||||
throw new Error("_drawRectangle must be implemented");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar showing download status of pieces.
|
||||
* Colors indicate downloading (green) vs downloaded (blue) states.
|
||||
*/
|
||||
class PiecesBar extends Base {
|
||||
static #STATUS_DOWNLOADING = 1;
|
||||
static #STATUS_DOWNLOADED = 2;
|
||||
|
||||
#pieces = [];
|
||||
|
||||
constructor(pieces = [], styles = {}) {
|
||||
super({
|
||||
downloadingColor: "hsl(110deg 94% 27%)",
|
||||
haveColor: "hsl(210deg 55% 55%)",
|
||||
...styles
|
||||
});
|
||||
this.#pieces = Array.isArray(pieces) ? pieces : [];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#pieces = [];
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
setPieces(pieces) {
|
||||
this.#pieces = Array.isArray(pieces) ? pieces : [];
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_getData() {
|
||||
return this.#pieces;
|
||||
}
|
||||
|
||||
_shouldRender(imageWidth) {
|
||||
let minStatus = Infinity;
|
||||
let maxStatus = 0;
|
||||
|
||||
@ -127,120 +203,174 @@ window.qBittorrent.PiecesBar ??= (() => {
|
||||
|
||||
// if no progress then don't do anything
|
||||
if (maxStatus === 0)
|
||||
return;
|
||||
return false;
|
||||
|
||||
// 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;
|
||||
this._ctx.fillStyle = this._styles.haveColor;
|
||||
this._ctx.fillRect(0, 0, this._canvasEl.width, this._canvasEl.height);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#drawStatus(start, width, statusValues) {
|
||||
/* 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 | | | | | | | | | | |
|
||||
* +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
*/
|
||||
_computePixelValue(piecesFrom, piecesTo, 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
|
||||
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);
|
||||
|
||||
return statusValues;
|
||||
}
|
||||
|
||||
_valuesEqual(left, right) {
|
||||
return (left[PiecesBar.#STATUS_DOWNLOADING] === right[PiecesBar.#STATUS_DOWNLOADING])
|
||||
&& (left[PiecesBar.#STATUS_DOWNLOADED] === right[PiecesBar.#STATUS_DOWNLOADED]);
|
||||
}
|
||||
|
||||
_drawRectangle(start, width, statusValues) {
|
||||
// mix the colors by using transparency and a composite mode
|
||||
this.#ctx.globalCompositeOperation = "lighten";
|
||||
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);
|
||||
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);
|
||||
this._ctx.globalAlpha = statusValues[PiecesBar.#STATUS_DOWNLOADED];
|
||||
this._ctx.fillStyle = this._styles.haveColor;
|
||||
this._ctx.fillRect(start, 0, width, this._canvasEl.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability bar showing peer availability of pieces.
|
||||
* Color gradient from gray (no peers) to blue (many peers).
|
||||
*/
|
||||
class AvailabilityBar extends Base {
|
||||
#availability = [];
|
||||
#maxAvailability = 0;
|
||||
|
||||
constructor(styles = {}) {
|
||||
super(styles);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#availability = [];
|
||||
this.#maxAvailability = 0;
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
setAvailability(availability) {
|
||||
this.#availability = Array.isArray(availability) ? availability : [];
|
||||
this.#maxAvailability = this.#availability.reduce((acc, val) => Math.max(acc, val), 0);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
_getData() {
|
||||
return this.#availability;
|
||||
}
|
||||
|
||||
_shouldRender(imageWidth) {
|
||||
if (this.#maxAvailability === 0)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_computePixelValue(piecesFrom, piecesTo, ratio) {
|
||||
const piecesToInt = Math.ceil(piecesTo);
|
||||
|
||||
let totalAvailability = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
// aggregate the availability 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 weight = pieceEnd - pieceStart;
|
||||
totalAvailability += this.#availability[piece] * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
// calculate weighted average availability for this pixel
|
||||
const avgAvailability = (totalWeight > 0) ? (totalAvailability / totalWeight) : 0;
|
||||
// normalize to 0-1 range based on max availability
|
||||
return Math.round((avgAvailability / this.#maxAvailability) * 100) / 100;
|
||||
}
|
||||
|
||||
_valuesEqual(left, right) {
|
||||
return left === right;
|
||||
}
|
||||
|
||||
_drawRectangle(start, width, normalizedValue) {
|
||||
// Interpolate between background color (gray) and available color (blue)
|
||||
// normalizedValue is 0-1, where 0 = no peers, 1 = max peers
|
||||
// Use HSL interpolation for smooth gradient
|
||||
// bgColor: hsl(0, 0%, 50%) - gray
|
||||
// targetColor: hsl(210, 55%, 55%) - blue
|
||||
const hue = 210 * normalizedValue; // 0 -> 210
|
||||
const saturation = 55 * normalizedValue; // 0% -> 55%
|
||||
const lightness = 50 + (5 * normalizedValue); // 50% -> 55%
|
||||
|
||||
this._ctx.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
this._ctx.fillRect(start, 0, width, this._canvasEl.height);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("pieces-bar", PiecesBar);
|
||||
customElements.define("availability-bar", AvailabilityBar);
|
||||
|
||||
return exports();
|
||||
})();
|
||||
|
||||
@ -42,8 +42,14 @@ window.qBittorrent.PropGeneral ??= (() => {
|
||||
});
|
||||
document.getElementById("progress").appendChild(piecesBar);
|
||||
|
||||
const availabilityBar = new window.qBittorrent.PiecesBar.AvailabilityBar({
|
||||
height: 18
|
||||
});
|
||||
document.getElementById("availability").appendChild(availabilityBar);
|
||||
|
||||
const clearData = () => {
|
||||
document.getElementById("progressPercentage").textContent = "";
|
||||
document.getElementById("averageAvailability").textContent = "";
|
||||
document.getElementById("time_elapsed").textContent = "";
|
||||
document.getElementById("eta").textContent = "";
|
||||
document.getElementById("nb_connections").textContent = "";
|
||||
@ -72,6 +78,8 @@ window.qBittorrent.PropGeneral ??= (() => {
|
||||
document.getElementById("comment").textContent = "";
|
||||
document.getElementById("private").textContent = "";
|
||||
piecesBar.clear();
|
||||
availabilityBar.clear();
|
||||
document.getElementById("propAvailabilityWrapper").style.display = "none";
|
||||
};
|
||||
|
||||
let loadTorrentDataTimer = -1;
|
||||
@ -114,6 +122,9 @@ window.qBittorrent.PropGeneral ??= (() => {
|
||||
|
||||
document.getElementById("progressPercentage").textContent = window.qBittorrent.Misc.friendlyPercentage(data.progress);
|
||||
|
||||
const avgAvailability = (data.availability >= 0) ? window.qBittorrent.Misc.toFixedPointString(data.availability, 3) : "";
|
||||
document.getElementById("averageAvailability").textContent = avgAvailability;
|
||||
|
||||
const timeElapsed = (data.seeding_time > 0)
|
||||
? "QBT_TR(%1 (seeded for %2))QBT_TR[CONTEXT=PropertiesWidget]"
|
||||
.replace("%1", window.qBittorrent.Misc.friendlyDuration(data.time_elapsed))
|
||||
@ -228,6 +239,17 @@ window.qBittorrent.PropGeneral ??= (() => {
|
||||
? "QBT_TR(Yes)QBT_TR[CONTEXT=PropertiesWidget]"
|
||||
: "QBT_TR(No)QBT_TR[CONTEXT=PropertiesWidget]")
|
||||
: "QBT_TR(N/A)QBT_TR[CONTEXT=PropertiesWidget]");
|
||||
|
||||
const row = torrentsTable.getRow(current_id);
|
||||
const state = row?.full_data?.state || "";
|
||||
const shouldShowAvailability = data.has_metadata
|
||||
&& (data.progress < 1)
|
||||
&& !state.includes("stopped")
|
||||
&& !state.includes("queued")
|
||||
&& !state.includes("checking")
|
||||
&& !state.includes("error")
|
||||
&& !state.includes("missingFiles");
|
||||
document.getElementById("propAvailabilityWrapper").style.display = shouldShowAvailability ? "contents" : "none";
|
||||
}
|
||||
else {
|
||||
clearData();
|
||||
@ -275,6 +297,25 @@ window.qBittorrent.PropGeneral ??= (() => {
|
||||
clearTimeout(loadTorrentDataTimer);
|
||||
loadTorrentDataTimer = loadTorrentData.delay(10000);
|
||||
});
|
||||
|
||||
const pieceAvailabilityURL = new URL("api/v2/torrents/pieceAvailability", window.location);
|
||||
pieceAvailabilityURL.search = new URLSearchParams({
|
||||
hash: current_id
|
||||
});
|
||||
fetch(pieceAvailabilityURL, {
|
||||
method: "GET",
|
||||
cache: "no-store"
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok)
|
||||
return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data)
|
||||
availabilityBar.setAvailability(data);
|
||||
}, (error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const updateData = () => {
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
<div id="propGeneral" class="propertiesTabContent invisible">
|
||||
<div id="propProgressWrapper">
|
||||
<span>QBT_TR(Progress:)QBT_TR[CONTEXT=PropertiesWidget]</span>
|
||||
<div id="propBarsWrapper">
|
||||
<span class="propBarLabel">QBT_TR(Progress:)QBT_TR[CONTEXT=PropertiesWidget]</span>
|
||||
<span id="progress"></span>
|
||||
<span id="progressPercentage"></span>
|
||||
|
||||
<span id="propAvailabilityWrapper">
|
||||
<span class="propBarLabel">QBT_TR(Availability:)QBT_TR[CONTEXT=PropertiesWidget]</span>
|
||||
<span id="availability"></span>
|
||||
<span id="averageAvailability"></span>
|
||||
</span>
|
||||
</div>
|
||||
<hr>
|
||||
<fieldset>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user