perf(TorrentDetail): Overview Piece Renderer (#1564)

This commit is contained in:
Fred Kilbourn 2024-03-11 21:57:15 -05:00 committed by GitHub
parent 82c55d0161
commit 5ed280c0dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 180 additions and 104 deletions

71
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@ctrl/tinycolor": "^4.0.3",
"@faker-js/faker": "^8.4.1",
"@flatten-js/interval-tree": "^1.1.2",
"@fontsource/roboto": "^5.0.12",
"@fontsource/roboto-mono": "^5.0.17",
"@mdi/font": "^7.4.47",
@ -21,6 +22,7 @@
"lodash.debounce": "^4.0.8",
"pinia": "^2.1.6",
"pinia-plugin-persist": "^1.0.0",
"pixi.js": "^8.0.1",
"uuid": "^9.0.1",
"vite-plugin-vuetify": "^2.0.2",
"vue": "^3.4.21",
@ -571,6 +573,11 @@
"npm": ">=6.14.13"
}
},
"node_modules/@flatten-js/interval-tree": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.2.tgz",
"integrity": "sha512-OwLoV9E/XM6b7bes2rSFnGNjyRy7vcoIHFTnmBR2WAaZTf0Fe4EX4GdA65vU1KgFAasti7iRSg2dZfYd1Zt00Q=="
},
"node_modules/@fontsource/roboto": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.12.tgz",
@ -881,6 +888,11 @@
}
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA=="
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -1276,6 +1288,16 @@
"integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==",
"dev": true
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA=="
},
"node_modules/@types/earcut": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ=="
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@ -1936,6 +1958,19 @@
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.40.tgz",
"integrity": "sha512-/BBkHLS6/eQjyWhY2H7Dx5DHcVrS2ICj9owvSRdgtQT6KcafLZA86tPze0xAOsd4FbsYKCUBUQyNi87q7gV7kw=="
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@yr/monotone-cubic-spline": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz",
@ -2457,6 +2492,11 @@
"node": ">=6.0.0"
}
},
"node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ=="
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@ -2768,6 +2808,11 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@ -3298,6 +3343,11 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw=="
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@ -3883,6 +3933,11 @@
"node": ">=6"
}
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="
},
"node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
@ -4078,6 +4133,22 @@
}
}
},
"node_modules/pixi.js": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.0.1.tgz",
"integrity": "sha512-SGtkod644kb/k+hTvSSk9ywpmvgdjiX+gK6NF8He1xyQ0XCRn5ZqN37EPEBsg0ffadjZ40mqtO+2oXyEOLrWzw==",
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/css-font-loading-module": "^0.0.12",
"@types/earcut": "^2.1.4",
"@webgpu/types": "^0.1.40",
"@xmldom/xmldom": "^0.8.10",
"earcut": "^2.2.4",
"eventemitter3": "^5.0.1",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.1.2"
}
},
"node_modules/pkg-types": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz",

View file

@ -17,6 +17,7 @@
"dependencies": {
"@ctrl/tinycolor": "^4.0.3",
"@faker-js/faker": "^8.4.1",
"@flatten-js/interval-tree": "^1.1.2",
"@fontsource/roboto": "^5.0.12",
"@fontsource/roboto-mono": "^5.0.17",
"@mdi/font": "^7.4.47",
@ -28,6 +29,7 @@
"lodash.debounce": "^4.0.8",
"pinia": "^2.1.6",
"pinia-plugin-persist": "^1.0.0",
"pixi.js": "^8.0.1",
"uuid": "^9.0.1",
"vite-plugin-vuetify": "^2.0.2",
"vue": "^3.4.21",

View file

@ -171,15 +171,6 @@ onBeforeMount(() => {
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model.number="vueTorrentStore.canvasRenderThreshold" hide-details type="number" :label="t('settings.vuetorrent.general.canvasRenderThreshold')" />
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model.number="vueTorrentStore.canvasRefreshThreshold" hide-details type="number" :label="t('settings.vuetorrent.general.canvasRefreshThreshold')" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select v-model="vueTorrentStore.language" flat hide-details :items="LOCALES" :label="t('settings.vuetorrent.general.language')" />

View file

@ -2,28 +2,24 @@
import ConfirmDeleteDialog from '@/components/Dialogs/ConfirmDeleteDialog.vue'
import MoveTorrentDialog from '@/components/Dialogs/MoveTorrentDialog.vue'
import MoveTorrentFileDialog from '@/components/Dialogs/MoveTorrentFileDialog.vue'
import { FilePriority, PieceState, TorrentState } from '@/constants/qbit'
import { FilePriority, TorrentState } from '@/constants/qbit'
import { formatData, formatDataUnit, formatDataValue, formatPercent, formatSpeed, getDomainBody, splitByUrl, stringContainsUrl } from '@/helpers'
import { useContentStore, useDialogStore, useMaindataStore, useTorrentDetailStore, useVueTorrentStore } from '@/stores'
import { useContentStore, useDialogStore, useTorrentDetailStore, useVueTorrentStore } from '@/stores'
import { Torrent } from '@/types/vuetorrent'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue3-toastify'
import { useTheme } from 'vuetify'
import PieceCanvas from './PieceCanvas.vue'
const props = defineProps<{ torrent: Torrent; isActive: boolean }>()
const { t } = useI18n()
const theme = useTheme()
const { cachedFiles } = storeToRefs(useContentStore())
const dialogStore = useDialogStore()
const maindataStore = useMaindataStore()
const { properties } = storeToRefs(useTorrentDetailStore())
const vuetorrentStore = useVueTorrentStore()
const canvas = ref<HTMLCanvasElement>()
const selectedFiles = computed(() => cachedFiles.value.filter(f => f.priority !== FilePriority.DO_NOT_DOWNLOAD))
const torrentFileCount = computed(() => cachedFiles.value.length)
const torrentFileName = computed(() => (selectedFiles.value.length === 1 ? selectedFiles.value[0].name : ''))
@ -38,62 +34,6 @@ const uploadSpeedAvg = computed(() => properties.value?.up_speed_avg ?? 0)
const torrentStateColor = computed(() => `torrent-${props.torrent.state}`)
const pieceSize = computed(() => `${parseInt(formatDataValue(torrentPieceSize.value, true))} ${formatDataUnit(torrentPieceSize.value, true)}`)
const isFetchingMetadata = computed(() => props.torrent.state === TorrentState.META_DL)
const shouldRenderPieceState = computed(() => !isFetchingMetadata.value && torrentPieceCount.value > 0 && torrentPieceCount.value < vuetorrentStore.canvasRenderThreshold)
const shouldRefreshPieceState = computed(() => shouldRenderPieceState.value && torrentPieceCount.value < vuetorrentStore.canvasRefreshThreshold)
/**
* Source:
* https://github.com/qbittorrent/qBittorrent/blob/6229b817300344759139d2fedbd59651065a561d/src/webui/www/private/scripts/prop-general.js#L230
*/
async function renderTorrentPieceStates() {
if (!canvas.value) return
const pieces = await maindataStore.fetchPieceState(props.torrent.hash)
canvas.value.width = pieces.length || -1
const ctx = canvas.value.getContext('2d') as CanvasRenderingContext2D
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
// Group contiguous colors together and draw as a single rectangle
let color = ''
let rectWidth = 1
for (let i = 0; i < pieces.length; ++i) {
const state = pieces[i]
let newColor = ''
if (state === PieceState.DOWNLOADING) newColor = theme.current.value.colors['torrent-downloading']
else if (state === PieceState.DOWNLOADED) newColor = theme.current.value.colors['torrent-pausedUP']
else if (state === PieceState.MISSING) {
const selected_piece_ranges = cachedFiles.value.filter(file => file.priority !== FilePriority.DO_NOT_DOWNLOAD).map(file => file.piece_range)
for (const [min_piece_range, max_piece_range] of selected_piece_ranges) {
if (i > min_piece_range && i < max_piece_range) {
newColor = theme.current.value.colors['torrent-pausedDL']
break
}
}
}
if (newColor === color) {
++rectWidth
continue
}
if (color !== '') {
ctx.fillStyle = color
ctx.fillRect(i - rectWidth, 0, rectWidth, canvas.value.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(pieces.length - rectWidth, 0, rectWidth, canvas.value.height)
}
}
async function copyHash() {
try {
@ -118,12 +58,6 @@ function openMoveTorrentFileDialog() {
})
}
watch(cachedFiles, () => {
if (props.isActive && shouldRefreshPieceState.value) {
renderTorrentPieceStates()
}
})
function handleKeyboardShortcuts(e: KeyboardEvent) {
if (dialogStore.hasActiveDialog || !props.isActive) return false
@ -156,7 +90,7 @@ onMounted(() => {
document.addEventListener('keydown', handleKeyboardShortcuts)
})
onUnmounted(() => {
onUnmounted(async () => {
document.removeEventListener('keydown', handleKeyboardShortcuts)
})
</script>
@ -193,15 +127,8 @@ onUnmounted(() => {
<div v-if="isFetchingMetadata">
<span>{{ $t('torrentDetail.overview.waitingForMetadata') }}</span>
</div>
<div v-else-if="shouldRenderPieceState">
<canvas ref="canvas" width="0" height="10" />
</div>
<div v-if="!isFetchingMetadata && !shouldRenderPieceState">
<span>{{ $t('torrentDetail.overview.canvasRenderDisabled') }}</span>
</div>
<div v-else-if="!isFetchingMetadata && !shouldRefreshPieceState">
<span>{{ $t('torrentDetail.overview.canvasRefreshDisabled') }}</span>
<div v-else>
<PieceCanvas :torrent="torrent" :isActive="isActive" />
</div>
<div v-if="torrentPieceCount > 0">
@ -327,11 +254,6 @@ onUnmounted(() => {
</template>
<style scoped>
canvas {
height: 100%;
width: 100%;
}
.chipgap {
gap: 4px;
}

View file

@ -0,0 +1,100 @@
<script setup lang="ts">
import { FilePriority, PieceState } from '@/constants/qbit'
import { useContentStore, useMaindataStore } from '@/stores'
import { Torrent } from '@/types/vuetorrent'
import IntervalTree from '@flatten-js/interval-tree'
import { storeToRefs } from 'pinia'
import { Application, Graphics } from 'pixi.js'
import { onMounted } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { useTheme } from 'vuetify'
const props = defineProps<{ torrent: Torrent; isActive: boolean }>()
const theme = useTheme()
const { cachedFiles } = storeToRefs(useContentStore())
const maindataStore = useMaindataStore()
const canvas = ref<HTMLCanvasElement>()
const app = ref<Application>()
const lastGraphics = ref<Graphics>()
async function renderCanvas() {
if (!canvas.value || !app.value) return
const pieces = await maindataStore.fetchPieceState(props.torrent.hash)
const selectedRanges = new IntervalTree()
cachedFiles.value.filter(file => file.priority !== FilePriority.DO_NOT_DOWNLOAD).forEach(file => selectedRanges.insert(file.piece_range, file.name))
const graphics = new Graphics()
// Group contiguous colors together and draw as a single rectangle
let color = ''
let rectWidth = 1
for (let i = 0; i < pieces.length; ++i) {
const state = pieces[i]
let newColor = ''
if (state === PieceState.DOWNLOADING) newColor = theme.current.value.colors['torrent-downloading']
else if (state === PieceState.DOWNLOADED) newColor = theme.current.value.colors['torrent-pausedUP']
else if (state === PieceState.MISSING && selectedRanges.intersect_any([i, i])) newColor = theme.current.value.colors['torrent-pausedDL']
if (newColor === color) {
++rectWidth
continue
}
if (color !== '') {
graphics.rect(((i - rectWidth) / pieces.length) * canvas.value.width, 0, (rectWidth / pieces.length) * canvas.value.width, canvas.value.height)
graphics.fill(color)
}
rectWidth = 1
color = newColor
}
// Fill a rect at the end of the canvas if one is needed
if (color !== '') {
graphics.rect(((pieces.length - rectWidth) / pieces.length) * canvas.value.width, 0, (rectWidth / pieces.length) * canvas.value.width, canvas.value.height)
graphics.fill(color)
}
app.value.stage.addChild(graphics)
if (lastGraphics.value) lastGraphics.value.destroy()
lastGraphics.value = graphics
}
watch(cachedFiles, () => {
if (props.isActive) {
renderCanvas()
}
})
onMounted(async () => {
if (!canvas.value) return
app.value = new Application()
await app.value.init({ antialias: true, width: canvas.value.width, height: canvas.value.height, canvas: canvas.value })
if (cachedFiles.value) {
renderCanvas()
}
})
onUnmounted(() => {
if (app.value) app.value.destroy(false, { children: true })
})
</script>
<template>
<canvas ref="canvas" width="4096" height="20" />
</template>
<style scoped>
canvas {
height: 100%;
width: 100%;
}
</style>

View file

@ -910,8 +910,6 @@
"title": "Settings",
"vuetorrent": {
"general": {
"canvasRefreshThreshold": "Piece count to disable canvas auto-refresh",
"canvasRenderThreshold": "Piece count to disable canvas rendering",
"check_new": "Check for new version",
"currentVersion": "Current Version",
"customTitle": "Custom title",
@ -1080,8 +1078,6 @@
"text_values": "Text values"
},
"overview": {
"canvasRefreshDisabled": "Canvas auto-refresh is disabled",
"canvasRenderDisabled": "Canvas rendering is disabled",
"copy_hash": "Copy Hash",
"dlSpeedAverage": "Download Speed Average",
"downloaded": "Downloaded",

View file

@ -33,8 +33,6 @@ export const useVueTorrentStore = defineStore(
const useBinarySize = ref(false)
const refreshInterval = ref(2000)
const fileContentInterval = ref(5000)
const canvasRenderThreshold = ref(3000)
const canvasRefreshThreshold = ref(5000)
const useIdForRssLinks = ref(false)
const _busyProperties = ref<PropertyData>(JSON.parse(JSON.stringify(propsData)))
@ -215,8 +213,6 @@ export const useVueTorrentStore = defineStore(
}
return {
canvasRenderThreshold,
canvasRefreshThreshold,
vuetorrentTheme,
dateFormat,
deleteWithFiles,
@ -290,8 +286,6 @@ export const useVueTorrentStore = defineStore(
useBinarySize.value = false
refreshInterval.value = 2000
fileContentInterval.value = 5000
canvasRenderThreshold.value = 3000
canvasRefreshThreshold.value = 5000
useIdForRssLinks.value = false
_busyProperties.value = JSON.parse(JSON.stringify(propsData))