Web UI: move downloads from bottom bar to ⬇ modal

The fixed bottom #jobs bar ate up to 40vh of screen real estate even
when only one job was running, and on mobile it competed with the URL
input. Replace it with a ⬇ button in the header that:

- shows a badge with active+queued count (turns accent-colored when >0)
- opens a modal listing all jobs when clicked (or with the 'd' shortcut)
- repaints live from the same WS/polling snapshot that drove the old bar

The footer URL input + quality picker stay where they are — that's the
entry point, not the status display.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 02:03:58 -07:00
parent 2e98d8553f
commit 0d7ae83873
3 changed files with 131 additions and 68 deletions

123
Cargo.lock generated
View file

@ -457,9 +457,9 @@ dependencies = [
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "axum" name = "axum"
@ -627,9 +627,9 @@ dependencies = [
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
@ -1001,9 +1001,9 @@ dependencies = [
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1697,9 +1697,9 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
@ -2013,9 +2013,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.98" version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@ -2120,9 +2120,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]] [[package]]
name = "mac-notification-sys" name = "mac-notification-sys"
@ -2153,9 +2153,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]] [[package]]
name = "memmap2" name = "memmap2"
@ -2332,9 +2332,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
@ -2658,9 +2658,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "orbclient" name = "orbclient"
version = "0.3.54" version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a570f6bca41d29acb2139229a7c873ec99bc9a313bd10804081d89bfac8ff329" checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
dependencies = [ dependencies = [
"libc", "libc",
"libredox", "libredox",
@ -2739,18 +2739,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.12" version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [ dependencies = [
"pin-project-internal", "pin-project-internal",
] ]
[[package]] [[package]]
name = "pin-project-internal" name = "pin-project-internal"
version = "1.1.12" version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3259,9 +3259,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@ -3732,21 +3732,21 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.2" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime 0.6.3", "toml_datetime 0.6.11",
"toml_edit 0.20.2", "toml_edit 0.22.27",
] ]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.3" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -3762,15 +3762,16 @@ dependencies = [
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.20.2" version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime 0.6.3", "toml_datetime 0.6.11",
"winnow 0.5.40", "toml_write",
"winnow 0.7.15",
] ]
[[package]] [[package]]
@ -3782,7 +3783,7 @@ dependencies = [
"indexmap", "indexmap",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow 1.0.2", "winnow 1.0.3",
] ]
[[package]] [[package]]
@ -3791,9 +3792,15 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [ dependencies = [
"winnow 1.0.2", "winnow 1.0.3",
] ]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@ -4053,9 +4060,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -4066,9 +4073,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.71" version = "0.4.72"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@ -4076,9 +4083,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -4086,9 +4093,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -4099,9 +4106,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.121" version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -4277,9 +4284,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.98" version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@ -4877,18 +4884,18 @@ dependencies = [
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.5.40" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -5180,7 +5187,7 @@ dependencies = [
"uds_windows", "uds_windows",
"uuid", "uuid",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"winnow 1.0.2", "winnow 1.0.3",
"zbus_macros 5.15.0", "zbus_macros 5.15.0",
"zbus_names 4.3.2", "zbus_names 4.3.2",
"zvariant 5.11.0", "zvariant 5.11.0",
@ -5256,7 +5263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d"
dependencies = [ dependencies = [
"serde", "serde",
"winnow 1.0.2", "winnow 1.0.3",
"zvariant 5.11.0", "zvariant 5.11.0",
] ]
@ -5295,9 +5302,9 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [ dependencies = [
"zerofrom-derive", "zerofrom-derive",
] ]
@ -5391,7 +5398,7 @@ dependencies = [
"enumflags2", "enumflags2",
"serde", "serde",
"url", "url",
"winnow 1.0.2", "winnow 1.0.3",
"zvariant_derive 5.11.0", "zvariant_derive 5.11.0",
"zvariant_utils 3.3.1", "zvariant_utils 3.3.1",
] ]
@ -5443,5 +5450,5 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"syn 2.0.117", "syn 2.0.117",
"winnow 1.0.2", "winnow 1.0.3",
] ]

View file

@ -19,7 +19,7 @@ optdepends=(
) )
makedepends=('rust' 'cargo') makedepends=('rust' 'cargo')
options=('!lto') # rusqlite bundled sqlite cannot be LTO-linked with rust-lld options=('!lto') # rusqlite bundled sqlite cannot be LTO-linked with rust-lld
source=("git+https://codeberg.org/anassaeneroi/yt-offline.git") source=("git+https://codeberg.org/anassaeneroi/yt-offline.git#branch=main")
sha256sums=('SKIP') sha256sums=('SKIP')
pkgver() { pkgver() {

View file

@ -92,7 +92,14 @@
.meta-tab{padding:4px 10px;cursor:pointer;font-size:12px;border:1px solid transparent;border-bottom:none;border-radius:4px 4px 0 0} .meta-tab{padding:4px 10px;cursor:pointer;font-size:12px;border:1px solid transparent;border-bottom:none;border-radius:4px 4px 0 0}
.meta-tab.active{background:var(--card);border-color:var(--border)} .meta-tab.active{background:var(--card);border-color:var(--border)}
.meta-raw{font-family:monospace;font-size:11px;background:var(--bg);padding:10px;border-radius:4px;white-space:pre;overflow:auto;max-height:60vh} .meta-raw{font-family:monospace;font-size:11px;background:var(--bg);padding:10px;border-radius:4px;white-space:pre;overflow:auto;max-height:60vh}
#jobs{background:var(--panel);border-top:1px solid var(--border);flex-shrink:0;max-height:40vh;overflow-y:auto} /* Jobs render inside the Downloads modal now. The dl-btn badge
on the header shows the active count so users still get an
at-a-glance status without the old fixed bottom bar eating
screen real estate. */
#dl-btn{position:relative}
#dl-btn.has-active{color:var(--accent)}
#dl-badge{position:absolute;top:-4px;right:-4px;background:var(--accent);color:#000;font-size:10px;font-weight:700;border-radius:9px;padding:1px 5px;min-width:16px;text-align:center;line-height:1.2;display:none}
#dl-btn.has-active #dl-badge{display:inline-block}
.job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;border-bottom:1px solid var(--border);flex-wrap:wrap;min-width:0} .job{display:flex;align-items:center;gap:8px;padding:5px 14px;font-size:12px;border-bottom:1px solid var(--border);flex-wrap:wrap;min-width:0}
.badge{font-weight:700;min-width:48px;flex-shrink:0} .badge{font-weight:700;min-width:48px;flex-shrink:0}
.badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171} .badge.running{color:#facc15}.badge.done{color:#4ade80}.badge.failed{color:#f87171}
@ -164,6 +171,7 @@
<span id="hdr-stats"></span> <span id="hdr-stats"></span>
<button onclick="shufflePlay()" title="Play a random unwatched video">🎲</button> <button onclick="shufflePlay()" title="Play a random unwatched video">🎲</button>
<button onclick="rescan()" title="Rescan library"></button> <button onclick="rescan()" title="Rescan library"></button>
<button id="dl-btn" onclick="openDownloads()" title="Downloads"><span id="dl-badge">0</span></button>
<button onclick="openStats()" title="Library statistics">📊</button> <button onclick="openStats()" title="Library statistics">📊</button>
<button onclick="openMaintenance()" title="Library health">🩺</button> <button onclick="openMaintenance()" title="Library health">🩺</button>
<button onclick="showShortcutsHelp()" title="Keyboard shortcuts (?)">?</button> <button onclick="showShortcutsHelp()" title="Keyboard shortcuts (?)">?</button>
@ -189,7 +197,6 @@
</section> </section>
</main> </main>
<div id="details"></div> <div id="details"></div>
<div id="jobs"></div>
<footer> <footer>
<input type="url" id="dl-url" placeholder="YouTube URL…" oninput="refreshTwitchClipsVisibility()" onkeydown="if(event.key==='Enter')previewDownload()"> <input type="url" id="dl-url" placeholder="YouTube URL…" oninput="refreshTwitchClipsVisibility()" onkeydown="if(event.key==='Enter')previewDownload()">
<button class="primary" onclick="previewDownload()">⬇ Download</button> <button class="primary" onclick="previewDownload()">⬇ Download</button>
@ -1282,7 +1289,7 @@ function fmtCountdown(secs){
async function updateYtdlp(btn){ async function updateYtdlp(btn){
btn.disabled=true; btn.disabled=true;
const st=document.getElementById('ytdlp-status'); const st=document.getElementById('ytdlp-status');
st.textContent='Started — see Downloads bar'; st.textContent='Started — click ⬇ in the header to view progress';
try{ try{
const r=await fetch('/api/ytdlp/update',{method:'POST'}); const r=await fetch('/api/ytdlp/update',{method:'POST'});
const t=await r.text(); const t=await r.text();
@ -1505,17 +1512,38 @@ async function repairAll(btn){
} }
/* ── Jobs ───────────────────────────────────────────────────────── */ /* ── Jobs ───────────────────────────────────────────────────────── */
// Jobs now live behind the ⬇ Downloads modal instead of a fixed bottom
// bar. This function:
// 1. Updates the header badge (active + queued count) on every snapshot
// 2. If the modal is open, repaints its body so progress bars stay live
// The modal is identified by its data-downloads-modal attribute so we can
// reach it without scanning every .modal-bg in the DOM.
function renderJobs(jobs,queued,maxConcurrent){ function renderJobs(jobs,queued,maxConcurrent){
const el=document.getElementById('jobs'); jobs=jobs||[];queued=queued||[];
if(!jobs.length&&!queued?.length){el.innerHTML='';return} // Badge: active jobs (running) + queued. Finished/failed don't bump the
// badge — they're informational, not in-progress.
const activeCount=jobs.filter(j=>j.state==='running').length+queued.length;
const btn=document.getElementById('dl-btn');
const badge=document.getElementById('dl-badge');
if(btn&&badge){
badge.textContent=String(activeCount);
btn.classList.toggle('has-active',activeCount>0);
}
// If the modal isn't open we're done.
const body=document.getElementById('downloads-body');
if(!body)return;
if(!jobs.length&&!queued.length){
body.innerHTML=`<div class="empty">No active or recent downloads.</div>`;
return;
}
const fin=jobs.some(j=>j.state!=='running'); const fin=jobs.some(j=>j.state!=='running');
const hdr=fin?`<div style="padding:4px 14px;display:flex;justify-content:flex-end;border-bottom:1px solid var(--border)"><button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button></div>`:''; const hdr=fin?`<div style="padding:4px 14px;display:flex;justify-content:flex-end;border-bottom:1px solid var(--border)"><button onclick="clearFinishedJobs()" style="font-size:11px;padding:2px 8px">✕ Clear finished</button></div>`:'';
const queuedHtml=(queued&&queued.length)?queued.map(q=>`<div class="job"> const queuedHtml=queued.map(q=>`<div class="job">
<span class="badge" style="color:var(--muted)">queued</span> <span class="badge" style="color:var(--muted)">queued</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)">${esc(q.label)} — ${esc(q.url)}</span> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)">${esc(q.label)} — ${esc(q.url)}</span>
</div>`).join(''):''; </div>`).join('');
const limitNote=maxConcurrent>0?`<div style="font-size:11px;color:var(--muted);padding:2px 14px">max ${maxConcurrent} concurrent</div>`:''; const limitNote=maxConcurrent>0?`<div style="font-size:11px;color:var(--muted);padding:6px 14px">max ${maxConcurrent} concurrent</div>`:'';
el.innerHTML=hdr+jobs.map((j,i)=>{ body.innerHTML=hdr+jobs.map((j,i)=>{
const dismiss=j.state!=='running'?`<button onclick="removeJob(${i})" style="font-size:11px;padding:1px 6px"></button>`:''; const dismiss=j.state!=='running'?`<button onclick="removeJob(${i})" style="font-size:11px;padding:1px 6px"></button>`:'';
return `<div class="job"> return `<div class="job">
<span class="badge ${j.state}">${j.state}</span> <span class="badge ${j.state}">${j.state}</span>
@ -1526,6 +1554,29 @@ function renderJobs(jobs,queued,maxConcurrent){
</div>`; </div>`;
}).join('')+queuedHtml+limitNote; }).join('')+queuedHtml+limitNote;
} }
// Open the Downloads modal. If it's already open, do nothing (clicking the
// ⬇ button again would otherwise stack duplicate modals).
async function openDownloads(){
if(document.querySelector('[data-downloads-modal]'))return;
const bg=document.createElement('div');
bg.className='modal-bg';
bg.setAttribute('data-downloads-modal','1');
bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:720px;width:100%">
<div class="modal-hdr"><h2>⬇ Downloads</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div id="downloads-body" style="overflow:auto;max-height:80vh;min-height:120px"><em style="color:var(--muted);padding:14px;display:block">Loading…</em></div>
</div>`;
document.body.appendChild(bg);
// Repaint immediately from the latest snapshot if we have one — saves a
// round trip. Falls through to /api/progress otherwise.
try{
const d=await(await api('/api/progress')).json();
renderJobs(d.jobs,d.queued,d.max_concurrent);
}catch(e){
document.getElementById('downloads-body').innerHTML=
`<div style="color:#f87171;padding:14px">Failed to load: ${esc(e.message)}</div>`;
}
}
async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}} async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}} async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}}
@ -1608,6 +1659,10 @@ document.addEventListener('keydown',(e)=>{
rescan(); rescan();
e.preventDefault(); e.preventDefault();
break; break;
case 'd':
openDownloads();
e.preventDefault();
break;
case '?': case '?':
showShortcutsHelp(); showShortcutsHelp();
e.preventDefault(); e.preventDefault();
@ -1624,6 +1679,7 @@ function showShortcutsHelp(){
<table><tbody> <table><tbody>
${row('/','Focus search')} ${row('/','Focus search')}
${row('r','Rescan library')} ${row('r','Rescan library')}
${row('d','Open downloads')}
${row('Esc','Close modal / cancel')} ${row('Esc','Close modal / cancel')}
${row('?','This help')} ${row('?','This help')}
</tbody></table> </tbody></table>