From 151e2cf3377df118891d59a598bba0b20cd1e781 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Fri, 17 Jul 2026 15:29:10 +0300 Subject: [PATCH] Many UI fixes --- Cargo.lock | 705 +++++++++++++++++------------------------- Cargo.toml | 2 +- src/app/event.rs | 14 +- src/app/mod.rs | 202 ++++++++++-- src/app/state.rs | 7 + src/app/update.rs | 184 ++++++++--- src/federation/mod.rs | 70 ++++- src/library/mod.rs | 168 +++++++++- src/library/models.rs | 9 + src/ui/global.rs | 31 +- src/ui/mod.rs | 12 +- 11 files changed, 889 insertions(+), 515 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec8fb39..81f12d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -65,7 +77,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -91,9 +103,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -121,9 +133,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -256,7 +268,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -291,7 +303,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -399,9 +411,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blake3" @@ -477,9 +489,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" [[package]] name = "byteorder" @@ -495,9 +507,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -510,9 +522,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -532,9 +544,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -740,7 +752,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -849,7 +861,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -872,9 +884,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossterm" @@ -882,7 +894,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "derive_more", "document-features", @@ -977,7 +989,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1011,7 +1023,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1024,7 +1036,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1035,7 +1047,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1046,7 +1058,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1078,7 +1090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.119", ] [[package]] @@ -1103,9 +1115,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derivative" @@ -1136,7 +1145,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1146,7 +1155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1168,7 +1177,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -1216,7 +1225,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1231,7 +1240,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -1245,7 +1254,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1336,7 +1345,7 @@ checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1357,7 +1366,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1373,7 +1382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1484,7 +1493,7 @@ dependencies = [ [[package]] name = "federation-net" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215" +source = "git+https://gt.hexor.cy/ab/frid.git#06158b78a4b606761c06b78fd4ac520620f10d2b" dependencies = [ "blake3", "data-encoding", @@ -1563,12 +1572,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1725,7 +1728,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1809,17 +1812,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -1885,11 +1886,11 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "foldhash 0.1.5", + "ahash", ] [[package]] @@ -1900,7 +1901,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1911,16 +1912,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] name = "hashlink" -version = "0.11.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.14.5", ] [[package]] @@ -2258,12 +2259,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2353,8 +2348,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", ] [[package]] @@ -2385,7 +2378,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2445,7 +2438,7 @@ dependencies = [ "derive_more", "ed25519-dalek", "futures-util", - "getrandom 0.4.2", + "getrandom 0.4.3", "hickory-resolver", "http", "ipnet", @@ -2492,7 +2485,7 @@ dependencies = [ "data-encoding-macro", "derive_more", "ed25519-dalek", - "getrandom 0.4.2", + "getrandom 0.4.3", "n0-error", "rand 0.10.2", "serde", @@ -2548,7 +2541,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2562,7 +2555,7 @@ dependencies = [ "cfg_aliases", "data-encoding", "derive_more", - "getrandom 0.4.2", + "getrandom 0.4.3", "hickory-resolver", "http", "http-body-util", @@ -2571,7 +2564,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru 0.18.1", "n0-error", "n0-future", "noq", @@ -2669,7 +2662,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2697,14 +2690,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -2734,12 +2727,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -2754,18 +2741,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -2778,7 +2765,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -2843,14 +2830,14 @@ checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -2873,9 +2860,9 @@ checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" [[package]] name = "lru" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" dependencies = [ "hashbrown 0.17.1", ] @@ -2923,7 +2910,7 @@ dependencies = [ "ed25519-dalek", "flume", "futures-lite 2.6.1", - "getrandom 0.4.2", + "getrandom 0.4.3", "lru 0.16.4", "serde", "serde_bencode", @@ -2953,9 +2940,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -2999,9 +2986,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -3039,7 +3026,7 @@ dependencies = [ [[package]] name = "music-dht" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#9504ac23bf9f44a14ec2a5d68080aecf6f5b5215" +source = "git+https://gt.hexor.cy/ab/frid.git#06158b78a4b606761c06b78fd4ac520620f10d2b" dependencies = [ "blake3", "data-encoding", @@ -3073,7 +3060,7 @@ checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3114,7 +3101,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3179,7 +3166,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "log", "netlink-packet-core", @@ -3267,7 +3254,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -3316,7 +3303,7 @@ dependencies = [ "bytes", "derive_more", "enum-assoc", - "getrandom 0.4.2", + "getrandom 0.4.3", "identity-hash", "lru-slab", "rand 0.10.2", @@ -3352,14 +3339,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3379,7 +3366,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3427,10 +3414,10 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3466,7 +3453,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "libc", "objc2", "objc2-core-audio", @@ -3504,7 +3491,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", ] @@ -3514,7 +3501,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "dispatch2", "libc", @@ -3527,7 +3514,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3547,7 +3534,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -3560,7 +3547,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -3581,7 +3568,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "libc", "objc2", @@ -3666,7 +3653,7 @@ dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3731,9 +3718,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -3741,9 +3728,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" dependencies = [ "pest", "pest_generator", @@ -3751,25 +3738,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -3809,7 +3795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.7", ] [[package]] @@ -3822,7 +3808,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3851,7 +3837,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3906,7 +3892,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -4021,7 +4007,7 @@ checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4059,16 +4045,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4076,7 +4052,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", - "toml_edit", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -4090,9 +4075,9 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -4111,9 +4096,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4132,9 +4117,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4158,7 +4143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4217,14 +4202,15 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.1" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", "serde", @@ -4232,18 +4218,17 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "compact_str", "critical-section", "hashbrown 0.17.1", - "indoc", "itertools", "kasuari", - "lru 0.18.0", + "lru 0.18.1", "palette", "serde", "strum", @@ -4255,9 +4240,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -4267,19 +4252,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.1" +name = "ratatui-termina" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -4287,11 +4283,11 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "hashbrown 0.17.1", "indoc", "instability", @@ -4311,7 +4307,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -4327,9 +4323,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4339,9 +4335,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -4424,29 +4420,18 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", - "sqlite-wasm-rs", ] [[package]] @@ -4484,7 +4469,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4497,11 +4482,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4559,7 +4544,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4581,9 +4566,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4627,7 +4612,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4651,7 +4636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4713,7 +4698,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4737,7 +4722,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4761,9 +4746,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4852,9 +4837,9 @@ checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" @@ -4878,7 +4863,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -4895,9 +4880,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -4952,7 +4937,7 @@ checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4980,18 +4965,6 @@ dependencies = [ "der", ] -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5034,7 +5007,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5214,9 +5187,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -5240,7 +5213,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5249,7 +5222,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5277,10 +5250,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.4.1", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", ] [[package]] @@ -5312,7 +5298,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.13.0", + "bitflags 2.13.1", "fancy-regex", "filedescriptor", "finl_unicode", @@ -5372,7 +5358,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5383,26 +5369,25 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "js-sys", "libc", "num-conv", @@ -5415,15 +5400,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -5456,9 +5441,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" dependencies = [ "bytes", "libc", @@ -5472,13 +5457,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5527,7 +5512,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "getrandom 0.4.2", + "getrandom 0.4.3", "http", "httparse", "rand 0.10.2", @@ -5542,9 +5527,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", @@ -5552,7 +5537,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -5581,20 +5566,32 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -5617,7 +5614,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -5661,7 +5658,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5819,12 +5816,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -5926,27 +5923,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5957,9 +5945,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5967,9 +5955,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5977,48 +5965,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -6032,23 +5998,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6188,7 +6142,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6259,7 +6213,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6270,7 +6224,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6561,17 +6515,11 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "wit-bindgen-rust-macro", + "memchr", ] [[package]] @@ -6580,85 +6528,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wmi" version = "0.18.4" @@ -6743,7 +6612,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -6774,7 +6643,7 @@ dependencies = [ "nix 0.26.4", "once_cell", "ordered-stream", - "rand 0.8.6", + "rand 0.8.7", "serde", "serde_repr", "sha1", @@ -6794,7 +6663,7 @@ version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "regex", @@ -6815,22 +6684,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6850,7 +6719,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "synstructure", ] @@ -6871,7 +6740,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6904,14 +6773,14 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" @@ -6948,7 +6817,7 @@ version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", diff --git a/Cargo.toml b/Cargo.toml index 99aa20e..ff6586f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ lofty = "0.22" music-dht = { git = "https://gt.hexor.cy/ab/frid.git" } ratatui = "0.30.1" rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } -rusqlite = { version = "0.39", features = ["bundled"] } +rusqlite = { version = "0.32", features = ["bundled", "functions"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } diff --git a/src/app/event.rs b/src/app/event.rs index 07ca277..2a4dbbb 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -56,6 +56,12 @@ pub enum AppEvent { track_id: i64, liked: bool, }, + /// Liked federated item ids for the ♥ markers. + FedLikesLoaded(Result, String>), + FedLikeToggled { + item_id: String, + liked: bool, + }, /// A release fetched for queueing (a / shift-a on a release). EnqueueTracks { tracks: Vec, @@ -107,9 +113,11 @@ pub enum AppEvent { release: Option, path: String, }, - /// A federated track finished downloading and is ready to play. - FedPlayReady { - result: Result, + /// A pending federated track finished downloading (or failed); the + /// queue swaps the placeholder for the resolved track. + FedTrackResolved { + placeholder_id: i64, + result: Result, String>, }, /// This peer's connection ticket, requested from the Federation tab. FedTicket(Result), diff --git a/src/app/mod.rs b/src/app/mod.rs index 76eef9b..990fa81 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -36,6 +36,8 @@ pub struct Runtime { pub federation: Arc, /// When the last Federation-tab status snapshot was requested. pub fed_status_at: Option, + /// Placeholder ids of federated tracks being downloaded right now. + pub fed_resolving: std::sync::Mutex>, /// Caps concurrent artwork loads so they never starve the disk. pub art_semaphore: Arc, /// Monotonic sequence for live search; stale responses are dropped. @@ -82,6 +84,7 @@ pub async fn run( library, federation, fed_status_at: None, + fed_resolving: std::sync::Mutex::new(std::collections::HashSet::new()), art_semaphore: Arc::new(tokio::sync::Semaphore::new(4)), search_seq: Arc::new(std::sync::atomic::AtomicU64::new(0)), player: player::spawn(move |event| { @@ -185,6 +188,8 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) { tokio::task::spawn_blocking(move || { let result = library.likes().map_err(err_string); let _ = tx.send(AppEvent::LikesLoaded(result)); + let result = library.fed_like_ids().map_err(err_string); + let _ = tx.send(AppEvent::FedLikesLoaded(result)); }); } @@ -378,10 +383,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } }); } - Effect::ToggleLikes { track_ids } => { - // Ephemeral federated tracks (negative ids) are not in the DB. + Effect::ToggleLikes { + track_ids, + fed_tracks, + } => { let track_ids: Vec = track_ids.into_iter().filter(|id| *id >= 0).collect(); - if track_ids.is_empty() { + if track_ids.is_empty() && fed_tracks.is_empty() { return; } let library = Arc::clone(&runtime.library); @@ -400,6 +407,22 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { } } } + for fed in fed_tracks { + match library.toggle_fed_like(&fed) { + Ok(liked) => { + let _ = tx.send(AppEvent::FedLikeToggled { + item_id: fed.item_id.clone(), + liked, + }); + } + Err(err) => { + tracing::warn!(%err, title = %fed.title, "federated like toggle failed"); + let _ = + tx.send(AppEvent::StatusMessage(format!("like failed: {err:#}"))); + break; + } + } + } }); } Effect::RemoveFromPlaylist { @@ -478,17 +501,6 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { }); } Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None), - Effect::FedPlay(fed_track) => { - let fed = Arc::clone(&runtime.federation); - let tx = runtime.event_tx.clone(); - tokio::spawn(async move { - let result = fed - .prepare_playback(&fed_track) - .await - .map_err(|err| format!("{err:#}")); - let _ = tx.send(AppEvent::FedPlayReady { result }); - }); - } Effect::RemoveQueueIndices { restart_paused, stop, @@ -550,6 +562,14 @@ fn start_current_audio( state.status_message = Some(format!("▶ {} — {}", track.title, track.artist_line())); runtime.player_start_pending = true; + if track.is_fed_pending() { + // A federated track that is not on disk yet: silence the previous + // audio, download it and resume through FedTrackResolved. + runtime.player.stop(); + state.status_message = Some(format!("federation: fetching \"{}\"…", track.title)); + spawn_fed_resolve(runtime, &track); + return; + } let controller = runtime.player.clone(); let volume = player::amplitude(state.player.volume); let tx = runtime.event_tx.clone(); @@ -606,6 +626,12 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) { let Some(next) = player.queue.get(next_pos).cloned() else { return; }; + if next.is_fed_pending() { + // Download the upcoming federated track ahead of time; the gapless + // enqueue happens on a later tick once it resolved to a file. + spawn_fed_resolve(runtime, &next); + return; + } state.player.prefetched_pos = Some(next_pos); tracing::debug!(title = %next.title, "prefetching next track"); let controller = runtime.player.clone(); @@ -667,6 +693,38 @@ pub(crate) fn fed_connect(runtime: &Runtime, ticket: String) { }); } +/// Downloads one pending federated track (into the cache, or the library +/// when save-on-listen is enabled) and reports back with the placeholder id +/// so the queue can swap the resolved track in. +fn spawn_fed_resolve(runtime: &Runtime, track: &crate::library::models::TrackItem) { + let Some(fed_track) = track.fed.clone() else { + return; + }; + { + let mut resolving = runtime + .fed_resolving + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !resolving.insert(track.id) { + return; + } + } + let placeholder_id = track.id; + let fed = Arc::clone(&runtime.federation); + let tx = runtime.event_tx.clone(); + tokio::spawn(async move { + let result = fed + .prepare_playback(&fed_track) + .await + .map(Box::new) + .map_err(|err| format!("{err:#}")); + let _ = tx.send(AppEvent::FedTrackResolved { + placeholder_id, + result, + }); + }); +} + /// Downloads federated tracks into the library one by one (with progress in /// the status bar) and optionally links them to a playlist afterwards. pub(crate) fn fed_download_spawn( @@ -893,8 +951,16 @@ fn on_library_changed(state: &mut AppState, runtime: &mut Runtime) { // until then. state.likes_loaded = false; - // Fresh copies of whatever sits in the queue. - let ids: Vec = state.player.queue.iter().map(|track| track.id).collect(); + // Fresh copies of whatever sits in the queue. Federated placeholders + // and ephemeral tracks (negative ids) are not library rows and keep + // their in-memory copies. + let ids: Vec = state + .player + .queue + .iter() + .map(|track| track.id) + .filter(|id| *id >= 0) + .collect(); if !ids.is_empty() { let library = Arc::clone(&runtime.library); let tx = runtime.event_tx.clone(); @@ -956,9 +1022,12 @@ fn apply_queue_refresh( .player .queue .iter() - .any(|track| !by_id.contains_key(&track.id)); + .any(|track| track.id >= 0 && !by_id.contains_key(&track.id)); if had_missing { - state.player.queue.retain(|track| by_id.contains_key(&track.id)); + state + .player + .queue + .retain(|track| track.id < 0 || by_id.contains_key(&track.id)); state.player.prefetched_pos = None; } if state.player.queue.is_empty() { @@ -1018,22 +1087,72 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent Err(message) => tracing::warn!(%message, "federated search failed"), } } - AppEvent::FedPlayReady { result } => match result { - Ok(playable) => { - if playable.imported { - // Save-on-listen imported the file; refresh the library - // views through the standard change path. - let _ = runtime.event_tx.send(AppEvent::LibraryChanged { - message: Some(format!("saved \"{}\" to the library", playable.track.title)), - }); + AppEvent::FedTrackResolved { + placeholder_id, + result, + } => { + runtime + .fed_resolving + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&placeholder_id); + match result { + Ok(playable) => { + if playable.imported { + // Save-on-listen imported the file; refresh the + // library views through the standard change path. + let _ = runtime.event_tx.send(AppEvent::LibraryChanged { + message: Some(format!( + "saved \"{}\" to the library", + playable.track.title + )), + }); + } + let resolved = playable.track.clone(); + // Swap the placeholder for the real track everywhere it + // sits in the queue. + for slot in &mut state.player.queue { + if slot.id == placeholder_id { + *slot = resolved.clone(); + } + } + let waiting = state + .player + .current + .as_ref() + .is_some_and(|current| current.id == placeholder_id); + if waiting { + // Playback was parked on this track; start it now. + let paused = state.player.paused; + start_current_audio(state, runtime, 0.0, paused); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } + } + Err(message) => { + state.status_message = Some(format!("federation: {message}")); + let waiting = state + .player + .current + .as_ref() + .is_some_and(|current| current.id == placeholder_id); + if waiting { + // Skip the failed track instead of stalling the queue. + if state.player.queue_pos + 1 < state.player.queue.len() { + state.player.queue_pos += 1; + start_current_audio(state, runtime, 0.0, state.player.paused); + push_media_metadata(state, runtime); + push_media_update(state, runtime, true); + } else { + runtime.player_start_pending = false; + state.player.playing = false; + state.player.current = None; + runtime.player.stop(); + } + } } - state.player.queue = vec![playable.track]; - state.player.queue_pos = 0; - update::on_new_queue(state); - perform_effect(state, runtime, Effect::PlayCurrent); } - Err(message) => state.status_message = Some(format!("federation: {message}")), - }, + } AppEvent::FedArtistLoaded { name, result } => { if let Some((current, data)) = &mut state.fed_artist_view && *current == name @@ -1237,6 +1356,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } Err(message) => tracing::warn!(%message, "likes load failed"), }, + AppEvent::FedLikesLoaded(result) => match result { + Ok(ids) => state.fed_likes = ids.into_iter().collect(), + Err(message) => tracing::warn!(%message, "federated likes load failed"), + }, + AppEvent::FedLikeToggled { item_id, liked } => { + if liked { + state.fed_likes.insert(item_id); + } else { + state.fed_likes.remove(&item_id); + } + // The virtual Likes playlist is stale now; refetch on next open. + state.playlist_views.remove(&state::LIKES_PLAYLIST_ID); + state.playlists.list = None; + state.status_message = Some(if liked { + "♥ liked (federation)".to_string() + } else { + "like removed".to_string() + }); + } AppEvent::LikeToggled { track_id, liked } => { if liked { state.likes.insert(track_id); diff --git a/src/app/state.rs b/src/app/state.rs index dd76069..d03d389 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -627,6 +627,9 @@ pub struct AppState { pub playlist_views: HashMap>, /// Liked track ids, for the ♥ markers everywhere tracks are shown. pub likes: std::collections::HashSet, + /// Liked federated tracks (DHT item ids) — likes that reference peers' + /// tracks without downloading them. + pub fed_likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, pub queue_tab: QueueTab, @@ -634,6 +637,10 @@ pub struct AppState { /// The one federated artist card being viewed (name + loading state); /// opening another card replaces it. pub fed_artist_view: Option<(String, Loadable)>, + /// The "search this artist in the federation" button of the open local + /// artist view has the focus (reached by pressing Up from the first + /// row, like the download button on a federated release). + pub artist_fed_button: bool, pub track_selection: TrackSelection, /// Shift-J jump in flight: focus this (release, track) once the release /// view finishes loading. diff --git a/src/app/update.rs b/src/app/update.rs index 14d549a..793579e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -30,6 +30,7 @@ pub enum Effect { }, ToggleLikes { track_ids: Vec, + fed_tracks: Vec, }, /// Remove tracks from a stored playlist in the library. RemoveFromPlaylist { @@ -47,8 +48,6 @@ pub enum Effect { FedSyncNow, /// Fetch this peer's ticket and show it in a popup. FedShowTicket, - /// Download (or resolve) a federated track and play it. - FedPlay(crate::federation::FedTrack), /// Assemble the federated artist card (fan-out to the owning peers). FedOpenArtist(String), /// Download federated tracks into the local library, one by one. @@ -225,32 +224,52 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } Action::ToggleLike => { - let tracks = selected_tracks(state); - let track_ids: Vec = if tracks.is_empty() { - state + let mut tracks = selected_tracks(state); + if tracks.is_empty() { + tracks = state .player .current .as_ref() - .map(|track| vec![track.id]) - .unwrap_or_default() - } else { - tracks.into_iter().map(|track| track.id).collect() - }; - if track_ids.is_empty() { + .map(|track| vec![track.clone()]) + .unwrap_or_default(); + } + // Local library rows are liked by id; federated tracks (pending + // or cached) are liked as references into the federation. + let mut track_ids: Vec = Vec::new(); + let mut fed_tracks: Vec = Vec::new(); + for track in tracks { + match &track.fed { + Some(fed) => fed_tracks.push(fed.clone()), + None if track.id >= 0 => track_ids.push(track.id), + None => {} + } + } + if track_ids.is_empty() && fed_tracks.is_empty() { state.status_message = Some("no track selected".into()); None } else { - let should_like = track_ids.iter().any(|id| !state.likes.contains(id)); + let should_like = track_ids.iter().any(|id| !state.likes.contains(id)) + || fed_tracks + .iter() + .any(|fed| !state.fed_likes.contains(&fed.item_id)); let toggles: Vec = track_ids .into_iter() .filter(|id| state.likes.contains(id) != should_like) .collect(); + let fed_toggles: Vec = fed_tracks + .into_iter() + .filter(|fed| state.fed_likes.contains(&fed.item_id) != should_like) + .collect(); + let total = toggles.len() + fed_toggles.len(); state.status_message = Some(if should_like { - format!("liking {} track(s)", toggles.len()) + format!("liking {total} track(s)") } else { - format!("removing like from {} track(s)", toggles.len()) + format!("removing like from {total} track(s)") }); - Some(Effect::ToggleLikes { track_ids: toggles }) + Some(Effect::ToggleLikes { + track_ids: toggles, + fed_tracks: fed_toggles, + }) } } Action::ToggleTrackSelection => { @@ -503,7 +522,10 @@ fn delete_selected(state: &mut AppState) -> Option { .filter(|id| state.likes.contains(id)) .collect(); state.status_message = Some(format!("removing {} like(s)", liked.len())); - return Some(Effect::ToggleLikes { track_ids: liked }); + return Some(Effect::ToggleLikes { + track_ids: liked, + fed_tracks: vec![], + }); } state.status_message = Some(format!("removing {} track(s) from playlist", track_ids.len())); @@ -676,6 +698,9 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val } fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope, usize, usize)> { + if state.artist_fed_button { + return None; + } match state.active_tab { Tab::Global => match state.global.stack.last()? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { @@ -786,6 +811,17 @@ fn current_track_list(state: &AppState) -> Option<(TrackSelectionScope, usize, & } pub fn selected_tracks(state: &AppState) -> Vec { + if state.artist_fed_button { + return Vec::new(); + } + // Federated contexts produce queueable placeholders that behave like + // regular tracks (queue, info, playback-on-demand). + { + let fed = selected_fed_tracks(state); + if !fed.is_empty() { + return fed.iter().map(crate::federation::pending_track).collect(); + } + } let Some((scope, cursor, tracks)) = current_track_list(state) else { return selected_track(state).into_iter().collect(); }; @@ -915,6 +951,9 @@ fn remove_queue_indices(state: &mut AppState, indices: &[usize]) -> QueueRemoval /// The track under the cursor in whatever view is showing tracks. pub fn selected_track(state: &AppState) -> Option { + if state.artist_fed_button { + return None; + } match state.active_tab { Tab::Global => match state.global.stack.last()? { GlobalView::Artist { id, cursor } => match state.artist_views.get(id)? { @@ -937,9 +976,31 @@ pub fn selected_track(state: &AppState) -> Option { GlobalView::Search { cursor } => { let results = state.search.results.as_ref()?; let offset = cursor.checked_sub(results.artists.len() + results.releases.len())?; - results.tracks.get(offset).cloned() + match results.tracks.get(offset) { + Some(track) => Some(track.clone()), + // Below the local tracks: the federated section. + None => { + let fed_offset = offset + .checked_sub(results.tracks.len() + state.search.fed_artists.len())?; + state + .search + .fed_tracks + .get(fed_offset) + .map(crate::federation::pending_track) + } + } + } + GlobalView::FedArtist { .. } => None, + GlobalView::FedRelease { index, cursor } => { + let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?; + let release = fed_card_release(state, *index)?; + release + .tracks + .get(cursor.checked_sub(1)?) + .and_then(|track| fed_track_from_card(&name, release, track)) + .as_ref() + .map(crate::federation::pending_track) } - GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None, }, Tab::Playlists => { let opened = state.playlists.opened.as_ref()?; @@ -1013,6 +1074,10 @@ fn queue_add(state: &mut AppState, next: bool) -> Option { /// track. If the release view is still loading, the focus is applied when /// it arrives (`pending_release_focus`). fn open_release_for_track(state: &mut AppState, track: &TrackItem) { + if track.release_id < 0 { + state.status_message = Some("this federated track has no local release".into()); + return; + } let release_id = track.release_id; let cursor = match state.release_views.get(&release_id) { Some(Loadable::Ready(detail)) => detail @@ -1338,6 +1403,17 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) { state.track_selection.clear(); } Some(GlobalView::Artist { id, cursor }) => { + if state.artist_fed_button { + // Down leaves the federation-search button; other keys stay. + if dy > 0 { + state.artist_fed_button = false; + } + return; + } + if cursor == 0 && dy < 0 && state.federation.settings.enabled { + state.artist_fed_button = true; + return; + } let Some(Loadable::Ready(detail)) = state.artist_views.get(&id) else { return; }; @@ -1503,6 +1579,7 @@ fn current_view_len(state: &AppState) -> usize { } fn jump_selection(state: &mut AppState, first: bool) { + state.artist_fed_button = false; if state.track_selection.is_active() && let Some((scope, _, len)) = current_track_list_context(state) { @@ -1634,6 +1711,15 @@ fn select_current(state: &mut AppState) -> Option { }), None => Outcome::Nothing, }, + Some(GlobalView::Artist { id, cursor: _ }) if state.artist_fed_button => { + match state.artist_views.get(&id) { + Some(Loadable::Ready(detail)) => { + state.artist_fed_button = false; + Outcome::OpenFedArtist(detail.name.clone()) + } + _ => Outcome::Nothing, + } + } Some(GlobalView::Artist { id, cursor }) => match state.artist_views.get(&id) { Some(Loadable::Ready(detail)) => { let tracks = detail.top_tracks.len(); @@ -1723,19 +1809,20 @@ fn select_current(state: &mut AppState) -> Option { tracks => Outcome::DownloadFed(tracks), } } - Some(release) => { - let name = state - .fed_artist_view - .as_ref() - .map(|(name, _)| name.clone()) - .unwrap_or_default(); - match release - .tracks - .get(cursor - 1) - .and_then(|track| fed_track_from_card(&name, release, track)) - { - Some(fed) => Outcome::PlayFed(fed), - None => Outcome::Nothing, + Some(_) => { + // Play the whole release starting at the selected track, + // exactly like a local release view. + let tracks: Vec<_> = fed_release_tracks(state, index) + .iter() + .map(crate::federation::pending_track) + .collect(); + if tracks.is_empty() || cursor > tracks.len() { + Outcome::Nothing + } else { + Outcome::Play { + tracks, + start: cursor - 1, + } } } None => Outcome::Nothing, @@ -1743,6 +1830,7 @@ fn select_current(state: &mut AppState) -> Option { }; match outcome { Outcome::Push(view) => { + state.artist_fed_button = false; state.global.stack.push(view); None } @@ -1752,10 +1840,6 @@ fn select_current(state: &mut AppState) -> Option { on_new_queue(state); Some(Effect::PlayCurrent) } - Outcome::PlayFed(fed) => { - state.status_message = Some(format!("federation: fetching \"{}\"…", fed.title)); - Some(Effect::FedPlay(fed)) - } Outcome::OpenFedArtist(name) => { state.fed_artist_view = Some((name.clone(), Loadable::Loading)); state.global.stack.push(GlobalView::FedArtist { cursor: 0 }); @@ -1853,22 +1937,30 @@ enum Outcome { tracks: Vec, start: usize, }, - PlayFed(crate::federation::FedTrack), OpenFedArtist(String), DownloadFed(Vec), Nothing, } /// Enter inside the federated section of the search results: artists open -/// their card, tracks play. +/// their card, tracks queue up like a local track list. fn fed_outcome(state: &AppState, fed_index: usize) -> Outcome { let artists = &state.search.fed_artists; if fed_index < artists.len() { return Outcome::OpenFedArtist(artists[fed_index].name.clone()); } - match state.search.fed_tracks.get(fed_index - artists.len()) { - Some(fed) => Outcome::PlayFed(fed.clone()), - None => Outcome::Nothing, + let start = fed_index - artists.len(); + if start >= state.search.fed_tracks.len() { + return Outcome::Nothing; + } + Outcome::Play { + tracks: state + .search + .fed_tracks + .iter() + .map(crate::federation::pending_track) + .collect(), + start, } } @@ -1888,6 +1980,9 @@ fn fed_track_from_card( artist_names: vec![artist.to_string()], year: release.year, duration_seconds: track.duration_seconds.map(|d| d.round() as i64), + release_title: Some(release.title.clone()), + track_number: track.track_number, + disc_number: track.disc_number, }) } @@ -1966,6 +2061,7 @@ fn shuffle_range(player: &mut super::state::PlayerBar, start: usize) { /// Esc/Backspace: pop the navigation stack; leaving a search view resets the /// search so the next `:/` starts clean. fn go_back(state: &mut AppState) { + state.artist_fed_button = false; state.track_selection.clear(); match state.active_tab { Tab::Playlists => { @@ -1995,6 +2091,7 @@ fn go_back(state: &mut AppState) { } fn switch_tab(state: &mut AppState, tab: Tab) { + state.artist_fed_button = false; state.active_tab = tab; state.help_visible = false; state.track_selection.clear(); @@ -2070,6 +2167,7 @@ mod tests { audio_bit_depth: None, file_size_bytes: None, play_count: 0, + fed: None, } } @@ -2294,6 +2392,7 @@ mod tests { audio_bit_depth: None, file_size_bytes: None, play_count: 0, + fed: None, }; let mut state = AppState::default(); state.player.queue = vec![track(1), track(2)]; @@ -2357,6 +2456,7 @@ mod tests { audio_bit_depth: None, file_size_bytes: None, play_count: 0, + fed: None, }; let mut state = AppState { active_tab: Tab::Queue, @@ -2485,6 +2585,7 @@ mod tests { update(&mut state, Action::ToggleLike), Some(Effect::ToggleLikes { track_ids: vec![2, 3], + fed_tracks: vec![], }) ); @@ -2493,6 +2594,7 @@ mod tests { update(&mut state, Action::ToggleLike), Some(Effect::ToggleLikes { track_ids: vec![1, 2, 3], + fed_tracks: vec![], }) ); } @@ -2519,6 +2621,7 @@ mod tests { audio_bit_depth: None, file_size_bytes: None, play_count: 0, + fed: None, }; let mut state = AppState::default(); state.player.queue = (1..=8).map(track).collect(); @@ -2564,6 +2667,7 @@ mod tests { audio_bit_depth: None, file_size_bytes: None, play_count: 0, + fed: None, }; let mut state = AppState { active_tab: Tab::Queue, diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 7ecd2fe..8a20348 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -117,6 +117,10 @@ pub struct FedTrack { pub artist_names: Vec, pub year: Option, pub duration_seconds: Option, + /// Release context, known when the track came from an artist card. + pub release_title: Option, + pub track_number: Option, + pub disc_number: Option, } impl FedTrack { @@ -388,14 +392,22 @@ impl Federation { match service.sync_library(specs).await { Ok(stats) => { *lock(&self.last_sync) = Some(format!( - "{} (+{} ~{} −{}, unchanged {})", + "{} (+{} ~{} −{}, unchanged {}, failed {})", now_label(), stats.added, stats.updated, stats.removed, - stats.unchanged + stats.unchanged, + stats.failed )); - self.set_error(None); + if stats.failed > 0 { + self.set_error(Some(format!( + "{} item(s) failed to publish in the last sync", + stats.failed + ))); + } else { + self.set_error(None); + } } Err(err) => { tracing::warn!("federation sync failed: {err}"); @@ -455,6 +467,9 @@ impl Federation { artist_names: item.artist_names.clone(), year: item.year, duration_seconds: item.duration_seconds.map(|d| d.round() as i64), + release_title: None, + track_number: None, + disc_number: None, }) .collect(); @@ -696,6 +711,7 @@ impl Federation { let import_metadata = downloaded.metadata.clone(); let import_cover = downloaded.cover.clone(); let artist_image = downloaded.artist_image.clone(); + let fed_item_id = fed.item_id.clone(); let imported = tokio::task::spawn_blocking(move || -> Result> { let mut import = crate::library::import::read_file(&import_path)?; // The owner's database is more authoritative than whatever @@ -709,6 +725,11 @@ impl Federation { import.cover = import_cover; } let (track_id, _) = crate::library::import::upsert_track(&library, &import)?; + // A like that referenced the federated track moves onto the + // freshly imported local row. + if let Err(err) = library.transfer_fed_like(&fed_item_id, track_id) { + tracing::warn!(%err, "federated like transfer failed"); + } // The owner's artist image fills the gap for a freshly // created (or still image-less) main artist. if let (Some((bytes, extension)), Some(artist_name)) = @@ -895,6 +916,46 @@ fn download_stem(fed: &FedTrack) -> String { } } +/// A queueable placeholder for a federated track that has not been +/// downloaded yet: it behaves like a regular track everywhere (queue, info, +/// selection) and resolves to a local file when playback reaches it. +pub fn pending_track(fed: &FedTrack) -> TrackItem { + let id = NEXT_EPHEMERAL_ID.fetch_sub(1, Ordering::Relaxed); + let refs = |names: &[String]| -> Vec { + names + .iter() + .map(|name| ArtistRef { + id: -1, + name: name.clone(), + }) + .collect() + }; + TrackItem { + id, + title: fed.title.clone(), + track_number: fed.track_number, + disc_number: fed.disc_number, + duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, + artists: refs(&fed.artist_names), + featured_artists: Vec::new(), + release_id: -1, + release_title: fed + .release_title + .clone() + .unwrap_or_else(|| format!("federation · {}", fed.owner_short())), + release_year: fed.year, + file_path: String::new(), + cover_path: None, + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + audio_bit_depth: None, + file_size_bytes: None, + play_count: 0, + fed: Some(fed.clone()), + } +} + /// A playable TrackItem for a downloaded-but-not-imported federated track. fn ephemeral_track( fed: &FedTrack, @@ -948,5 +1009,8 @@ fn ephemeral_track( audio_bit_depth: None, file_size_bytes: file_size, play_count: 0, + // Keep the federation reference: the cached track can still be + // liked, re-downloaded and marked as federated in the lists. + fed: Some(fed.clone()), } } diff --git a/src/library/mod.rs b/src/library/mod.rs index d56a5d0..2d95635 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -81,6 +81,18 @@ CREATE TABLE IF NOT EXISTS likes ( track_id INTEGER PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, liked_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS fed_likes ( + item_id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + title TEXT NOT NULL, + artist_names TEXT NOT NULL DEFAULT '', + year INTEGER, + duration_seconds REAL, + release_title TEXT, + track_number INTEGER, + disc_number INTEGER, + liked_at TEXT NOT NULL DEFAULT (datetime('now')) +); CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY, track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, @@ -155,6 +167,7 @@ impl Library { .with_context(|| format!("opening database {}", db_path.display()))?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; + register_norm_function(&conn)?; conn.execute_batch(SCHEMA).context("applying schema")?; let covers_dir = db_path .parent() @@ -337,13 +350,19 @@ impl Library { if query.is_empty() { return Ok(SearchResults::default()); } - let pattern = format!("%{}%", like_escape(query)); + // instr over norm() folds case for every script, unlike LIKE/NOCASE + // which only handle ASCII. + let pattern = music_dht::normalize_name(query); + if pattern.is_empty() { + // Punctuation-only queries (e.g. "%") normalize to nothing. + return Ok(SearchResults::default()); + } let conn = self.lock(); let mut statement = conn.prepare( "SELECT a.id, a.name, a.image_path, (SELECT COUNT(*) FROM release_artists ra WHERE ra.artist_id = a.id), (SELECT COUNT(*) FROM track_artists ta WHERE ta.artist_id = a.id) - FROM artists a WHERE a.name LIKE ?1 ESCAPE '\\' + FROM artists a WHERE instr(norm(a.name), ?1) > 0 ORDER BY a.name COLLATE NOCASE LIMIT ?2", )?; let artists = statement @@ -360,7 +379,7 @@ impl Library { let mut statement = conn.prepare( "SELECT r.id, r.title, r.release_type, r.year, r.cover_path, (SELECT COUNT(*) FROM tracks t WHERE t.release_id = r.id) - FROM releases r WHERE r.title LIKE ?1 ESCAPE '\\' + FROM releases r WHERE instr(norm(r.title), ?1) > 0 ORDER BY r.title COLLATE NOCASE LIMIT ?2", )?; let releases = statement @@ -371,7 +390,7 @@ impl Library { &format!( "SELECT {TRACK_COLUMNS} FROM tracks t JOIN releases r ON r.id = t.release_id - WHERE t.title LIKE ?1 ESCAPE '\\' + WHERE instr(norm(t.title), ?1) > 0 ORDER BY t.title COLLATE NOCASE LIMIT ?2" ), params![pattern, limit], @@ -485,7 +504,11 @@ impl Library { pub fn playlists(&self) -> Result> { let conn = self.lock(); - let liked: i64 = conn.query_row("SELECT COUNT(*) FROM likes", [], |row| row.get(0))?; + let liked: i64 = conn.query_row( + "SELECT (SELECT COUNT(*) FROM likes) + (SELECT COUNT(*) FROM fed_likes)", + [], + |row| row.get(0), + )?; let mut list = vec![PlaylistCard { id: LIKES_PLAYLIST_ID, title: "Liked tracks".to_string(), @@ -514,7 +537,7 @@ impl Library { pub fn playlist(&self, id: i64) -> Result { let conn = self.lock(); if id == LIKES_PLAYLIST_ID { - let tracks = query_tracks( + let mut tracks = query_tracks( &conn, &format!( "SELECT {TRACK_COLUMNS} FROM tracks t @@ -524,6 +547,12 @@ impl Library { ), params![], )?; + drop(conn); + // Liked federated tracks follow the local ones as queueable + // placeholders (downloaded on playback like everywhere else). + for fed in self.fed_likes()? { + tracks.push(crate::federation::pending_track(&fed)); + } return Ok(PlaylistDetail { id, title: "Liked tracks".to_string(), @@ -616,6 +645,89 @@ impl Library { Ok(()) } + /// Liked federated tracks, newest first, as playable references. + pub fn fed_likes(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare( + "SELECT item_id, owner, title, artist_names, year, duration_seconds, + release_title, track_number, disc_number + FROM fed_likes ORDER BY liked_at DESC", + )?; + let rows = statement + .query_map([], |row| { + let artists: String = row.get(3)?; + Ok(crate::federation::FedTrack { + item_id: row.get(0)?, + owner: row.get(1)?, + own: false, + title: row.get(2)?, + artist_names: artists + .split("; ") + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(), + year: row.get(4)?, + duration_seconds: row.get::<_, Option>(5)?.map(|d| d.round() as i64), + release_title: row.get(6)?, + track_number: row.get(7)?, + disc_number: row.get(8)?, + }) + })? + .collect::>>()?; + Ok(rows) + } + + /// Item ids of every liked federated track (for the ♥ markers). + pub fn fed_like_ids(&self) -> Result> { + let conn = self.lock(); + let mut statement = conn.prepare("SELECT item_id FROM fed_likes")?; + let rows = statement + .query_map([], |row| row.get(0))? + .collect::>>()?; + Ok(rows) + } + + /// Toggles a like on a federated track; returns the resulting state. + pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result { + let conn = self.lock(); + let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?; + if removed > 0 { + return Ok(false); + } + conn.execute( + "INSERT INTO fed_likes (item_id, owner, title, artist_names, year, + duration_seconds, release_title, track_number, disc_number) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + fed.item_id, + fed.owner, + fed.title, + fed.artist_names.join("; "), + fed.year, + fed.duration_seconds.map(|d| d as f64), + fed.release_title, + fed.track_number, + fed.disc_number, + ], + )?; + Ok(true) + } + + /// Moves a federated like onto a freshly imported local track. Returns + /// whether a transfer happened. + pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result { + let conn = self.lock(); + let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [item_id])?; + if removed == 0 { + return Ok(false); + } + conn.execute( + "INSERT OR IGNORE INTO likes (track_id) VALUES (?1)", + [track_id], + )?; + Ok(true) + } + pub fn likes(&self) -> Result> { let conn = self.lock(); let mut statement = conn.prepare("SELECT track_id FROM likes")?; @@ -724,7 +836,7 @@ impl Library { let conn = self.lock(); Ok(conn .query_row( - "SELECT id FROM artists WHERE name = ?1 COLLATE NOCASE", + "SELECT id FROM artists WHERE norm(name) = norm(?1) LIMIT 1", [name], |row| row.get(0), ) @@ -740,7 +852,7 @@ impl Library { "SELECT r.cover_path FROM releases r JOIN release_artists ra ON ra.release_id = r.id JOIN artists a ON a.id = ra.artist_id - WHERE a.name = ?1 COLLATE NOCASE AND r.title = ?2 COLLATE NOCASE + WHERE norm(a.name) = norm(?1) AND norm(r.title) = norm(?2) LIMIT 1", params![artist, release], |row| row.get(0), @@ -767,7 +879,7 @@ impl Library { let conn = self.lock(); let missing: Option = conn .query_row( - "SELECT image_path IS NULL FROM artists WHERE name = ?1 COLLATE NOCASE", + "SELECT image_path IS NULL FROM artists WHERE norm(name) = norm(?1)", [name], |row| row.get(0), ) @@ -781,7 +893,7 @@ impl Library { let conn = self.lock(); let changed = conn.execute( "UPDATE artists SET image_path = ?2 - WHERE name = ?1 COLLATE NOCASE AND image_path IS NULL", + WHERE norm(name) = norm(?1) AND image_path IS NULL", params![name, image_path], )?; Ok(changed > 0) @@ -850,7 +962,7 @@ pub(crate) fn find_or_create_artist(conn: &Connection, name: &str) -> Result>>()?; @@ -933,11 +1046,22 @@ fn query_tracks( } /// Escape LIKE wildcards in user input; queries use `ESCAPE '\'`. -fn like_escape(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") +/// Registers `norm(text)` — Unicode-aware case folding and normalization +/// (NFKC, lowercase, punctuation stripped). SQLite's own LIKE/NOCASE only +/// fold ASCII, so Cyrillic and other non-Latin names were unsearchable +/// without it. Shares the exact algorithm with the federation DHT. +fn register_norm_function(conn: &Connection) -> Result<()> { + use rusqlite::functions::FunctionFlags; + conn.create_scalar_function( + "norm", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + let value: String = ctx.get(0)?; + Ok(music_dht::normalize_name(&value)) + }, + )?; + Ok(()) } #[cfg(test)] @@ -947,6 +1071,7 @@ mod tests { fn test_library() -> Library { let conn = Connection::open_in_memory().unwrap(); conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + register_norm_function(&conn).unwrap(); conn.execute_batch(SCHEMA).unwrap(); Library { conn: Mutex::new(conn), @@ -1018,6 +1143,17 @@ mod tests { assert_eq!(lib.search("%", 10).unwrap().len(), 0); } + #[test] + fn search_folds_case_beyond_ascii() { + let lib = test_library(); + add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом"); + // SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script. + assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1); + assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1); + assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1); + } + #[test] fn playlists_and_likes_round_trip() { let lib = test_library(); diff --git a/src/library/models.rs b/src/library/models.rs index 62c88fc..c403716 100644 --- a/src/library/models.rs +++ b/src/library/models.rs @@ -40,9 +40,18 @@ pub struct TrackItem { pub file_size_bytes: Option, /// Completed local plays, from the history table. pub play_count: i64, + /// Set for federated tracks that are not in the local library (yet): + /// carries everything needed to download them from the owning peer. + /// With an empty `file_path` the player resolves the track on demand. + pub fed: Option, } impl TrackItem { + /// A federated track that still needs downloading before playback. + pub fn is_fed_pending(&self) -> bool { + self.fed.is_some() && self.file_path.is_empty() + } + pub fn artist_line(&self) -> String { let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect(); if !self.featured_artists.is_empty() { diff --git a/src/ui/global.rs b/src/ui/global.rs index b9c756e..33f3ada 100644 --- a/src/ui/global.rs +++ b/src/ui/global.rs @@ -405,7 +405,7 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: if !detail.featured_tracks.is_empty() { about.push_str(&format!(" · appears on {}", detail.featured_tracks.len())); } - let info = vec![ + let mut info = vec![ Line::default(), Line::styled(detail.name.clone(), theme::header()), Line::default(), @@ -418,6 +418,19 @@ fn draw_artist(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor: ), Line::styled(about, theme::dim()), ]; + if state.federation.settings.enabled { + // Reached with Up from the first row, like the download button on + // a federated release. + info.push(Line::default()); + info.push(Line::styled( + " ⌕ Search this artist in the federation ", + if state.artist_fed_button { + theme::tab_active() + } else { + theme::accent() + }, + )); + } frame.render_widget(Paragraph::new(info), info_area); // Scrollable content: top tracks, releases grouped by type, then the @@ -827,6 +840,11 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { index += 1; } for fed in &state.search.fed_tracks { + let heart = if state.fed_likes.contains(&fed.item_id) { + Span::styled("♥ ", theme::accent()) + } else { + Span::raw(" ") + }; let origin = if fed.own { "your library".to_string() } else { @@ -841,6 +859,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) { } rows.push(( Line::from(vec![ + heart, Span::styled("⇅ ", theme::accent()), Span::raw(fed.title.clone()), Span::styled( @@ -1098,7 +1117,17 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz duration }; let in_selection = state.track_selection.contains(&scope, position); + let liked = track + .sources + .iter() + .any(|(_, item_id)| state.fed_likes.contains(item_id)); + let heart = if liked { + Span::styled("♥ ", theme::accent()) + } else { + Span::raw(" ") + }; let line = Line::from(vec![ + heart, Span::styled("⇅ ", theme::accent()), Span::raw(format!("{number}{}", track.title)), ]); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4dc8c2a..ad1f74c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -63,14 +63,24 @@ pub(crate) fn track_row( selected: bool, visual_selected: bool, ) { - let heart = if state.likes.contains(&track.id) { + let fed_liked = track + .fed + .as_ref() + .is_some_and(|fed| state.fed_likes.contains(&fed.item_id)); + let heart = if state.likes.contains(&track.id) || fed_liked { Span::styled("♥ ", theme::accent()) } else { Span::raw(" ") }; + let fed_marker = if track.fed.is_some() { + Span::styled("⇅ ", theme::accent()) + } else { + Span::raw("") + }; let line = Line::from(vec![ Span::styled(format!("{index_label:>3} "), theme::dim()), heart, + fed_marker, Span::raw(track.title.clone()), Span::styled(format!(" {}", track.artist_line()), theme::dim()), ]);