@@ -113,29 +113,46 @@
@click.stop="$store.torrents.removeSession(job.id)">{{ t.player_delete }}
+
-
-
-
-
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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;
}