diff --git a/Cargo.toml b/Cargo.toml index 1d1b6bf..6d20f7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index b46ca2f..ab1ef57 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -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" , "пиры"; diff --git a/src/torrents.rs b/src/torrents.rs index a7729d7..e0ce543 100644 --- a/src/torrents.rs +++ b/src/torrents.rs @@ -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 { diff --git a/templates/player/modals.html b/templates/player/modals.html index 8a32326..0b988b5 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -74,7 +74,7 @@ + x-text="$store.torrents.sessions.length + ' ' + T.saved"> @@ -94,7 +94,7 @@ +
-
-
- - + + + @@ -163,10 +202,10 @@ x-text="$store.torrents.previewData.files.length + ' {{ t.player_files_count }} - ' + $store.torrents.bytes($store.torrents.previewData.total_size)">
diff --git a/templates/player/scripts.html b/templates/player/scripts.html index a1d98e9..ac2bae2 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -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); }, diff --git a/templates/player/styles.html b/templates/player/styles.html index 49299a9..af9b19e 100644 --- a/templates/player/styles.html +++ b/templates/player/styles.html @@ -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; }