<script setup lang="ts"> import MoveTorrentDialog from '@/components/Dialogs/MoveTorrentDialog.vue' import MoveTorrentFileDialog from '@/components/Dialogs/MoveTorrentFileDialog.vue' import { FilePriority, PieceState, TorrentState } from '@/constants/qbit' import { formatData, formatDataUnit, formatDataValue, formatPercent, formatSpeed, getDomainBody, splitByUrl, stringContainsUrl } from '@/helpers' import { useDialogStore, useMaindataStore, useTorrentStore, useVueTorrentStore } from '@/stores' import { TorrentFile } from '@/types/qbit/models' import { Torrent } from '@/types/vuetorrent' import { useIntervalFn } from '@vueuse/core' import { computed, ref, watch, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { useTheme } from 'vuetify' const props = defineProps<{ torrent: Torrent; isActive: boolean }>() const { t } = useI18n() const theme = useTheme() const dialogStore = useDialogStore() const maindataStore = useMaindataStore() const torrentStore = useTorrentStore() const vuetorrentStore = useVueTorrentStore() const canvas = ref<HTMLCanvasElement>() const comment = ref('') const downloadSpeedAvg = ref(0) const files = ref<TorrentFile[]>([]) const selectedFileCount = ref(0) const torrentFileCount = ref(0) const torrentFileName = ref('') const torrentPieceSize = ref(0) const torrentPieceOwned = ref(0) const torrentPieceCount = ref(0) const uploadSpeedAvg = ref(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) async function getTorrentProperties() { const ppts = await torrentStore.getTorrentProperties(props.torrent.hash) comment.value = ppts.comment downloadSpeedAvg.value = ppts.dl_speed_avg torrentPieceCount.value = ppts.pieces_num torrentPieceOwned.value = ppts.pieces_have torrentPieceSize.value = ppts.piece_size uploadSpeedAvg.value = ppts.up_speed_avg } async function updateTorrentFiles() { files.value = await maindataStore.fetchFiles(props.torrent.hash) torrentFileCount.value = files.value.length const selectedFiles = files.value.filter(f => f.priority !== FilePriority.DO_NOT_DOWNLOAD) selectedFileCount.value = selectedFiles.length if (selectedFileCount.value === 1) { torrentFileName.value = selectedFiles[0].name } } /** * 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 = files.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() { await navigator.clipboard.writeText(props.torrent.hash) } function openMoveTorrentDialog(mode: 'dl' | 'save') { dialogStore.createDialog(MoveTorrentDialog, { hashes: [props.torrent.hash], mode }) } function openMoveTorrentFileDialog() { dialogStore.createDialog(MoveTorrentFileDialog, { hash: props.torrent.hash, isFolder: false, oldName: torrentFileName.value }) } const { resume: resumeTimer, pause: pauseTimer } = useIntervalFn( async () => { await updateTorrentFiles() if (shouldRefreshPieceState.value) { await renderTorrentPieceStates() } }, vuetorrentStore.fileContentInterval, { immediate: true, immediateCallback: true } ) watch( () => props.isActive, newValue => { if (newValue) { resumeTimer() } else { pauseTimer() } } ) watch( () => props.torrent, async () => { await getTorrentProperties() } ) function handleKeyboardShortcuts(e: KeyboardEvent) { if (dialogStore.hasActiveDialog) return false if (e.key === 'd') { e.preventDefault() openMoveTorrentDialog('dl') return true } if (e.key === 's') { e.preventDefault() openMoveTorrentDialog('save') return true } if (e.key === 'f' && selectedFileCount.value === 1) { e.preventDefault() openMoveTorrentFileDialog() return true } } onMounted(() => { document.addEventListener('keydown', handleKeyboardShortcuts) }) onUnmounted(() => { document.removeEventListener('keydown', handleKeyboardShortcuts) }) </script> <template> <v-card v-if="torrent"> <v-card-title class="text-wrap">{{ torrent.name }}</v-card-title> <v-card-subtitle> <div> <span v-for="commentPart in splitByUrl(comment)"> <a v-if="stringContainsUrl(commentPart)" target="_blank" :href="commentPart">{{ commentPart }}</a> <span v-else>{{ commentPart }}</span> </span> </div> <div class="my-1"> <span class="mr-2">{{ torrent.hash }}</span> <v-btn variant="outlined" rounded @click="copyHash">{{ $t('torrentDetail.overview.copy_hash') }}</v-btn> </div> </v-card-subtitle> <v-card-text> <v-row> <v-col cols="12" md="6"> <v-row> <v-col cols="4"> <v-progress-circular :color="torrentStateColor" :indeterminate="isFetchingMetadata" :size="100" :model-value="torrent?.progress * 100 ?? 0" :width="15"> <template v-slot> <span v-if="isFetchingMetadata">{{ $t('torrentDetail.overview.fetchingMetadata') }}</span> <v-icon v-else-if="torrent.progress === 1" icon="mdi-check" size="x-large" /> <span v-else>{{ formatPercent(torrent.progress) }}</span> </template> </v-progress-circular> </v-col> <v-col cols="8" class="d-flex flex-column align-center justify-center"> <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> <div v-if="torrentPieceCount > 0"> <span> {{ t('torrentDetail.overview.pieceCount', { owned: torrentPieceOwned, total: torrentPieceCount, pieceSize }) }} </span> </div> <div> <v-icon icon="mdi-arrow-down" /> {{ formatSpeed(torrent.dlspeed, vuetorrentStore.useBitSpeed) }} <v-icon icon="mdi-arrow-up" /> {{ formatSpeed(torrent.upspeed, vuetorrentStore.useBitSpeed) }} </div> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrent.properties.download_path') }}:</div> <div>{{ torrent.download_path }}</div> <v-btn icon="mdi-pencil" color="accent" size="x-small" @click="openMoveTorrentDialog('dl')" /> </v-col> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.fileCount') }}:</div> <div>{{ selectedFileCount }} / {{ torrentFileCount }}</div> <div v-if="selectedFileCount === 1">{{ torrentFileName }}</div> <v-btn v-if="selectedFileCount === 1" icon="mdi-pencil" color="accent" size="x-small" @click="openMoveTorrentFileDialog" /> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrent.properties.save_path') }}:</div> <div>{{ torrent.savePath }}</div> <v-btn icon="mdi-pencil" color="accent" size="x-small" @click="openMoveTorrentDialog('save')" /> </v-col> <v-col cols="6"> <div>{{ $t('torrent.properties.content_path') }}:</div> <div>{{ torrent.content_path }}</div> </v-col> </v-row> </v-col> <v-col cols="12" md="6"> <v-row> <v-col cols="6"> <div>{{ $t('torrent.properties.state') }}:</div> <v-chip variant="flat" :color="torrentStateColor">{{ $t(`torrent.state.${torrent.state}`) }}</v-chip> </v-col> <v-col cols="6"> <div>{{ $t('torrent.properties.category') }}:</div> <v-chip variant="flat" color="category"> {{ torrent.category.length ? torrent.category : $t('navbar.side.filters.uncategorized') }} </v-chip> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrent.properties.tracker') }}:</div> <v-chip variant="flat" color="tracker"> {{ torrent.tracker ? getDomainBody(torrent.tracker) : $t('navbar.side.filters.untracked') }} </v-chip> </v-col> <v-col cols="6" class="d-flex flex-wrap chipgap"> <div>{{ $t('torrent.properties.tags') }}:</div> <v-chip v-if="torrent.tags" v-for="tag in torrent.tags" :key="tag" variant="flat" color="tag"> {{ tag }} </v-chip> <v-chip v-if="!torrent.tags || torrent.tags.length === 0" variant="flat" color="tag"> {{ $t('navbar.side.filters.untagged') }} </v-chip> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.selectedFileSize') }}:</div> <div> {{ formatData(torrent.size, vuetorrentStore.useBinarySize) }} / {{ formatData(torrent.total_size, vuetorrentStore.useBinarySize) }} </div> </v-col> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.ratio') }}:</div> <div>{{ torrent.ratio }}</div> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.downloaded') }}:</div> <div>{{ formatData(torrent.downloaded, vuetorrentStore.useBinarySize) }}</div> </v-col> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.uploaded') }}:</div> <div>{{ formatData(torrent.uploaded, vuetorrentStore.useBinarySize) }}</div> </v-col> </v-row> <v-row> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.dlSpeedAverage') }}:</div> <div>{{ formatSpeed(downloadSpeedAvg, vuetorrentStore.useBitSpeed) }}</div> </v-col> <v-col cols="6"> <div>{{ $t('torrentDetail.overview.upSpeedAverage') }}:</div> <div>{{ formatSpeed(uploadSpeedAvg, vuetorrentStore.useBitSpeed) }}</div> </v-col> </v-row> </v-col> </v-row> </v-card-text> </v-card> </template> <style scoped> canvas { height: 100%; width: 100%; } .chipgap { gap: 4px; } </style>