Improved torrent UI
Build and Publish / Build and Publish Docker Image (push) Successful in 2m45s

This commit is contained in:
Ultradesu
2026-05-26 16:21:21 +03:00
parent 3878d746d2
commit 82923c871e
6 changed files with 293 additions and 53 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.1.12"
version = "0.1.13"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+4
View File
@@ -357,6 +357,8 @@ translations! {
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
player_refresh: "Refresh" , "Обновить";
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
player_add_torrent: "Add torrent" , "Добавить торрент";
player_choose_saved_or_add_torrent: "Choose a saved torrent or add a new one." , "Выберите сохранённый торрент или добавьте новый.";
player_torrent_file: "Torrent file" , "Torrent-файл";
player_magnet_link: "Magnet link" , "Magnet-ссылка";
player_preview_content: "Preview content" , "Предпросмотр";
@@ -372,6 +374,8 @@ translations! {
player_failed: "Failed" , "Ошибка";
player_paused: "Paused" , "Пауза";
player_no_torrent_selected: "No torrent selected" , "Торрент не выбран";
player_downloaded: "Downloaded" , "Загружено";
player_speed: "Speed" , "Скорость";
player_down: "down" , "вниз";
player_up: "up" , "вверх";
player_peers: "peers" , "пиры";
+12 -4
View File
@@ -194,10 +194,14 @@ impl TorrentSessionRow {
self.status.as_str()
};
let stats = handle.map(|h| h.stats());
let downloaded_bytes = stats
let mut downloaded_bytes = stats
.as_ref()
.map(|s| s.progress_bytes)
.unwrap_or_else(|| i64_to_u64(self.downloaded_bytes));
let selected_size = i64_to_u64(self.selected_size);
if status == "complete" {
downloaded_bytes = selected_size;
}
let uploaded_bytes = stats
.as_ref()
.map(|s| s.uploaded_bytes)
@@ -225,7 +229,7 @@ impl TorrentSessionRow {
status: status.to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: i64_to_u64(self.total_size),
selected_size: i64_to_u64(self.selected_size),
selected_size,
downloaded_bytes,
uploaded_bytes,
progress_percent,
@@ -313,10 +317,14 @@ impl TorrentJob {
fn dto(&self) -> TorrentJobDto {
let stats = self.handle.as_ref().map(|h| h.stats());
let downloaded_bytes = stats
let mut downloaded_bytes = stats
.as_ref()
.map(|s| s.progress_bytes)
.unwrap_or(self.downloaded_bytes);
let selected_size = self.selected_size();
if self.status == TorrentJobStatus::Complete {
downloaded_bytes = selected_size;
}
let uploaded_bytes = stats
.as_ref()
.map(|s| s.uploaded_bytes)
@@ -336,7 +344,7 @@ impl TorrentJob {
status: self.status.as_str().to_string(),
client_state: stats.as_ref().map(|s| s.state.to_string()),
total_size: self.total_size(),
selected_size: self.selected_size(),
selected_size,
downloaded_bytes,
uploaded_bytes,
progress_percent: if self.status == TorrentJobStatus::Complete {
+66 -27
View File
@@ -74,7 +74,7 @@
<span x-text="$store.torrents.agentSummary()"></span>
</span>
<span class="torrent-status-pill"
x-text="$store.torrents.sessions.length + ' saved'"></span>
x-text="$store.torrents.sessions.length + ' ' + T.saved"></span>
</div>
</div>
@@ -94,7 +94,7 @@
</template>
<template x-for="job in $store.torrents.sessions" :key="job.id">
<div class="torrent-session-row"
:class="{ active: $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
:class="{ active: $store.torrents.workspaceMode === 'session' && $store.torrents.previewData && $store.torrents.previewData.id === job.id }"
@click="$store.torrents.openSession(job.id)">
<div class="torrent-session-main">
<div class="torrent-session-topline">
@@ -113,29 +113,46 @@
@click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}</button>
</div>
</template>
<button type="button"
class="torrent-session-row torrent-session-add"
:class="{ active: $store.torrents.isImporting() }"
@click="$store.torrents.addNew()"
:disabled="$store.torrents.loading">
<span class="torrent-session-add-icon">+</span>
<span>{{ t.player_add_torrent }}</span>
</button>
</div>
</aside>
<section class="torrent-workspace">
<div class="torrent-modal-grid">
<div>
<label for="torrent-file-input">{{ t.player_torrent_file }}</label>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
<template x-if="$store.torrents.workspaceMode === 'empty'">
<div class="empty-state torrent-workspace-empty">
<p x-text="T.chooseSavedOrAddTorrent"></p>
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</template>
<template x-if="$store.torrents.isImporting()">
<div class="torrent-import-panel">
<div class="torrent-modal-grid">
<div>
<label for="torrent-file-input">{{ t.player_torrent_file }}</label>
<input id="torrent-file-input" type="file" accept=".torrent,application/x-bittorrent"
@change="$store.torrents.file = $event.target.files[0] || null">
</div>
<div>
<label for="torrent-magnet-input">{{ t.player_magnet_link }}</label>
<input id="torrent-magnet-input" type="text"
x-model="$store.torrents.magnet"
placeholder="magnet:?xt=urn:btih:...">
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_preview_content }}
</button>
</div>
</div>
</div>
<div class="torrent-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.preview()" :disabled="$store.torrents.loading">
{{ t.player_preview_content }}
</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearSelection()" :disabled="!$store.torrents.previewData">{{ t.player_clear }}</button>
</div>
</template>
<template x-if="$store.torrents.currentJob">
<div class="torrent-progress-card">
@@ -147,10 +164,32 @@
<div class="torrent-progress-bar"
:style="'width:' + $store.torrents.progressValue($store.torrents.currentJob) + '%'"></div>
</div>
<div class="torrent-progress-details">
<span x-text="$store.torrents.bytes($store.torrents.currentJob.downloaded_bytes) + ' / ' + $store.torrents.bytes($store.torrents.currentJob.selected_size || $store.torrents.currentJob.total_size)"></span>
<span x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
<span x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
<div class="torrent-progress-details"
:class="{ completed: $store.torrents.isCompleted($store.torrents.currentJob) }">
<span class="torrent-progress-metric">
<span class="torrent-progress-label"
x-text="$store.torrents.isCompleted($store.torrents.currentJob) ? T.size : T.downloaded"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.progressDetailText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.speed"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.speedText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.peers"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.peerText($store.torrents.currentJob)"></span>
</span>
<span class="torrent-progress-metric"
x-show="!$store.torrents.isCompleted($store.torrents.currentJob) && $store.torrents.etaText($store.torrents.currentJob)">
<span class="torrent-progress-label" x-text="T.eta"></span>
<span class="torrent-progress-value"
x-text="$store.torrents.etaText($store.torrents.currentJob)"></span>
</span>
</div>
</div>
</template>
@@ -163,10 +202,10 @@
x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)"></div>
</div>
<button class="modal-btn"
:class="$store.torrents.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary'"
@click="$store.torrents.isCurrentDownloading() ? $store.torrents.pause() : $store.torrents.start()"
:disabled="$store.torrents.loading">
<span x-text="$store.torrents.isCurrentDownloading() ? '{{ t.player_pause_download }}' : '{{ t.player_download_selected }}'"></span>
:class="$store.torrents.actionButtonClass()"
@click="$store.torrents.toggleDownloadAction()"
:disabled="$store.torrents.actionButtonDisabled()">
<span x-text="$store.torrents.actionButtonText()"></span>
</button>
</div>
<div class="torrent-tree-toolbar">
+130 -18
View File
@@ -29,6 +29,7 @@ const T = {
processing: "{{ t.player_processing }}",
queued: "{{ t.player_queued }}",
saved: "{{ t.player_saved }}",
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
preview: "{{ t.player_preview }}",
downloading: "{{ t.player_downloading }}",
moving: "{{ t.player_moving }}",
@@ -36,6 +37,8 @@ const T = {
failed: "{{ t.player_failed }}",
paused: "{{ t.player_paused }}",
noTorrentSelected: "{{ t.player_no_torrent_selected }}",
downloaded: "{{ t.player_downloaded }}",
speed: "{{ t.player_speed }}",
down: "{{ t.player_down }}",
up: "{{ t.player_up }}",
peers: "{{ t.player_peers }}",
@@ -43,6 +46,8 @@ const T = {
seen: "{{ t.player_seen }}",
eta: "{{ t.player_eta }}",
selected: "{{ t.player_selected }}",
downloadSelected: "{{ t.player_download_selected }}",
pauseDownload: "{{ t.player_pause_download }}",
chooseTorrent: "{{ t.player_choose_torrent }}",
readingTorrent: "{{ t.player_reading_torrent }}",
resolvingMagnet: "{{ t.player_resolving_magnet }}",
@@ -1083,6 +1088,7 @@ document.addEventListener('alpine:init', () => {
loadingSessions: false,
currentJob: null,
previewData: null,
workspaceMode: 'empty',
treeRoot: null,
selected: new Set(),
expanded: new Set(),
@@ -1090,6 +1096,7 @@ document.addEventListener('alpine:init', () => {
message: '',
error: false,
_pollTimer: null,
_pollJobId: null,
_refreshTimer: null,
queuedTasks: 0,
processingTasks: 0,
@@ -1107,6 +1114,31 @@ document.addEventListener('alpine:init', () => {
close() {
this.modal = false;
this._stopRefresh();
this._stopPoll();
},
_stopPoll() {
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = null;
this._pollJobId = null;
},
isImporting() {
return this.workspaceMode === 'new';
},
addNew() {
if (this.loading) return;
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
this.magnet = '';
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
this.selected = new Set();
this.expanded = new Set();
this._setMessage(T.chooseTorrent);
},
_setMessage(message, error = false) {
@@ -1138,6 +1170,35 @@ document.addEventListener('alpine:init', () => {
return this.isDownloading(this.currentJob);
},
isCompleted(job) {
return this.normalizedStatus(job) === 'completed';
},
isCurrentCompleted() {
return this.isCompleted(this.currentJob);
},
selectedArray() {
return [...this.selected].sort((a, b) => Number(a) - Number(b));
},
selectionMatchesJob(job) {
if (!job) return false;
const saved = Array.isArray(job.selected_files) ? job.selected_files : [];
const selected = this.selectedArray();
if (saved.length !== selected.length) return false;
const savedSet = new Set(saved.map(index => Number(index)));
return selected.every(index => savedSet.has(Number(index)));
},
hasCurrentSelectionChanges() {
return !!this.currentJob && !this.selectionMatchesJob(this.currentJob);
},
isCurrentCompletedLocked() {
return this.isCurrentCompleted() && !this.hasCurrentSelectionChanges();
},
normalizedStatus(job) {
const status = String(job?.status || 'preview').toLowerCase();
if (status === 'complete') return 'completed';
@@ -1195,15 +1256,45 @@ document.addEventListener('alpine:init', () => {
speedText(job) {
if (!job) return '0 B/s';
const down = Number(job.download_speed_mbps || 0);
const up = Number(job.upload_speed_mbps || 0);
return T.down + ' ' + down.toFixed(2) + ' MiB/s - ' + T.up + ' ' + up.toFixed(2) + ' MiB/s';
return down.toFixed(2) + ' MiB/s';
},
peerText(job) {
if (!job) return T.peers + ' n/a';
if (!job) return 'n/a';
const live = job.peers_live == null ? '?' : job.peers_live;
const seen = job.peers_seen == null ? '?' : job.peers_seen;
return T.peers + ' ' + live + ' ' + T.live + ' / ' + seen + ' ' + T.seen + (job.eta ? ' - ' + T.eta + ' ' + job.eta : '');
return live + ' ' + T.live + ' / ' + seen + ' ' + T.seen;
},
etaText(job) {
return job && job.eta ? job.eta : '';
},
progressDetailText(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
if (this.isCompleted(job)) return size;
return this.bytes(job.downloaded_bytes) + ' / ' + size;
},
actionButtonClass() {
if (this.isCurrentCompletedLocked()) return 'modal-btn-ghost';
return this.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary';
},
actionButtonText() {
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
return this.loading || this.isCurrentCompletedLocked();
},
toggleDownloadAction() {
if (this.isCurrentCompletedLocked()) return;
if (this.isCurrentDownloading()) this.pause();
else this.start();
},
sessionMeta(job) {
@@ -1249,9 +1340,26 @@ document.addEventListener('alpine:init', () => {
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
},
_isSelectedJob(id) {
return this.workspaceMode === 'session'
&& this.currentJob
&& this.currentJob.id === id
&& this.previewData
&& this.previewData.id === id;
},
_syncCurrentJobFromSessions() {
if (!this.currentJob || !this.previewData) return;
const selected = this.sessions.find(job => job.id === this.currentJob.id && job.id === this.previewData.id);
if (selected) this.currentJob = selected;
},
_applySession(data) {
const preview = data.preview || data;
const job = data.job || null;
this.workspaceMode = 'session';
this.file = null;
this.magnet = '';
this.previewData = preview;
this.currentJob = job;
const selected = Array.isArray(data.selected_files) && data.selected_files.length
@@ -1269,6 +1377,7 @@ document.addEventListener('alpine:init', () => {
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : [];
this._syncCurrentJobFromSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
@@ -1278,6 +1387,7 @@ document.addEventListener('alpine:init', () => {
async openSession(id) {
if (!id || this.loading) return;
this._stopPoll();
this.loading = true;
this._setMessage(T.openingSavedTorrent);
try {
@@ -1310,6 +1420,7 @@ document.addEventListener('alpine:init', () => {
this.currentJob = null;
this.treeRoot = null;
this.selected = new Set();
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
}
this._setMessage(T.torrentRemoved);
} catch (err) {
@@ -1343,6 +1454,7 @@ document.addEventListener('alpine:init', () => {
this.previewData = null;
this.treeRoot = null;
this.currentJob = null;
this.workspaceMode = 'new';
this.selected = new Set();
this.expanded = new Set();
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
@@ -1554,7 +1666,7 @@ document.addEventListener('alpine:init', () => {
async start() {
if (!this.previewData || this.loading) return;
const selected = [...this.selected];
const selected = this.selectedArray();
if (selected.length === 0) {
this._setMessage(T.selectOneFile, true);
return;
@@ -1596,8 +1708,7 @@ document.addEventListener('alpine:init', () => {
this.currentJob = data;
this._rememberJob(data);
this._setMessage(T.downloadPaused);
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = null;
this._stopPoll();
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
@@ -1607,28 +1718,29 @@ document.addEventListener('alpine:init', () => {
},
_poll(id) {
if (this._pollTimer) clearInterval(this._pollTimer);
this._stopPoll();
this._pollJobId = id;
this._pollTimer = setInterval(async () => {
try {
const res = await fetch(`/api/player/torrents/${id}/status`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.statusFailed);
this.currentJob = data;
this._rememberJob(data);
this._setMessage(
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
data.status === 'failed'
);
if (this._isSelectedJob(id)) {
this.currentJob = data;
this._setMessage(
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
data.status === 'failed'
);
}
if (data.status === 'complete' || data.status === 'failed') {
clearInterval(this._pollTimer);
this._pollTimer = null;
this._stopPoll();
this.loadSessions();
this.loadAgentStatus();
}
} catch (err) {
this._setMessage(err.message || String(err), true);
clearInterval(this._pollTimer);
this._pollTimer = null;
if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
this._stopPoll();
}
}, 2000);
},
+80 -3
View File
@@ -1864,6 +1864,38 @@ button.user-stat:hover {
cursor: pointer;
}
.torrent-session-add {
width: 100%;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
text-align: left;
font: inherit;
font-size: 13px;
font-weight: 800;
}
.torrent-session-add:disabled {
cursor: default;
opacity: 0.6;
}
.torrent-session-add-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid rgba(29,185,84,0.38);
color: #9ff0b9;
font-size: 16px;
line-height: 1;
}
.torrent-session-row:last-child { border-bottom: 0; }
.torrent-session-row:hover,
.torrent-session-row.active { background: var(--bg-hover); }
@@ -2005,12 +2037,44 @@ button.user-stat:hover {
}
.torrent-progress-details {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
color: var(--text-subdued);
min-height: 38px;
}
.torrent-progress-details.completed {
grid-template-columns: minmax(0, 1fr);
}
.torrent-progress-metric {
min-width: 0;
min-height: 38px;
padding: 5px 7px;
border-radius: 6px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.torrent-progress-label {
color: var(--text-muted);
font-size: 10px;
line-height: 12px;
text-transform: uppercase;
font-weight: 800;
}
.torrent-progress-value {
color: var(--text-secondary);
font-size: 12px;
line-height: 14px;
font-weight: 700;
overflow-wrap: anywhere;
}
.history-modal {
@@ -2074,6 +2138,11 @@ button.user-stat:hover {
max-width: 360px;
}
.torrent-import-panel,
.torrent-workspace-empty {
min-height: 150px;
}
.torrent-modal label {
display: block;
margin-bottom: 6px;
@@ -2753,6 +2822,14 @@ button.user-stat:hover {
gap: 4px;
}
.torrent-progress-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.torrent-progress-details.completed {
grid-template-columns: minmax(0, 1fr);
}
.torrent-modal h3 {
margin-bottom: 12px;
}