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