From 307060ca49d402f42fd042c3d9a87cbf44fd0e4d Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Wed, 20 May 2026 16:28:35 +0800 Subject: [PATCH 01/39] Update deps, async play_music, Progress rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packageManager metadata to package.json and bump web-audio-api in src-tauri/Cargo.toml. Make play_music an async Tauri command with Result return and async audio decoding + error handling. Svelte updates: minor import formatting in Header, PlayerBar now imports and uses Progress (replacing the Slider volume control), and Progress.svelte was heavily rewritten — modernized reactive logic, improved pointer handling, accessibility attributes, and updated styles. --- package.json | 3 +- src-tauri/Cargo.lock | 397 ++++++++++------------------ src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 15 +- src/lib/components/Header.svelte | 4 +- src/lib/components/PlayerBar.svelte | 4 +- src/lib/components/Progress.svelte | 271 +++++++++---------- 7 files changed, 292 insertions(+), 404 deletions(-) diff --git a/package.json b/package.json index 2f37191..6cba909 100644 --- a/package.json +++ b/package.json @@ -30,5 +30,6 @@ "svelte-check": "^4.4.5", "typescript": "~5.6.3", "vite": "^6.4.1" - } + }, + "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6d60a71..336bfce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -40,9 +40,9 @@ checksum = "3aa2999eb46af81abb65c2d30d446778d7e613b60bbf4e174a027e80f90a3c14" [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.11.0", @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -268,24 +268,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.11.0", - "cexpr", - "clang-sys", - "itertools", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.117", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -475,8 +457,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -486,15 +466,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfb" version = "0.7.3" @@ -534,17 +505,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading 0.8.9", -] - [[package]] name = "combine" version = "4.6.7" @@ -622,45 +582,46 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.11.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - -[[package]] -name = "coreaudio-sys" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" -dependencies = [ - "bindgen", + "bitflags 2.11.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", ] [[package]] name = "cpal" -version = "0.15.3" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", - "core-foundation-sys", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", "mach2", - "ndk 0.8.0", + "ndk", "ndk-context", - "oboe", + "num-derive", + "num-traits", + "objc2", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", ] [[package]] @@ -1009,12 +970,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "embed-resource" version = "3.0.7" @@ -1142,14 +1097,13 @@ dependencies = [ [[package]] name = "fft-convolver" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdcf0473d3952d710173f7f2acc8ff88065928e01b7d5467ec28449a3abae4fc" +checksum = "ecb4fbed063c755ecaa4cd1356cd82cc48e9d5c7edd5c30cd144656833fc0661" dependencies = [ - "num", "realfft", - "rustfft", - "thiserror 1.0.69", + "rtsan-standalone", + "thiserror 2.0.18", ] [[package]] @@ -2028,15 +1982,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -2088,16 +2033,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.91" @@ -2185,7 +2120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] @@ -2205,16 +2140,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libredox" version = "0.1.14" @@ -2265,9 +2190,9 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] @@ -2335,12 +2260,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2383,20 +2302,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.11.0", - "jni-sys", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2406,7 +2311,7 @@ dependencies = [ "bitflags 2.11.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "raw-window-handle", "thiserror 1.0.69", @@ -2418,15 +2323,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2444,9 +2340,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "no_denormals" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a63f8a8efd33b4706cecbc4b845b0c63e9c883f8a5404a0fd7ce2a0933421f" +checksum = "b6bcfe410abc339c9f8c226aceebf946bf26e3e2eca738be3fec9e9ee97d54aa" [[package]] name = "nodrop" @@ -2454,40 +2350,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2524,34 +2386,22 @@ dependencies = [ ] [[package]] -name = "num-iter" -version = "0.1.45" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "num-integer", - "num-traits", ] [[package]] -name = "num-rational" -version = "0.4.2" +name = "num_cpus" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", + "hermit-abi", + "libc", ] [[package]] @@ -2599,6 +2449,54 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2606,7 +2504,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -2646,6 +2546,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2699,29 +2600,6 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3436,6 +3314,36 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7204ed6420f698836b76d4d5c2ec5dec7585fd5c3a788fd1cde855d1de598239" +[[package]] +name = "rtsan-standalone" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd1b6d61d69481a68e555916d3a52213846f5c2d2140bdc74ebc308e2338fc3" +dependencies = [ + "rtsan-standalone-macros", + "rtsan-standalone-sys", +] + +[[package]] +name = "rtsan-standalone-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320af894782374141c8e0d4521ee613a8f24d7ab08b6ad359881311e37b9ef" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "rtsan-standalone-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362a9c531f731e574a870cdb28ee7d81178ba7a87ed54380eb9b8d8f0c3b5301" +dependencies = [ + "num_cpus", + "tempfile", +] + [[package]] name = "rubato" version = "0.14.1" @@ -3863,7 +3771,7 @@ checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "bytemuck", "js-sys", - "ndk 0.9.0", + "ndk", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -4239,9 +4147,9 @@ dependencies = [ "jni", "libc", "log", - "ndk 0.9.0", + "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", @@ -4251,7 +4159,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4322,7 +4230,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows", ] [[package]] @@ -4435,7 +4343,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows 0.61.3", + "windows", "zbus", ] @@ -4461,7 +4369,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", ] [[package]] @@ -4486,7 +4394,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", "wry", ] @@ -5229,9 +5137,9 @@ dependencies = [ [[package]] name = "web-audio-api" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "015053d21b75159d00b45f8b862d9c05b783440537d9751d659ad2982911eb7f" +checksum = "84af8a6acf0652c839297eebf360d25e6108c7376085cd446fc59289101110dd" dependencies = [ "almost", "arc-swap", @@ -5332,7 +5240,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5356,7 +5264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows 0.61.3", + "windows", "windows-core 0.61.2", ] @@ -5406,16 +5314,6 @@ dependencies = [ "windows-version", ] -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -5438,16 +5336,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.61.2" @@ -5529,15 +5417,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -5965,7 +5844,7 @@ dependencies = [ "javascriptcore-rs", "jni", "libc", - "ndk 0.9.0", + "ndk", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -5983,7 +5862,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", + "windows", "windows-core 0.61.2", "windows-version", "x11-dl", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47b049b..8ce0e0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,5 +22,5 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -web-audio-api = "1.2.0" +web-audio-api = "1.4.0" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7f1264..cd743ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,14 +6,17 @@ use web_audio_api::context::{AudioContext, BaseAudioContext}; use web_audio_api::node::{AudioNode, AudioScheduledSourceNode}; #[command] -fn play_music(name: &str) -> String { +async fn play_music(name: &str) -> Result { let context = AudioContext::default(); let current_dir = std::env::current_dir().unwrap(); let file_path = current_dir.join("..").join("static").join(name); - let file = File::open(file_path).unwrap(); - let buffer = context.decode_audio_data_sync(file).unwrap(); + let file = File::open(file_path).map_err(|e| format!("Failed to open file: {}", e))?; + let buffer = context + .decode_audio_data(file) + .await + .map_err(|e| format!("Failed to decode audio data: {}", e))?; let mut src = context.create_buffer_source(); src.set_buffer(buffer); @@ -30,10 +33,10 @@ fn play_music(name: &str) -> String { src.start(); - let buffer_duration = src.buffer().unwrap().duration() as f64; - let sleep_duration = std::time::Duration::from_secs_f64(buffer_duration); + // let buffer_duration = src.buffer().unwrap().duration() as f64; + // let sleep_duration = std::time::Duration::from_secs_f64(buffer_duration); - format!("Playing music: {}", name) + Ok(format!("Playing music: {}", name)) } #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index d9319b5..ce3cb21 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,6 +1,6 @@
-
-
-
+
+
+
-
+
\ No newline at end of file + .container { + --width: 100; + width: calc(1px * var(--width)); + --thumbHeight: 18; + position: relative; + contain: paint; + height: calc(var(--thumbHeight) * 1px); + } + + .track { + --progress: 0; + --height: 12; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: calc(100% - 2px); + height: calc(var(--height) * 1px); + border-radius: 12px; + overflow: hidden; + } + + .container > *, + .track > * { + pointer-events: none; + } + + .thumb-box { + width: 100%; + height: calc(var(--height) * 1px); + position: relative; + display: flex; + gap: 7px; + } + + .thumb-box::before { + content: ''; + position: relative; + box-sizing: border-box; + left: 0; + width: calc(var(--progress) * var(--width) * 1px - 3px); + height: 100%; + border-radius: 12px 4px 4px 12px; + background-color: rgb(var(--mdui-color-primary)); + } + + .thumb-box::after { + content: ''; + box-sizing: border-box; + position: relative; + right: 0; + width: calc((1 - var(--progress)) * var(--width) * 1px - 3px); + height: 100%; + border-radius: 4px 12px 12px 4px; + background-color: rgb( + var(--mdui-color-surface-container-highest-light) + ); + } + + .divider { + position: absolute; + box-sizing: border-box; + top: 50%; + left: calc(var(--progress) * var(--scale) * 100% + 2px); + transform: translate(-50%, -50%); + display: flex; + width: 1.5px; + border: solid 1.5px rgb(var(--mdui-color-primary)); + border-radius: 3px; + height: calc(var(--thumbHeight) * 1px); + pointer-events: none; + transition: all 0s ease; + } + From f45587c5ad83d7be3ecdeb88f8785609686de0f9 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Wed, 20 May 2026 20:50:02 +0800 Subject: [PATCH 02/39] update --- src-tauri/src/commands.rs | 61 +++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 49 ++++------------------- src/lib/components/PlayerBar.svelte | 14 +++++-- 3 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 src-tauri/src/commands.rs diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..12481d8 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,61 @@ +use std::fs::File; +use std::sync::OnceLock; + +use tauri::command; + +use web_audio_api::context::{AudioContext, BaseAudioContext}; +use web_audio_api::node::{AudioNode, AudioScheduledSourceNode}; +use web_audio_api::MediaElement; + +fn get_audio_context() -> &'static AudioContext { + static CONTEXT: OnceLock = OnceLock::new(); + CONTEXT.get_or_init(|| AudioContext::default()) +} + +#[command] +pub async fn play_music(name: &str) -> Result { + let current_dir = std::env::current_dir().unwrap(); + let file_path = current_dir.join("..").join("static").join(name); + + let file = File::open(&file_path).map_err(|e| format!("Failed to open file: {}", e))?; + + let context = get_audio_context(); + + let buffer = context + .decode_audio_data(file) + .await + .map_err(|e| format!("Failed to decode audio data: {}", e))?; + + let mut src = context.create_buffer_source(); + src.set_buffer(buffer); + src.set_loop(false); + src.connect(&context.destination()); + + src.start(); + + Ok(format!("Playing music: {}", name)) +} + +#[command] +pub async fn pause_music() { + let context = get_audio_context(); + context.suspend().await; +} + +#[command] +pub async fn resume_music() { + let context = get_audio_context(); + context.resume().await; +} + +#[command] +pub async fn current_time() -> f64 { + let context = get_audio_context(); + context.current_time() +} + +#[command] +pub async fn set_current_time(time: f64) { + let context = get_audio_context(); + // TODO: set_current_time +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd743ea..36be0ec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,49 +1,16 @@ -use std::fs::File; - -use tauri::command; - -use web_audio_api::context::{AudioContext, BaseAudioContext}; -use web_audio_api::node::{AudioNode, AudioScheduledSourceNode}; - -#[command] -async fn play_music(name: &str) -> Result { - let context = AudioContext::default(); - - let current_dir = std::env::current_dir().unwrap(); - let file_path = current_dir.join("..").join("static").join(name); - - let file = File::open(file_path).map_err(|e| format!("Failed to open file: {}", e))?; - let buffer = context - .decode_audio_data(file) - .await - .map_err(|e| format!("Failed to decode audio data: {}", e))?; - - let mut src = context.create_buffer_source(); - src.set_buffer(buffer); - src.set_loop(false); - - // // create a biquad filter - // let biquad = context.create_biquad_filter(); - // biquad.frequency().set_value(125.); - // connect the audio nodes - // src.connect(&biquad); - // biquad.connect(&context.destination()); - - src.connect(&context.destination()); - - src.start(); - - // let buffer_duration = src.buffer().unwrap().duration() as f64; - // let sleep_duration = std::time::Duration::from_secs_f64(buffer_duration); - - Ok(format!("Playing music: {}", name)) -} +mod commands; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() // .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![play_music]) + .invoke_handler(tauri::generate_handler![ + commands::play_music, + commands::pause_music, + commands::resume_music, + commands::current_time, + commands::set_current_time + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte index b227ad5..f04652e 100644 --- a/src/lib/components/PlayerBar.svelte +++ b/src/lib/components/PlayerBar.svelte @@ -21,6 +21,7 @@ export function toggle() { playerState.isPlaying = !playerState.isPlaying + playerState.isPlaying ? resume_music() : pause_music() } @@ -57,8 +58,10 @@ onkeydown={e => e.key === 'Enter' && prev()} >
- + - +
From 6a9ecda90f9fa7f0fbbc0900ada22d95652b60d9 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Thu, 21 May 2026 17:32:03 +0800 Subject: [PATCH 03/39] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E6=92=AD=E6=94=BE=E5=9F=BA=E7=A1=80=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=E5=85=83=E6=95=B0=E6=8D=AE=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增lofty依赖用于解析音频文件元数据 - 合并暂停/恢复为切换播放状态的接口 - 新增获取歌曲信息的后端命令 - 完善前端播放器状态管理与UI展示 - 优化进度条组件支持键盘与鼠标操作 --- src-tauri/Cargo.lock | 48 ++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 135 +++++++++++++---- src-tauri/src/lib.rs | 6 +- src/lib/components/Header.svelte | 50 ++++--- src/lib/components/PlayerBar.svelte | 223 +++++++++++++++++++--------- src/lib/components/Progress.svelte | 31 +++- src/lib/components/Slider.svelte | 63 ++++---- src/lib/player.svelte.ts | 18 +++ 9 files changed, 420 insertions(+), 155 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 336bfce..5336def 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -798,6 +798,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -2176,6 +2182,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" version = "0.4.29" @@ -2600,6 +2632,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "ogg_pager" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2688,6 +2729,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -4237,6 +4284,7 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "lofty", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ce0e0a..1027eb2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,4 +23,5 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" web-audio-api = "1.4.0" +lofty = "0.24.0" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 12481d8..f64cf00 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,61 +1,138 @@ -use std::fs::File; -use std::sync::OnceLock; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use lofty::prelude::*; +use lofty::probe::Probe; use tauri::command; use web_audio_api::context::{AudioContext, BaseAudioContext}; -use web_audio_api::node::{AudioNode, AudioScheduledSourceNode}; +use web_audio_api::node::AudioNode; use web_audio_api::MediaElement; +use serde::Serialize; + fn get_audio_context() -> &'static AudioContext { static CONTEXT: OnceLock = OnceLock::new(); CONTEXT.get_or_init(|| AudioContext::default()) } -#[command] -pub async fn play_music(name: &str) -> Result { +struct AudioState { + media: Option, +} + +fn get_audio_state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| { + Mutex::new(AudioState { media: None }) + }) +} + +fn test_path() -> PathBuf { let current_dir = std::env::current_dir().unwrap(); - let file_path = current_dir.join("..").join("static").join(name); + current_dir.join("..").join("static") +} - let file = File::open(&file_path).map_err(|e| format!("Failed to open file: {}", e))?; +#[command] +pub async fn play_music(name: &str) -> Result { + let file_path = test_path().join(name); + let mut media = MediaElement::new(&file_path).map_err(|e| format!("Failed to create media element: {}", e))?; let context = get_audio_context(); + let src = context.create_media_element_source(&mut media); + src.connect(&context.destination()); - let buffer = context - .decode_audio_data(file) - .await - .map_err(|e| format!("Failed to decode audio data: {}", e))?; + media.set_loop(false); + media.set_current_time(0.0); - let mut src = context.create_buffer_source(); - src.set_buffer(buffer); - src.set_loop(false); - src.connect(&context.destination()); + let mut state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; - src.start(); + if let Some(old_media) = state.media.replace(media) { + old_media.pause(); + } + + if let Some(ref media) = state.media { + media.play(); + } Ok(format!("Playing music: {}", name)) } #[command] -pub async fn pause_music() { - let context = get_audio_context(); - context.suspend().await; +pub async fn toggle_music() -> Result { + let state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; + if let Some(ref media) = state.media { + if media.paused() { + media.play(); + Ok(true) + } else { + media.pause(); + Ok(false) + } + } else { + Err("No media available".into()) + } } #[command] -pub async fn resume_music() { - let context = get_audio_context(); - context.resume().await; +pub async fn current_time() -> f64 { + let state = get_audio_state().lock().unwrap(); + if let Some(ref media) = state.media { + media.current_time() + } else { + 0.0 + } } #[command] -pub async fn current_time() -> f64 { - let context = get_audio_context(); - context.current_time() +pub async fn set_current_time(time: f64) { + let state = get_audio_state().lock().unwrap(); + if let Some(ref media) = state.media { + media.set_current_time(time); + } +} + + +#[derive(Serialize)] +pub struct SongInfo { + title: String, + artist: String, + album: String, + duration: f64, + sample_rate: Option } #[command] -pub async fn set_current_time(time: f64) { - let context = get_audio_context(); - // TODO: set_current_time -} \ No newline at end of file +pub fn get_song_info(name: String) -> SongInfo { + let file_path = test_path().join(name); + let tagged_file = Probe::open(&file_path) + .unwrap() + .read() + .unwrap(); + + let tag = tagged_file.primary_tag() + .or_else(|| tagged_file.first_tag()); + + let title = tag + .and_then(|t| t.title()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let artist = tag + .and_then(|t| t.artist()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let album = tag + .and_then(|t| t.album()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + + let props = tagged_file.properties(); + let duration = props.duration().as_secs_f64(); + + SongInfo { + title, + artist, + album, + duration, + sample_rate: props.sample_rate(), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36be0ec..592076e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,10 +6,10 @@ pub fn run() { // .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ commands::play_music, - commands::pause_music, - commands::resume_music, + commands::toggle_music, commands::current_time, - commands::set_current_time + commands::set_current_time, + commands::get_song_info ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index ce3cb21..774d826 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,26 +1,38 @@
-
Header PlaceHolder
- { - if (e.key === 'Enter' || e.key === ' ') playMusic() - }} - class="px-4 py-2" - role="none" - > - 播放音乐 - +
Header PlaceHolder
+ { + if (e.key === 'Enter' || e.key === ' ') playMusic() + }} + class="px-4 py-2" + role="none" + > + 播放音乐 +
diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte index f04652e..cd9b3d9 100644 --- a/src/lib/components/PlayerBar.svelte +++ b/src/lib/components/PlayerBar.svelte @@ -1,90 +1,167 @@ - - -
-
{playerState.current?.title}
-
{playerState.current?.artist}
-
- -
-
-
- {playerState.current?.title ?? '未在播放'} -
-
- {playerState.current?.artist ?? '未知艺术家'} -
+
+ + {formatTime(currentTime)} + +
+ seek(e.target.value)} + duration = {playerState.duration} + /> +
+ + {formatTime(playerState.duration)} +
-
- e.key === 'Enter' && prev()} - > - e.key === 'Enter' && toggle()} - > - - e.key === 'Enter' && next()} - > +
+
{playerState.current?.title}
+
{playerState.current?.artist}
-
- - - - +
+
+
+ {playerState.current?.title ?? '未在播放'} +
+
+ {playerState.current?.artist ?? '未知艺术家'} +
+
+ +
+ e.key === 'Enter' && prev()} + > + e.key === 'Enter' && toggle()} + > + + e.key === 'Enter' && next()} + > +
+ +
+ (muted = !muted)} + onkeydown={e => e.key === 'Enter' && (muted = !muted)} + role="button" + tabindex="0" + class="text-lg opacity-70" + > + + + +
-
diff --git a/src/lib/components/Progress.svelte b/src/lib/components/Progress.svelte index c854c2c..46f68c9 100644 --- a/src/lib/components/Progress.svelte +++ b/src/lib/components/Progress.svelte @@ -5,6 +5,9 @@ width = 100, height = 12, thumbHeight = 18, + onmousedown, + onmouseover, + onmousemove, } = $props() let startMove = $state(false) @@ -14,19 +17,32 @@ let progressRaw = $derived(value / 100) let scale = $derived((width - 4) / width) - function mouseDown(ev) { + function mouseDown(e) { startMove = true - dx = ev.offsetX - let _value = clamp((ev.offsetX / inputEl.offsetWidth) * 100, 0, 100) + dx = e.offsetX + let _value = clamp((e.offsetX / inputEl.offsetWidth) * 100, 0, 100) value = _value + onmousedown?.(e) } - function mouseMove(ev) { - ev.stopPropagation() + function mouseMove(e) { + e.stopPropagation() if (!startMove) return - dx += ev.movementX + dx += e.movementX let _value = clamp((dx / inputEl.offsetWidth) * 100, 0, 100) value = _value + onmousemove?.(e) + } + + function handleKeydown(e) { + if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { + e.preventDefault() + value = clamp(value + 10, 0, 100) + console.log(value) + } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { + e.preventDefault() + value = clamp(value - 10, 0, 100) + } } $effect(() => { @@ -59,6 +75,9 @@ class="container" style="--width: {width}; --thumbHeight: {thumbHeight}; --scale: {scale}; {cssStyle}" onmousedown={mouseDown} + {onmouseover} + onfocus={onmouseover} + onkeydown={handleKeydown} role="slider" aria-valuemin={0} aria-valuemax={100} diff --git a/src/lib/components/Slider.svelte b/src/lib/components/Slider.svelte index eebc193..c11dfc9 100644 --- a/src/lib/components/Slider.svelte +++ b/src/lib/components/Slider.svelte @@ -1,12 +1,18 @@ - diff --git a/src/lib/player.svelte.ts b/src/lib/player.svelte.ts index 20ebdda..b936e47 100644 --- a/src/lib/player.svelte.ts +++ b/src/lib/player.svelte.ts @@ -3,6 +3,10 @@ type PlayerState = { isPlaying: boolean playlist: Track[] progress: number + duration: number + volume: number + isMuted: boolean + previousVolume: number } type Track = { @@ -16,4 +20,18 @@ export const playerState = $state({ isPlaying: false, playlist: [], progress: 0, + duration: 0, + volume: 0.8, + isMuted: false, + previousVolume: 0.8, }) + +export interface SongInfo { + title: string; + artist: string; + album: string; + duration: number; + sample_rate?: number; + bit_depth?: number; + channels?: number; +} \ No newline at end of file From cbf9e2fecff6c7895915885f0c8b02a4cb88d92a Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Fri, 22 May 2026 15:50:34 +0800 Subject: [PATCH 04/39] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E9=9F=B3=E4=B9=90=E6=92=AD=E6=94=BE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=9F=B3=E9=87=8F=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=92=8C=E5=85=83=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构播放器状态管理为类形式,统一控制播放逻辑 - 新增音量调节和静音功能,支持实时生效 - 添加专辑封面展示,使用base64嵌入音频封面图片 - 重命名相关方法和类型,统一代码命名规范 - 新增git提交信息规范文档 - 添加默认封面静态资源 --- .trae/rules/git-commit-message.md | 4 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 58 ++++++++++-- src-tauri/src/lib.rs | 3 +- src/lib/components/Header.svelte | 15 +-- src/lib/components/PlayerBar.svelte | 140 +++++++++------------------- src/lib/player.svelte.ts | 108 ++++++++++++++------- static/default_cover.png | Bin 0 -> 242434 bytes 9 files changed, 180 insertions(+), 150 deletions(-) create mode 100644 .trae/rules/git-commit-message.md create mode 100644 static/default_cover.png diff --git a/.trae/rules/git-commit-message.md b/.trae/rules/git-commit-message.md new file mode 100644 index 0000000..76a55c7 --- /dev/null +++ b/.trae/rules/git-commit-message.md @@ -0,0 +1,4 @@ +alwaysApply: true +scene: git_message +--- +使用无序列表逐条描述具体变更,每条以 `- ` 开头,动词使用一般现在时(如 Add, Fix, Update, Merge, Improve)。 \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5336def..397424a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4284,6 +4284,7 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "base64 0.22.1", "lofty", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1027eb2..9e8377a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" web-audio-api = "1.4.0" lofty = "0.24.0" +base64 = "0.22.1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f64cf00..3ab1482 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,12 +1,12 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; - +use base64::{Engine as _, engine::general_purpose}; use lofty::prelude::*; use lofty::probe::Probe; use tauri::command; use web_audio_api::context::{AudioContext, BaseAudioContext}; -use web_audio_api::node::AudioNode; +use web_audio_api::node::{AudioNode, GainNode}; use web_audio_api::MediaElement; use serde::Serialize; @@ -18,12 +18,17 @@ fn get_audio_context() -> &'static AudioContext { struct AudioState { media: Option, + gain_node: Option, + volume: f32, } fn get_audio_state() -> &'static Mutex { static STATE: OnceLock> = OnceLock::new(); STATE.get_or_init(|| { - Mutex::new(AudioState { media: None }) + Mutex::new(AudioState { + media: None, + gain_node: None, + volume: 0.8 }) }) } @@ -39,17 +44,26 @@ pub async fn play_music(name: &str) -> Result { let mut media = MediaElement::new(&file_path).map_err(|e| format!("Failed to create media element: {}", e))?; let context = get_audio_context(); let src = context.create_media_element_source(&mut media); - src.connect(&context.destination()); + + // src.connect(&context.destination()); + let gain_node = context.create_gain(); + + src.connect(&gain_node); + gain_node.connect(&context.destination()); media.set_loop(false); media.set_current_time(0.0); let mut state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; + + gain_node.gain().set_value(state.volume); if let Some(old_media) = state.media.replace(media) { old_media.pause(); } + state.gain_node = Some(gain_node); + if let Some(ref media) = state.media { media.play(); } @@ -75,7 +89,9 @@ pub async fn toggle_music() -> Result { #[command] pub async fn current_time() -> f64 { - let state = get_audio_state().lock().unwrap(); + let Ok(state) = get_audio_state().lock() else { + return 0.0; + }; if let Some(ref media) = state.media { media.current_time() } else { @@ -93,16 +109,17 @@ pub async fn set_current_time(time: f64) { #[derive(Serialize)] -pub struct SongInfo { +pub struct TrackInfo { title: String, artist: String, album: String, duration: f64, - sample_rate: Option + sample_rate: Option, + cover: Option, } #[command] -pub fn get_song_info(name: String) -> SongInfo { +pub fn get_track_info(name: String) -> TrackInfo { let file_path = test_path().join(name); let tagged_file = Probe::open(&file_path) .unwrap() @@ -125,14 +142,37 @@ pub fn get_song_info(name: String) -> SongInfo { .map(|s| s.to_string()) .unwrap_or_else(|| "Unknown".to_string()); + let cover = tag.and_then(|t| t.pictures().first()).map(|pic| { + let b64_encoded = general_purpose::STANDARD.encode(pic.data()); + let mime_type = pic.mime_type() + .map(|m| m.as_str()) + .unwrap_or("image/jpeg"); + format!("data:{};base64,{}", mime_type, b64_encoded) + }); + let props = tagged_file.properties(); let duration = props.duration().as_secs_f64(); - SongInfo { + TrackInfo { title, artist, album, duration, sample_rate: props.sample_rate(), + cover, + } +} + +#[command] +pub async fn set_volume(volume: u8) -> Result<(), String> { + let mut state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; + let volume: f32 = volume as f32 / 100.0; + let safe_volume = volume.clamp(0.0, 1.0); + state.volume = safe_volume; + + if let Some(ref gain_node) = state.gain_node { + gain_node.gain().set_value(safe_volume); } + + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 592076e..fa8e507 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,8 @@ pub fn run() { commands::toggle_music, commands::current_time, commands::set_current_time, - commands::get_song_info + commands::get_track_info, + commands::set_volume ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 774d826..9ef1d4f 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte index cd9b3d9..719aa27 100644 --- a/src/lib/components/PlayerBar.svelte +++ b/src/lib/components/PlayerBar.svelte @@ -1,35 +1,12 @@ -
+
- {formatTime(currentTime)} + {formatTime(playerState.currentTime)}
seek(e.target.value)} - duration = {playerState.duration} + bind:value={playerState.currentTime} + bind:this={slider} + oninput={e => playerState.seek(+e.target.value)} + duration={playerState.current?.duration ?? 0} />
- {formatTime(playerState.duration)} + {formatTime(playerState.current?.duration ?? 0)}
-
-
{playerState.current?.title}
-
{playerState.current?.artist}
-
-
-
-
- {playerState.current?.title ?? '未在播放'} -
-
- {playerState.current?.artist ?? '未知艺术家'} +
+ Album Cover +
+
+ {playerState.current?.title ?? '未在播放'} +
+
+ {playerState.current?.artist ?? '未知艺术家'} +
@@ -127,41 +72,42 @@ icon="skip_previous--rounded" role="button" tabindex="0" - onclick={prev} - onkeydown={e => e.key === 'Enter' && prev()} + onclick={() => playerState.prev()} + onkeydown={e => e.key === 'Enter' && playerState.prev()} > e.key === 'Enter' && toggle()} + onclick={() => playerState.toggle()} + onkeydown={e => e.key === 'Enter' && playerState.toggle()} > e.key === 'Enter' && next()} + onclick={() => playerState.next()} + onkeydown={e => e.key === 'Enter' && playerState.next()} >
(muted = !muted)} - onkeydown={e => e.key === 'Enter' && (muted = !muted)} + onclick={() => (playerState.muted = !playerState.muted)} + onkeydown={e => + e.key === 'Enter' && + (playerState.muted = !playerState.muted)} role="button" tabindex="0" class="text-lg opacity-70" > - - +
diff --git a/src/lib/player.svelte.ts b/src/lib/player.svelte.ts index b936e47..f56ad89 100644 --- a/src/lib/player.svelte.ts +++ b/src/lib/player.svelte.ts @@ -1,37 +1,79 @@ -type PlayerState = { - current: Track | null - isPlaying: boolean - playlist: Track[] - progress: number - duration: number - volume: number - isMuted: boolean - previousVolume: number +import { invoke } from "@tauri-apps/api/core" + + +class PlayerState { + current = $state(null) + playing = $state(false) + playlist = $state([]) + currentTime = $state(0) + volume = $state(80) + muted = $state(false) + + #pollTimer: any = null + + startPolling = () => { + this.stopPolling() + this.#pollTimer = setInterval(async () => { + if (!this.playing) { + this.stopPolling() + return + } + this.currentTime = await invoke('current_time') + }, 250) + } + + stopPolling = () => { + if (this.#pollTimer) { + clearInterval(this.#pollTimer) + this.#pollTimer = null + } + } + + toggle = async () => { + this.playing = await invoke('toggle_music') + if (this.playing) { + this.startPolling() + } else { + this.stopPolling() + } + } + + seek = async (time: number) => { + this.stopPolling() + await invoke('set_current_time', { time }) + this.currentTime = time + if (this.playing) this.startPolling() + } + + setVolume = async () => { + await invoke('set_volume', { volume: this.volume }) + } + + switchTrack = async (step: number) => { + if (!this.playlist.length || !this.current) return + + const len = this.playlist.length + const currentIndex = this.playlist.indexOf(this.current) + const newIndex = (currentIndex + step + len) % len + + this.current = this.playlist[newIndex] + this.currentTime = 0 + this.playing = true + + this.startPolling() + } + + next = () => this.switchTrack(1) + prev = () => this.switchTrack(-1) } -type Track = { - title: string - artist: string - path: string +export interface TrackInfo { + title: string + artist: string + album: string + duration: number + sample_rate?: number + cover?: string } -export const playerState = $state({ - current: null, - isPlaying: false, - playlist: [], - progress: 0, - duration: 0, - volume: 0.8, - isMuted: false, - previousVolume: 0.8, -}) - -export interface SongInfo { - title: string; - artist: string; - album: string; - duration: number; - sample_rate?: number; - bit_depth?: number; - channels?: number; -} \ No newline at end of file +export const playerState = new PlayerState() \ No newline at end of file diff --git a/static/default_cover.png b/static/default_cover.png new file mode 100644 index 0000000000000000000000000000000000000000..6c7c6c15a78201b9168776e4bb79d6bb387ff4dc GIT binary patch literal 242434 zcmZ_01yq#V7Y7PT=SWMBfQ(3qNJ$M1(j{Hel1d|;k^%|}NarY`gp`zkh)9<-f=Wp! zAPsNN{l8dmt@q}7uUuuAne&~q_pkQ%SX)z>oP?1C2M32-RYgG;2j^lW_Ftk4@FzaJ zjxO*U`#lw14IG?+t2j8J@RvvMN1-b?INpLdIBQloI1YzC@bKc zWBEpYMn7#XeNvUwHQaerMC&7ltLnQB^?e`%V3R>HA2}Zn}G`I#t-*3vHTLQ_Q42 zv$CY*@nP{oq;i-t9(`P%5DmfuCvHI&jjyOpgLnBtK`1*@id54|^l)=^|D*Hz%xQTN z{CBRy|BixBtcXf`R5NFmeJz)KZ)BES!b6*&Fc>O1qx7g&=gd!nWGCe^>ZzPL%LWEN z#WX{>d#jkcoicy@)!P1?Su%8+OBM6_H7^g(=boOP&!5G-*Yw@o-0bY^{QVmW;OCHp z=Q(8-pqRVc?@`GKCx+dO+_|JUWb^Ku%lo)E5yIWe$!xu6#GC9kaqr;Sc`Op2-&l133)KWQnt1y_j0wdn^zkmN?Fqkj@ zJr)-iQ&UsnzYmxDfByVQOHaT2>z5GO#a0^ufl3kADL;Sz8)O%^C_nUg^*Jy^+dVEo zWtP0-B_)Kv@DB`BR#fcj?&iLF)n{ceUp|Inn_@>&i|ExkjcO_<(S;!4dStIqZxwGZ zydH*UU}u}-C>imHY~lUcqnXoS4x&X?2AV>mVO>w1f&f|amGjYgEh0}bW~IU*#rNTy zRCwrA&a6H|W8)X_c=pWu_qTUaf&YF_*Ciu9R&_cGzrR1x?>+8$Q6D1l<)stT(hH@!i zThTvCEIM3g(ikzbVE+)7x4rNap59y4Xa7))xwyF4@KtS55vPny>)W^6GYtUn!35jo32|2;IWQg?gunjRy=EG1}5e43i8s;UYKF3c@NM%Lfn`f&5cjT=bh zHDTeJd)-NMb93SBf@Fmlo-!i?1LO2(c9(r_ZTEMOiybzavTMKE^(B#6->hRsBSb@O zXwW8NnbdMb-JTwYrRPQG*O3ZrqGRW27!3B3(bBH2uEJh||0*Xji~DWoq2hRZqa!2z z0t1;6l~fdD+8Sbz%uR%=qBsHjL0(vR&! zZFu6r!NK!Bdp0(T6i{cgTv#yq2^o1|>%+AT{B4GnMa+CKa1*RF{N{&PX2 z+b63X^mKLM)l*YaaJw1E9(j1YO-`mnMp`ov`FyEz5oX-*x!qe8SD>%2|McloN5(=Y zlRCHAH%*(3ot>S}pFh`pDWRyOM1YT$o@`;vVF z7xEYuf`fk5ym3P){`vairm?ZHax&}eyL-yc&dzFTB*;$KjVM&?vVTOo?9En9u6()J zH*eo+>*&kHwzHdsobC}56MK4k4h;=iTU&qs{@rz|<^}_qg@pxgZ-lmwxGMcF(&98# zF{G%tB*5H%6>Q`}?JXj&^&}xYN?o zNJ&X)6*(en?$ci(3h%KE#}WTAoO&6_uc zgnYKS_K2qfo8c$Rxx-0W0`MHG)%l*x%r{k8xf&5qH z_v%-U0~B&FUxa)3Rdo#v#u^?j$Hm82S5-k3(S=LSDwUa%g6zfg*b-4t(BW}JY9bxK z?0o3>zTlrzvPXt|qaOC#SuS|bW z&1^Bx?S=0g%1N>grbb53Ctac~gmaejt!RR);S5GaMIjK)8}FyxT2KG+n>TWDa(b=5*OjggFJ^IrZf+C_YI~u(%{Q{iNLrE)i64}hqG6Z{CDy4@A^*h>KYoRXJrL0c0NvI66H6p!jo8p%N_LS5guCGV9Zfi zt~UjPx%J=U*|TQ^5#OioS2q93yiWxWYHFy{a&mHC?Qcqwy6Ruiv@0JVT92?O((DLq zZdjU9u`oyCr^YtYn`@`7o=?qiC0VquFSTd9uHy1e-SggR$h$`$WD!(4+Evs#=3G%$ z_W8>fH4P1@(Fd@&lx)&wIy(7ICVqbPJ+?wweKiu7edytc93CEeP}I%(n9jEZHTiC_ zvarmy1O@uGP7;OJlfx%l^c+p387CJ_m6+XRUnuj!G5vDWp`Tg8%MqUN%CO?gzyNn- zWK2v}pMBvFk<4{fs#vO5V*&qu8%JA1*Kun(`~|()XLXqC>eUjBEDo zx4$*wg_nmu3YD9Xf?3Sx_qXpIF?Pmo)9?B4(8y!TS~h5%RoQ827g=k9pp!wB-QS$$ z;NXaeh#*F4zf`Z|s6~k3QK|wLG=SN<0Ef(o^pL40?;CnV3mUFV) z8m}RdNMT`NUfu+R+^Mfw&c|7IszuL16^t~erJi0|R#sMKX28Mr_X^WGRtYbabUT0^ zj*f-h9ad3mYirrr%yKFdZ_Kyqmy%4O=B>S|Op1-oi`L^GSQ%b4)Ux9{9V|$`7VgK3 zQo7VxJy@5eZK@q@&HkN2N8Kc?6eCnWxV*gVU}txFcD&VkcC_9SbWDsyqj9a;@Oq35 z49E#0#+(JNT`Pa{h7RvLl-0(a{@d;!{(OQ%vc2sQFh*#N&P|da86)qz^4V_DL+tO) zj#jR8bIAEwM+~L=x3_2-MU4>vQ&xfuWCR!aUE?$^c!xt5x| z@`8@mMHT)b?PW48a;Dr%Qv9kOYmJJxzq1ceh`NZFLM_nL?8iba76~klfE7;bpM*|gO{$m+Kc&EyuFlte*HTyCmvHt}VYD=d`!m}v3cf19Hq zhv4DjhebL%x*RdjMW_K&Rd&5>Aue{tCGZMXRTl}=5#)UE;O8F~esH`|H~Vlv3hJyY zRQ~Dr`B&2(k2-NT+G_B4eoFUXpn4!~HdxT=7LtXE!^g*m>PAS(I`i&c_f+kD@uUd- z7Ivby*CYH(F&KM0yK)Tt=|tdG^F9=m2Dr2x9UXi9+Fn~;?Fo`fyO%oep>M>o=iqNh zl|EJ+%!40xyuUTy=)Dd-Q!`hZk4R2I0k6Z+s_9}7li{nq-;kdIc2!hCTpI_q46wfuB1sRV1Z3My^%s<-cGjH_5-7C+%WwL(_ zf%ivUJK;*KLq40%y}D^VJw14zt+qX>(CVvrr=glWkHnK!%PeusG&C|YGB=+s*DIZN zZ#^A1b>En44m2~%uB_aOIvG%%eV^QDe(1r}ee=pWk;$O?#RC3uog*qOXW2W-H&*oy z>EgM#xtBX1Q%~2q&sRGPq;M!3r6;9PE zFm87Df1C0NK`4)Sz3lM)IsCL}eqXt11hNQ&SpyINy1cuz00sCL zz$|mO)OOp&WXZoO$*fXVspM#C?V_I-EcqGC1Gg92q5KB@n{T4~8!WJO1R2-`%bhY&aa>$n(BQDIx!q3a4loZ|oQR~P z8Qc5FKO)Z!luabJbp-hMG&D8mT27OpJ^-nSw^p&W<<417RN_!LB}PJT1a`6H@ALTy zGFkd)1rRzt)iA}>@$PV%lDc`z2bAiiq8?c|9^^{1K5Wf|Vz*qbM55w!jg9^P{^)U? ze&3fRS_uebG|d4-4%6nQ^j}at0&1M3@rbszAYqj zT3x{}=>Mf&4$ScD*OQ$AnL3xrs(?Lb1Fntf7@&GL$?gfga1qI3CxW)w(BMFXnNjGsU#hax)0K!C&as95{0w`70j@rYoe*uHF7+*(OR-4hjJAF& zNa181+LXplbkwM&F~aa`Ydxdm`{&P}zqN*(LwkiEU4kjnD^0qZMl3|Ia8<|9P{+)S znU;3%Ge`37=Irq&CimU_eQx@y&Qf_cL9sY?4V*X~JSUvpdujY-__s9fz@=^S*~q+g z9}xD&@-jUg-6v1xfgPWwSt~)H&OqGdR4Of+v87p6vAEun)`gd%fuN zpcua5`PdS41}0%t#T(O`6__;up#R{=I*pV7Q>X=u={l{AMbAhe?Ou3SuU@5Klm2kG z(gHyC6OTNO!Ii@Az9eWyN-Xk2Ws5)jHCnEB{qCESUs>*)s&UE5G4U_hi4Y%p`ub`o zT_~jw7VafW`E-zrDkv$12ckUtr;GeUj<_m9D7ZCMIu#jlJH3ZH)ti#q7SDl0_ zDoa?h$Ve5_;khO(CnGgUr zz3F_@Rd%Fe2M?H)?cTsv{PKl_lyn_ipj(0f^-!ETE!2^r8Hr)39#}8D!j(htN`4pV zOP1Ho8Gm8t;NU*nxVMxnBOQF=r=kKtp%s`Tm2OOj4s=*U!*RISXQ#(dPj{e?56aM6 z{9t@ST4E?reUR+TvY1Ug`j)#yRF++W5-%fwtmqF;)S8DFbjN0>zVPAQXX=M0CPamW zpZ*)|tuiw-ga*XG%q$7uB!(?m?8c3hFJ|d>=ED3)1$X?YdtVVhaq|N)-M0gW;4T@60h^ zhU;uM0AHGbKi{npKfKkZOOXS;Lku+yP;Qo}yG5<@SlC6vj~_pN`0(M;qer&Jbc#AU zPeG%J?;3_vjC+w_#%J0UU}r#Jd7}ySX1<;vQE>aEA41rIN3SR#<+PSu?ZSgFVP2^x17`-`yzqQ?}e(tUK`YEdG>) z-{*uYNfGl%vvyD2v?{`i|`$@gmEA}qom1A+>16;4U8*k(~ooiX80(RE{PaUmS z5+gzPA&5X57!(dUR!@Ebntta_llQua?dK=Qn@yA^OW&OdXIn-e(y)@#s&b&G&0-%K zT|ec{3~4o@|0fefCq*wTT5naf^FV~tF=24Q48#;iWsMQw-#!3WphyGRIywr7iHv-% zeie1_ON<|Ms;a8SyOlWra$A>k0#8m)FLPJ%?tDO>^KMskZ;OPRQDNV%&aU~TR8D}4 zZgU?F;X3wx{YpkoP7YYNQr{8ZZv`4T7tmH$PpX%;bo+qu;+U z&vLNr5U20)L?M}3u*Q$S>SSlDC5vp2?~vt+@*I_*$__@eswQ3D|M>BPoM{|;6rqj7 zwh_$g+f~t_xp}ji80p?BRSR$&G@1TP9f}7WaRs671i>Q!FR<5pFCT-Mb(8U2?8F$n zvhU9>cQSeONOCBuUQJC6nE58O3T3vSU4T2z^DWK2+4m8zGfTc(6@4mv^YnFK8`rIN z+DN28L$HOQwp#yu_6k>T*MlRxJ;#gr=X@idi-RSlZe2DocxGJ#nkt;LO4B;G?;jPz z+q$~C(0^HiMQ-1ocCMvl64`ysowG96+*Da9!$SSjCh7a;L?SyYIrNSO9FH!ZYuB)4 zIhE^9@X>1N!omVbusO?i#v+V~hOce`T|@j`j2AfDZ#|FFMhWw+^4X(~zVoX@JiIYv zD5|AKST-DJYwRP&Oc5iWZl^L6aq0CUp1qF6wm<0?o$A z=v$$3a(l%2K6FDoLPGgrwwAv}_rusT@BrkPg=S}G17y?478?SL_WRqAi^xM}LOW}~ zSg;(vv)&UR@d9rB7aL1-+7+#NDRP_m8KcM7RsmgwHC%~27K#{DTq@_axCfxgDC$_9 za6H{H)Ymtv7zb_Hz`y|Z866GH(`U~>S2%hlbKWOGyhhE+2SN!7h2rJq6%-ttn5YBN z1ZO`iEUdk~J)B*ZO?2VsPcEVh>YA+!*N}HZ@8IdX+u=mbZr>_>r*2Hw{`v8gz|)Kj zki!DUFb4BM`!Csv061)Oh-4)$)$i_-B`sM5?Tw)$2)H;ow{yToYo=6qLs0Ooxuf6G>2OK_XiMCh z+24RIe~cmpZPNM?BQkPHY{iE6`gJBrD01x)P@Dq5FYs94_Wsal)*{9ruV})DQc9ug z?V7Sjr>0KfMr3oU^7c+`{KJA-;5~OT_({pgLK8l;TfLohSr~z)U#e56B(*!p0V{r# zd2@<=YcpfoNcL&QsAuPgg_Iw12Fb#L>LZ1_mAm_eAM3BFq<#@9>c0+^G(NufKl=ez z1-=HhVQOI!3>XA@!tk)J_6x}YIV0GA@+R4z>J}Qz7@zOD5zw5{74wrD-N9&$tPhZ5 zZaYd{VJXK+F^})Esh+eo=86;~8xsE8dQN`!;OuUVCJrfe7Q;ao?7Fi*-E5?Eyx(f? z-vtO35gxvhsjZ`vlA9aS&EiYOB=UPMa4RZUx%sj*dHctgUUS#NYviz7g#2{Hc@R;> zYw7W9LRce}StQGKZ*2@Krsjs6T?Wp*zD{bW0R@zn_SrLollk*m8JBNwP2HPsftq41 zCKro}lN^|!rgtf?7fxPR4qKlmVH7MWNVD)YcX+{*%G(P>0lWoJ4juZldtf~V1_nUK z@9zhg_s*mOgSiB1n1H3FWzrI$o*_rPdFChY7rb$PKG*+rX?f3LzMt$u_&BMB;G?-R zclKSg5tW-#Qht2fXM;P00!Wh|nB*H1qoaWKS?2i;!&qkmq$6{1HpC+~B0Ihx0Kf-u zbUNvfdpYqFaB638OD@p-%a#t35EA*BXf`jgAeUyKc^HLr30`V?oqAcb#QPvK0G96k=T9L%?jH7x>pd-Yw6i>=MJ65 z0*M!50%eE?kd^rRqT?O4$Uo% z2s)I`>k8wdQbINvkcfNv>K;|Em>x`yYhPc~862YJZ|?`jKOcPTI#i(8oy?XT7Z;w1 zIEVrPAg(|R6hi~Vb2D>3R{rC1@ZY%#X{+VD|L6%wkVqFT%W!b!;XNKir|Q*30U-vj@Z=wg1N+Gg-a4dmf3BpMv?mR(6zWKD(w~O@*Oko1Ji8LRHwG%;S z3(ZlQt@zk=C58S`u?G8vwW+Oz7zvydK zcT1e6Ufgsz17>%)vwRLm0^ARFRbr%|SQ>vFwZK^Y? z67ao%UL9N(tOjSjXj!WHQor_n)BDCo3DJG^IyZCJJ<|ZRft^#m>{eDF z190(c_skgg+L86+bhNb4MnJxRM=9+2q9SmC-tw+*Y;1rRx3S?W%$P5!2})+$YKY|# zuefcQ(+>kSTP|<8=QUI~EPty(H-X!L=6c5+JDTlStH zV{d%>#V*3k5U0_upcQmx4A29p{C7d54L`g2c3;<^wg&s~g6gLw`9}eY4$?xCny9{3 z)&vP3HJ!kGyu6#!?62?U%wZS3C9mmwUhwLmwnic%+S%i5z-xlN3Z*mm(chlioAnWP z#;0AfmU!j#7Dp}2zO9Q&KWwBkym& zl19zzoh?F(BIY8-eUH_sW;{(=pyE|T>@{)A=1UHb1&3-0hqSLqE!Y@pklTItI^1%Y zZO~v>j;)TwnY*%0R9NmG#4u)Gy|*&7)WjuI6HO;US>&cxJ4-XJjq&ya6gM+1!oMK}2u#{H|^W3vbDfTR}Y%sn6f7a9)_D@{dk*ticChNicN z^U`H=yGtwf0qMgkOmDTs3~)z}NJhr{XRD?H#@K`UHUQKS_M5b{g@Hkt1aP+N*Vkph zNh#AQ1a+Gglxp8rB0SHFAXFqPSw1dq45hEWoK9GwhdZ{Xjrh*Ney4|g=RV5;{qHF zlWoJ>Y#LOGs;SWZK^lZ!oXjTE0)SVqR1<9UoDIZ57X?=-NuK7f#Tn4thxk; zu{=rm=9)-feT}<(sxOdKFf2~sVBhvy0W5|8Krj90?(Qz=#|ezLFn~qC8C1IGO6~|Q zW&aD`pyt-`Xxm6BDmtW1sRTqHO+7<3ll zW@$my<{0e+5ptAL7X8r_GHI#*776KegRn#g!Nc_|W5_7!_%;QJ&{J!D15oSw2L`lM zRS!Xf0mA6@@3**|ild_`7FT7hE+Nc4FGQy8eUk6nb#?K{V{o;or^!ahF+`Vx(e~E5 z1C$nc77aZ;p^k2}H80&AXb~Cw<`!_mz#KR@IvTcnveL4|FW-+SGLEpJ`Qy0qTGQlx znbzdUCb`(cfXsRJZQsqmKYMR`ui+^@1OGim58k^{BBM88)u`y`wL1*?$moCFPb7AZ zBy$^`0yIP;(^e$>h>(~oPMM#?cLEfnqCouS*2=-sc34=DJNgAVB_+v9?1acVb|jxV z>1J?!!yyWnWy?G)n%y!OOcX7Y^~uTldF-e@XcKhQ>^O zPNI5PUtdp5eRT%pd$5y7m=4Bn?G&1`_xV0%<59LNd=T(P^YKw2sG~w%2T%QlJ|+rjL!-Xp&7wKR(V+PMr0OraVS`KD@i1_Sht~~X zgB{qMo6Dx(b|>{IT(0NO#qNDd04bBLrP``Je)f(^FLqH^SGY&ou2AG?Kz~00N=V0V z_N`oxJE9#p9Xn}ycJ>SqwB_887LaLSVL6ruf?5_LS%WSHpHeGGkvE5M?@=QZFELg_ zHL?Eo>wf}6D+V(qEjj8a3@Sc#>VlQW;|T|x?^5}57x>*20vs+*UB$mUY0s$R_TTQN0-g$%%=@L)uNVIcaHN^6ZBa(>(12N;(zwn7xml zrZ84nzUgCm9$t5gVmw`6w)jmkF)?;_92c~8F$EbJz{VpG`REuJjP~=1kPEbG9mIS} zGDBizkP_kLm3;8yGuWW--Dbfnw1#96P)LoD1f{|&@uQY6#i%z&Rdcc}Q`c=U#iHrl zn$vfbM2o9$sVnUvV6|_r@!#?$UtLLgk{8o?3Q)D6u(0{VhlTFm-rj+1@txN5)2+kh z+*XKH5G7G6n&4sUc^>P&-!iji7$P+zqt5ToB5;+X!^5b^NbQHm&AkpCPM^m-9I~$}B;}_1WKsc*o{!lc>wL?P+%=<8+WnHLR@I zh2=vpBh|AX?7vcA#GxtVV>KcJRs#s=Uk`UK2>KjCLc=a1)N*-C($I52J755#)!?qV z;_}r&eyzsrMo!_(yW~{E0oa;{cl1Zndt===9g5W%*mi)Y?h7+Q!RXxiz^R&9VrOsf zvo`AV<8#VI0x}|U@~A(l!kP;`*9H1U8ots;9q(1>Yp3U(8`oRvJjHzZo5roR3EOhE z(L3A?+h0KKA=~p| ze=ZOUuE3r&V-2H|4)~R~+<3z(gOpf&UU<}Q?jD40zz_td`<>HLCB!;zyijMk?Stn; zYOnqze&RS(q^U<&MGETIq}d<|2GH8+(H_(%3&{4U=wdT%pf;;}d+))v1wW7tUQ~2d zOCv9jg@hir^CDP=ek9vEMdi*$qUQ%o$skSoLLsptAwQJ>V+OEEWy{eTslbOpkbIlT zr6r$QJpL$^Q=ZvkJKKtjUoO>y#o7!Wn9TXfLv{6pNf!b|1Bkk1WbA*rUP;C%yn;u3V1Bjhq{foW%!;DZ7rtza;=yXz(Q8TKv&SA0%| z3M_G?JfswW=fx`LxfAvE^_kZ=4gsAQfp3A-$)GMp3o%+$C_8MZ}!3Acc&4n?R6bI4VPHi}FKI z%^q+C-Ig4lLEo4Hv3()0NloHm(ytllc87pNu~Ig`c+g!u-lF0FQEJJY?#5o?X!PCE zY|1oay^NNG_(?3Gb=@@bq9T#g#2XgB+qapMprXSGhrPI!6S(>3@81I2Vty*UO6AAP z#lHk%OPmIcv;&K+Rbd}N-~#J&fu6h6oqT+BM1Y3}(KqAt@+J0N?yw!nTX^`YB$xCu zhz%6(HZE-5O%TRGDVa+0FZIbLk9%vSajt?WR&C#B(j4Foo8F2H5*wg(L9Sz@RErH_ zBA_^w7rzX~5eoOCz+QUUks6?liI;C)g&c>7NV+w{HWGRmINiSiS<9(c#CGWI7uUD$)z*D!t-i`o62EY)cZSuhT?V6b}(-`>$ zb-=0(2Rxrfmabx)Kf)k?B!29g5-HCtntMUkkuBl9Hlii-QYIu=U7d_fD#NY;ifBQB z4UpiAgcJ#}u^z_pv!_R9`Bg@2wIHb48Og<#V95BaqC_}Q%epjjDu866bdN($Wu%Kh=zKvw&qR6vNvYWD5)MngjbU}Hb1 z1(2z+=W#V>7BQn6`&IQq*@O((+oveQ8y_wAX2m0)WM*axw4OYesrST2-6$vsa2={A zA3S^r2SytD5M;BSNL1wI$awzPimb;#GBTn$S2LPQjszCr(ZM!E48TS-)G@xqLfZ#d z^O6K_PEy3ax-U;>A_xJyE452N@LEp(_Chm&h5}(pz=2aPBG5WtzQpE@U{3*ue6jKc z$MGm5V4x3XuJe3i@*~05xWob7&R{(Nk5FgweE+*Ai=ZQVX8@U(^WM!ReGyCir5o44; zya2vQlER5#{Z_Ags~q*^F*WzEzN|W{_VBVYl#zk321GWm@$sGhNYl#k|8*H$FQ&k? z1Ep%MzBjluPPl$6-Yia=p}__6(YwAFOaPR4?i{}YAPJHKzN^FdnLk0k)VMlQpco$v ziXc>lG`ibs&2ssOFS7!hAM3L#;{Qg+cGZEj0-h$sTgKpMq;i(u$x!gXi#))q7g9|v z61`WzdO#G;P9JG!Y$8P&0#|9>AvfqfEGGm(skt>no0Z_y!11v3|M_g%t;rQicqGCZ zLrI?0xoRJGpJKRk!JEQ~X3fX#OVw*96FA0zu$2=Sh5%!L?Jf2it4)adetCKgG!0u9 z7v7C&rkHJm`~sP@6nF93hVn3KR6`y*M2^(Y25n0gi$aZc)gTz~5Y5 zUf$m;$y!+1d?y}2vO&fkNkdEPdVH^UxcE-U;ZORIkaKv!cA0k6HHiq;->>uy#_qeF z(o>Jv#*#v9ks2*~e)eqBD*qR^sOUbpRHkpO&=4@Zc<~|^WH)Q`@?>ecuz>wS=pO%`cel-mh2il}HgZ&|_a5^8BG4vdg zkbQ`AOhFZ;=^Nk^ufLOJ-&q+dq`s<7L`fO4_!tdcT~%G(WxhqKObd-r(22aOc8d1T zAw0h-(MZioF0{uwR}sY?8#v@XYTZ}VKx1SJ)nykD+}2d>D_|S&{IF~TkePt2V1+aQ zGi+iWI6PD64UjK*hj;JZ#hS4_sa()I^gKK~WMy#%ZlrR)P`|33Jy@#fg?mpzlbD}dKUw!4uYrO2 zuL%w!C0NX(L-o$fD6U4MYZY!he0=6$<1@K`fMhKs$w?a+{{zZ5?Y=x6Dpbx=zgjp1 zFfy`60*Y8hlm%}iLrA@Fue=py!E<14*$||M&IL3Q+-G(oXrQ3-eUD+ohOTNSEbb^z zHyrPoFYJAM+}@zmswL-q_t0crTZ4m4UYHNQbALX>^1aLCzm40seO$JH!XvGjTOSJ( zz}5Gih{*8W*ogHzb# zD_~ON2qOL2*%??!P{I8D(Uy`Z7u44W`|GkM>-slU*K1x5qW_yDM24$fQLp5;2LtL+W`%|={#BnxO@(BW&nAt9>A1G_S2`rj)QqX z4W*@|Aea7oP+$)c4jv$*%u#kkrFqTVdm2$PGNDka) zrtnkDk$F$!RAOaI<>t&zQBGuV>6(V^r&uEf+?6Cy6iv2L5VbQ2~$RNUJUCpvsAVf$vQut zKQAdNI@t<2zoVg1QC^OW#3LYpj1FjByJ3W^DtGQgDA2tei)1H<6P|Ki0m81MC;z71 zA>80$Wo4zJLS*|8v~#Nqf77(2SJ&6?m=2Zi?rvY(=`^Odt2KcMhjanUGmya{Z~(3i zvE==CMi3i~b%N;cW4*K?+rc&6ddg&5)nDt0=Q>M%ICSic=K7QlE(OSZ<+5bL@HfkwdG z4mth=1eMedb<{++7Iz<%uICYM#_Aiu&fj`|;~sXN0_b^HXD93f!&FXiA%z)NV>Mrm zk<{hCCb?2*qz9FkTRw(w-GLV!Pt{w6P3M5LV-c`g{Nsmh)9f2mTwR^7oZLrfWCY}l zUW=W?x}{yL=rbuBKpTcOE&@2Rdh++? zU5_Dp?>QQKV{7Ad!2}HI^~eV&s45%JyP=Yac0jkRALYyBC{BTC%pl2u1 z}1BYC?oMTgzKk9j5P zJ|%3`78 zpfUMHjHHV%&dUQ>h7Y3p{+~a8zN+RN$InblQ%}=%2pL%24@&yj&ox1wPMfI%4Fs)a>RP59hFt|*-ryM zh^dx=P>HgipV&8@o+`31T;ue7mK=%;w2CR1hj^X>|Lv?s{YHkIGK_QGG)R#*-Dc_& z5)z;qgTc}J0H`#G5}>l5Z=IiMZ5eF-`v zH5FB!{|R{CQ2v+LLQZZxI`DuFV&u8tH?Zpxt`|PR)%zEIHn5Fgka+EEZU2|Ule0#R zF33+(8|`+SBIx$oCZgDhhC{VwoSJh{obxbe28RP{$j&?*0C%j-q8SP~G=boi{21(s zfQw2h%?P=jgoG>fq0u5Rkb>>-_@s1R>*M4z@d`TXT3Y7|5tLY14^1tDQSZQ0FxYoL zzP|0cu7UUY*(vtu0dU7=<(8MbpG>{;W0%TT(H{|3O=X|C9(RRvmxY2bNl#7^4_-qf zx`mY{J)mi}1$I|AYapaE1W_mz1RmDn6nX>6CgW=RS?Ewlp(q07lwMp8so3jYWk=Xp z!I%NaueCfkZs?2%qY(f{!XqMJx@8ZxKe$S-FltSs*3Su1;*`HjY>oc`cEmPxkhO!) zPxtHV#o*nWD3ZkUCKAfq!n-r!R$Ev8AD&<&Ww@=Jf1jfwKjs)8?FUN(KO8ddVPC(h z$8@ysJTfyggKG(zM59&vh0_SDHd1WZY3u%mgF=f#`DxO(Gm5SYQ8&eK^XEEb38Us^9EzWgt`}LS`25P$79vNE*Ee{*|NqXWPr918$r30j% z%-w?OaH8SYF-$2L7#hM@n1Ml}(mg1)SVamVgP_Dn99|Aqllcj)slGyVR^H?$s13K6 zyV2}Vd^D9hQ$olhJDV# zCI+*+5VmJQ%EGRC(rP0LbIDhOII9GOQDKZcsCEkdH? zwaxBkro1a*lzTcP+~-zTY0^*y;=7iNlv(;ufSn%wdIq#^vXTssbY~B5%Odcc&KxsA z<_6^62?@)rzF!b-3fomzQ_BPv1D`1+J6k=Q@0cmWtUje|9c$Ip-Aa5k0_wVL_>7w z3+scHtmeS(;E#F{kk~L%Qu+h{!>xJm9{?DbvINONrPGv`F5o#k5xZ<3B=Xf}PXnEaZf;H=1rx80C+#1>tkuvU-|U6q>@#vJX$^A?cDe8C>&LLKK?JLYNE4vqDqg0< zPXM!^uUA6i9_a6Ogva4Q(>{&U>_S)l-UHBqEt&&PcSp3a+eIc5W{|;J2ReBQI)4!< z9=R0NGRcKY%7TgyCVKR4W5}6ABw-Q(DAnV{L~>$c+`t%731I&xPF?x65Ykut9Gs^$o zj$(dp7@laELz;9PC_BI>z2G{5)~KkUAbaB{4531j1uE#gd?i&aY1CD2=c^>XA_u%z zPG`jHb(*FPZxuKzaMYfQpMP=u@j1U^K<0RcgHp=O%*e}N!i~A3Dl+VgPPDaE0aZ3; zEpqDakj(5UlRR9{xj|lP@2u$XH{09W0I`DMnn2C0GO1whzDyPkV^WZoQO^<~f34~i zmn$J5kB@1xq#hmx4PRWghRSW7X|A1`X7@6(sHDUmkUjvOm{AzO0Ad36fea8=kdZEK zNN)xqYg*E<-fP=J;FAd$jdr$1w?Dn6=hnE2&86Rh3v4UYQGkKND2-Juu+19$eADpT-#&l-uPKy1Ylwv0? zXJ%$V)cW|l0BjwYwPj4$d|b`=a#Jm z_iPF{u3TZ~!po#xOMm;12#x5Z&KtjuAct|G=fKGS7Yl@%+93!Ni2&XK%%6jfu#j1d z!NeF5UeTb88%n=8s}@eh)gP{d%EY>r*w6%2A5e+^v%*bGrm(;qEMCBPpr#bOItc47 zH+7lv3(b($sC~$M_rCVPPNX$3D*J&P$cRh93qO5=fQg0GWW+KrifFy@I3pAw_Ne{Z z-jD{^f-u*127C;r?O(rE9}$MhHYkq2s|Q;Cez^|&B@%J^TstA*JrDGBgyTRxr>cGr z`b!g(4y?s?!$tv`D@0}sP!IvXnzjmS!7OAOTURIAFv$9JXvnrwZvLa?bPT7eOr`M% zIIVw{=40dIv93X9KzqFfjiMY;XDF?rXJGC1aCRcOi`l{*1XOxDPr>HKx?~W6y$b`u zyi+j!WelbWC~Scz2cBz_cTG#8Y8bZ{XXWz7=EK=Tsqn3_>=s~?3;2alYoY>&M@9fF z{(+IVIXJd8b`~;&M2-PmYOgL>Md=#c0+$dlF{7YWC)fI7|z^t#mLJ5f}IWlyDgY_TMuNH*ZZv_Fbn*7!Uedf`_>vmQ-!T z3&3^urSDvWeP1SoP5_Vz;vyqeQac3`P;qeuu?0le0?`P4gDBZ+uuh9gj_ifwG+o5x zPvK4jiL6pb5SPVrZLV2cA#Z4TK)ny3?yg3)%~oD?0*OrI?%F0JtzwwFv;u;j&%_H} z4uo{sE0ApY3o19*=?pT#A0}O{F7HdW@BX`b`(fM9DA^Bb*3Y4MP%?{cmzU-UF_1w* zn722sKxLU59YeQ_m(EwF+m@*VR7Ojdtv8fFZ&CP87$*!Jfm{=MPtfN--eS#A-s=gcP%c1ee>wi2WXHbPC!E- z#t-8>em_!_W8diX@U<1l7rtfZ{~iTtqrS{ruOJ}@zc$7zm^Krzw*+B0nk#`3fMAFv zWAz0!wYYXGi2FeZAvfrsG2n5SpNeuyEi?`N7WC_9hsj8?5v*cT8Cj zs5QP|X|GbLr~1Wn1`@>BTNMBi`RK)!vkVC)*m@G+&VZyc=w5Y)MN99(u(2glmIwkf zSaT)-(jqTkvQSfBBsENHP(y;wuMsMZENT6|w6p_Sj(}y$3FsRL+`T`4;7%XdGzY}y zW2=d11xWbdW*~i(nG?~g8{2#K#y+sBVEMqX03;Q8dm#%2h5f*O!U#cVl&cLjJ zFd#P<|7Ri1I`rP}thrD=8~inm7G}Y8i7+c20-My3UQ?0KFiVG}&-(dg=7I|M{7l zja+te9sLkZ!8PVPu_b~ET$wh)+X@{NYCzQKF05$Qll_s@GX1^!lB@&z<^TkB;3f;2 zkWruBz+gUgcVnS97H2?!@6$CC9*B2w=^N&VKeQjrLxa7krJ@qLdkPj4M6p_65(l`O z7=g*9y)3e=UI0dsAI!cgX@=eDcf8dM?1*T2vBw)rd9js@Nandh1g`cEehO$ z2WZD0%m)w2updd)l4&OdNE}u2cT#*e1ytJt+V8B*JFN0XqX_muO3FKr?;k;;kgNV+ z!6G6uI7CUn9Eo_oth7`Wi7-xog~5OV7&z_f3l2HlLWd+AXHVYS4%>Rdcemz4Ao&12 z56sX!Z2%4+I9J>DP&sR>$YY+X-XG6n*wZe1T9jVEejvMZ3gs1!isML0B6K@-dzkf{ z`rwbnM!Vk!uEz3y+-EDu(oR<+v~MFfx&|l*>#M-{@Kr7@DB0}_TvP8N-B{q7sF-6X z2VG_xYvDu;4tG|$n3>H$*b!iwd`t&A`%h9y!yGb^FiwXu$}sAq8P^jx_ASsRMqt&_3W+O>Ke51K8~looa%jr7ehe z>$Qg|0#EcP2kx7~p7RxAG604#P`GE3LQw}^|0ZPPfa}O!w*gQGlT6KCtHbx-G(?L9J}ZlJ4d00zmE4Fgy14j#0HS1I zc?`A}d2mMRv@bk1tZF(wRRYQ>Nl||i6%zw8OyI{zVn{p=YQjx%b|4_1$Z(?o?&G8T z@Beybc1DtGF3)p`sh5=#8OLCbcJLvT#Q?xZV7AgHJ?=XTmdmM zChQ7M99&Y<4}PBD8eq>m8l|7gnQjMDzqEZuB47-KkKzkCVK5L>(t!K}426QGQ#a`n z&JJqZDNuCiep(tDTq`ZZU(q+#8bV*=bs1`F3xY8Kq?VJ53+7G?3?O1UAFy5lB)P4- zJ4UQ8GQWdZskzCqQz`uF3^8*1H*>df#WmFh=?qVQNh5Ip#8qmBEf}6NR(OVe3cQIB{ithLhZ483Vt9!1$w=(mKo=f;kUnJ`!)~=TEzL zM%Wu{fcV{X{3zu*VOQBPgzBr-w(m|c5$OtviywknE(3ufxcfr4r$`oH#{UOP-yM%--~NvfB9|>>WF*;1ly%w44p9`{DVm5vku7^v zL^3Y2lSE1OC?&F@q!P-C$V}n)KA-RJeqQ$<_v`hP%Xxjy<8vJEb^K5Xb75+7NtN0Y z{nY>SJ3qnej{P0eMiF!Lf&bl44@q9^f@hFkSQ-q&F5X<1cOy$ht#XM6!gsjU5LU6rr)BeOUpVWz5es}_b zHTc093o;-qEUZi#S5iVs%5#9a;ex(5Nl-~}MM+S)t?}(D0v#Y%2_8{F1JA>= z+-hQ#G?ZiUG=*N#A;TlNCqlMS8{8-l;cN{cYSGD}_98L0&`w(*>x_6Xu;k`UO zJo}(g1Q^|PwxU}ouua^WVyil7k;&_|XZ6>nwzB!LcZ3WHpbGPAz?`GtC~@oXb8^y# ze#q3FFqWv1zhE_9>_hMyzz9FZ?E6xQL)qL+yT6pPBfi60`~QAZQ%ZQR+AH_dPkcbh zX85nVq7oDq1_|T4@(*j2}91SIRXM;a9jRq1QK`+s6*-JT zzSH>bY{!9Lv~+ZA+V$&z(|~P!vbF zxlUy-H>pD`05u4j*O;iNJ-p1!O^7`*Nl^rSzpJ*k)=iArddL1Pmcwscx-m>jv6oN<2BP(6DYmxgKww*;j!fOblFIXm=Quv z&}2=ZUp{?WB4-5_i>h1x)vimdd#+uiZgJrtM;#EkVrX6P{_K5VoR|+jKq`4tIyW~L zIO;N{BI;{C120Wwr1-c@9T~G5ZzTQ{F3xo=qa4KPX$$rfEFC!8_$mCX?l-!=vqfyo zD-ZPIxfz<|;m2I^89($A!7di}36P9`-$iLj$$-B|h(dC~iR*KaVxKqZp(STlJ(;oQ z+C%k!ALmv-k`SLpJAZ^~08|9onDg`-Ad2l7nm3wnviWj|zclJ8FY2c^*lU7#fOJA2 zh=&#Kc979Jwd z6A%$N*XzFz9=@$~AWq2ZS-?q3M5epR-T!i2k0SjH7~NlZ&?@E6H zyu&H1Mr#DO+3=to^x`r$v~}eS-P(c1u|T4`?(jOTvb)N{N4`=?$PWl$s@N$8&np6~K^ zWy^U$vxT$RuymvN>y3m|$rGjoCpWb}?YTiy-s_=4B4cb_HK^P4KMDYRZt@~

%yHs0)q!wSB;)AQOqcD6t2MU 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�%Z$xL$_@}cO`6XpqTC_B#0O#LN$Oj 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≤(tK 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⛭g~T=xVhgw-ii%g2_2^d3Mp3X5r%0wh%=mOBdAuo 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 literal 0 HcmV?d00001 From 9e516391e9e7f16c8a994aa3e84eb3aaa3472a64 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sat, 23 May 2026 14:32:45 +0800 Subject: [PATCH 05/39] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E6=96=87=E4=BB=B6=E5=A4=B9=E6=89=AB=E6=8F=8F=E4=B8=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E6=92=AD=E6=94=BE=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增依赖:walkdir 用于遍历目录、tauri-plugin-dialog 用于选择文件夹 2. 新增 scan_music_directory 命令,支持扫描指定目录下的音轨文件 3. 重构播放器状态管理,替换 playerState 为 Player 类 4. 更新导航栏按钮,添加从文件夹导入音乐并播放的功能 5. 移除旧的硬编码测试播放逻辑,优化音量控制逻辑 6. 更新权限配置,添加对话框插件权限 7. 更新包管理器与依赖版本 --- package.json | 3 +- pnpm-lock.yaml | 15 +++ src-tauri/Cargo.lock | 83 ++++++++++++++++ src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/commands.rs | 141 ++++++++++++++++++---------- src-tauri/src/lib.rs | 5 +- src/lib/components/Header.svelte | 24 ----- src/lib/components/NavRail.svelte | 49 +++++++++- src/lib/components/PlayerBar.svelte | 57 ++++++----- src/lib/player.svelte.ts | 53 +++++++---- 11 files changed, 307 insertions(+), 128 deletions(-) diff --git a/package.json b/package.json index 6cba909..9bf8a25 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@mdui/icons": "^1.0.3", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-dialog": "~2.7.1", "@tauri-apps/plugin-opener": "^2.5.3", "material-symbols": "^0.43.0", "mdui": "^2.1.4", @@ -31,5 +32,5 @@ "typescript": "~5.6.3", "vite": "^6.4.1" }, - "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d" + "packageManager": "pnpm@11.2.2+sha512.36e6621fad506178936455e70247b8808ef4ec25797a9f437a93281a020484e2607f6a469a22e982987c3dbb8866e3071514ab10a4a1749e06edcd1ec118436f" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99a3523..b48cf13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tauri-apps/api': specifier: ^2.10.1 version: 2.10.1 + '@tauri-apps/plugin-dialog': + specifier: ~2.7.1 + version: 2.7.1 '@tauri-apps/plugin-opener': specifier: ^2.5.3 version: 2.5.3 @@ -535,6 +538,9 @@ packages: '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} + '@tauri-apps/api@2.11.0': + resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==} + '@tauri-apps/cli-darwin-arm64@2.10.1': resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==} engines: {node: '>= 10'} @@ -611,6 +617,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + '@tauri-apps/plugin-opener@2.5.3': resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} @@ -1287,6 +1296,8 @@ snapshots: '@tauri-apps/api@2.10.1': {} + '@tauri-apps/api@2.11.0': {} + '@tauri-apps/cli-darwin-arm64@2.10.1': optional: true @@ -1334,6 +1345,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.10.1 '@tauri-apps/cli-win32-x64-msvc': 2.10.1 + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-opener@2.5.3': dependencies: '@tauri-apps/api': 2.10.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 397424a..e267359 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3355,6 +3355,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rtrb" version = "0.3.3" @@ -4290,7 +4314,9 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", + "walkdir", "web-audio-api", ] @@ -4374,6 +4400,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.0.7+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4665,6 +4733,21 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 1.0.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.0", +] + [[package]] name = "toml_datetime" version = "0.6.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e8377a..7a52231 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,4 +25,6 @@ serde_json = "1" web-audio-api = "1.4.0" lofty = "0.24.0" base64 = "0.22.1" +walkdir = "2.5.0" +tauri-plugin-dialog = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8ff4eff..ed4becc 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-start-dragging", "core:window:allow-minimize", "core:window:allow-toggle-maximize", - "core:window:allow-close" + "core:window:allow-close", + "dialog:default" ] } \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3ab1482..b133143 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; -use base64::{Engine as _, engine::general_purpose}; +use base64::{engine::general_purpose, Engine as _}; use lofty::prelude::*; use lofty::probe::Probe; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; use tauri::command; +use walkdir::WalkDir; use web_audio_api::context::{AudioContext, BaseAudioContext}; use web_audio_api::node::{AudioNode, GainNode}; use web_audio_api::MediaElement; @@ -25,26 +26,26 @@ struct AudioState { fn get_audio_state() -> &'static Mutex { static STATE: OnceLock> = OnceLock::new(); STATE.get_or_init(|| { - Mutex::new(AudioState { - media: None, - gain_node: None, - volume: 0.8 }) + Mutex::new(AudioState { + media: None, + gain_node: None, + volume: 0.8, + }) }) } -fn test_path() -> PathBuf { - let current_dir = std::env::current_dir().unwrap(); - current_dir.join("..").join("static") -} - #[command] -pub async fn play_music(name: &str) -> Result { - let file_path = test_path().join(name); +pub async fn play_music(full_path: &str) -> Result { + let file_path = Path::new(&full_path); + if !file_path.exists() { + return Err("音频文件不存在或已被移动".into()); + } - let mut media = MediaElement::new(&file_path).map_err(|e| format!("Failed to create media element: {}", e))?; + let mut media = MediaElement::new(&file_path) + .map_err(|e| format!("Failed to create media element: {}", e))?; let context = get_audio_context(); let src = context.create_media_element_source(&mut media); - + // src.connect(&context.destination()); let gain_node = context.create_gain(); @@ -54,8 +55,10 @@ pub async fn play_music(name: &str) -> Result { media.set_loop(false); media.set_current_time(0.0); - let mut state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; - + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + gain_node.gain().set_value(state.volume); if let Some(old_media) = state.media.replace(media) { @@ -68,12 +71,14 @@ pub async fn play_music(name: &str) -> Result { media.play(); } - Ok(format!("Playing music: {}", name)) + Ok(format!("Playing music: {}", full_path)) } #[command] pub async fn toggle_music() -> Result { - let state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; if let Some(ref media) = state.media { if media.paused() { media.play(); @@ -89,8 +94,8 @@ pub async fn toggle_music() -> Result { #[command] pub async fn current_time() -> f64 { - let Ok(state) = get_audio_state().lock() else { - return 0.0; + let Ok(state) = get_audio_state().lock() else { + return 0.0; }; if let Some(ref media) = state.media { media.current_time() @@ -107,7 +112,6 @@ pub async fn set_current_time(time: f64) { } } - #[derive(Serialize)] pub struct TrackInfo { title: String, @@ -116,63 +120,98 @@ pub struct TrackInfo { duration: f64, sample_rate: Option, cover: Option, + path: String, } #[command] -pub fn get_track_info(name: String) -> TrackInfo { - let file_path = test_path().join(name); - let tagged_file = Probe::open(&file_path) - .unwrap() - .read() - .unwrap(); - - let tag = tagged_file.primary_tag() +pub async fn set_volume(volume: u8) -> Result<(), String> { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + let volume: f32 = volume as f32 / 100.0; + let safe_volume = volume.clamp(0.0, 1.0); + state.volume = safe_volume; + + if let Some(ref gain_node) = state.gain_node { + gain_node.gain().set_value(safe_volume); + } + + Ok(()) +} + +fn parse_single_track(file_path: &Path) -> Option { + let tagged_file = Probe::open(file_path).ok()?.read().ok()?; + let tag = tagged_file + .primary_tag() .or_else(|| tagged_file.first_tag()); let title = tag .and_then(|t| t.title()) .map(|s| s.to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|| { + file_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown Track") + .to_string() + }); + let artist = tag .and_then(|t| t.artist()) .map(|s| s.to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|| "Unknown Artist".to_string()); + let album = tag .and_then(|t| t.album()) .map(|s| s.to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|| "Unknown Album".to_string()); + + let props = tagged_file.properties(); + let duration = props.duration().as_secs_f64(); + let sample_rate = props.sample_rate(); let cover = tag.and_then(|t| t.pictures().first()).map(|pic| { let b64_encoded = general_purpose::STANDARD.encode(pic.data()); - let mime_type = pic.mime_type() - .map(|m| m.as_str()) - .unwrap_or("image/jpeg"); + let mime_type = pic.mime_type().map(|m| m.as_str()).unwrap_or("image/jpeg"); format!("data:{};base64,{}", mime_type, b64_encoded) }); - let props = tagged_file.properties(); - let duration = props.duration().as_secs_f64(); - - TrackInfo { + Some(TrackInfo { title, artist, album, duration, - sample_rate: props.sample_rate(), + sample_rate, cover, - } + path: file_path.to_string_lossy().into_owned(), + }) } +static SUPPORTED_EXT: [&str; 4] = ["mp3", "flac", "m4a", "wav"]; + #[command] -pub async fn set_volume(volume: u8) -> Result<(), String> { - let mut state = get_audio_state().lock().map_err(|_| "Failed to lock audio state")?; - let volume: f32 = volume as f32 / 100.0; - let safe_volume = volume.clamp(0.0, 1.0); - state.volume = safe_volume; +pub async fn scan_music_directory(dir_path: String) -> Result, String> { + let root_path = Path::new(&dir_path); + if !root_path.is_dir() { + return Err("Invalid directory path".into()); + } - if let Some(ref gain_node) = state.gain_node { - gain_node.gain().set_value(safe_volume); + let mut playlist = Vec::new(); + + for entry in WalkDir::new(root_path).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + let ext = ext.to_lowercase(); + if SUPPORTED_EXT.contains(&ext.as_str()) { + if let Some(track) = parse_single_track(path) { + playlist.push(track); + } + } + } } - Ok(()) + Ok(playlist) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa8e507..a652c42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,14 +3,15 @@ mod commands; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) // .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ commands::play_music, commands::toggle_music, commands::current_time, commands::set_current_time, - commands::get_track_info, - commands::set_volume + commands::set_volume, + commands::scan_music_directory, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 9ef1d4f..e8e2c03 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,33 +1,9 @@

Header PlaceHolder
- { - if (e.key === 'Enter' || e.key === ' ') playMusic() - }} - class="px-4 py-2" - role="none" - > - 播放音乐 -
diff --git a/src/lib/components/NavRail.svelte b/src/lib/components/NavRail.svelte index ef952e9..5acabc3 100644 --- a/src/lib/components/NavRail.svelte +++ b/src/lib/components/NavRail.svelte @@ -1,10 +1,55 @@ - + { + if (e.key === 'Enter' || e.key === ' ') playMusic() + }} + role="button" + tabindex="0" + > Artist - - diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte index 719aa27..07f1b61 100644 --- a/src/lib/components/PlayerBar.svelte +++ b/src/lib/components/PlayerBar.svelte @@ -1,11 +1,11 @@ - {formatTime(playerState.currentTime)} + {formatTime(player.currentTime)}
playerState.seek(+e.target.value)} - duration={playerState.current?.duration ?? 0} + oninput={e => player.seek(+e.target.value)} + duration={player.currentTrack?.duration ?? 0} />
- {formatTime(playerState.current?.duration ?? 0)} + {formatTime(player.currentTrack?.duration ?? 0)}
-
-
+
+
Album Cover
- {playerState.current?.title ?? '未在播放'} + {player.currentTrack?.title ?? '未在播放'}
- {playerState.current?.artist ?? '未知艺术家'} + {player.currentTrack?.artist ?? '未知艺术家'}
@@ -72,42 +68,43 @@ icon="skip_previous--rounded" role="button" tabindex="0" - onclick={() => playerState.prev()} - onkeydown={e => e.key === 'Enter' && playerState.prev()} + onclick={() => player.prev()} + onkeydown={e => e.key === 'Enter' && player.prev()} > playerState.toggle()} - onkeydown={e => e.key === 'Enter' && playerState.toggle()} + onclick={() => player.toggle()} + onkeydown={e => e.key === 'Enter' && player.toggle()} > playerState.next()} - onkeydown={e => e.key === 'Enter' && playerState.next()} + onclick={() => player.next()} + onkeydown={e => e.key === 'Enter' && player.next()} >
- -
+
+ (playerState.muted = !playerState.muted)} + onclick={() => (player.muted = !player.muted)} onkeydown={e => e.key === 'Enter' && - (playerState.muted = !playerState.muted)} + (player.muted = !player.muted)} role="button" tabindex="0" class="text-lg opacity-70" - > - - + > +
diff --git a/src/lib/player.svelte.ts b/src/lib/player.svelte.ts index f56ad89..cbf8610 100644 --- a/src/lib/player.svelte.ts +++ b/src/lib/player.svelte.ts @@ -1,14 +1,27 @@ import { invoke } from "@tauri-apps/api/core" -class PlayerState { - current = $state(null) +class Player { + currentIndex = $state(-1) playing = $state(false) playlist = $state([]) currentTime = $state(0) - volume = $state(80) muted = $state(false) + currentTrack = $derived( + this.currentIndex >= 0 && this.currentIndex < this.playlist.length + ? this.playlist[this.currentIndex] + : null + ) + // duration = $derived(this.currentTrack?.duration ?? 0) + + #volume = $state(80); + get volume() { return this.#volume } + set volume(volume: number) { + this.#volume = volume + invoke('set_volume', { volume }).catch(console.error) + } + #pollTimer: any = null startPolling = () => { @@ -45,22 +58,29 @@ class PlayerState { if (this.playing) this.startPolling() } - setVolume = async () => { - await invoke('set_volume', { volume: this.volume }) - } + playByIndex = async (index: number) => { + if (index < 0 || index >= this.playlist.length) return - switchTrack = async (step: number) => { - if (!this.playlist.length || !this.current) return + try { + const track = this.playlist[index] + await invoke('play_music', { fullPath: track.path }) - const len = this.playlist.length - const currentIndex = this.playlist.indexOf(this.current) - const newIndex = (currentIndex + step + len) % len + this.currentIndex = index + this.currentTime = 0 + this.playing = true - this.current = this.playlist[newIndex] - this.currentTime = 0 - this.playing = true + this.startPolling() + } catch (err) { + console.error('切换歌曲失败:', err) + } + } - this.startPolling() + switchTrack = async (step: number) => { + const len = this.playlist.length + if (len === 0) return + + const newIndex = (this.currentIndex + step + len) % len + this.playByIndex(newIndex) } next = () => this.switchTrack(1) @@ -74,6 +94,7 @@ export interface TrackInfo { duration: number sample_rate?: number cover?: string + path: string } -export const playerState = new PlayerState() \ No newline at end of file +export const player = new Player() \ No newline at end of file From d5d73ecd6dfe6904d7f3090b9bda26b9475c36c2 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 24 May 2026 03:03:15 +0800 Subject: [PATCH 06/39] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E6=92=AD=E6=94=BE=E5=99=A8=E6=A0=B8=E5=BF=83=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交实现了完整的音乐播放器功能: 1. 新增媒体库管理模块,支持扫描、加载本地音乐文件 2. 新增最近播放功能,记录并展示最近播放的歌曲 3. 新增音乐封面加载和懒加载显示功能 4. 重构播放器逻辑,自动切歌和更新播放状态 5. 新增Library和Recent页面,完善导航栏路由 6. 重构进度条和滑块组件,优化交互体验 7. 新增TrackList组件,支持列表展示歌曲信息和交互 8. 后端新增对应命令,支持封面读取、最近播放数据持久化 --- src-tauri/src/commands.rs | 267 +++++++++++++++++++++++++--- src-tauri/src/lib.rs | 5 + src/lib/components/NavRail.svelte | 112 ++++++------ src/lib/components/PlayerBar.svelte | 71 +++++--- src/lib/components/Progress.svelte | 88 +++++---- src/lib/components/Slider.svelte | 122 ++++++++----- src/lib/components/TrackList.svelte | 160 +++++++++++++++++ src/lib/library.svelte.ts | 50 ++++++ src/lib/player.svelte.ts | 18 +- src/lib/recent.svelte.ts | 74 ++++++++ src/lib/trackCovers.svelte.ts | 51 ++++++ src/routes/+layout.svelte | 5 +- src/routes/library/+page.svelte | 61 +++++++ src/routes/recent/+page.svelte | 63 +++++++ 14 files changed, 965 insertions(+), 182 deletions(-) create mode 100644 src/lib/components/TrackList.svelte create mode 100644 src/lib/library.svelte.ts create mode 100644 src/lib/recent.svelte.ts create mode 100644 src/lib/trackCovers.svelte.ts create mode 100644 src/routes/library/+page.svelte create mode 100644 src/routes/recent/+page.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b133143..8864402 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,16 +1,20 @@ use base64::{engine::general_purpose, Engine as _}; use lofty::prelude::*; use lofty::probe::Probe; +use std::fs::{self, File}; use std::path::Path; use std::sync::{Mutex, OnceLock}; -use tauri::command; +use tauri::{command, Manager}; use walkdir::WalkDir; use web_audio_api::context::{AudioContext, BaseAudioContext}; use web_audio_api::node::{AudioNode, GainNode}; use web_audio_api::MediaElement; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +const RECENTLY_PLAYED_FILE: &str = "recently_played.json"; +const MAX_RECENTLY_PLAYED: usize = 100; fn get_audio_context() -> &'static AudioContext { static CONTEXT: OnceLock = OnceLock::new(); @@ -34,6 +38,17 @@ fn get_audio_state() -> &'static Mutex { }) } +#[derive(Serialize, Deserialize, Clone)] +pub struct TrackInfo { + title: String, + artist: String, + album: String, + duration: f64, + sample_rate: Option, + cover: Option, + path: String, +} + #[command] pub async fn play_music(full_path: &str) -> Result { let file_path = Path::new(&full_path); @@ -112,17 +127,6 @@ pub async fn set_current_time(time: f64) { } } -#[derive(Serialize)] -pub struct TrackInfo { - title: String, - artist: String, - album: String, - duration: f64, - sample_rate: Option, - cover: Option, - path: String, -} - #[command] pub async fn set_volume(volume: u8) -> Result<(), String> { let mut state = get_audio_state() @@ -170,27 +174,87 @@ fn parse_single_track(file_path: &Path) -> Option { let duration = props.duration().as_secs_f64(); let sample_rate = props.sample_rate(); - let cover = tag.and_then(|t| t.pictures().first()).map(|pic| { - let b64_encoded = general_purpose::STANDARD.encode(pic.data()); - let mime_type = pic.mime_type().map(|m| m.as_str()).unwrap_or("image/jpeg"); - format!("data:{};base64,{}", mime_type, b64_encoded) - }); - Some(TrackInfo { title, artist, album, duration, sample_rate, - cover, + cover: None, path: file_path.to_string_lossy().into_owned(), }) } +#[command] +pub async fn get_track_cover(full_path: String) -> Result, String> { + let file_path = Path::new(&full_path); + + if !file_path.exists() { + return Err("音频文件不存在或已被移动".into()); + } + + let tagged_file = Probe::open(file_path) + .map_err(|e| format!("无法打开音频文件: {}", e))? + .read() + .map_err(|e| format!("无法读取音频元数据: {}", e))?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let cover = tag.and_then(|t| t.pictures().first()).map(|pic| { + let b64_encoded = general_purpose::STANDARD.encode(pic.data()); + let mime_type = pic.mime_type().map(|m| m.as_str()).unwrap_or("image/jpeg"); + format!("data:{};base64,{}", mime_type, b64_encoded) + }); + + Ok(cover) +} + +async fn scan_music_directory_logic( + app_handle: &tauri::AppHandle, +) -> Result, String> { + let music_dir = app_handle + .path() + .audio_dir() + .map_err(|e| format!("无法获取系统音乐目录: {}", e))?; + + let mut tracks = Vec::new(); + visit_dirs(&music_dir, &mut tracks); + + Ok(tracks) +} + +fn visit_dirs(dir: &Path, tracks: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + visit_dirs(&path, tracks); + } else if let Some(ext) = path.extension().and_then(|s| s.to_str()) { + let ext_lower = ext.to_lowercase(); + if ext_lower == "mp3" + || ext_lower == "flac" + || ext_lower == "wav" + || ext_lower == "m4a" + || ext_lower == "ogg" + { + if let Some(track) = parse_single_track(&path) { + tracks.push(track); + } + } + } + } + } +} + static SUPPORTED_EXT: [&str; 4] = ["mp3", "flac", "m4a", "wav"]; #[command] -pub async fn scan_music_directory(dir_path: String) -> Result, String> { +pub async fn scan_music_directory( + app_handle: tauri::AppHandle, + dir_path: String, +) -> Result, String> { let root_path = Path::new(&dir_path); if !root_path.is_dir() { return Err("Invalid directory path".into()); @@ -213,5 +277,166 @@ pub async fn scan_music_directory(dir_path: String) -> Result, St } } + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {}", e))?; + + if !app_data_path.exists() { + fs::create_dir_all(&app_data_path).map_err(|e| format!("创建数据目录失败: {}", e))?; + } + + let json_file_path = app_data_path.join("library.json"); + + let file = File::create(&json_file_path).map_err(|e| format!("无法创建库文件: {}", e))?; + + serde_json::to_writer_pretty(file, &playlist).map_err(|e| format!("写入 JSON 失败: {}", e))?; + Ok(playlist) } + +#[command] +pub async fn load_music_library(app_handle: tauri::AppHandle) -> Result, String> { + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("无法获取数据目录: {}", e))?; + + if !app_data_path.exists() { + fs::create_dir_all(&app_data_path).map_err(|e| format!("创建数据目录失败: {}", e))?; + } + + let json_file_path = app_data_path.join("library.json"); + + if !json_file_path.exists() { + return rebuild_and_get_library(&app_handle).await; + } + + let file = File::open(&json_file_path).map_err(|e| format!("无法打开库文件: {}", e))?; + + match serde_json::from_reader::<_, Vec>(file) { + Ok(mut library) => { + let had_covers = library.iter().any(|track| track.cover.is_some()); + + if had_covers { + for track in &mut library { + track.cover = None; + } + + save_music_library(&app_handle, &library)?; + } + + Ok(library) + } + Err(e) => { + println!("[load_music_library] JSON 解析失败,准备重建: {}", e); + let _ = std::fs::remove_file(json_file_path); + rebuild_and_get_library(&app_handle).await + } + } +} + +async fn rebuild_and_get_library(app_handle: &tauri::AppHandle) -> Result, String> { + let fresh_library = scan_music_directory_logic(app_handle).await?; + + save_music_library(app_handle, &fresh_library)?; + + Ok(fresh_library) +} + +pub fn save_music_library( + app_handle: &tauri::AppHandle, + library: &Vec, +) -> Result<(), String> { + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("{}", e))?; + + let json_file_path = app_data_path.join("library.json"); + let temp_file_path = app_data_path.join("library.json.tmp"); + + let json_str = serde_json::to_string_pretty(library).map_err(|e| format!("{}", e))?; + + std::fs::write(&temp_file_path, json_str).map_err(|e| format!("{}", e))?; + std::fs::rename(temp_file_path, json_file_path).map_err(|e| format!("{}", e))?; + + Ok(()) +} + +fn recently_played_path(app_handle: &tauri::AppHandle) -> Result { + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {}", e))?; + + if !app_data_path.exists() { + fs::create_dir_all(&app_data_path).map_err(|e| format!("创建数据目录失败: {}", e))?; + } + + Ok(app_data_path.join(RECENTLY_PLAYED_FILE)) +} + +fn read_recently_played(app_handle: &tauri::AppHandle) -> Result, String> { + let file_path = recently_played_path(app_handle)?; + + if !file_path.exists() { + return Ok(Vec::new()); + } + + let file = File::open(&file_path).map_err(|e| format!("无法打开最近播放文件: {}", e))?; + + serde_json::from_reader::<_, Vec>(file) + .map_err(|e| format!("无法解析最近播放文件: {}", e)) +} + +fn save_recently_played(app_handle: &tauri::AppHandle, tracks: &[TrackInfo]) -> Result<(), String> { + let file_path = recently_played_path(app_handle)?; + let temp_file_path = file_path.with_extension("json.tmp"); + + let json = + serde_json::to_string_pretty(tracks).map_err(|e| format!("序列化最近播放失败: {}", e))?; + + std::fs::write(&temp_file_path, json) + .map_err(|e| format!("写入最近播放临时文件失败: {}", e))?; + + std::fs::rename(temp_file_path, file_path).map_err(|e| format!("保存最近播放失败: {}", e))?; + + Ok(()) +} + +#[command] +pub async fn load_recently_played(app_handle: tauri::AppHandle) -> Result, String> { + read_recently_played(&app_handle) +} + +#[command] +pub async fn add_recently_played( + app_handle: tauri::AppHandle, + track: TrackInfo, +) -> Result, String> { + let mut list = read_recently_played(&app_handle).unwrap_or_default(); + + let mut track = track; + + // 不建议把 base64 封面写入 recently_played.json,否则文件会越来越大 + track.cover = None; + + // 去重:同一路径只保留一条,并把新的放到最前面 + list.retain(|item| item.path != track.path); + list.insert(0, track); + + if list.len() > MAX_RECENTLY_PLAYED { + list.truncate(MAX_RECENTLY_PLAYED); + } + + save_recently_played(&app_handle, &list)?; + + Ok(list) +} + +#[command] +pub async fn clear_recently_played(app_handle: tauri::AppHandle) -> Result<(), String> { + save_recently_played(&app_handle, &[])?; + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a652c42..0e1d7aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,11 @@ pub fn run() { commands::set_current_time, commands::set_volume, commands::scan_music_directory, + commands::load_music_library, + commands::get_track_cover, + commands::load_recently_played, + commands::add_recently_played, + commands::clear_recently_played, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/NavRail.svelte b/src/lib/components/NavRail.svelte index 5acabc3..77ff42b 100644 --- a/src/lib/components/NavRail.svelte +++ b/src/lib/components/NavRail.svelte @@ -1,67 +1,71 @@ - { - if (e.key === 'Enter' || e.key === ' ') playMusic() - }} - role="button" - tabindex="0" - > - + { + if (e.key === 'Enter' || e.key === ' ') playMusic() + }} + role="button" + tabindex="0" + > + - Recent - Library - Track - Artist + Recent + Library + Track + Artist diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte index 07f1b61..55675ce 100644 --- a/src/lib/components/PlayerBar.svelte +++ b/src/lib/components/PlayerBar.svelte @@ -1,25 +1,51 @@ -
{formatTime(player.currentTime)} @@ -36,7 +62,7 @@ player.seek(+e.target.value)} + oninput={handleSeekInput} duration={player.currentTrack?.duration ?? 0} />
@@ -48,8 +74,7 @@
Album Cover @@ -69,17 +94,17 @@ role="button" tabindex="0" onclick={() => player.prev()} - onkeydown={e => e.key === 'Enter' && player.prev()} + onkeydown={(e: KeyboardEvent) => + e.key === 'Enter' && player.prev()} > player.toggle()} - onkeydown={e => e.key === 'Enter' && player.toggle()} + onkeydown={(e: KeyboardEvent) => + e.key === 'Enter' && player.toggle()} > player.next()} - onkeydown={e => e.key === 'Enter' && player.next()} + onkeydown={(e: KeyboardEvent) => + e.key === 'Enter' && player.next()} >
- + (player.muted = !player.muted)} - onkeydown={e => - e.key === 'Enter' && - (player.muted = !player.muted)} + onkeydown={(e: KeyboardEvent) => + e.key === 'Enter' && (player.muted = !player.muted)} role="button" tabindex="0" class="text-lg opacity-70" > - +
diff --git a/src/lib/components/Progress.svelte b/src/lib/components/Progress.svelte index 46f68c9..a521c6c 100644 --- a/src/lib/components/Progress.svelte +++ b/src/lib/components/Progress.svelte @@ -1,4 +1,15 @@ -
+ \ No newline at end of file diff --git a/src/lib/components/Slider.svelte b/src/lib/components/Slider.svelte index c11dfc9..c70e732 100644 --- a/src/lib/components/Slider.svelte +++ b/src/lib/components/Slider.svelte @@ -1,67 +1,103 @@ - +> \ No newline at end of file diff --git a/src/lib/components/TrackList.svelte b/src/lib/components/TrackList.svelte new file mode 100644 index 0000000..6d7fd13 --- /dev/null +++ b/src/lib/components/TrackList.svelte @@ -0,0 +1,160 @@ + + + + +
#
+
标题
+ +
时长
+
+ +
+ {#each tracks as track, index (track.path)} + {@const isCurrent = + player.playlist[player.currentIndex]?.path === track.path} + {@const cover = trackCovers.get(track)} + + handlePlay(track, index)} + role="button" + tabindex="0" + > + + +
+
+ {#if isCurrent && player.playing} +
+ + + +
+ {:else} + + {index + 1} + + {/if} +
+ +
+
+ {#if cover} + cover + {:else} +
+ 🎵 +
+ {/if} +
+ +
+ + {track.title} + + + {track.artist} + +
+
+ + + +
+ {formatDuration(track.duration)} +
+
+
+ {/each} +
+
diff --git a/src/lib/library.svelte.ts b/src/lib/library.svelte.ts new file mode 100644 index 0000000..c41caf6 --- /dev/null +++ b/src/lib/library.svelte.ts @@ -0,0 +1,50 @@ +import { invoke } from '@tauri-apps/api/core' +import type { TrackInfo } from './player.svelte' + +class MusicLibrary { + tracks = $state([]) + isLoading = $state(false) + error = $state(null) + + private refreshPromise: Promise | null = null + + async refresh(options: { force?: boolean } = {}): Promise { + const { force = false } = options + + if (this.refreshPromise) { + return this.refreshPromise + } + + if (!force && this.tracks.length > 0) { + return this.tracks + } + + this.isLoading = true + this.error = null + + this.refreshPromise = (async () => { + try { + console.trace('[MusicLibrary] refresh called') + console.log('[MusicLibrary] invoke load_music_library start') + + const tracks = await invoke('load_music_library') + + console.log('[MusicLibrary] invoke load_music_library end', tracks.length) + + this.tracks = tracks + return tracks + } catch (err) { + console.error('全局加载媒体库失败:', err) + this.error = String(err) + return this.tracks + } finally { + this.isLoading = false + this.refreshPromise = null + } + })() + + return this.refreshPromise + } +} + +export const musicLibrary = new MusicLibrary() \ No newline at end of file diff --git a/src/lib/player.svelte.ts b/src/lib/player.svelte.ts index cbf8610..6ff4268 100644 --- a/src/lib/player.svelte.ts +++ b/src/lib/player.svelte.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core" +import { recentlyPlayed } from "./recent.svelte" class Player { @@ -32,6 +33,9 @@ class Player { return } this.currentTime = await invoke('current_time') + if (this.currentTrack && this.currentTime >= this.currentTrack.duration) { + this.next() + } }, 250) } @@ -63,11 +67,17 @@ class Player { try { const track = this.playlist[index] - await invoke('play_music', { fullPath: track.path }) + if (!track) { + return + } + this.currentIndex = index this.currentTime = 0 this.playing = true + await invoke('play_music', { fullPath: track.path }) + + recentlyPlayed.add(track) this.startPolling() } catch (err) { @@ -78,7 +88,7 @@ class Player { switchTrack = async (step: number) => { const len = this.playlist.length if (len === 0) return - + const newIndex = (this.currentIndex + step + len) % len this.playByIndex(newIndex) } @@ -92,8 +102,8 @@ export interface TrackInfo { artist: string album: string duration: number - sample_rate?: number - cover?: string + sample_rate?: number | null + cover?: string | null path: string } diff --git a/src/lib/recent.svelte.ts b/src/lib/recent.svelte.ts new file mode 100644 index 0000000..beda1b0 --- /dev/null +++ b/src/lib/recent.svelte.ts @@ -0,0 +1,74 @@ +import type { TrackInfo } from '$lib/player.svelte' +import { invoke } from '@tauri-apps/api/core' + +class RecentlyPlayed { + tracks = $state([]) + isLoading = $state(false) + error = $state(null) + + private loadPromise: Promise | null = null + + async load(): Promise { + if (this.loadPromise) { + return this.loadPromise + } + + this.isLoading = true + this.error = null + + this.loadPromise = invoke('load_recently_played') + .then((tracks) => { + this.tracks = tracks + return tracks + }) + .catch((err) => { + console.error('加载最近播放失败:', err) + this.error = String(err) + return this.tracks + }) + .finally(() => { + this.isLoading = false + this.loadPromise = null + }) + + return this.loadPromise + } + + async add(track: TrackInfo): Promise { + const safeTrack: TrackInfo = { + ...track, + cover: null + } + + // 先立即更新前端状态,让 UI 马上变化 + this.tracks = [ + safeTrack, + ...this.tracks.filter((item) => item.path !== safeTrack.path) + ].slice(0, 100) + + try { + const tracks = await invoke('add_recently_played', { + track: safeTrack + }) + + this.tracks = tracks + return tracks + } catch (err) { + console.error('写入最近播放失败:', err) + this.error = String(err) + return this.tracks + } + } + + async clear() { + try { + await invoke('clear_recently_played') + this.tracks = [] + } catch (err) { + console.error('清空最近播放失败:', err) + this.error = String(err) + } + } +} + +export const recentlyPlayed = new RecentlyPlayed() \ No newline at end of file diff --git a/src/lib/trackCovers.svelte.ts b/src/lib/trackCovers.svelte.ts new file mode 100644 index 0000000..e19289a --- /dev/null +++ b/src/lib/trackCovers.svelte.ts @@ -0,0 +1,51 @@ +import type { TrackInfo } from '$lib/player.svelte' +import { invoke } from '@tauri-apps/api/core' + +class TrackCovers { + covers = $state>({}) + loading = $state>({}) + + private promises = new Map>() + + async load(track: TrackInfo | null | undefined): Promise { + if (!track?.path) return null + + if (this.covers[track.path] !== undefined) { + return this.covers[track.path] + } + + if (this.promises.has(track.path)) { + return await this.promises.get(track.path)! + } + + this.loading[track.path] = true + + const promise = invoke('get_track_cover', { + fullPath: track.path + }) + .then((cover) => { + this.covers[track.path] = cover + return cover + }) + .catch((err) => { + console.error('加载封面失败:', track.path, err) + this.covers[track.path] = null + return null + }) + .finally(() => { + this.loading[track.path] = false + this.promises.delete(track.path) + }) + + this.promises.set(track.path, promise) + + return await promise + } + + get(track: TrackInfo | null | undefined): string | null { + if (!track?.path) return null + return this.covers[track.path] ?? null + } +} + +export const trackCovers = new TrackCovers() \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b084323..ddb267a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,5 @@ - + +
+ {#if musicLibrary.tracks.length !== 0} +
+

+ 我的音乐库 +

+ {musicLibrary.tracks.length} 首歌曲 +
+ + {:else} +
+ {#if musicLibrary.isLoading} + 正在加载媒体库... + {:else} + 没有音乐哦 + {/if} +
+ {/if} +
diff --git a/src/routes/recent/+page.svelte b/src/routes/recent/+page.svelte new file mode 100644 index 0000000..83f583e --- /dev/null +++ b/src/routes/recent/+page.svelte @@ -0,0 +1,63 @@ + + + + 最近播放 + + +
+
+
+

最近播放

+

+ 这里会显示你最近播放过的歌曲 +

+
+ + {#if recentlyPlayed.tracks.length > 0} + recentlyPlayed.clear()} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') recentlyPlayed.clear() + }} + role="button" + tabindex="0" + > + 清空 + + {/if} +
+ + {#if recentlyPlayed.isLoading} +
+ +
+ {:else if recentlyPlayed.error} +
+ {recentlyPlayed.error} +
+ {:else if recentlyPlayed.tracks.length === 0} +
+
🎧
+
还没有最近播放记录
+
播放一首歌后,它会出现在这里
+
+ {:else} +
+ +
+ {/if} +
\ No newline at end of file From 8af9d2e1147f2d69984cfbc791d197800c26597c Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 24 May 2026 03:11:47 +0800 Subject: [PATCH 07/39] Update TrackList.svelte add color for Track List Component --- src/lib/components/TrackList.svelte | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/lib/components/TrackList.svelte b/src/lib/components/TrackList.svelte index 6d7fd13..07d93f2 100644 --- a/src/lib/components/TrackList.svelte +++ b/src/lib/components/TrackList.svelte @@ -158,3 +158,63 @@ {/each}
+ + From deda3e97836f759d38141f32dee955e380148561 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 24 May 2026 23:22:13 +0800 Subject: [PATCH 08/39] feat: add media library pages and shared components - add tracks, artists, and albums library pages - introduce reusable MediaGrid for media card layouts - add LibraryFilters to share search and sort controls - wrap mdui select and text field as reusable base components - add SearchField preset for repeated search inputs - define shared Track, Artist, Album, and Playlist types - reorganize components, features, state, utils, and types directories - refactor PlayerBar, TrackList, and PlaylistGrid structure - clean up redundant files and improve import paths --- src-tauri/src/commands.rs | 32 +-- src/app.css | 5 + src/lib/components/Header.svelte | 9 - src/lib/components/PlayerBar.svelte | 133 ---------- src/lib/components/PlaylistCard.svelte | 28 -- src/lib/components/Playlists.svelte | 19 -- src/lib/components/RecentSongs.svelte | 19 -- src/lib/components/SongCard.svelte | 36 --- src/lib/components/TrackList.svelte | 220 ---------------- src/lib/components/base/Button.svelte | 14 + src/lib/components/base/IconButton.svelte | 12 + .../{Slider.svelte => base/MduiSlider.svelte} | 0 src/lib/components/base/SearchField.svelte | 34 +++ src/lib/components/base/Select.svelte | 73 ++++++ .../{Progress.svelte => base/Slider.svelte} | 0 src/lib/components/base/TextField.svelte | 56 ++++ src/lib/components/media/MediaGrid.svelte | 119 +++++++++ src/lib/features/Filters.svelte | 37 +++ src/lib/features/PlayerBar.svelte | 131 ++++++++++ src/lib/features/PlaylistGrid.svelte | 23 ++ src/lib/features/TrackList.svelte | 240 ++++++++++++++++++ .../shell}/Appbar.svelte | 0 .../shell}/NavRail.svelte | 20 +- .../covers.svelte.ts} | 7 +- src/lib/{ => state}/library.svelte.ts | 16 +- src/lib/{ => state}/player.svelte.ts | 16 +- src/lib/{ => state}/recent.svelte.ts | 17 +- src/lib/types/index.ts | 1 + src/lib/types/types.ts | 38 +++ src/lib/utils/index.ts | 1 + src/lib/utils/time.ts | 34 +++ src/routes/+layout.svelte | 6 +- src/routes/+page.svelte | 4 +- src/routes/album/+page.svelte | 196 ++++++++++++++ src/routes/artists/+page.svelte | 177 +++++++++++++ src/routes/library/+page.svelte | 154 +++++++---- src/routes/recent/+page.svelte | 30 +-- 37 files changed, 1369 insertions(+), 588 deletions(-) delete mode 100644 src/lib/components/Header.svelte delete mode 100644 src/lib/components/PlayerBar.svelte delete mode 100644 src/lib/components/PlaylistCard.svelte delete mode 100644 src/lib/components/Playlists.svelte delete mode 100644 src/lib/components/RecentSongs.svelte delete mode 100644 src/lib/components/SongCard.svelte delete mode 100644 src/lib/components/TrackList.svelte create mode 100644 src/lib/components/base/Button.svelte create mode 100644 src/lib/components/base/IconButton.svelte rename src/lib/components/{Slider.svelte => base/MduiSlider.svelte} (100%) create mode 100644 src/lib/components/base/SearchField.svelte create mode 100644 src/lib/components/base/Select.svelte rename src/lib/components/{Progress.svelte => base/Slider.svelte} (100%) create mode 100644 src/lib/components/base/TextField.svelte create mode 100644 src/lib/components/media/MediaGrid.svelte create mode 100644 src/lib/features/Filters.svelte create mode 100644 src/lib/features/PlayerBar.svelte create mode 100644 src/lib/features/PlaylistGrid.svelte create mode 100644 src/lib/features/TrackList.svelte rename src/lib/{components => features/shell}/Appbar.svelte (100%) rename src/lib/{components => features/shell}/NavRail.svelte (73%) rename src/lib/{trackCovers.svelte.ts => state/covers.svelte.ts} (87%) rename src/lib/{ => state}/library.svelte.ts (67%) rename src/lib/{ => state}/player.svelte.ts (91%) rename src/lib/{ => state}/recent.svelte.ts (78%) create mode 100644 src/lib/types/index.ts create mode 100644 src/lib/types/types.ts create mode 100644 src/lib/utils/index.ts create mode 100644 src/lib/utils/time.ts create mode 100644 src/routes/album/+page.svelte create mode 100644 src/routes/artists/+page.svelte diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8864402..0e6afdb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -39,7 +39,7 @@ fn get_audio_state() -> &'static Mutex { } #[derive(Serialize, Deserialize, Clone)] -pub struct TrackInfo { +pub struct Track { title: String, artist: String, album: String, @@ -143,7 +143,7 @@ pub async fn set_volume(volume: u8) -> Result<(), String> { Ok(()) } -fn parse_single_track(file_path: &Path) -> Option { +fn parse_single_track(file_path: &Path) -> Option { let tagged_file = Probe::open(file_path).ok()?.read().ok()?; let tag = tagged_file .primary_tag() @@ -174,7 +174,7 @@ fn parse_single_track(file_path: &Path) -> Option { let duration = props.duration().as_secs_f64(); let sample_rate = props.sample_rate(); - Some(TrackInfo { + Some(Track { title, artist, album, @@ -213,7 +213,7 @@ pub async fn get_track_cover(full_path: String) -> Result, String async fn scan_music_directory_logic( app_handle: &tauri::AppHandle, -) -> Result, String> { +) -> Result, String> { let music_dir = app_handle .path() .audio_dir() @@ -225,7 +225,7 @@ async fn scan_music_directory_logic( Ok(tracks) } -fn visit_dirs(dir: &Path, tracks: &mut Vec) { +fn visit_dirs(dir: &Path, tracks: &mut Vec) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); @@ -254,7 +254,7 @@ static SUPPORTED_EXT: [&str; 4] = ["mp3", "flac", "m4a", "wav"]; pub async fn scan_music_directory( app_handle: tauri::AppHandle, dir_path: String, -) -> Result, String> { +) -> Result, String> { let root_path = Path::new(&dir_path); if !root_path.is_dir() { return Err("Invalid directory path".into()); @@ -296,7 +296,7 @@ pub async fn scan_music_directory( } #[command] -pub async fn load_music_library(app_handle: tauri::AppHandle) -> Result, String> { +pub async fn load_music_library(app_handle: tauri::AppHandle) -> Result, String> { let app_data_path = app_handle .path() .app_data_dir() @@ -314,7 +314,7 @@ pub async fn load_music_library(app_handle: tauri::AppHandle) -> Result>(file) { + match serde_json::from_reader::<_, Vec>(file) { Ok(mut library) => { let had_covers = library.iter().any(|track| track.cover.is_some()); @@ -336,7 +336,7 @@ pub async fn load_music_library(app_handle: tauri::AppHandle) -> Result Result, String> { +async fn rebuild_and_get_library(app_handle: &tauri::AppHandle) -> Result, String> { let fresh_library = scan_music_directory_logic(app_handle).await?; save_music_library(app_handle, &fresh_library)?; @@ -346,7 +346,7 @@ async fn rebuild_and_get_library(app_handle: &tauri::AppHandle) -> Result, + library: &Vec, ) -> Result<(), String> { let app_data_path = app_handle .path() @@ -377,7 +377,7 @@ fn recently_played_path(app_handle: &tauri::AppHandle) -> Result Result, String> { +fn read_recently_played(app_handle: &tauri::AppHandle) -> Result, String> { let file_path = recently_played_path(app_handle)?; if !file_path.exists() { @@ -386,11 +386,11 @@ fn read_recently_played(app_handle: &tauri::AppHandle) -> Result, let file = File::open(&file_path).map_err(|e| format!("无法打开最近播放文件: {}", e))?; - serde_json::from_reader::<_, Vec>(file) + serde_json::from_reader::<_, Vec>(file) .map_err(|e| format!("无法解析最近播放文件: {}", e)) } -fn save_recently_played(app_handle: &tauri::AppHandle, tracks: &[TrackInfo]) -> Result<(), String> { +fn save_recently_played(app_handle: &tauri::AppHandle, tracks: &[Track]) -> Result<(), String> { let file_path = recently_played_path(app_handle)?; let temp_file_path = file_path.with_extension("json.tmp"); @@ -406,15 +406,15 @@ fn save_recently_played(app_handle: &tauri::AppHandle, tracks: &[TrackInfo]) -> } #[command] -pub async fn load_recently_played(app_handle: tauri::AppHandle) -> Result, String> { +pub async fn load_recently_played(app_handle: tauri::AppHandle) -> Result, String> { read_recently_played(&app_handle) } #[command] pub async fn add_recently_played( app_handle: tauri::AppHandle, - track: TrackInfo, -) -> Result, String> { + track: Track, +) -> Result, String> { let mut list = read_recently_played(&app_handle).unwrap_or_default(); let mut track = track; diff --git a/src/app.css b/src/app.css index 348e34e..3f639d8 100644 --- a/src/app.css +++ b/src/app.css @@ -93,6 +93,11 @@ h6 { font-weight: inherit; } +p { + margin: 0; + padding: 0; +} + /* Reset links to optimize for opt-in styling instead of opt-out. */ diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte deleted file mode 100644 index e8e2c03..0000000 --- a/src/lib/components/Header.svelte +++ /dev/null @@ -1,9 +0,0 @@ - - -
-
Header PlaceHolder
-
diff --git a/src/lib/components/PlayerBar.svelte b/src/lib/components/PlayerBar.svelte deleted file mode 100644 index 55675ce..0000000 --- a/src/lib/components/PlayerBar.svelte +++ /dev/null @@ -1,133 +0,0 @@ - - - -
- - {formatTime(player.currentTime)} - -
- -
- - {formatTime(player.currentTrack?.duration ?? 0)} - -
- -
-
- Album Cover -
-
- {player.currentTrack?.title ?? '未在播放'} -
-
- {player.currentTrack?.artist ?? '未知艺术家'} -
-
-
- -
- player.prev()} - onkeydown={(e: KeyboardEvent) => - e.key === 'Enter' && player.prev()} - > - player.toggle()} - onkeydown={(e: KeyboardEvent) => - e.key === 'Enter' && player.toggle()} - > - - player.next()} - onkeydown={(e: KeyboardEvent) => - e.key === 'Enter' && player.next()} - > -
-
- - (player.muted = !player.muted)} - onkeydown={(e: KeyboardEvent) => - e.key === 'Enter' && (player.muted = !player.muted)} - role="button" - tabindex="0" - class="text-lg opacity-70" - > - -
-
-
diff --git a/src/lib/components/PlaylistCard.svelte b/src/lib/components/PlaylistCard.svelte deleted file mode 100644 index eed8668..0000000 --- a/src/lib/components/PlaylistCard.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -
- -
- {playlist.name} -
-
-

{playlist.name}

-

播放列表

-
-
-
\ No newline at end of file diff --git a/src/lib/components/Playlists.svelte b/src/lib/components/Playlists.svelte deleted file mode 100644 index 7020867..0000000 --- a/src/lib/components/Playlists.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- {#each playlists as playlist} - - {/each} -
-
\ No newline at end of file diff --git a/src/lib/components/RecentSongs.svelte b/src/lib/components/RecentSongs.svelte deleted file mode 100644 index 1b9b6b2..0000000 --- a/src/lib/components/RecentSongs.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- {#each songs as song} - - {/each} -
-
\ No newline at end of file diff --git a/src/lib/components/SongCard.svelte b/src/lib/components/SongCard.svelte deleted file mode 100644 index adbe7c7..0000000 --- a/src/lib/components/SongCard.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - -
- {song.title} -
-
-

{song.title}

-

- {song.artist} - {song.album} -

-
-
- - play_arrow - - - more_vert - -
-
\ No newline at end of file diff --git a/src/lib/components/TrackList.svelte b/src/lib/components/TrackList.svelte deleted file mode 100644 index 07d93f2..0000000 --- a/src/lib/components/TrackList.svelte +++ /dev/null @@ -1,220 +0,0 @@ - - - - -
#
-
标题
- -
时长
-
- -
- {#each tracks as track, index (track.path)} - {@const isCurrent = - player.playlist[player.currentIndex]?.path === track.path} - {@const cover = trackCovers.get(track)} - - handlePlay(track, index)} - role="button" - tabindex="0" - > - - -
-
- {#if isCurrent && player.playing} -
- - - -
- {:else} - - {index + 1} - - {/if} -
- -
-
- {#if cover} - cover - {:else} -
- 🎵 -
- {/if} -
- -
- - {track.title} - - - {track.artist} - -
-
- - - -
- {formatDuration(track.duration)} -
-
-
- {/each} -
-
- - diff --git a/src/lib/components/base/Button.svelte b/src/lib/components/base/Button.svelte new file mode 100644 index 0000000..c59c789 --- /dev/null +++ b/src/lib/components/base/Button.svelte @@ -0,0 +1,14 @@ + + + onclick()} + onkeydown={(e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && onclick()} + role="button" + tabindex="0" + {...props} +> + {@render children()} + diff --git a/src/lib/components/base/IconButton.svelte b/src/lib/components/base/IconButton.svelte new file mode 100644 index 0000000..fd9f7f4 --- /dev/null +++ b/src/lib/components/base/IconButton.svelte @@ -0,0 +1,12 @@ + + + onclick()} + onkeydown={(e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && onclick()} + role="button" + tabindex="0" + {...props} +> diff --git a/src/lib/components/Slider.svelte b/src/lib/components/base/MduiSlider.svelte similarity index 100% rename from src/lib/components/Slider.svelte rename to src/lib/components/base/MduiSlider.svelte diff --git a/src/lib/components/base/SearchField.svelte b/src/lib/components/base/SearchField.svelte new file mode 100644 index 0000000..79df8ad --- /dev/null +++ b/src/lib/components/base/SearchField.svelte @@ -0,0 +1,34 @@ + + + \ No newline at end of file diff --git a/src/lib/components/base/Select.svelte b/src/lib/components/base/Select.svelte new file mode 100644 index 0000000..7aced8f --- /dev/null +++ b/src/lib/components/base/Select.svelte @@ -0,0 +1,73 @@ + + +
+ (opened = false)} + aria-expanded={opened} + {...props} + > + {#each options as option (option.value)} + + {option.label} + + {/each} + +
diff --git a/src/lib/components/Progress.svelte b/src/lib/components/base/Slider.svelte similarity index 100% rename from src/lib/components/Progress.svelte rename to src/lib/components/base/Slider.svelte diff --git a/src/lib/components/base/TextField.svelte b/src/lib/components/base/TextField.svelte new file mode 100644 index 0000000..a4fe110 --- /dev/null +++ b/src/lib/components/base/TextField.svelte @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/src/lib/components/media/MediaGrid.svelte b/src/lib/components/media/MediaGrid.svelte new file mode 100644 index 0000000..6bf3d44 --- /dev/null +++ b/src/lib/components/media/MediaGrid.svelte @@ -0,0 +1,119 @@ + + +{#snippet mediaCard(item: MediaGridItem)} +
+
+
+ {#if item.image} + {item.title} + {:else} +
+ {item.shape === 'circle' ? '👤' : '🎵'} +
+ {/if} + +
+ +
+
+ +
+

+ {item.title} +

+ + {#if item.subtitle} +

+ {item.subtitle} +

+ {/if} +
+
+
+{/snippet} + +{#if items.length === 0} +
+
🎧
+
{emptyTitle}
+
+ {emptyDescription} +
+
+{:else} +
+
+ {#each items as item (item.id)} + {@render mediaCard(item)} + {/each} +
+
+{/if} \ No newline at end of file diff --git a/src/lib/features/Filters.svelte b/src/lib/features/Filters.svelte new file mode 100644 index 0000000..b451e16 --- /dev/null +++ b/src/lib/features/Filters.svelte @@ -0,0 +1,37 @@ + + +
+ + + { + settings.update({ + theme: value as ThemeMode, + }) + }} + /> +
+
+ +
+
+
减少动画
+
+ 降低界面动画和过渡效果 +
+
+ + + handleSwitchChange(event, 'reduceMotion')} + > +
+
+ + +
+

+ 媒体库 +

+ +
+
+
+
启动时扫描
+
+ 应用启动时自动刷新媒体库 +
+
+ + + handleSwitchChange(event, 'scanOnStartup')} + > +
+ +
+
+
媒体库目录
+
+ 后续可以接入 Tauri 文件夹选择器 +
+
+ +
+ + + +
+ + {#if settings.data.libraryDirs.length > 0} +
+ {#each settings.data.libraryDirs as dir (dir)} +
+
+ {dir} +
+ + +
+ {/each} +
+ {:else} +
+ 暂未添加媒体库目录 +
+ {/if} +
+
+
+ +
+

+ 扩展 +

+ +
+ {#each extensionRegistry.extensions as extension (extension.id)} +
+
+
+ {extension.name} +
+ + {#if extension.description} +
+ {extension.description} +
+ {/if} +
+ + { + const target = + event.currentTarget as HTMLElement & { + checked: boolean + } + + extensionRegistry.setEnabled( + extension.id, + target.checked, + ) + }} + > +
+ {/each} +
+
+ + {#if settings.error} +
+ {settings.error} +
+ {/if} +
+ {/if} + \ No newline at end of file From 9642880c05aca180c151a889cd0cc3027f427b46 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Mon, 25 May 2026 20:54:06 +0800 Subject: [PATCH 11/39] Refactor settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor src/routes/settings/+page.svelte to use these components: sections (外观, 媒体库, 扩展) are replaced with SettingsSection, individual rows use SettingsRow, and list items use SettingsListItem; imports were updated accordingly. Functionality (switches, theme select, library dir add/remove, extension toggles) is preserved while markup is simplified and made more consistent. --- .../features/settings/SettingsListItem.svelte | 41 +++ src/lib/features/settings/SettingsRow.svelte | 37 +++ .../features/settings/SettingsSection.svelte | 33 +++ src/routes/settings/+page.svelte | 265 +++++++----------- 4 files changed, 210 insertions(+), 166 deletions(-) create mode 100644 src/lib/features/settings/SettingsListItem.svelte create mode 100644 src/lib/features/settings/SettingsRow.svelte create mode 100644 src/lib/features/settings/SettingsSection.svelte diff --git a/src/lib/features/settings/SettingsListItem.svelte b/src/lib/features/settings/SettingsListItem.svelte new file mode 100644 index 0000000..8497606 --- /dev/null +++ b/src/lib/features/settings/SettingsListItem.svelte @@ -0,0 +1,41 @@ + + +
+
+
+ {title} +
+ + {#if description} +
+ {description} +
+ {/if} +
+ + {#if children} +
+ {@render children()} +
+ {/if} +
\ No newline at end of file diff --git a/src/lib/features/settings/SettingsRow.svelte b/src/lib/features/settings/SettingsRow.svelte new file mode 100644 index 0000000..3c2c6de --- /dev/null +++ b/src/lib/features/settings/SettingsRow.svelte @@ -0,0 +1,37 @@ + + +
+
+
{title}
+ + {#if description} +
+ {description} +
+ {/if} +
+ + {#if children} +
+ {@render children()} +
+ {/if} +
\ No newline at end of file diff --git a/src/lib/features/settings/SettingsSection.svelte b/src/lib/features/settings/SettingsSection.svelte new file mode 100644 index 0000000..003da9a --- /dev/null +++ b/src/lib/features/settings/SettingsSection.svelte @@ -0,0 +1,33 @@ + + +
+

+ {title} +

+ + {#if children} +
+ {@render children()} +
+ {/if} +
\ No newline at end of file diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 700101a..cb2cde6 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -5,6 +5,9 @@ import TextField from '$lib/components/base/TextField.svelte' import { registerBuiltinExtensions } from '$lib/extensions/builtin' import { extensionRegistry } from '$lib/extensions/registry.svelte' + import SettingsListItem from '$lib/features/settings/SettingsListItem.svelte' + import SettingsRow from '$lib/features/settings/SettingsRow.svelte' + import SettingsSection from '$lib/features/settings/SettingsSection.svelte' import { settings } from '$lib/state/settings.svelte' import type { ThemeMode } from '$lib/types' import { onMount } from 'svelte' @@ -57,184 +60,114 @@
{:else}
-
-

- 外观 -

- -
-
-
-
主题
-
- 设置应用的明暗模式 -
-
- -
- { + settings.update({ + theme: value as ThemeMode, + }) + }} + />
+ -
-
-
减少动画
-
- 降低界面动画和过渡效果 -
-
- - - handleSwitchChange(event, 'reduceMotion')} - > -
-
-
- -
-

+ + handleSwitchChange(event, 'reduceMotion')} + > + + + + + - 媒体库 -

- -
-
-
-
启动时扫描
-
- 应用启动时自动刷新媒体库 -
+ + handleSwitchChange(event, 'scanOnStartup')} + > + + +
+
+
媒体库目录
+
+ 后续可以接入 Tauri 文件夹选择器
- - - handleSwitchChange(event, 'scanOnStartup')} - >
-
-
-
媒体库目录
-
- 后续可以接入 Tauri 文件夹选择器 -
-
- -
- +
+ - -
- - {#if settings.data.libraryDirs.length > 0} -
- {#each settings.data.libraryDirs as dir (dir)} -
-
- {dir} -
- - -
- {/each} -
- {:else} -
- 暂未添加媒体库目录 -
- {/if} +
-
-
-
-

- 扩展 -

- -
- {#each extensionRegistry.extensions as extension (extension.id)} + {#if settings.data.libraryDirs.length > 0} +
+ {#each settings.data.libraryDirs as dir (dir)} + + + + {/each} +
+ {:else}
-
-
- {extension.name} -
- - {#if extension.description} -
- {extension.description} -
- {/if} -
- - { - const target = - event.currentTarget as HTMLElement & { - checked: boolean - } - - extensionRegistry.setEnabled( - extension.id, - target.checked, - ) - }} - > + 暂未添加媒体库目录
- {/each} + {/if}
-
+ + + + {#each extensionRegistry.extensions as extension (extension.id)} + + { + const target = + event.currentTarget as HTMLElement & { + checked: boolean + } + + extensionRegistry.setEnabled( + extension.id, + target.checked, + ) + }} + > + + {/each} + {#if settings.error}
@@ -243,4 +176,4 @@ {/if}
{/if} - \ No newline at end of file + From 2ce78b06b51eaa43f5ad2d3d224468a5873f7b51 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Tue, 26 May 2026 15:13:51 +0800 Subject: [PATCH 12/39] fix styles --- src-tauri/src/lib.rs | 23 + src-tauri/src/models/settings.rs | 4 +- src-tauri/src/models/track.rs | 1 + src/app.css | 806 ++++++++++--------- src/lib/components/media/MediaGrid.svelte | 4 +- src/lib/extensions/builtin.ts | 15 - src/lib/features/TrackList.svelte | 2 +- src/lib/features/settings/SettingsRow.svelte | 2 +- src/lib/features/shell/NavRail.svelte | 6 +- src/lib/state/settings.svelte.ts | 2 +- src/lib/types/settings.ts | 2 +- src/routes/+layout.svelte | 17 +- src/routes/+page.svelte | 6 +- src/routes/settings/+page.svelte | 244 +++--- 14 files changed, 585 insertions(+), 549 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 084691f..087af21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod commands; mod metadata; mod models; +use std::thread; + use commands::library::{ get_track_cover, load_music_library, @@ -28,11 +30,32 @@ use commands::settings::{ save_settings, }; +fn scan_media_libraries(dirs: Vec) { + println!("开始扫描以下目录中的音乐文件: {:?}", dirs); + // 扫描 + +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) // .plugin(tauri_plugin_opener::init()) + // .setup(|app| { + // let app_handle = app.handle().clone(); + + // thread::spawn(move || { + // let scan_on_startup = true; + // let library_dirs = vec![String::from("D:/Music")]; + + // if scan_on_startup && !library_dirs.is_empty() { + // scan_media_libraries(library_dirs); + // // app_handle.emit("scan-finished", "success").unwrap(); + // } + // }); + + // Ok(()) + // }) .invoke_handler(tauri::generate_handler![ play_music, toggle_music, diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 145f8e3..85ccab1 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -13,7 +13,7 @@ pub struct AppSettings { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ThemeMode { - System, + Auto, Light, Dark, } @@ -21,7 +21,7 @@ pub enum ThemeMode { impl Default for AppSettings { fn default() -> Self { Self { - theme: ThemeMode::System, + theme: ThemeMode::Auto, volume: 80, library_dirs: Vec::new(), scan_on_startup: false, diff --git a/src-tauri/src/models/track.rs b/src-tauri/src/models/track.rs index 64baa77..a2a2514 100644 --- a/src-tauri/src/models/track.rs +++ b/src-tauri/src/models/track.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct Track { pub title: String, pub artist: String, diff --git a/src/app.css b/src/app.css index 3f639d8..407ca74 100644 --- a/src/app.css +++ b/src/app.css @@ -5,407 +5,409 @@ @import "tailwindcss/utilities.css" layer(utilities); @import 'material-symbols'; -* { - transition: all 0.2s ease; -} - -/* - 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) - 2. Remove default margins and padding - 3. Reset all borders. -*/ - -/* *, */ -html, -body, -::after, -::before, -::backdrop, -::file-selector-button { - box-sizing: border-box; /* 1 */ - margin: 0; /* 2 */ - padding: 0; /* 2 */ - border: 0 solid; /* 3 */ -} - -/* - 1. Use a consistent sensible line-height in all browsers. - 2. Prevent adjustments of font size after orientation changes in iOS. - 3. Use a more readable tab size. - 4. Use the user's configured `sans` font-family by default. - 5. Use the user's configured `sans` font-feature-settings by default. - 6. Use the user's configured `sans` font-variation-settings by default. - 7. Disable tap highlights on iOS. -*/ - -html, -:host { - line-height: 1.5; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ - tab-size: 4; /* 3 */ - font-family: --theme( - --default-font-family, - ui-sans-serif, - system-ui, - sans-serif, - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - 'Noto Color Emoji' - ); /* 4 */ - font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ - font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ - -webkit-tap-highlight-color: transparent; /* 7 */ -} - -/* - 1. Add the correct height in Firefox. - 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) - 3. Reset the default border style to a 1px solid border. -*/ - -hr { - height: 0; /* 1 */ - color: inherit; /* 2 */ - border-top-width: 1px; /* 3 */ -} - -/* - Add the correct text decoration in Chrome, Edge, and Safari. -*/ - -abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} - -/* - Remove the default font size and weight for headings. -*/ - -h1, -h2, -h3, -h4, -h5, -h6 { - font-size: inherit; - font-weight: inherit; -} - -p { - margin: 0; - padding: 0; -} - -/* - Reset links to optimize for opt-in styling instead of opt-out. -*/ - -a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; -} - -/* - Add the correct font weight in Edge and Safari. -*/ - -b, -strong { - font-weight: bolder; -} - -/* - 1. Use the user's configured `mono` font-family by default. - 2. Use the user's configured `mono` font-feature-settings by default. - 3. Use the user's configured `mono` font-variation-settings by default. - 4. Correct the odd `em` font sizing in all browsers. -*/ - -code, -kbd, -samp, -pre { - font-family: --theme( - --default-mono-font-family, - ui-monospace, - SFMono-Regular, - Menlo, - Monaco, - Consolas, - 'Liberation Mono', - 'Courier New', - monospace - ); /* 1 */ - font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ - font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ - font-size: 1em; /* 4 */ -} - -/* - Add the correct font size in all browsers. -*/ - -small { - font-size: 80%; -} - -/* - Prevent `sub` and `sup` elements from affecting the line height in all browsers. -*/ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* - 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) - 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) - 3. Remove gaps between table borders by default. -*/ - -table { - text-indent: 0; /* 1 */ - border-color: inherit; /* 2 */ - border-collapse: collapse; /* 3 */ -} - -/* - Use the modern Firefox focus style for all focusable elements. -*/ - -:-moz-focusring { - outline: auto; -} - -/* - Add the correct vertical alignment in Chrome and Firefox. -*/ - -progress { - vertical-align: baseline; -} - -/* - Add the correct display in Chrome and Safari. -*/ - -summary { - display: list-item; -} - -/* - Make lists unstyled by default. -*/ - -ol, -ul, -menu { - list-style: none; -} - -/* - 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) - 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) - This can trigger a poorly considered lint error in some tools but is included by design. -*/ - -img, -svg, -video, -canvas, -audio, -iframe, -embed, -object { - display: block; /* 1 */ - vertical-align: middle; /* 2 */ -} - -/* - Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) -*/ - -img, -video { - max-width: 100%; - height: auto; -} - -/* - 1. Inherit font styles in all browsers. - 2. Remove border radius in all browsers. - 3. Remove background color in all browsers. - 4. Ensure consistent opacity for disabled states in all browsers. -*/ - -button, -input, -select, -optgroup, -textarea, -::file-selector-button { - font: inherit; /* 1 */ - font-feature-settings: inherit; /* 1 */ - font-variation-settings: inherit; /* 1 */ - letter-spacing: inherit; /* 1 */ - color: inherit; /* 1 */ - border-radius: 0; /* 2 */ - background-color: transparent; /* 3 */ - opacity: 1; /* 4 */ -} - -/* - Restore default font weight. -*/ - -:where(select:is([multiple], [size])) optgroup { - font-weight: bolder; -} - -/* - Restore indentation. -*/ - -:where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; -} - -/* - Restore space after button. -*/ - -::file-selector-button { - margin-inline-end: 4px; -} - -/* - Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) -*/ - -::placeholder { - opacity: 1; -} - -/* - Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not - crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) -*/ - -@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or - (contain-intrinsic-size: 1px) /* Safari 17+ */ { +@layer base { + * { + transition: all 0.2s ease; + } + + /* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. + */ + + /* *, */ + html, + body, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ + } + + /* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. + */ + + html, + :host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: --theme( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ + } + + /* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. + */ + + hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ + } + + /* + Add the correct text decoration in Chrome, Edge, and Safari. + */ + + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + + /* + Remove the default font size and weight for headings. + */ + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: inherit; + font-weight: inherit; + } + + p { + margin: 0; + padding: 0; + } + + /* + Reset links to optimize for opt-in styling instead of opt-out. + */ + + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + + /* + Add the correct font weight in Edge and Safari. + */ + + b, + strong { + font-weight: bolder; + } + + /* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. + */ + + code, + kbd, + samp, + pre { + font-family: --theme( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 1 */ + font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ + font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ + font-size: 1em; /* 4 */ + } + + /* + Add the correct font size in all browsers. + */ + + small { + font-size: 80%; + } + + /* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. + */ + + sub, + sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + + sub { + bottom: -0.25em; + } + + sup { + top: -0.5em; + } + + /* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. + */ + + table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ + } + + /* + Use the modern Firefox focus style for all focusable elements. + */ + + :-moz-focusring { + outline: auto; + } + + /* + Add the correct vertical alignment in Chrome and Firefox. + */ + + progress { + vertical-align: baseline; + } + + /* + Add the correct display in Chrome and Safari. + */ + + summary { + display: list-item; + } + + /* + Make lists unstyled by default. + */ + + ol, + ul, + menu { + list-style: none; + } + + /* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. + */ + + img, + svg, + video, + canvas, + audio, + iframe, + embed, + object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ + } + + /* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) + */ + + img, + video { + max-width: 100%; + height: auto; + } + + /* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. + */ + + button, + input, + select, + optgroup, + textarea, + ::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ + } + + /* + Restore default font weight. + */ + + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + + /* + Restore indentation. + */ + + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + + /* + Restore space after button. + */ + + ::file-selector-button { + margin-inline-end: 4px; + } + + /* + Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) + */ + ::placeholder { - color: color-mix(in oklab, currentcolor 50%, transparent); - } -} - -/* - Prevent resizing textareas horizontally by default. -*/ - -textarea { - resize: vertical; -} - -/* - Remove the inner padding in Chrome and Safari on macOS. -*/ - -::-webkit-search-decoration { - -webkit-appearance: none; -} - -/* - 1. Ensure date/time inputs have the same height when empty in iOS Safari. - 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. -*/ - -::-webkit-date-and-time-value { - min-height: 1lh; /* 1 */ - text-align: inherit; /* 2 */ -} - -/* - Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. -*/ - -::-webkit-datetime-edit { - display: inline-flex; -} - -/* - Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. -*/ - -::-webkit-datetime-edit-fields-wrapper { - padding: 0; -} - -::-webkit-datetime-edit, -::-webkit-datetime-edit-year-field, -::-webkit-datetime-edit-month-field, -::-webkit-datetime-edit-day-field, -::-webkit-datetime-edit-hour-field, -::-webkit-datetime-edit-minute-field, -::-webkit-datetime-edit-second-field, -::-webkit-datetime-edit-millisecond-field, -::-webkit-datetime-edit-meridiem-field { - padding-block: 0; -} - -/* - Center dropdown marker shown on inputs with paired ``s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) -*/ - -::-webkit-calendar-picker-indicator { - line-height: 1; -} - -/* - Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) -*/ - -:-moz-ui-invalid { - box-shadow: none; -} - -/* - Correct the inability to style the border radius in iOS Safari. -*/ - -button, -input:where([type='button'], [type='reset'], [type='submit']), -::file-selector-button { - appearance: button; -} - -/* - Correct the cursor style of increment and decrement buttons in Safari. -*/ - -::-webkit-inner-spin-button, -::-webkit-outer-spin-button { - height: auto; -} - -/* - Make elements with the HTML hidden attribute stay hidden by default. -*/ - -[hidden]:where(:not([hidden='until-found'])) { - display: none !important; + opacity: 1; + } + + /* + Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not + crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) + */ + + @supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or + (contain-intrinsic-size: 1px) /* Safari 17+ */ { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + + /* + Prevent resizing textareas horizontally by default. + */ + + textarea { + resize: vertical; + } + + /* + Remove the inner padding in Chrome and Safari on macOS. + */ + + ::-webkit-search-decoration { + -webkit-appearance: none; + } + + /* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. + */ + + ::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ + } + + /* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`. + */ + + ::-webkit-datetime-edit { + display: inline-flex; + } + + /* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. + */ + + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + + ::-webkit-datetime-edit, + ::-webkit-datetime-edit-year-field, + ::-webkit-datetime-edit-month-field, + ::-webkit-datetime-edit-day-field, + ::-webkit-datetime-edit-hour-field, + ::-webkit-datetime-edit-minute-field, + ::-webkit-datetime-edit-second-field, + ::-webkit-datetime-edit-millisecond-field, + ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + + /* + Center dropdown marker shown on inputs with paired ``s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) + */ + + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + + /* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) + */ + + :-moz-ui-invalid { + box-shadow: none; + } + + /* + Correct the inability to style the border radius in iOS Safari. + */ + + button, + input:where([type='button'], [type='reset'], [type='submit']), + ::file-selector-button { + appearance: button; + } + + /* + Correct the cursor style of increment and decrement buttons in Safari. + */ + + ::-webkit-inner-spin-button, + ::-webkit-outer-spin-button { + height: auto; + } + + /* + Make elements with the HTML hidden attribute stay hidden by default. + */ + + [hidden]:where(:not([hidden='until-found'])) { + display: none !important; + } } \ No newline at end of file diff --git a/src/lib/components/media/MediaGrid.svelte b/src/lib/components/media/MediaGrid.svelte index 80df282..b9f2e68 100644 --- a/src/lib/components/media/MediaGrid.svelte +++ b/src/lib/components/media/MediaGrid.svelte @@ -25,7 +25,7 @@ {#snippet mediaCard(item: MediaGridItem)}
{:else} diff --git a/src/lib/extensions/builtin.ts b/src/lib/extensions/builtin.ts index 24166e1..5a65d21 100644 --- a/src/lib/extensions/builtin.ts +++ b/src/lib/extensions/builtin.ts @@ -42,19 +42,4 @@ export function registerBuiltinExtensions() { }, ], }) - - extensionRegistry.register({ - id: 'kino.settings', - name: 'Settings', - description: '应用设置页面', - navItems: [ - { - id: 'settings', - title: '设置', - icon: 'settings--rounded', - href: '/settings', - order: 100, - }, - ], - }) } \ No newline at end of file diff --git a/src/lib/features/TrackList.svelte b/src/lib/features/TrackList.svelte index 91482c8..05e010f 100644 --- a/src/lib/features/TrackList.svelte +++ b/src/lib/features/TrackList.svelte @@ -80,7 +80,7 @@ {#snippet trackCover(track: Track, cover: string | null)}
{#if cover}
-
{title}
+
{title}
{#if description}
{ - if (e.key === 'Enter' || e.key === ' ') playMusic() + if (e.key === 'Enter' || e.key === ' ') imoprtMusic() }} role="button" tabindex="0" diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 78dae8e..04b0ffe 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -2,7 +2,7 @@ import type { AppSettings } from '$lib/types' import { invoke } from '@tauri-apps/api/core' export const DEFAULT_SETTINGS: AppSettings = { - theme: 'system', + theme: 'auto', volume: 80, libraryDirs: [], scanOnStartup: false, diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts index 057a21b..88bd643 100644 --- a/src/lib/types/settings.ts +++ b/src/lib/types/settings.ts @@ -1,5 +1,5 @@ -export type ThemeMode = 'system' | 'light' | 'dark' +export type ThemeMode = 'auto' | 'light' | 'dark' export interface AppSettings { theme: ThemeMode diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 09c326d..2f9d97e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,10 +10,16 @@ let { children } = $props() - onMount(() => { void settings.load() }) + + $effect(() => { + document.documentElement.style.setProperty( + '--transition-duration', + settings.data.reduceMotion ? '0s' : '0.2s', + ) + }) @@ -27,3 +33,12 @@ {@render children()} + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f12c787..0b72a39 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,7 +9,9 @@ diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index cb2cde6..b9b3844 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -2,7 +2,6 @@ import Button from '$lib/components/base/Button.svelte' import Heading from '$lib/components/base/Heading.svelte' import Select from '$lib/components/base/Select.svelte' - import TextField from '$lib/components/base/TextField.svelte' import { registerBuiltinExtensions } from '$lib/extensions/builtin' import { extensionRegistry } from '$lib/extensions/registry.svelte' import SettingsListItem from '$lib/features/settings/SettingsListItem.svelte' @@ -12,13 +11,15 @@ import type { ThemeMode } from '$lib/types' import { onMount } from 'svelte' + import { open } from '@tauri-apps/plugin-dialog' import 'mdui/components/circular-progress.js' import 'mdui/components/switch.js' + import { setTheme } from 'mdui/functions/setTheme.js' let newLibraryDir = $state('') const themeOptions: { label: string; value: ThemeMode }[] = [ - { label: '跟随系统', value: 'system' }, + { label: '跟随系统', value: 'auto' }, { label: '浅色模式', value: 'light' }, { label: '深色模式', value: 'dark' }, ] @@ -45,6 +46,22 @@ [key]: target.checked, }) } + + async function handleSelectDirectory() { + try { + const selected = await open({ + directory: true, + multiple: false, + title: '选择音乐媒体库目录', + }) + + if (selected && typeof selected === 'string') { + settings.addLibraryDir(selected) + } + } catch (err) { + console.error('打开文件夹选择器失败:', err) + } + } @@ -54,126 +71,117 @@
- {#if settings.isLoading} -
- -
- {:else} -
- - -
- { + settings.update({ + theme: value as ThemeMode, + }) + setTheme(value as ThemeMode) + }} + /> +
+
+ + + + handleSwitchChange(event, 'reduceMotion')} + > + +
+ + + + + handleSwitchChange(event, 'scanOnStartup')} + > + +
+
+
媒体库目录
+
+ 管理应用扫描音乐文件的本地文件夹
- +
- - - handleSwitchChange(event, 'reduceMotion')} - > - - +
+ +
- - 0} +
+ {#each settings.data.libraryDirs as dir (dir)} + + + + {/each} +
+ {:else} +
+ 暂未添加媒体库目录,应用将无法扫描到任何歌曲。 +
+ {/if} +
+
+ + + {#each extensionRegistry.extensions as extension (extension.id)} + - handleSwitchChange(event, 'scanOnStartup')} + checked={extension.enabled !== false} + onchange={(event: Event) => { + const target = + event.currentTarget as HTMLElement & { + checked: boolean + } + extensionRegistry.setEnabled( + extension.id, + target.checked, + ) + }} > - - -
-
-
媒体库目录
-
- 后续可以接入 Tauri 文件夹选择器 -
-
- -
- - - -
- - {#if settings.data.libraryDirs.length > 0} -
- {#each settings.data.libraryDirs as dir (dir)} - - - - {/each} -
- {:else} -
- 暂未添加媒体库目录 -
- {/if} -
-
- - - {#each extensionRegistry.extensions as extension (extension.id)} - - { - const target = - event.currentTarget as HTMLElement & { - checked: boolean - } - - extensionRegistry.setEnabled( - extension.id, - target.checked, - ) - }} - > - - {/each} - - - {#if settings.error} -
- {settings.error} -
- {/if} -
- {/if} + + {/each} + + + {#if settings.error} +
+ {settings.error} +
+ {/if} +
From c6f67e96f6231d8c75d0722ec0fa0dcac932491e Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Thu, 28 May 2026 20:45:33 +0800 Subject: [PATCH 13/39] refactor(library): move startup scan to Tauri setup with event-driven sync - Move `scanOnStartup` logic from frontend layout to Tauri backend `.setup` hook - Read `settings.json` natively in Rust to determine startup scanning behavior - Utilize `spawn_blocking` for disk I/O to prevent blocking Tokio async threads - Implement `library:refreshed` event using domain-based naming convention - Update frontend layout to only load cached data for near-instant zero-lag startup - Register event listener in root layout component to smoothly sync scanned tracks --- src-tauri/src/commands/library.rs | 242 +++++++++++----------- src-tauri/src/lib.rs | 57 +++--- src/app.css | 2 + src/lib/features/shell/NavRail.svelte | 115 +++++------ src/lib/state/library.svelte.ts | 29 ++- src/lib/state/player.svelte.ts | 283 +++++++++++++++++++++----- src/lib/utils/library.ts | 26 +++ src/routes/+layout.svelte | 11 + src/routes/+layout.ts | 14 ++ src/routes/+page.svelte | 17 -- src/routes/+page.ts | 5 + src/routes/album/+page.svelte | 7 +- src/routes/artists/+page.svelte | 9 +- src/routes/library/+page.svelte | 10 +- src/routes/recent/+page.svelte | 5 - src/routes/settings/+page.svelte | 51 ++--- 16 files changed, 546 insertions(+), 337 deletions(-) create mode 100644 src/lib/utils/library.ts delete mode 100644 src/routes/+page.svelte create mode 100644 src/routes/+page.ts diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index 6bccb7f..56a7188 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fs::{self, File}; use std::path::Path; @@ -7,109 +8,135 @@ use lofty::probe::Probe; use tauri::{command, Manager}; use walkdir::WalkDir; +use crate::commands::settings::load_settings; use crate::metadata::parse_single_track; +use crate::models::settings::AppSettings; use crate::models::Track; const SUPPORTED_EXT: [&str; 5] = ["mp3", "flac", "m4a", "wav", "ogg"]; -#[command] -pub async fn get_track_cover(full_path: String) -> Result, String> { - let file_path = Path::new(&full_path); +fn is_supported_audio_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + SUPPORTED_EXT.contains(&ext.to_lowercase().as_str()) + }) + .unwrap_or(false) +} - if !file_path.exists() { - return Err("音频文件不存在或已被移动".into()); +fn scan_single_directory(dir: &str) -> Result, String> { + let root_path = Path::new(dir); + + if !root_path.exists() || !root_path.is_dir() { + return Err(format!("无效目录: {}", dir)); } - let tagged_file = Probe::open(file_path) - .map_err(|e| format!("无法打开音频文件: {}", e))? - .read() - .map_err(|e| format!("无法读取音频元数据: {}", e))?; + let mut tracks = Vec::new(); - let tag = tagged_file - .primary_tag() - .or_else(|| tagged_file.first_tag()); + for entry in WalkDir::new(root_path) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + let path = entry.path(); - let cover = tag.and_then(|t| t.pictures().first()).map(|pic| { - let b64_encoded = general_purpose::STANDARD.encode(pic.data()); - let mime_type = pic.mime_type().map(|m| m.as_str()).unwrap_or("image/jpeg"); + if !path.is_file() || !is_supported_audio_file(path) { + continue; + } - format!("data:{};base64,{}", mime_type, b64_encoded) - }); + if let Some(mut track) = parse_single_track(path) { + track.cover = None; + tracks.push(track); + } + } - Ok(cover) + Ok(tracks) } -async fn scan_music_directory_logic( - app_handle: &tauri::AppHandle, -) -> Result, String> { - let music_dir = app_handle - .path() - .audio_dir() - .map_err(|e| format!("无法获取系统音乐目录: {}", e))?; +fn dedupe_tracks(tracks: &mut Vec) { + let mut seen = HashSet::new(); + tracks.retain(|track| seen.insert(track.path.clone())); +} - let mut tracks = Vec::new(); +pub fn execute_scan(dirs: Vec) -> Result, String> { + let mut all_tracks = Vec::new(); - visit_dirs(&music_dir, &mut tracks); + for dir in dirs { + if let Ok(mut tracks) = scan_single_directory(&dir) { + all_tracks.append(&mut tracks); + } + } - Ok(tracks) + dedupe_tracks(&mut all_tracks); + Ok(all_tracks) } -fn visit_dirs(dir: &Path, tracks: &mut Vec) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); +pub fn save_music_library( + app_handle: &tauri::AppHandle, + library: &[Track], +) -> Result<(), String> { + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + + if !app_data_path.exists() { + fs::create_dir_all(&app_data_path) + .map_err(|e| format!("创建数据目录失败: {}", e))?; + } - if path.is_dir() { - visit_dirs(&path, tracks); - continue; - } + let json_file_path = app_data_path.join("library.json"); + let temp_file_path = app_data_path.join("library.json.tmp"); - if !is_supported_audio_file(&path) { - continue; - } + let json_str = serde_json::to_string_pretty(library) + .map_err(|e| e.to_string())?; - if let Some(track) = parse_single_track(&path) { - tracks.push(track); - } - } - } -} + fs::write(&temp_file_path, json_str).map_err(|e| e.to_string())?; + fs::rename(temp_file_path, json_file_path).map_err(|e| e.to_string())?; -fn is_supported_audio_file(path: &Path) -> bool { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| SUPPORTED_EXT.contains(&ext.to_lowercase().as_str())) - .unwrap_or(false) + Ok(()) } #[command] -pub async fn scan_music_directory( - app_handle: tauri::AppHandle, - dir_path: String, -) -> Result, String> { - let root_path = Path::new(&dir_path); +pub fn get_track_cover(full_path: String) -> Result, String> { + let file_path = Path::new(&full_path); - if !root_path.is_dir() { - return Err("Invalid directory path".into()); + if !file_path.exists() { + return Err("音频文件不存在".into()); } - let mut playlist = Vec::new(); + let tagged_file = Probe::open(file_path) + .map_err(|e| e.to_string())? + .read() + .map_err(|e| e.to_string())?; - for entry in WalkDir::new(root_path).into_iter().filter_map(|entry| entry.ok()) { - let path = entry.path(); + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); - if !path.is_file() || !is_supported_audio_file(path) { - continue; - } + let cover = tag + .and_then(|t| t.pictures().first()) + .map(|pic| { + let b64_encoded = general_purpose::STANDARD.encode(pic.data()); + let mime_type = pic + .mime_type() + .map(|m| m.as_str()) + .unwrap_or("image/jpeg"); - if let Some(track) = parse_single_track(path) { - playlist.push(track); - } - } + format!("data:{};base64,{}", mime_type, b64_encoded) + }); - save_music_library(&app_handle, &playlist)?; + Ok(cover) +} - Ok(playlist) +#[command] +pub fn scan_music_directories( + app_handle: tauri::AppHandle, + dirs: Vec, +) -> Result, String> { + let tracks = execute_scan(dirs)?; + save_music_library(&app_handle, &tracks)?; + Ok(tracks) } #[command] @@ -119,12 +146,7 @@ pub async fn load_music_library( let app_data_path = app_handle .path() .app_data_dir() - .map_err(|e| format!("无法获取数据目录: {}", e))?; - - if !app_data_path.exists() { - fs::create_dir_all(&app_data_path) - .map_err(|e| format!("创建数据目录失败: {}", e))?; - } + .map_err(|e| e.to_string())?; let json_file_path = app_data_path.join("library.json"); @@ -132,28 +154,12 @@ pub async fn load_music_library( return rebuild_and_get_library(&app_handle).await; } - let file = File::open(&json_file_path) - .map_err(|e| format!("无法打开库文件: {}", e))?; + let file = File::open(&json_file_path).map_err(|e| e.to_string())?; match serde_json::from_reader::<_, Vec>(file) { - Ok(mut library) => { - let had_covers = library.iter().any(|track| track.cover.is_some()); - - if had_covers { - for track in &mut library { - track.cover = None; - } - - save_music_library(&app_handle, &library)?; - } - - Ok(library) - } - Err(error) => { - println!("[load_music_library] JSON 解析失败,准备重建: {}", error); - - let _ = fs::remove_file(json_file_path); - + Ok(library) => Ok(library), + Err(_) => { + let _ = fs::remove_file(&json_file_path); rebuild_and_get_library(&app_handle).await } } @@ -162,38 +168,18 @@ pub async fn load_music_library( async fn rebuild_and_get_library( app_handle: &tauri::AppHandle, ) -> Result, String> { - let fresh_library = scan_music_directory_logic(app_handle).await?; - - save_music_library(app_handle, &fresh_library)?; - - Ok(fresh_library) -} - -pub fn save_music_library( - app_handle: &tauri::AppHandle, - library: &[Track], -) -> Result<(), String> { - let app_data_path = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("{}", e))?; - - if !app_data_path.exists() { - fs::create_dir_all(&app_data_path) - .map_err(|e| format!("创建数据目录失败: {}", e))?; - } - - let json_file_path = app_data_path.join("library.json"); - let temp_file_path = app_data_path.join("library.json.tmp"); - - let json_str = serde_json::to_string_pretty(library) - .map_err(|e| format!("{}", e))?; - - fs::write(&temp_file_path, json_str) - .map_err(|e| format!("{}", e))?; - - fs::rename(temp_file_path, json_file_path) - .map_err(|e| format!("{}", e))?; + let settings: AppSettings = load_settings(app_handle.clone()).await?; + + let tracks = tauri::async_runtime::spawn_blocking({ + let handle = app_handle.clone(); + move || { + let res = execute_scan(settings.library_dirs)?; + save_music_library(&handle, &res)?; + Ok::, String>(res) + } + }) + .await + .map_err(|e| e.to_string())??; - Ok(()) + Ok(tracks) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 087af21..37ef9b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,12 @@ mod commands; mod metadata; mod models; -use std::thread; - use commands::library::{ get_track_cover, + save_music_library, load_music_library, - scan_music_directory, + execute_scan, + scan_music_directories, }; use commands::playback::{ @@ -29,40 +29,49 @@ use commands::settings::{ load_settings, save_settings, }; +use tauri::Emitter; -fn scan_media_libraries(dirs: Vec) { - println!("开始扫描以下目录中的音乐文件: {:?}", dirs); - // 扫描 - +pub fn init_startup_scan(app_handle: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + if let Ok(settings) = crate::commands::settings::load_settings(app_handle.clone()).await { + if settings.scan_on_startup { + if let Ok(tracks) = crate::commands::library::execute_scan(settings.library_dirs) { + let _ = crate::commands::library::save_music_library(&app_handle, &tracks); + } + } + } + }); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - // .plugin(tauri_plugin_opener::init()) - // .setup(|app| { - // let app_handle = app.handle().clone(); - - // thread::spawn(move || { - // let scan_on_startup = true; - // let library_dirs = vec![String::from("D:/Music")]; - - // if scan_on_startup && !library_dirs.is_empty() { - // scan_media_libraries(library_dirs); - // // app_handle.emit("scan-finished", "success").unwrap(); - // } - // }); - - // Ok(()) - // }) + .setup(|app| { + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + if let Ok(settings) = load_settings(handle.clone()).await { + if settings.scan_on_startup { + let _ = tauri::async_runtime::spawn_blocking(move || { + if let Ok(tracks) = execute_scan(settings.library_dirs) { + if save_music_library(&handle, &tracks).is_ok() { + let _ = handle.emit("library:refreshed", tracks); + } + } + }) + .await; + } + } + }); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ play_music, toggle_music, current_time, set_current_time, set_volume, - scan_music_directory, + scan_music_directories, load_music_library, get_track_cover, load_recently_played, diff --git a/src/app.css b/src/app.css index 407ca74..c6a14df 100644 --- a/src/app.css +++ b/src/app.css @@ -6,6 +6,7 @@ @import 'material-symbols'; @layer base { + * { transition: all 0.2s ease; } @@ -57,6 +58,7 @@ font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ -webkit-tap-highlight-color: transparent; /* 7 */ + background-color: theme(--color-gray-100); } /* diff --git a/src/lib/features/shell/NavRail.svelte b/src/lib/features/shell/NavRail.svelte index 2caca44..b051f36 100644 --- a/src/lib/features/shell/NavRail.svelte +++ b/src/lib/features/shell/NavRail.svelte @@ -1,77 +1,78 @@ - + { - if (e.key === 'Enter' || e.key === ' ') imoprtMusic() + if (e.key === 'Enter' || e.key === ' ') { + importMusicDirectory() + } }} role="button" tabindex="0" > - - - Recent - Library - Album - Artist - Settings + {#each navItems as item} + + {item.label} + + {/each} diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index 6cd0b26..94bf84f 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core' import type { Track } from '../types/music' +import { settings } from './settings.svelte' class MusicLibrary { tracks = $state([]) @@ -8,7 +9,7 @@ class MusicLibrary { private refreshPromise: Promise | null = null - async refresh(options: { force?: boolean } = {}): Promise { + async load(options: { force?: boolean } = {}): Promise { const { force = false } = options if (this.refreshPromise) { @@ -39,6 +40,32 @@ class MusicLibrary { return this.refreshPromise } + + async scan(): Promise { + try { + this.isLoading = true + this.error = null + + const tracks = await invoke( + 'scan_music_directories', + { + dirs: settings.data.libraryDirs, + } + ) + + this.tracks = tracks + + return tracks + } catch (err) { + console.error('扫描媒体库失败:', err) + + this.error = String(err) + + return this.tracks + } finally { + this.isLoading = false + } + } } export const musicLibrary = new MusicLibrary() \ No newline at end of file diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index de86076..8e804df 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -2,57 +2,207 @@ import { invoke } from "@tauri-apps/api/core" import type { Track } from "../types/music" import { recentlyPlayed } from "./recent.svelte" +type PlayMode = 'list' | 'single' | 'shuffle' + +interface QueueSource { + type: 'album' | 'playlist' | 'search' | 'artist' | 'local' + id: string +} class Player { + queue = $state([]) currentIndex = $state(-1) - playing = $state(false) - playlist = $state([]) - currentTime = $state(0) - muted = $state(false) - - currentTrack = $derived( - this.currentIndex >= 0 && this.currentIndex < this.playlist.length - ? this.playlist[this.currentIndex] - : null - ) - // duration = $derived(this.currentTrack?.duration ?? 0) - - #volume = $state(80); + playing = $state(false) + currentTime = $state(0) + playMode = $state('list') + queueSource = $state(null) + playbackHistory: string[] = [] + #muted = $state(false) + #volume = $state(80) + #previousVolume = 80 + #pollTimer: any = null + private loadToken = 0 + + constructor() {} + + get currentTrack(): Track | null { + if (this.currentIndex >= 0 && this.currentIndex < this.queue.length) { + return this.queue[this.currentIndex] + } + return null + } + get volume() { return this.#volume } set volume(volume: number) { this.#volume = volume + if (volume > 0) { + this.#muted = false + } invoke('set_volume', { volume }).catch(console.error) } - #pollTimer: any = null + get muted() { return this.#muted } + set muted(value: boolean) { + this.#muted = value + if (value) { + this.#previousVolume = this.#volume + this.#volume = 0 + invoke('set_volume', { volume: 0 }).catch(console.error) + } else { + this.#volume = this.#previousVolume + invoke('set_volume', { volume: this.#previousVolume }).catch(console.error) + } + } - startPolling = () => { - this.stopPolling() - this.#pollTimer = setInterval(async () => { - if (!this.playing) { - this.stopPolling() - return + replaceQueueAndPlay(tracks: Track[], targetId: string, source: QueueSource | null = null) { + if (tracks.length === 0) { + this.queue = [] + this.currentIndex = -1 + this.playing = false + this.queueSource = null + this.playbackHistory = [] + this.stopPolling() + invoke('toggle_music').catch(console.error) + return + } + + const nextQueue = [...tracks] + const index = nextQueue.findIndex(t => t.id === targetId) + const nextIndex = index !== -1 ? index : 0 + + this.playbackHistory = [] + this.queue = nextQueue + this.queueSource = source + this.currentIndex = nextIndex + void this.loadAndPlay() + } + + appendTrack(track: Track) { + if (this.queue.some(t => t.id === track.id)) return + this.queue = [...this.queue, track] + } + + insertNext(track: Track) { + const existingIndex = this.queue.findIndex(item => item.id === track.id) + if (existingIndex === this.currentIndex && this.currentIndex !== -1) { + return + } + + let nextQueue = [...this.queue] + let nextIndex = this.currentIndex + + if (existingIndex !== -1) { + nextQueue.splice(existingIndex, 1) + if (existingIndex < nextIndex) { + nextIndex-- } - this.currentTime = await invoke('current_time') - if (this.currentTrack && this.currentTime >= this.currentTrack.duration) { - this.next() + } + + if (nextQueue.length === 0 || nextIndex === -1) { + this.queue = [track] + this.currentIndex = 0 + return + } + + nextQueue.splice(nextIndex + 1, 0, track) + this.queue = nextQueue + this.currentIndex = nextIndex + } + + removeTrack(id: string) { + const removeIndex = this.queue.findIndex(t => t.id === id) + if (removeIndex === -1) return + + const isCurrent = removeIndex === this.currentIndex + const nextQueue = this.queue.filter(t => t.id !== id) + + if (nextQueue.length === 0) { + this.queue = [] + this.currentIndex = -1 + this.playing = false + this.queueSource = null + this.playbackHistory = [] + this.stopPolling() + invoke('toggle_music').catch(console.error) + return + } + + if (isCurrent) { + const nextIndex = Math.min(removeIndex, nextQueue.length - 1) + this.queue = nextQueue + this.currentIndex = nextIndex + void this.loadAndPlay() + return + } + + let nextIndex = this.currentIndex + if (removeIndex < nextIndex) { + nextIndex-- + } + this.queue = nextQueue + this.currentIndex = nextIndex + } + + private getNextIndex(): number { + if (this.queue.length <= 1) return 0 + if (this.playMode === 'shuffle') { + let nextIndex = this.currentIndex + while (nextIndex === this.currentIndex) { + nextIndex = Math.floor(Math.random() * this.queue.length) } - }, 250) + return nextIndex + } + return this.currentIndex >= this.queue.length - 1 ? 0 : this.currentIndex + 1 } - stopPolling = () => { - if (this.#pollTimer) { - clearInterval(this.#pollTimer) - this.#pollTimer = null + private getPrevIndex(): number { + if (this.queue.length <= 1) return 0 + if (this.playMode === 'shuffle') { + let prevIndex = this.currentIndex + while (prevIndex === this.currentIndex) { + prevIndex = Math.floor(Math.random() * this.queue.length) + } + return prevIndex + } + return this.currentIndex <= 0 ? this.queue.length - 1 : this.currentIndex - 1 + } + + next() { + if (this.queue.length === 0) return + const current = this.currentTrack + if (current) { + this.playbackHistory.push(current.id) } + this.currentIndex = this.getNextIndex() + void this.loadAndPlay() + } + + prev() { + if (this.queue.length === 0) return + if (this.playbackHistory.length > 0) { + const lastId = this.playbackHistory.pop() + const index = this.queue.findIndex(t => t.id === lastId) + if (index !== -1) { + this.currentIndex = index + void this.loadAndPlay() + return + } + } + this.currentIndex = this.getPrevIndex() + void this.loadAndPlay() } toggle = async () => { - this.playing = await invoke('toggle_music') - if (this.playing) { - this.startPolling() - } else { - this.stopPolling() + try { + const isPlayingNow = await invoke('toggle_music') + this.playing = isPlayingNow + if (this.playing) { + this.startPolling() + } else { + this.stopPolling() + } + } catch (err) { + console.error(err) } } @@ -63,40 +213,69 @@ class Player { if (this.playing) this.startPolling() } - playByIndex = async (index: number) => { - if (index < 0 || index >= this.playlist.length) return + private async loadAndPlay() { + const track = this.currentTrack + if (!track) return + + const currentToken = ++this.loadToken + this.stopPolling() try { - const track = this.playlist[index] + this.currentTime = 0 + if (track.path) { + await invoke('play_music', { fullPath: track.path }) + } else { + await invoke('play_online_music', { url: track.url, id: track.id }) + } - if (!track) { + if (currentToken !== this.loadToken) { return } - - this.currentIndex = index - this.currentTime = 0 - this.playing = true - await invoke('play_music', { fullPath: track.path }) + this.playing = true recentlyPlayed.add(track) - this.startPolling() } catch (err) { - console.error('切换歌曲失败:', err) + if (currentToken === this.loadToken) { + this.playing = false + console.error(err) + } } } - switchTrack = async (step: number) => { - const len = this.playlist.length - if (len === 0) return - + private startPolling = () => { this.stopPolling() - const newIndex = (this.currentIndex + step + len) % len - this.playByIndex(newIndex) + this.#pollTimer = setInterval(async () => { + if (!this.playing) { + this.stopPolling() + return + } + try { + this.currentTime = await invoke('current_time') + if (this.currentTrack && this.currentTime >= this.currentTrack.duration - 0.5) { + if (this.playMode === 'single') { + this.currentTime = 0 + void this.loadAndPlay() + } else { + this.next() + } + } + } catch (e) { + this.stopPolling() + } + }, 250) } - next = () => this.switchTrack(1) - prev = () => this.switchTrack(-1) + private stopPolling = () => { + if (this.#pollTimer) { + clearInterval(this.#pollTimer) + this.#pollTimer = null + } + } + + destroy() { + this.stopPolling() + } } export const player = new Player() \ No newline at end of file diff --git a/src/lib/utils/library.ts b/src/lib/utils/library.ts new file mode 100644 index 0000000..047dd30 --- /dev/null +++ b/src/lib/utils/library.ts @@ -0,0 +1,26 @@ +import { musicLibrary } from '$lib/state/library.svelte' +import { settings } from '$lib/state/settings.svelte' +import { open } from '@tauri-apps/plugin-dialog' + +export async function importMusicDirectory() { + try { + const selected = await open({ + directory: true, + multiple: false, + title: '选择音乐媒体库目录', + }) + + if (!selected || typeof selected !== 'string') return + + settings.addLibraryDir(selected) + + await musicLibrary.scan() + } catch (err) { + console.error('导入音乐媒体库目录失败:', err) + } +} + +export async function removeMusicDirectory(dir: string) { + settings.removeLibraryDir(dir) + await musicLibrary.scan() +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2f9d97e..2b88139 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,7 +2,9 @@ import PlayerBar from '$lib/features/PlayerBar.svelte' import Appbar from '$lib/features/shell/Appbar.svelte' import NavRail from '$lib/features/shell/NavRail.svelte' + import { musicLibrary } from '$lib/state/library.svelte' import { settings } from '$lib/state/settings.svelte' + import { listen } from '@tauri-apps/api/event' import 'mdui' import 'mdui/mdui.css' import { onMount } from 'svelte' @@ -12,6 +14,15 @@ onMount(() => { void settings.load() + onMount(() => { + const unlistenPromise = listen("library:refreshed", (event) => { + musicLibrary.tracks = event.payload + }) + + return () => { + void unlistenPromise.then((unlisten) => unlisten()) + } + }) }) $effect(() => { diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 1b04546..02086ed 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -3,5 +3,19 @@ // See: https://svelte.dev/docs/kit/single-page-apps // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info +import { musicLibrary } from '$lib/state/library.svelte' +import { recentlyPlayed } from '$lib/state/recent.svelte' +import { settings } from '$lib/state/settings.svelte' + export const prerender = true export const ssr = false +export async function load() { + await settings.load() + + await Promise.all([ + recentlyPlayed.load(), + musicLibrary.load(), + ]) + + return {} +} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index 0b72a39..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 0000000..dca901d --- /dev/null +++ b/src/routes/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit' + +export async function load() { + redirect(307, '/library') +} \ No newline at end of file diff --git a/src/routes/album/+page.svelte b/src/routes/album/+page.svelte index 7c4cb58..9cd9bb8 100644 --- a/src/routes/album/+page.svelte +++ b/src/routes/album/+page.svelte @@ -6,7 +6,6 @@ import { trackCovers } from '$lib/state/covers.svelte' import { musicLibrary } from '$lib/state/library.svelte' import type { Album, Track } from '$lib/types' - import { onMount } from 'svelte' import 'mdui/components/circular-progress.js' @@ -20,10 +19,6 @@ sensitivity: 'base', }) - onMount(() => { - void musicLibrary.refresh() - }) - function createAlbumId(title: string) { return encodeURIComponent(title) } @@ -118,7 +113,7 @@ diff --git a/src/routes/artists/+page.svelte b/src/routes/artists/+page.svelte index 2dc8557..ebc9645 100644 --- a/src/routes/artists/+page.svelte +++ b/src/routes/artists/+page.svelte @@ -2,7 +2,6 @@ import MediaGrid from '$lib/components/media/MediaGrid.svelte' import { musicLibrary } from '$lib/state/library.svelte' import type { Artist, Track } from '$lib/types' - import { onMount } from 'svelte' import Button from '$lib/components/base/Button.svelte' import Heading from '$lib/components/base/Heading.svelte' @@ -19,11 +18,7 @@ numeric: true, sensitivity: 'base', }) - - onMount(() => { - void musicLibrary.refresh() - }) - + function createArtistId(name: string) { return encodeURIComponent(name) } @@ -115,7 +110,7 @@ diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 8e845ad..f9981cb 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -3,7 +3,6 @@ import { musicLibrary } from '$lib/state/library.svelte' import { player } from '$lib/state/player.svelte' import type { Track } from '$lib/types' - import { onMount } from 'svelte' import Button from '$lib/components/base/Button.svelte' import Heading from '$lib/components/base/Heading.svelte' @@ -21,10 +20,6 @@ sensitivity: 'base', }) - onMount(() => { - void musicLibrary.refresh() - }) - let filteredTracks = $derived.by(() => { const keyword = query.trim().toLowerCase() @@ -48,8 +43,7 @@ function playAll() { if (filteredTracks.length === 0) return - player.playlist = filteredTracks - void player.playByIndex(0) + player.replaceQueueAndPlay(filteredTracks, filteredTracks[0].id) } @@ -70,7 +64,7 @@ diff --git a/src/routes/recent/+page.svelte b/src/routes/recent/+page.svelte index 3a45c23..c12e543 100644 --- a/src/routes/recent/+page.svelte +++ b/src/routes/recent/+page.svelte @@ -1,16 +1,11 @@ diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index b9b3844..b5554e3 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -9,14 +9,13 @@ import SettingsSection from '$lib/features/settings/SettingsSection.svelte' import { settings } from '$lib/state/settings.svelte' import type { ThemeMode } from '$lib/types' + import { importMusicDirectory, removeMusicDirectory } from '$lib/utils/library' import { onMount } from 'svelte' - import { open } from '@tauri-apps/plugin-dialog' + import { setTheme } from '@tauri-apps/api/app' import 'mdui/components/circular-progress.js' import 'mdui/components/switch.js' - import { setTheme } from 'mdui/functions/setTheme.js' - - let newLibraryDir = $state('') + import { setTheme as setMduiTheme } from 'mdui/functions/setTheme.js' const themeOptions: { label: string; value: ThemeMode }[] = [ { label: '跟随系统', value: 'auto' }, @@ -29,11 +28,6 @@ void settings.load() }) - function addLibraryDir() { - settings.addLibraryDir(newLibraryDir) - newLibraryDir = '' - } - function handleSwitchChange( event: Event, key: 'scanOnStartup' | 'reduceMotion', @@ -47,20 +41,18 @@ }) } - async function handleSelectDirectory() { - try { - const selected = await open({ - directory: true, - multiple: false, - title: '选择音乐媒体库目录', - }) - - if (selected && typeof selected === 'string') { - settings.addLibraryDir(selected) - } - } catch (err) { - console.error('打开文件夹选择器失败:', err) - } + export async function setThemeMode(theme: ThemeMode) { + settings.update({ + theme, + }) + + await applyTheme(theme) + } + + export async function applyTheme(theme: ThemeMode) { + setMduiTheme(theme) + + await setTheme(theme === 'auto' ? null : theme) } @@ -85,12 +77,7 @@ + {#if value} +
value = ''} + onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') value = '' }} + role="button" + tabindex="0" + > + +
+ {/if} +
\ No newline at end of file diff --git a/src/lib/features/TrackList.svelte b/src/lib/features/TrackList.svelte index 6dcc92b..fe40507 100644 --- a/src/lib/features/TrackList.svelte +++ b/src/lib/features/TrackList.svelte @@ -7,37 +7,58 @@ import 'mdui/components/list-item.js' import 'mdui/components/list.js' - let { tracks }: { tracks: Track[] } = $props() - - function lazyCover(node: HTMLElement, track: Track) { - let currentTrack = track - - const observer = new IntersectionObserver( - entries => { - const entry = entries[0] - - if (entry.isIntersecting) { - void trackCovers.load(currentTrack) - observer.disconnect() - } - }, - { - rootMargin: '300px', - }, - ) - - observer.observe(node) - - return { - update(nextTrack: Track) { - currentTrack = nextTrack - }, - destroy() { + type TrackColumn = 'index' | 'title' | 'album' | 'tags' | 'duration' + + interface Props { + tracks: Track[] + columns?: TrackColumn[] + } + + const columnWidths: Record = { + index: '48px', + title: '1fr', + album: '1fr', + tags: 'auto', + duration: '60px', + } + + let { tracks, columns = ['index', 'title', 'album', 'duration'] }: Props = + $props() + + let gridTemplate = $derived( + columns.map(column => columnWidths[column]).join(' '), + ) + + function lazyCover(track: Track) { + return (node: HTMLElement) => { + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + + if (entry.isIntersecting) { + void trackCovers.load(track) + observer.disconnect() + } + }, + { + rootMargin: '300px', + }, + ) + + observer.observe(node) + + return () => { observer.disconnect() - }, + } } } + function getTrackTags(track: Track): string[] { + const tags = (track as Track & { tags?: string[] }).tags + + return Array.isArray(tags) ? tags : [] + } + function handlePlay(clickedTrack: Track) { player.replacePlaylistAndPlay(tracks, clickedTrack.id) @@ -47,15 +68,15 @@ {#snippet trackIndex(index: number, isCurrent: boolean)}
{#if isCurrent && player.playing} - + {:else} {index + 1} @@ -65,7 +86,7 @@ {#snippet trackCover(track: Track, cover: string | null)}
{#if cover} @@ -86,21 +107,20 @@ {/snippet} {#snippet trackTitle(track: Track, cover: string | null, isCurrent: boolean)} -
+
{@render trackCover(track, cover)}
{track.title} - + {track.artist}
@@ -109,16 +129,32 @@ {#snippet trackAlbum(track: Track)} {/snippet} +{#snippet trackTags(track: Track)} + {@const tags = getTrackTags(track)} + +
+ {#if tags.length > 0} + {#each tags as tag (tag)} + + {tag} + + {/each} + {:else} + + {/if} +
+{/snippet} + {#snippet trackDuration(track: Track)} -
+
{formatDuration(track.duration)}
{/snippet} @@ -128,33 +164,68 @@ {@const cover = trackCovers.get(track)} handlePlay(track)} role="button" tabindex="0" > -
- {@render trackIndex(index, isCurrent)} - {@render trackTitle(track, cover, isCurrent)} - {@render trackAlbum(track)} - {@render trackDuration(track)} +
+ {#if columns.includes('index')} + {@render trackIndex(index, isCurrent)} + {/if} + + {#if columns.includes('title')} + {@render trackTitle(track, cover, isCurrent)} + {/if} + + {#if columns.includes('album')} + {@render trackAlbum(track)} + {/if} + + {#if columns.includes('tags')} + {@render trackTags(track)} + {/if} + + {#if columns.includes('duration')} + {@render trackDuration(track)} + {/if}
{/snippet} -
#
-
标题
- -
时长
+ {#if columns.includes('index')} +
#
+ {/if} + + {#if columns.includes('title')} +
标题
+ {/if} + + {#if columns.includes('album')} + + {/if} + + {#if columns.includes('tags')} +
标签
+ {/if} + + {#if columns.includes('duration')} +
时长
+ {/if}
@@ -165,15 +236,6 @@ diff --git a/src/lib/features/shell/Appbar.svelte b/src/lib/features/shell/Appbar.svelte index 6cfcf1f..cc76ca7 100644 --- a/src/lib/features/shell/Appbar.svelte +++ b/src/lib/features/shell/Appbar.svelte @@ -1,4 +1,6 @@ + + 专辑 -
- - - - - -
- -{#if musicLibrary.isLoading} -
- -
-{:else if musicLibrary.error} -
- {musicLibrary.error} -
-{:else} -
- -
-{/if} +
+
+ +
+ +
+
+ + {#if musicLibrary.isLoading} +
+ +
+ {:else if musicLibrary.error} +
+ {musicLibrary.error} +
+ {:else if isMobile} +
+ {#if selectedAlbum === null} +
+ {#if filteredAlbums.length === 0} +
+ 🎵 + 没有找到专辑 +
+ {:else} + + {#each filteredAlbums as album (album.id)} + {@const cover = trackCovers.get(album.representativeTrack) ?? album.cover} + { selectedAlbum = album }} + onkeydown={(e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && (selectedAlbum = album)} + role="button" + tabindex="0" + > + {#if cover} + + {:else} +
+ 🎵 +
+ {/if} +
+ {/each} +
+ {/if} +
+ {:else} +
+
+ { selectedAlbum = null }} /> +
+

{selectedAlbum.title}

+

{selectedAlbum.trackCount} 首歌曲

+
+ +
+
+ +
+
+ {/if} +
+ {:else} +
+
+ {#if filteredAlbums.length === 0} +
+ 🎵 + 没有找到专辑 +
+ {:else} + + {#each filteredAlbums as album (album.id)} + {@const cover = trackCovers.get(album.representativeTrack) ?? album.cover} + { selectedAlbum = album }} + onkeydown={(e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && (selectedAlbum = album)} + role="button" + tabindex="0" + active={selectedAlbum?.id === album.id} + > + {#if cover} + + {:else} +
+ 🎵 +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ {#if selectedAlbum === null} +
+ 🎵 + 选择一个专辑以查看歌曲 +
+ {:else} +
+
+

{selectedAlbum.title}

+

{selectedAlbum.trackCount} 首歌曲

+
+ +
+
+ +
+ {/if} +
+
+ {/if} +
diff --git a/src/routes/artists/+page.svelte b/src/routes/artists/+page.svelte index c2c4067..e9d31c7 100644 --- a/src/routes/artists/+page.svelte +++ b/src/routes/artists/+page.svelte @@ -1,24 +1,28 @@ + + 艺术家 -
- -
- +
+
+ +
+
+ {/if} +
+ {:else} +
+
- 刷新 - + {#if filteredArtists.length === 0} +
+ 👤 + 没有找到歌手 +
+ {:else} + + {#each filteredArtists as artist (artist.id)} + { + selectedArtist = artist + }} + onkeydown={(e: KeyboardEvent) => + (e.key === 'Enter' || e.key === ' ') && + (selectedArtist = artist)} + role="button" + tabindex="0" + active={selectedArtist?.id === artist.id} + > + {#if artist.cover} + + {:else} +
+ 👤 +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ {#if selectedArtist === null} +
+ 👤 + 选择一个歌手以查看歌曲 +
+ {:else} +
+
+

+ {selectedArtist.name} +

+

+ {selectedArtist.trackCount} 首歌曲 · {selectedArtist.albumCount} + 张专辑 +

+
+ +
+
+ +
+ {/if} +
- - - - - -{#if musicLibrary.isLoading} -
- -
-{:else if musicLibrary.error} -
- {musicLibrary.error} -
-{:else} -
- -
-{/if} + {/if} +
diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index aa6bd66..2c278a5 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -6,13 +6,16 @@ import Button from '$lib/components/base/Button.svelte' import Heading from '$lib/components/base/Heading.svelte' - import Filters from '$lib/features/Filters.svelte' + import SearchBar from '$lib/components/base/SearchBar.svelte' + import 'mdui/components/button.js' import 'mdui/components/circular-progress.js' + import 'mdui/components/segmented-button-group.js' + import 'mdui/components/segmented-button.js' type SortBy = 'title' | 'artist' | 'album' - let query = $state('') + let searchQuery = $state('') let sortBy = $state('title') const collator = new Intl.Collator('zh-Hans-CN', { @@ -20,30 +23,32 @@ sensitivity: 'base', }) - let filteredTracks = $derived.by(() => { - const keyword = query.trim().toLowerCase() - - const tracks = keyword - ? musicLibrary.tracks.filter(track => - [track.title, track.artist, track.album, track.path] - .filter(Boolean) - .some(value => value.toLowerCase().includes(keyword)), - ) - : musicLibrary.tracks + const searchResults = $derived( + musicLibrary.tracks.filter(track => + (track.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) || + (track.artist ?? '').toLowerCase().includes(searchQuery.toLowerCase()) || + (track.album ?? '').toLowerCase().includes(searchQuery.toLowerCase()) + ) + ) - return [...tracks].sort((a, b) => { + const defaultSortedTracks = $derived.by(() => { + return [...musicLibrary.tracks].sort((a, b) => { return collator.compare(a[sortBy] ?? '', b[sortBy] ?? '') }) }) - let totalDuration = $derived( - musicLibrary.tracks.reduce((sum, track) => sum + track.duration, 0), - ) + const displayTracks = $derived(searchQuery ? searchResults : defaultSortedTracks) function playAll() { - if (filteredTracks.length === 0) return + if (displayTracks.length === 0) return + player.replacePlaylistAndPlay(displayTracks, displayTracks[0].id) + } - player.replacePlaylistAndPlay(filteredTracks, filteredTracks[0].id) + function handleSortChange(event: Event) { + const value = (event.currentTarget as HTMLElement & { value: string }).value + if (value === 'title' || value === 'artist' || value === 'album') { + sortBy = value + } } @@ -51,11 +56,12 @@ 歌曲 -
- +
+
+ - - -
+ +
+
+ +
+
+ + 标题 + 歌手 + 专辑 + +
+
+
-{#if musicLibrary.isLoading} -
- -
-{:else if musicLibrary.error} -
- {musicLibrary.error} -
-{:else if filteredTracks.length === 0} -
-
🎵
-
没有找到歌曲
-
尝试刷新媒体库或修改搜索关键词
-
-{:else} -
- -
-{/if} + {#if musicLibrary.isLoading} +
+ +
+ {:else if musicLibrary.error} +
+ {musicLibrary.error} +
+ {:else if displayTracks.length === 0} +
+
🎵
+
没有找到歌曲
+
尝试刷新媒体库或修改搜索关键词
+
+ {:else} +
+ +
+ {/if} +
From 656413335255e2b454e0ceacd88ae201c53062f5 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 31 May 2026 03:28:34 +0800 Subject: [PATCH 19/39] refactor: implement derived music maps and toggleable album grouping - Add private derived maps (`#albumsMap`/`#artistsMap`) and public getters to MusicLibrary. - Implement persistent `useAlbumArtistGrouping` state and UI toggle switch. - Refactor album and artist pages to use ID-based selection and updated filtering. - Update related types, settings configurations, and UI layout alignments. --- src/lib/state/library.svelte.ts | 138 +++++++++++++++++--- src/lib/state/settings.svelte.ts | 1 + src/lib/types/music.ts | 4 +- src/lib/types/settings.ts | 1 + src/routes/album/+page.svelte | 216 ++++++++++++++++++++----------- src/routes/artists/+page.svelte | 82 +++--------- src/routes/library/+page.svelte | 2 +- 7 files changed, 287 insertions(+), 157 deletions(-) diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index 78c8c01..62b049e 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -1,28 +1,139 @@ import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' -import type { Track } from '../types/music' +import type { Album, Artist, Track } from '../types/music' import { settings } from './settings.svelte' class MusicLibrary { tracks = $state([]) isLoading = $state(false) error = $state(null) - + useAlbumArtistGrouping = $state(false) initialized = false private refreshPromise: Promise | null = null + #albumsMap = $derived.by(() => { + const albumMap = new Map() + const useAlbumArtistGrouping = this.useAlbumArtistGrouping + + for (const track of this.tracks) { + const albumTitle = track.album?.trim() || '未知专辑' + + const albumOwner = track.albumArtist?.trim() || track.artist?.trim() || '未知歌手' + const albumKey = useAlbumArtistGrouping + ? `${albumOwner}_${albumTitle}`.toLowerCase() + : albumTitle.toLowerCase() + const id = encodeURIComponent(albumKey) + + if (!albumMap.has(id)) { + albumMap.set(id, { + id, + title: albumTitle, + artist: albumOwner, + tracks: [], + cover: track.cover ?? null, + trackCount: 0, + representativeTrack: track, + }) + } + + const album = albumMap.get(id)! + album.tracks.push(track) + album.trackCount = album.tracks.length + + if (!album.cover && track.cover) { + album.cover = track.cover + } + } + + return albumMap + }) + + #artistsMap = $derived.by(() => { + const artistMap = new Map< + string, + Artist & { + albumKeys: Set + } + >() + + for (const track of this.tracks) { + const artistName = track.artist?.trim() || '未知歌手' + const artistKey = artistName.toLowerCase() + const artistId = encodeURIComponent(artistKey) + + if (!artistMap.has(artistId)) { + artistMap.set(artistId, { + id: artistId, + name: artistName, + cover: track.cover ?? null, + trackCount: 0, + albumCount: 0, + tracks: [], + albumKeys: new Set(), + }) + } + + const artist = artistMap.get(artistId)! + + artist.tracks.push(track) + artist.trackCount = artist.tracks.length + + const albumTitle = track.album?.trim() || '未知专辑' + const albumKey = albumTitle.toLowerCase() + + artist.albumKeys.add(albumKey) + artist.albumCount = artist.albumKeys.size + + if (!artist.cover && track.cover) { + artist.cover = track.cover + } + } + + return new Map( + Array.from(artistMap.values()).map(({ albumKeys, ...artist }) => [ + artist.id, + artist, + ]), + ) + }) constructor() { + this.useAlbumArtistGrouping = settings.data.useAlbumArtistGrouping ?? false void this.setupListener() } + get albums(): Album[] { + return Array.from(this.#albumsMap.values()) + } + + get artists(): Artist[] { + return Array.from(this.#artistsMap.values()) + } + + get albumCount(): number { + return this.#albumsMap.size + } + + get artistCount(): number { + return this.#artistsMap.size + } + + get trackCount(): number { + return this.tracks.length + } + + getAlbum(id: string): Album | undefined { + return this.#albumsMap.get(id) + } + + getArtist(id: string): Artist | undefined { + return this.#artistsMap.get(id) + } + private async setupListener() { - await listen( - 'library:refreshed', - event => { - this.tracks = event.payload - }, - ) + await listen('library:refreshed', event => { + this.tracks = event.payload + }) } async load(options: { force?: boolean } = {}): Promise { @@ -62,21 +173,16 @@ class MusicLibrary { this.isLoading = true this.error = null - const tracks = await invoke( - 'scan_music_directories', - { - dirs: settings.data.libraryDirs, - } - ) + const tracks = await invoke('scan_music_directories', { + dirs: settings.data.libraryDirs, + }) this.tracks = tracks return tracks } catch (err) { console.error('扫描媒体库失败:', err) - this.error = String(err) - return this.tracks } finally { this.isLoading = false diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index 04b0ffe..24cbc00 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -7,6 +7,7 @@ export const DEFAULT_SETTINGS: AppSettings = { libraryDirs: [], scanOnStartup: false, reduceMotion: false, + useAlbumArtistGrouping: false, } class SettingsState { diff --git a/src/lib/types/music.ts b/src/lib/types/music.ts index 663907c..8881133 100644 --- a/src/lib/types/music.ts +++ b/src/lib/types/music.ts @@ -9,6 +9,7 @@ export interface Track { cover?: string | null path: string url?: string + tags?: string[] } export interface Playlist { @@ -31,8 +32,9 @@ export interface Artist { export interface Album { id: string title: string + artist: string cover?: string | null - trackCount: number tracks: Track[] + trackCount: number representativeTrack: Track } \ No newline at end of file diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts index 88bd643..d9cef76 100644 --- a/src/lib/types/settings.ts +++ b/src/lib/types/settings.ts @@ -7,4 +7,5 @@ export interface AppSettings { libraryDirs: string[] scanOnStartup: boolean reduceMotion: boolean + useAlbumArtistGrouping: boolean } \ No newline at end of file diff --git a/src/routes/album/+page.svelte b/src/routes/album/+page.svelte index 20a51ca..2f87f18 100644 --- a/src/routes/album/+page.svelte +++ b/src/routes/album/+page.svelte @@ -7,69 +7,38 @@ import { trackCovers } from '$lib/state/covers.svelte' import { musicLibrary } from '$lib/state/library.svelte' import { player } from '$lib/state/player.svelte' + import { settings } from '$lib/state/settings.svelte' import type { Album, Track } from '$lib/types' import 'mdui/components/circular-progress.js' import 'mdui/components/list-item.js' import 'mdui/components/list.js' + import 'mdui/components/switch.js' let searchQuery = $state('') - let selectedAlbum = $state(null) + let selectedAlbumId = $state(null) let windowWidth = $state(1024) const isMobile = $derived(windowWidth < 768) + const selectedAlbum: Album | undefined = $derived( + selectedAlbumId === null + ? undefined + : musicLibrary.getAlbum(selectedAlbumId), + ) const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base', }) - function buildAlbums(tracks: Track[]): Album[] { - const albumMap = new Map< - string, - { - title: string - tracks: Track[] - cover?: string | null - representativeTrack: Track - } - >() - - for (const track of tracks) { - const title = track.album?.trim() || '未知专辑' - const key = title.toLowerCase() - - if (!albumMap.has(key)) { - albumMap.set(key, { - title, - tracks: [], - cover: track.cover ?? null, - representativeTrack: track, - }) - } - - const album = albumMap.get(key)! - album.tracks.push(track) - - if (!album.cover && track.cover) { - album.cover = track.cover - } - } - - return Array.from(albumMap.values()).map(album => ({ - ...album, - id: encodeURIComponent(album.title), - trackCount: album.tracks.length, - })) - } - - const albums = $derived(buildAlbums(musicLibrary.tracks)) - const filteredAlbums = $derived.by(() => { const keyword = searchQuery.trim().toLowerCase() + const albums = musicLibrary.albums const list = keyword - ? albums.filter(album => - album.title.toLowerCase().includes(keyword), + ? albums.filter( + album => + album.title.toLowerCase().includes(keyword) || + album.artist.toLowerCase().includes(keyword), ) : albums @@ -86,6 +55,20 @@ if (tracks.length === 0) return player.replacePlaylistAndPlay(tracks, tracks[0].id) } + + async function handleAlbumArtistGroupingChange(event: Event) { + const checked = ( + event.currentTarget as HTMLElement & { checked: boolean } + ).checked + + if (checked === musicLibrary.useAlbumArtistGrouping) return + + musicLibrary.useAlbumArtistGrouping = checked + settings.data.useAlbumArtistGrouping = checked + selectedAlbumId = null + + await settings.save() + } @@ -95,10 +78,24 @@
-
+
-
- +
+
+ +
+ +
+ 按专辑艺术家分组 + +
@@ -112,30 +109,46 @@
{:else if isMobile}
- {#if selectedAlbum === null} + {#if selectedAlbum === undefined}
{#if filteredAlbums.length === 0} -
+
🎵 没有找到专辑
{:else} {#each filteredAlbums as album (album.id)} - {@const cover = trackCovers.get(album.representativeTrack) ?? album.cover} + {@const cover = + trackCovers.get( + album.representativeTrack, + ) ?? album.cover} { selectedAlbum = album }} + onclick={() => { + selectedAlbumId = album.id + }} onkeydown={(e: KeyboardEvent) => - (e.key === 'Enter' || e.key === ' ') && (selectedAlbum = album)} + (e.key === 'Enter' || e.key === ' ') && + (selectedAlbumId = album.id)} role="button" tabindex="0" > {#if cover} - + {:else} -
+
🎵
{/if} @@ -146,13 +159,32 @@
{:else}
-
- { selectedAlbum = null }} /> +
+ { + selectedAlbumId = null + }} + />
-

{selectedAlbum.title}

-

{selectedAlbum.trackCount} 首歌曲

+

+ {selectedAlbum.title} +

+

+ {selectedAlbum.trackCount} 首歌曲 +

- +
@@ -162,30 +194,47 @@
{:else}
-
+
{#if filteredAlbums.length === 0} -
+
🎵 没有找到专辑
{:else} {#each filteredAlbums as album (album.id)} - {@const cover = trackCovers.get(album.representativeTrack) ?? album.cover} + {@const cover = + trackCovers.get(album.representativeTrack) ?? + album.cover} { selectedAlbum = album }} + onclick={() => { + selectedAlbumId = album.id + }} onkeydown={(e: KeyboardEvent) => - (e.key === 'Enter' || e.key === ' ') && (selectedAlbum = album)} + (e.key === 'Enter' || e.key === ' ') && + (selectedAlbumId = album.id)} role="button" tabindex="0" - active={selectedAlbum?.id === album.id} + active={selectedAlbumId === album.id} > {#if cover} - + {:else} -
+
🎵
{/if} @@ -195,21 +244,40 @@ {/if}
- {#if selectedAlbum === null} -
+ {#if selectedAlbum === undefined} +
🎵 选择一个专辑以查看歌曲
{:else} -
+
-

{selectedAlbum.title}

-

{selectedAlbum.trackCount} 首歌曲

+

+ {selectedAlbum.title} +

+

+ {selectedAlbum.trackCount} 首歌曲 +

- +
- +
{/if}
diff --git a/src/routes/artists/+page.svelte b/src/routes/artists/+page.svelte index e9d31c7..8c1b893 100644 --- a/src/routes/artists/+page.svelte +++ b/src/routes/artists/+page.svelte @@ -13,70 +13,22 @@ import 'mdui/components/list.js' let searchQuery = $state('') - let selectedArtist = $state(null) + let selectedArtistId = $state(null) let windowWidth = $state(1024) const isMobile = $derived(windowWidth < 768) + const selectedArtist: Artist | undefined = $derived( + selectedArtistId === null ? undefined : musicLibrary.getArtist(selectedArtistId), + ) const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base', }) - function createArtistId(name: string) { - return encodeURIComponent(name) - } - - function buildArtists(tracks: Track[]): Artist[] { - const artistMap = new Map< - string, - { - name: string - tracks: Track[] - albums: Set - cover?: string | null - } - >() - - for (const track of tracks) { - const name = track.artist?.trim() || '未知歌手' - const key = name.toLowerCase() - - if (!artistMap.has(key)) { - artistMap.set(key, { - name, - tracks: [], - albums: new Set(), - cover: track.cover ?? null, - }) - } - - const artist = artistMap.get(key)! - artist.tracks.push(track) - - if (track.album?.trim()) { - artist.albums.add(track.album.trim()) - } - - if (!artist.cover && track.cover) { - artist.cover = track.cover - } - } - - return Array.from(artistMap.values()).map(artist => ({ - id: createArtistId(artist.name), - name: artist.name, - cover: artist.cover, - trackCount: artist.tracks.length, - albumCount: artist.albums.size, - tracks: artist.tracks, - })) - } - - const artists = $derived(buildArtists(musicLibrary.tracks)) - const filteredArtists = $derived.by(() => { const keyword = searchQuery.trim().toLowerCase() + const artists = musicLibrary.artists const list = keyword ? artists.filter(artist => artist.name.toLowerCase().includes(keyword), @@ -118,7 +70,7 @@
{:else if isMobile}
- {#if selectedArtist === null} + {#if selectedArtist === undefined}
{#if filteredArtists.length === 0}
{ - selectedArtist = artist + selectedArtistId = artist.id }} onkeydown={(e: KeyboardEvent) => (e.key === 'Enter' || e.key === ' ') && - (selectedArtist = artist)} + (selectedArtistId = artist.id)} role="button" tabindex="0" > @@ -170,7 +122,7 @@ { - selectedArtist = null + selectedArtistId = null }} />
@@ -188,7 +140,7 @@
@@ -201,7 +153,7 @@ {:else}
{#if filteredArtists.length === 0}
{ - selectedArtist = artist + selectedArtistId = artist.id }} onkeydown={(e: KeyboardEvent) => (e.key === 'Enter' || e.key === ' ') && - (selectedArtist = artist)} + (selectedArtistId = artist.id)} role="button" tabindex="0" - active={selectedArtist?.id === artist.id} + active={selectedArtistId === artist.id} > {#if artist.cover}
- {#if selectedArtist === null} + {#if selectedArtist === undefined}
@@ -273,12 +225,12 @@
- +
{/if}
diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 2c278a5..5af8e9f 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -74,7 +74,7 @@ 刷新 -
+
From d765c6eb8a2daea8f24a0452431ac66b5300aa40 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 31 May 2026 12:09:54 +0800 Subject: [PATCH 20/39] Add media session controls and tray settings Add resume/pause Tauri commands and expose them to the invoke handler; wire tray menu to include a "settings" item which emits a tray:navigate event to unminimize and focus the main window. Integrate Web Media Session API in the player (action handlers for play/pause/prev/next/seek, metadata, playback and position state), update play/pause/toggle/seek flows to sync media session state and polling, and add a small refactor (onTrackStarted). UI tweaks: make SearchBar accept a placeholder prop, adjust Appbar layout, and listen for the tray:navigate event in +layout.svelte to navigate to the settings route. --- src-tauri/src/commands/playback.rs | 28 ++++ src-tauri/src/lib.rs | 27 ++- src/lib/components/base/SearchBar.svelte | 4 +- src/lib/features/shell/Appbar.svelte | 2 +- src/lib/state/player.svelte.ts | 204 +++++++++++++++++++++-- src/routes/+layout.svelte | 13 ++ 6 files changed, 254 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index c86e95e..aa76444 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -47,6 +47,34 @@ pub async fn play_music(full_path: &str) -> Result { Ok(format!("Playing music: {}", full_path)) } +#[command] +pub async fn resume_music() -> Result<(), String> { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + media.play(); + Ok(()) + } else { + Err("No media available".into()) + } +} + +#[command] +pub async fn pause_music() -> Result<(), String> { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + media.pause(); + Ok(()) + } else { + Err("No media available".into()) + } +} + #[command] pub async fn toggle_music() -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ab0700..c4cb30f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod audio; mod commands; mod metadata; mod models; + use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, @@ -12,7 +13,7 @@ use commands::library::{ execute_scan, get_track_cover, load_music_library, save_music_library, scan_music_directories, }; -use commands::playback::{current_time, play_music, set_current_time, set_volume, toggle_music}; +use commands::playback::{current_time, play_music, set_current_time, set_volume, resume_music, pause_music, toggle_music}; use commands::recent::{add_recently_played, clear_recently_played, load_recently_played}; @@ -36,24 +37,33 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { - let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&quit_i])?; + let settings_item = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&settings_item, &quit_item])?; let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app: &tauri::AppHandle, event| match event.id.as_ref() { + "settings" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit("tray:navigate", "settings"); + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + } "quit" => { app.exit(0); } _ => {} }) - .on_tray_icon_event(|tray, event| match event { - TrayIconEvent::Click { + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { button: MouseButton::Left, .. - } => { + } = event { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); @@ -61,9 +71,6 @@ pub fn run() { let _ = window.set_focus(); } } - _ => { - println!("unhandled event {event:?}"); - } }) .build(app)?; @@ -86,6 +93,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ play_music, + resume_music, + pause_music, toggle_music, current_time, set_current_time, diff --git a/src/lib/components/base/SearchBar.svelte b/src/lib/components/base/SearchBar.svelte index 4aeb707..3a4e61a 100644 --- a/src/lib/components/base/SearchBar.svelte +++ b/src/lib/components/base/SearchBar.svelte @@ -2,7 +2,7 @@ import '@mdui/icons/close--rounded.js' import '@mdui/icons/search--rounded.js' - let { value = $bindable('') } = $props() + let { value = $bindable(''), placeholder="搜索歌曲、歌手、专辑..." } = $props()
diff --git a/src/lib/features/shell/Appbar.svelte b/src/lib/features/shell/Appbar.svelte index cc76ca7..581c34a 100644 --- a/src/lib/features/shell/Appbar.svelte +++ b/src/lib/features/shell/Appbar.svelte @@ -47,7 +47,7 @@
-
+
diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index 7177f11..07b7c3e 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -28,7 +28,152 @@ class Player { #pollTimer: any = null private loadToken = 0 - constructor() {} + constructor() { + this.setupMediaSession() + } + + private setupMediaSession() { + if (!('mediaSession' in navigator)) { + return + } + + navigator.mediaSession.setActionHandler( + 'play', + () => this.resume(), + ) + + navigator.mediaSession.setActionHandler( + 'pause', + () => this.pause(), + ) + + navigator.mediaSession.setActionHandler( + 'previoustrack', + () => this.prev(), + ) + + navigator.mediaSession.setActionHandler( + 'nexttrack', + () => this.next(), + ) + + navigator.mediaSession.setActionHandler( + 'seekto', + details => { + const seekTime = details.seekTime + if (seekTime == null) { + return + } + void this.seek(seekTime) + }, + ) + + navigator.mediaSession.setActionHandler( + 'seekforward', + () => { + if (!this.currentTrack) { + return + } + void this.seek( + Math.min( + this.currentTime + 10, + this.currentTrack.duration, + ), + ) + }, + ) + + navigator.mediaSession.setActionHandler( + 'seekbackward', + () => { + if (!this.currentTrack) { + return + } + void this.seek( + Math.max( + this.currentTime - 10, + 0, + ), + ) + }, + ) + } + + private updateMediaMetadata() { + if ( + !('mediaSession' in navigator) || + !this.currentTrack + ) { + return + } + + navigator.mediaSession.metadata = + new MediaMetadata({ + title: this.currentTrack.title, + artist: + this.currentTrack.artist ?? + '未知歌手', + album: + this.currentTrack.album ?? + '未知专辑', + artwork: this.buildArtwork(), + }) + } + + private buildArtwork() { + const cover = this.currentTrack?.cover + if (!cover) { + return [] + } + return [ + { + src: cover + }, + ] + } + + private updatePlaybackState() { + if (!('mediaSession' in navigator)) { + return + } + + navigator.mediaSession.playbackState = + this.playing + ? 'playing' + : 'paused' + } + + private updatePositionState() { + const track = this.currentTrack + if ( + !track || + !('mediaSession' in navigator) || + !('setPositionState' in navigator.mediaSession) + ) { + return + } + + navigator.mediaSession.setPositionState({ + duration: track.duration, + playbackRate: this.playing ? 1 : 0, + position: this.currentTime, + }) + } + + private syncMediaSession() { + this.updateMediaMetadata() + this.updatePlaybackState() + this.updatePositionState() + } + + private clearMediaSession() { + if (!('mediaSession' in navigator)) { + return + } + + navigator.mediaSession.metadata = null + navigator.mediaSession.playbackState = 'none' + } get currentTrack(): Track | null { if (this.currentIndex >= 0 && this.currentIndex < this.playlist.length) { @@ -66,6 +211,7 @@ class Player { this.currentIndex = -1 this.playing = false this.playbackHistory = [] + this.clearMediaSession() this.stopPolling() invoke('toggle_music').catch(console.error) return @@ -126,6 +272,7 @@ class Player { this.currentIndex = -1 this.playing = false this.playbackHistory = [] + this.clearMediaSession() this.stopPolling() invoke('toggle_music').catch(console.error) return @@ -189,6 +336,15 @@ class Player { return this.currentIndex <= 0 ? this.playlist.length - 1 : this.currentIndex - 1 } + private storePromise: ReturnType | null = null + + private async getStore() { + if (!this.storePromise) { + this.storePromise = load(STORE_NAME) + } + return this.storePromise + } + next() { if (this.playlist.length === 0) return const current = this.currentTrack @@ -214,25 +370,44 @@ class Player { void this.loadAndPlay() } - toggle = async () => { + resume = async () => { try { - const isPlayingNow = await invoke('toggle_music') - this.playing = isPlayingNow - if (this.playing) { - this.startPolling() - } else { - this.stopPolling() - } + await invoke('resume_music') + this.playing = true + this.syncMediaSession() + this.startPolling() } catch (err) { console.error(err) } } + pause = async () => { + try { + await invoke('pause_music') + this.playing = false + this.syncMediaSession() + this.stopPolling() + } catch (err) { + console.error(err) + } + } + + toggle = async () => { + if (this.playing) { + await this.pause() + } else { + await this.resume() + } + } + seek = async (time: number) => { this.stopPolling() await invoke('set_current_time', { time }) this.currentTime = time - if (this.playing) this.startPolling() + this.updatePositionState() + if (this.playing) { + this.startPolling() + } } async loadState() { @@ -273,6 +448,12 @@ class Player { console.error(err) } } + + private onTrackStarted(track: Track) { + this.playing = true + this.syncMediaSession() + recentlyPlayed.add(track) + } private async loadAndPlay() { const track = this.currentTrack @@ -293,8 +474,7 @@ class Player { return } - this.playing = true - recentlyPlayed.add(track) + this.onTrackStarted(track) this.startPolling() } catch (err) { if (currentToken === this.loadToken) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index de71a18..07afaa5 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,12 +11,25 @@ import { onMount } from 'svelte' import '../app.css' + import { goto } from '$app/navigation' + import { listen } from '@tauri-apps/api/event' + let { children } = $props() onMount(() => { void settings.load() void musicLibrary.load() void player.loadState() + + const unlisten = listen('tray:navigate', event => { + if (event.payload === 'settings') { + goto('/settings') + } + }) + + return () => { + unlisten.then(fn => fn()) + } }) $effect(() => { From 7ae427476086b559cf7c0802a6a1f0a7e0fd2f32 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 31 May 2026 12:28:28 +0800 Subject: [PATCH 21/39] Update player.svelte.ts --- src/lib/state/player.svelte.ts | 285 ++++++++++++--------------------- 1 file changed, 98 insertions(+), 187 deletions(-) diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index 07b7c3e..72df544 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -5,13 +5,6 @@ import { recentlyPlayed } from "./recent.svelte" type PlayMode = 'list' | 'single' | 'shuffle' -interface PersistedState { - playlist: Track[] - currentIndex: number - playMode: PlayMode - volume: number -} - const STORE_NAME = "player-state.json" class Player { @@ -26,132 +19,59 @@ class Player { #volume = $state(80) #previousVolume = 80 #pollTimer: any = null - private loadToken = 0 + #loadToken = 0 + #storePromise: ReturnType | null = null constructor() { - this.setupMediaSession() + this.#setupMediaSession() } - private setupMediaSession() { - if (!('mediaSession' in navigator)) { - return - } + #setupMediaSession() { + if (!('mediaSession' in navigator)) return - navigator.mediaSession.setActionHandler( - 'play', - () => this.resume(), - ) - - navigator.mediaSession.setActionHandler( - 'pause', - () => this.pause(), - ) - - navigator.mediaSession.setActionHandler( - 'previoustrack', - () => this.prev(), - ) - - navigator.mediaSession.setActionHandler( - 'nexttrack', - () => this.next(), - ) - - navigator.mediaSession.setActionHandler( - 'seekto', - details => { - const seekTime = details.seekTime - if (seekTime == null) { - return - } - void this.seek(seekTime) - }, - ) - - navigator.mediaSession.setActionHandler( - 'seekforward', - () => { - if (!this.currentTrack) { - return - } - void this.seek( - Math.min( - this.currentTime + 10, - this.currentTrack.duration, - ), - ) - }, - ) - - navigator.mediaSession.setActionHandler( - 'seekbackward', - () => { - if (!this.currentTrack) { - return - } - void this.seek( - Math.max( - this.currentTime - 10, - 0, - ), - ) - }, - ) - } - - private updateMediaMetadata() { - if ( - !('mediaSession' in navigator) || - !this.currentTrack - ) { - return - } + navigator.mediaSession.setActionHandler('play', () => this.resume()) + navigator.mediaSession.setActionHandler('pause', () => this.pause()) + navigator.mediaSession.setActionHandler('previoustrack', () => this.prev()) + navigator.mediaSession.setActionHandler('nexttrack', () => this.next()) + navigator.mediaSession.setActionHandler('seekto', details => { + const seekTime = details.seekTime + if (seekTime == null) return + void this.seek(seekTime) + }) + navigator.mediaSession.setActionHandler('seekforward', () => { + if (!this.currentTrack) return + void this.seek(Math.min(this.currentTime + 10, this.currentTrack.duration)) + }) + navigator.mediaSession.setActionHandler('seekbackward', () => { + if (!this.currentTrack) return + void this.seek(Math.max(this.currentTime - 10, 0)) + }) + } + + #updateMediaMetadata() { + if (!('mediaSession' in navigator) || !this.currentTrack) return - navigator.mediaSession.metadata = - new MediaMetadata({ - title: this.currentTrack.title, - artist: - this.currentTrack.artist ?? - '未知歌手', - album: - this.currentTrack.album ?? - '未知专辑', - artwork: this.buildArtwork(), - }) + navigator.mediaSession.metadata = new MediaMetadata({ + title: this.currentTrack.title, + artist: this.currentTrack.artist ?? '未知歌手', + album: this.currentTrack.album ?? '未知专辑', + artwork: this.#buildArtwork(), + }) } - private buildArtwork() { + #buildArtwork() { const cover = this.currentTrack?.cover - if (!cover) { - return [] - } - return [ - { - src: cover - }, - ] + return cover ? [{ src: cover }] : [] } - private updatePlaybackState() { - if (!('mediaSession' in navigator)) { - return - } - - navigator.mediaSession.playbackState = - this.playing - ? 'playing' - : 'paused' + #updatePlaybackState() { + if (!('mediaSession' in navigator)) return + navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused' } - private updatePositionState() { + #updatePositionState() { const track = this.currentTrack - if ( - !track || - !('mediaSession' in navigator) || - !('setPositionState' in navigator.mediaSession) - ) { - return - } + if (!track || !('mediaSession' in navigator) || !('setPositionState' in navigator.mediaSession)) return navigator.mediaSession.setPositionState({ duration: track.duration, @@ -160,17 +80,14 @@ class Player { }) } - private syncMediaSession() { - this.updateMediaMetadata() - this.updatePlaybackState() - this.updatePositionState() + #syncMediaSession() { + this.#updateMediaMetadata() + this.#updatePlaybackState() + this.#updatePositionState() } - private clearMediaSession() { - if (!('mediaSession' in navigator)) { - return - } - + #clearMediaSession() { + if (!('mediaSession' in navigator)) return navigator.mediaSession.metadata = null navigator.mediaSession.playbackState = 'none' } @@ -189,7 +106,7 @@ class Player { this.#muted = false } invoke('set_volume', { volume }).catch(console.error) - void this.persist() + void this.#persist() } get muted() { return this.#muted } @@ -205,14 +122,21 @@ class Player { } } + async #getStore() { + if (!this.#storePromise) { + this.#storePromise = load(STORE_NAME) + } + return this.#storePromise + } + replacePlaylistAndPlay(tracks: Track[], targetId: string) { if (tracks.length === 0) { this.playlist = [] this.currentIndex = -1 this.playing = false this.playbackHistory = [] - this.clearMediaSession() - this.stopPolling() + this.#clearMediaSession() + this.#stopPolling() invoke('toggle_music').catch(console.error) return } @@ -224,8 +148,8 @@ class Player { this.playbackHistory = [] this.playlist = nextQueue this.currentIndex = nextIndex - void this.loadAndPlay() - void this.persist() + void this.#loadAndPlay() + void this.#persist() } appendTrack(track: Track) { @@ -235,9 +159,7 @@ class Player { insertNext(track: Track) { const existingIndex = this.playlist.findIndex(item => item.id === track.id) - if (existingIndex === this.currentIndex && this.currentIndex !== -1) { - return - } + if (existingIndex === this.currentIndex && this.currentIndex !== -1) return let nextQueue = [...this.playlist] let nextIndex = this.currentIndex @@ -272,8 +194,8 @@ class Player { this.currentIndex = -1 this.playing = false this.playbackHistory = [] - this.clearMediaSession() - this.stopPolling() + this.#clearMediaSession() + this.#stopPolling() invoke('toggle_music').catch(console.error) return } @@ -282,7 +204,7 @@ class Player { const nextIndex = Math.min(removeIndex, nextQueue.length - 1) this.playlist = nextQueue this.currentIndex = nextIndex - void this.loadAndPlay() + void this.#loadAndPlay() return } @@ -297,22 +219,22 @@ class Player { playTrackInQueue(index: number) { if (index < 0 || index >= this.playlist.length) return this.currentIndex = index - void this.loadAndPlay() - void this.persist() + void this.#loadAndPlay() + void this.#persist() } cyclePlayMode() { const modes: PlayMode[] = ['list', 'single', 'shuffle'] const currentModeIndex = modes.indexOf(this.playMode) this.playMode = modes[(currentModeIndex + 1) % modes.length] - void this.persist() + void this.#persist() } toggleQueue() { this.queueOpen = !this.queueOpen } - private getNextIndex(): number { + #getNextIndex(): number { if (this.playlist.length <= 1) return 0 if (this.playMode === 'shuffle') { let nextIndex = this.currentIndex @@ -324,7 +246,7 @@ class Player { return this.currentIndex >= this.playlist.length - 1 ? 0 : this.currentIndex + 1 } - private getPrevIndex(): number { + #getPrevIndex(): number { if (this.playlist.length <= 1) return 0 if (this.playMode === 'shuffle') { let prevIndex = this.currentIndex @@ -336,23 +258,14 @@ class Player { return this.currentIndex <= 0 ? this.playlist.length - 1 : this.currentIndex - 1 } - private storePromise: ReturnType | null = null - - private async getStore() { - if (!this.storePromise) { - this.storePromise = load(STORE_NAME) - } - return this.storePromise - } - next() { if (this.playlist.length === 0) return const current = this.currentTrack if (current) { this.playbackHistory.push(current.id) } - this.currentIndex = this.getNextIndex() - void this.loadAndPlay() + this.currentIndex = this.#getNextIndex() + void this.#loadAndPlay() } prev() { @@ -362,20 +275,20 @@ class Player { const index = this.playlist.findIndex(t => t.id === lastId) if (index !== -1) { this.currentIndex = index - void this.loadAndPlay() + void this.#loadAndPlay() return } } - this.currentIndex = this.getPrevIndex() - void this.loadAndPlay() + this.currentIndex = this.#getPrevIndex() + void this.#loadAndPlay() } resume = async () => { try { await invoke('resume_music') this.playing = true - this.syncMediaSession() - this.startPolling() + this.#syncMediaSession() + this.#startPolling() } catch (err) { console.error(err) } @@ -385,8 +298,8 @@ class Player { try { await invoke('pause_music') this.playing = false - this.syncMediaSession() - this.stopPolling() + this.#syncMediaSession() + this.#stopPolling() } catch (err) { console.error(err) } @@ -401,18 +314,18 @@ class Player { } seek = async (time: number) => { - this.stopPolling() + this.#stopPolling() await invoke('set_current_time', { time }) this.currentTime = time - this.updatePositionState() + this.#updatePositionState() if (this.playing) { - this.startPolling() + this.#startPolling() } } async loadState() { try { - const store = await load(STORE_NAME) + const store = await this.#getStore() const playlist = await store.get("playlist") const currentIndex = await store.get("currentIndex") const playMode = await store.get("playMode") @@ -436,9 +349,9 @@ class Player { } } - private async persist() { + async #persist() { try { - const store = await load(STORE_NAME) + const store = await this.#getStore() await store.set("playlist", this.playlist.map(t => ({ ...t, cover: null }))) await store.set("currentIndex", this.currentIndex) await store.set("playMode", this.playMode) @@ -448,19 +361,19 @@ class Player { console.error(err) } } - - private onTrackStarted(track: Track) { + + #onTrackStarted(track: Track) { this.playing = true - this.syncMediaSession() + this.#syncMediaSession() recentlyPlayed.add(track) } - private async loadAndPlay() { + async #loadAndPlay() { const track = this.currentTrack if (!track) return - const currentToken = ++this.loadToken - this.stopPolling() + const currentToken = ++this.#loadToken + this.#stopPolling() try { this.currentTime = 0 @@ -470,25 +383,23 @@ class Player { await invoke('play_online_music', { url: track.url, id: track.id }) } - if (currentToken !== this.loadToken) { - return - } + if (currentToken !== this.#loadToken) return - this.onTrackStarted(track) - this.startPolling() + this.#onTrackStarted(track) + this.#startPolling() } catch (err) { - if (currentToken === this.loadToken) { + if (currentToken === this.#loadToken) { this.playing = false console.error(err) } } } - private startPolling = () => { - this.stopPolling() + #startPolling = () => { + this.#stopPolling() this.#pollTimer = setInterval(async () => { if (!this.playing) { - this.stopPolling() + this.#stopPolling() return } try { @@ -496,18 +407,18 @@ class Player { if (this.currentTrack && this.currentTime >= this.currentTrack.duration - 0.5) { if (this.playMode === 'single') { this.currentTime = 0 - void this.loadAndPlay() + void this.#loadAndPlay() } else { this.next() } } } catch (e) { - this.stopPolling() + this.#stopPolling() } }, 250) } - private stopPolling = () => { + #stopPolling = () => { if (this.#pollTimer) { clearInterval(this.#pollTimer) this.#pollTimer = null @@ -515,7 +426,7 @@ class Player { } destroy() { - this.stopPolling() + this.#stopPolling() } } From a81722c84908eeda85d4befe5ea5cd152ab8d3ba Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Sun, 31 May 2026 15:00:36 +0800 Subject: [PATCH 22/39] refactor some styles --- src/lib/components/base/Heading.svelte | 11 +- src/lib/components/media/MediaGrid.svelte | 164 +++++++++++++++------- src/lib/features/TrackList.svelte | 10 +- src/lib/state/player.svelte.ts | 29 +++- src/routes/album/+page.svelte | 115 ++++++--------- src/routes/artists/+page.svelte | 119 +++++++--------- src/routes/library/+page.svelte | 102 +++++++++----- src/routes/recent/+page.svelte | 19 ++- 8 files changed, 319 insertions(+), 250 deletions(-) diff --git a/src/lib/components/base/Heading.svelte b/src/lib/components/base/Heading.svelte index 3614c0e..ac1b195 100644 --- a/src/lib/components/base/Heading.svelte +++ b/src/lib/components/base/Heading.svelte @@ -1,22 +1,19 @@ -
+

- - {#if children} -
- {@render children()} -
- {/if}
diff --git a/src/lib/components/media/MediaGrid.svelte b/src/lib/components/media/MediaGrid.svelte index b9f2e68..68e2f92 100644 --- a/src/lib/components/media/MediaGrid.svelte +++ b/src/lib/components/media/MediaGrid.svelte @@ -15,69 +15,139 @@ items, emptyTitle = '暂无内容', emptyDescription = '这里还没有可以显示的内容', + selectedId = null, + onselect, + onplay, }: { items: MediaGridItem[] emptyTitle?: string emptyDescription?: string + selectedId?: string | number | null + onselect?: (item: MediaGridItem) => void + onplay?: (item: MediaGridItem, event: Event) => void } = $props() + + function handleSelect(item: MediaGridItem) { + onselect?.(item) + } + + function handleKeydown(item: MediaGridItem, event: KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') return + + event.preventDefault() + handleSelect(item) + } + + function handlePlay(item: MediaGridItem, event: Event) { + event.preventDefault() + event.stopPropagation() + onplay?.(item, event) + } + + function handlePlayKeydown(item: MediaGridItem, event: KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') return + + handlePlay(item, event) + } -{#snippet mediaCard(item: MediaGridItem)} - -
- {#if item.image} - {item.title} - {:else} -
- {item.shape === 'circle' ? '👤' : '🎵'} -
- {/if} + {#if item.image} + {item.title} + {:else} +
+ {item.shape === 'circle' ? '👤' : '🎵'} +
+ {/if} + {#if onplay}
- handlePlay(item, event)} + onkeydown={(event: KeyboardEvent) => + handlePlayKeydown(item, event)} >
-
+ {/if} +
+ +
+

+ {item.title} +

-
-

- {item.title} -

+ {item.subtitle} +

+ {/if} +
+{/snippet} - {#if item.subtitle} -

- {item.subtitle} -

- {/if} -
- +{#snippet mediaCard(item: MediaGridItem)} + {@const isSelected = selectedId === item.id} + {#if onselect} + handleSelect(item)} + onkeydown={(event: KeyboardEvent) => handleKeydown(item, event)} + role="button" + tabindex="0" + > + {@render mediaCardContent(item, isSelected)} + + {:else} + + {@render mediaCardContent(item, isSelected)} + + {/if} {/snippet} {#if items.length === 0} diff --git a/src/lib/features/TrackList.svelte b/src/lib/features/TrackList.svelte index fe40507..8f8109c 100644 --- a/src/lib/features/TrackList.svelte +++ b/src/lib/features/TrackList.svelte @@ -15,7 +15,7 @@ } const columnWidths: Record = { - index: '48px', + index: '24px', title: '1fr', album: '1fr', tags: 'auto', @@ -87,7 +87,7 @@ {#snippet trackCover(track: Track, cover: string | null)}
{#if cover} @@ -252,10 +252,6 @@ border-bottom: 1px solid rgb(var(--mdui-color-outline-variant)); } - .themed-surface-container { - background-color: rgb(var(--mdui-color-surface-container-highest)); - } - .themed-surface-high { background-color: rgb(var(--mdui-color-surface-container-high)); } diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index 72df544..16d51e4 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -21,6 +21,7 @@ class Player { #pollTimer: any = null #loadToken = 0 #storePromise: ReturnType | null = null + #debounceTimer: any = null constructor() { this.#setupMediaSession() @@ -106,7 +107,7 @@ class Player { this.#muted = false } invoke('set_volume', { volume }).catch(console.error) - void this.#persist() + this.#saveDebounced() } get muted() { return this.#muted } @@ -120,6 +121,7 @@ class Player { this.#volume = this.#previousVolume invoke('set_volume', { volume: this.#previousVolume }).catch(console.error) } + this.#saveDebounced() } async #getStore() { @@ -129,6 +131,13 @@ class Player { return this.#storePromise } + #saveDebounced() { + if (this.#debounceTimer) clearTimeout(this.#debounceTimer) + this.#debounceTimer = setTimeout(() => { + void this.#persist() + }, 1000) + } + replacePlaylistAndPlay(tracks: Track[], targetId: string) { if (tracks.length === 0) { this.playlist = [] @@ -149,12 +158,13 @@ class Player { this.playlist = nextQueue this.currentIndex = nextIndex void this.#loadAndPlay() - void this.#persist() + this.#saveDebounced() } appendTrack(track: Track) { if (this.playlist.some(t => t.id === track.id)) return this.playlist = [...this.playlist, track] + this.#saveDebounced() } insertNext(track: Track) { @@ -174,12 +184,14 @@ class Player { if (nextQueue.length === 0 || nextIndex === -1) { this.playlist = [track] this.currentIndex = 0 + this.#saveDebounced() return } nextQueue.splice(nextIndex + 1, 0, track) this.playlist = nextQueue this.currentIndex = nextIndex + this.#saveDebounced() } removeTrack(id: string) { @@ -197,6 +209,7 @@ class Player { this.#clearMediaSession() this.#stopPolling() invoke('toggle_music').catch(console.error) + this.#saveDebounced() return } @@ -205,6 +218,7 @@ class Player { this.playlist = nextQueue this.currentIndex = nextIndex void this.#loadAndPlay() + this.#saveDebounced() return } @@ -214,20 +228,21 @@ class Player { } this.playlist = nextQueue this.currentIndex = nextIndex + this.#saveDebounced() } playTrackInQueue(index: number) { if (index < 0 || index >= this.playlist.length) return this.currentIndex = index void this.#loadAndPlay() - void this.#persist() + this.#saveDebounced() } cyclePlayMode() { const modes: PlayMode[] = ['list', 'single', 'shuffle'] const currentModeIndex = modes.indexOf(this.playMode) this.playMode = modes[(currentModeIndex + 1) % modes.length] - void this.#persist() + this.#saveDebounced() } toggleQueue() { @@ -266,6 +281,7 @@ class Player { } this.currentIndex = this.#getNextIndex() void this.#loadAndPlay() + this.#saveDebounced() } prev() { @@ -276,11 +292,13 @@ class Player { if (index !== -1) { this.currentIndex = index void this.#loadAndPlay() + this.#saveDebounced() return } } this.currentIndex = this.#getPrevIndex() void this.#loadAndPlay() + this.#saveDebounced() } resume = async () => { @@ -427,6 +445,9 @@ class Player { destroy() { this.#stopPolling() + if (this.#debounceTimer) { + clearTimeout(this.#debounceTimer) + } } } diff --git a/src/routes/album/+page.svelte b/src/routes/album/+page.svelte index 2f87f18..fb14763 100644 --- a/src/routes/album/+page.svelte +++ b/src/routes/album/+page.svelte @@ -1,8 +1,10 @@ @@ -55,8 +87,10 @@ class="pb-4 shrink-0 border-b border-[rgb(var(--mdui-color-outline-variant))]" > -
- +
+
+ +
@@ -153,50 +187,16 @@ {:else}
- {#if filteredArtists.length === 0} -
- 👤 - 没有找到歌手 -
- {:else} - - {#each filteredArtists as artist (artist.id)} - { - selectedArtistId = artist.id - }} - onkeydown={(e: KeyboardEvent) => - (e.key === 'Enter' || e.key === ' ') && - (selectedArtistId = artist.id)} - role="button" - tabindex="0" - active={selectedArtistId === artist.id} - > - {#if artist.cover} - - {:else} -
- 👤 -
- {/if} -
- {/each} -
- {/if} +
{#if selectedArtist === undefined} @@ -207,30 +207,11 @@ 选择一个歌手以查看歌曲
{:else} -
-
-

- {selectedArtist.name} -

-

- {selectedArtist.trackCount} 首歌曲 · {selectedArtist.albumCount} - 张专辑 -

-
- -
- +
{/if}
diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 5af8e9f..afe6b7e 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -6,8 +6,11 @@ import Button from '$lib/components/base/Button.svelte' import Heading from '$lib/components/base/Heading.svelte' + import IconButton from '$lib/components/base/IconButton.svelte' import SearchBar from '$lib/components/base/SearchBar.svelte' + import '@mdui/icons/play-arrow--rounded.js' + import '@mdui/icons/refresh--rounded.js' import 'mdui/components/button.js' import 'mdui/components/circular-progress.js' import 'mdui/components/segmented-button-group.js' @@ -24,11 +27,18 @@ }) const searchResults = $derived( - musicLibrary.tracks.filter(track => - (track.title ?? '').toLowerCase().includes(searchQuery.toLowerCase()) || - (track.artist ?? '').toLowerCase().includes(searchQuery.toLowerCase()) || - (track.album ?? '').toLowerCase().includes(searchQuery.toLowerCase()) - ) + musicLibrary.tracks.filter( + track => + (track.title ?? '') + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + (track.artist ?? '') + .toLowerCase() + .includes(searchQuery.toLowerCase()) || + (track.album ?? '') + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ), ) const defaultSortedTracks = $derived.by(() => { @@ -37,7 +47,9 @@ }) }) - const displayTracks = $derived(searchQuery ? searchResults : defaultSortedTracks) + const displayTracks = $derived( + searchQuery ? searchResults : defaultSortedTracks, + ) function playAll() { if (displayTracks.length === 0) return @@ -45,7 +57,8 @@ } function handleSortChange(event: Event) { - const value = (event.currentTarget as HTMLElement & { value: string }).value + const value = (event.currentTarget as HTMLElement & { value: string }) + .value if (value === 'title' || value === 'artist' || value === 'album') { sortBy = value } @@ -57,37 +70,50 @@
-
- - +
+ +
+
+ - - -
-
- -
-
- musicLibrary.scan()} > - 标题 - 歌手 - 专辑 - + 刷新 + +
+
+
+ +
+
+ + 标题 + 歌手 + 专辑 + +
@@ -101,7 +127,9 @@ {musicLibrary.error}
{:else if displayTracks.length === 0} -
+
🎵
没有找到歌曲
尝试刷新媒体库或修改搜索关键词
diff --git a/src/routes/recent/+page.svelte b/src/routes/recent/+page.svelte index c12e543..f3d0d89 100644 --- a/src/routes/recent/+page.svelte +++ b/src/routes/recent/+page.svelte @@ -13,13 +13,26 @@
- + +
+ + {#if recentlyPlayed.tracks.length > 0} - {/if} - +
{#if recentlyPlayed.isLoading} From 3b3beac938d328e7e40aa0b35de473851a7f1214 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Mon, 1 Jun 2026 01:12:37 +0800 Subject: [PATCH 23/39] Update deps; add shortcuts, config, media controls - Bump multiple Rust dependencies (src-tauri/Cargo.toml & Cargo.lock) and update lockfile. - Add backend modules for configuration, menu, media controls and shortcuts (src-tauri/src/commands/*, media_controls.rs, shortcuts.rs, models/config.rs) and integrate changes into audio, playback and lib. - Add frontend pieces: ShortcutRecorder and ShortcutSettingsPanel components, config store and types, plus updates to Slider, QueueDrawer, TrackList, player state and routes (library, settings). - These changes introduce persistent config, global shortcut/media control handling and related UI for settings. --- src-tauri/Cargo.lock | 1974 ++++++++++------- src-tauri/Cargo.toml | 9 +- src-tauri/src/audio.rs | 25 + src-tauri/src/commands/config.rs | 63 + src-tauri/src/commands/library.rs | 98 + src-tauri/src/commands/menu.rs | 13 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/playback.rs | 773 ++++++- src-tauri/src/lib.rs | 80 +- src-tauri/src/media_controls.rs | 112 + src-tauri/src/models/config.rs | 64 + src-tauri/src/models/mod.rs | 2 + src-tauri/src/shortcuts.rs | 189 ++ src/lib/components/base/Slider.svelte | 31 +- src/lib/features/QueueDrawer.svelte | 181 +- src/lib/features/TrackList.svelte | 415 +++- .../features/settings/ShortcutRecorder.svelte | 151 ++ .../settings/ShortcutSettingsPanel.svelte | 89 + src/lib/state/config.svelte.ts | 64 + src/lib/state/player.svelte.ts | 330 ++- src/lib/types/config.ts | 12 + src/lib/types/index.ts | 1 + src/routes/library/+page.svelte | 389 +++- src/routes/settings/+page.svelte | 5 + 24 files changed, 3937 insertions(+), 1135 deletions(-) create mode 100644 src-tauri/src/commands/config.rs create mode 100644 src-tauri/src/commands/menu.rs create mode 100644 src-tauri/src/media_controls.rs create mode 100644 src-tauri/src/models/config.rs create mode 100644 src-tauri/src/shortcuts.rs create mode 100644 src/lib/features/settings/ShortcutRecorder.svelte create mode 100644 src/lib/features/settings/ShortcutSettingsPanel.svelte create mode 100644 src/lib/state/config.svelte.ts create mode 100644 src/lib/types/config.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fe547a4..7088ea5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -45,7 +45,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -77,9 +77,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -188,9 +188,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -252,9 +252,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -291,13 +291,19 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -331,9 +337,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -342,19 +348,28 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byte-slice-cast" @@ -374,6 +389,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -389,7 +410,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cairo-sys-rs", "glib", "libc", @@ -452,9 +473,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -505,6 +526,36 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cocoa" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.22.3", + "foreign-types 0.3.2", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + [[package]] name = "combine" version = "4.6.7" @@ -524,12 +575,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - [[package]] name = "cookie" version = "0.18.1" @@ -540,6 +585,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -556,16 +611,40 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.3.2", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", - "core-foundation", - "core-graphics-types", - "foreign-types", + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", "libc", ] @@ -575,8 +654,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", - "core-foundation", + "bitflags 2.11.1", + "core-foundation 0.10.1", "libc", ] @@ -586,7 +665,7 @@ version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -621,7 +700,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.62.2", ] [[package]] @@ -670,7 +749,7 @@ checksum = "416063cb8f815ee27036075e544a778770b577e82348f017d145a704df90f246" dependencies = [ "creek-core", "log", - "symphonia", + "symphonia 0.5.5", ] [[package]] @@ -708,23 +787,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -734,7 +796,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -750,14 +812,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.23.0" @@ -805,26 +873,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "deranged" -version = "0.5.8" +name = "dbus" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" dependencies = [ - "powerfmt", - "serde_core", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "dbus-crossroads" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "64bff0bd181fba667660276c6b7ebdc50cff37ce593e7adf9e734f89c8f444e8" dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", + "dbus", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", ] [[package]] @@ -879,13 +954,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -893,9 +974,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -932,12 +1013,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -964,6 +1045,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -978,14 +1074,14 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "embed-resource" -version = "3.0.7" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg", ] @@ -1088,9 +1184,9 @@ checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -1162,6 +1258,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1169,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1183,6 +1288,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1198,16 +1309,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -1293,15 +1394,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1412,14 +1504,13 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.1.16" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "rustix", + "windows-link 0.2.1", ] [[package]] @@ -1430,7 +1521,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] @@ -1496,7 +1587,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", @@ -1543,6 +1634,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1623,9 +1732,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1668,18 +1777,6 @@ dependencies = [ "rustfft", ] -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -1687,14 +1784,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1731,9 +1828,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1744,7 +1841,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1804,17 +1900,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1822,9 +1919,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1835,9 +1932,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1849,15 +1946,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1869,15 +1966,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1913,14 +2010,27 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1934,12 +2044,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1959,16 +2069,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -1990,9 +2090,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -2026,7 +2126,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -2035,16 +2135,40 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2077,23 +2201,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "serde", "unicode-segmentation", ] -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.13.0", - "selectors 0.24.0", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2132,9 +2244,18 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] [[package]] name = "libloading" @@ -2148,9 +2269,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2163,9 +2284,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "llq" @@ -2210,15 +2331,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "mac" -version = "0.1.1" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "mach2" @@ -2230,17 +2345,12 @@ dependencies = [ ] [[package]] -name = "markup5ever" -version = "0.14.1" +name = "malloc_buf" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", + "libc", ] [[package]] @@ -2250,32 +2360,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -2304,20 +2397,30 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" -version = "0.17.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" dependencies = [ "crossbeam-channel", "dpi", @@ -2328,10 +2431,10 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2340,8 +2443,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", - "jni-sys", + "bitflags 2.11.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -2361,7 +2464,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2376,12 +2479,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bcfe410abc339c9f8c226aceebf946bf26e3e2eca738be3fec9e9ee97d54aa" -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "num-complex" version = "0.4.6" @@ -2393,9 +2490,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -2458,6 +2555,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2474,7 +2580,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "objc2", "objc2-core-foundation", @@ -2487,7 +2593,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "libc", "objc2", "objc2-core-audio", @@ -2506,6 +2612,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-audio" version = "0.3.2" @@ -2525,8 +2642,18 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", + "objc2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ "objc2", + "objc2-foundation", ] [[package]] @@ -2535,7 +2662,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "dispatch2", "libc", @@ -2548,13 +2675,45 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2576,7 +2735,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "libc", "objc2", @@ -2589,7 +2748,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -2600,7 +2759,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -2612,9 +2771,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -2624,7 +2802,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", "objc2", "objc2-app-kit", @@ -2649,9 +2827,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.3.3" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "dunce", "is-wsl", @@ -2747,105 +2925,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator", + "phf_shared", ] [[package]] @@ -2855,34 +2953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -2891,47 +2962,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -2940,12 +2984,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -2965,18 +3003,18 @@ checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -2995,6 +3033,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3011,9 +3062,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3024,15 +3075,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" @@ -3084,7 +3126,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.5+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3111,12 +3153,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3126,11 +3162,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -3156,87 +3198,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3258,7 +3219,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -3315,6 +3276,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3323,9 +3290,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -3381,9 +3348,9 @@ dependencies = [ [[package]] name = "rtrb" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7204ed6420f698836b76d4d5c2ec5dec7585fd5c3a788fd1cde855d1de598239" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" [[package]] name = "rtsan-standalone" @@ -3441,9 +3408,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3474,7 +3441,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -3553,48 +3520,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", + "bitflags 2.11.1", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -3655,9 +3604,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3688,24 +3637,25 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -3716,9 +3666,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", @@ -3748,16 +3698,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "servo_arc" version = "0.4.3" @@ -3780,9 +3720,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3796,21 +3736,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "siphasher" -version = "0.3.11" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3826,9 +3760,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3882,6 +3816,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "souvlaki" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" +dependencies = [ + "base64 0.22.1", + "block", + "cocoa", + "core-graphics 0.22.3", + "dbus", + "dbus-crossroads", + "dispatch", + "objc", + "thiserror 1.0.69", + "windows 0.44.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3894,19 +3846,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - [[package]] name = "string_cache" version = "0.9.0" @@ -3915,30 +3854,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -3967,19 +3894,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" dependencies = [ "lazy_static", - "symphonia-bundle-flac", - "symphonia-bundle-mp3", - "symphonia-codec-aac", - "symphonia-codec-adpcm", - "symphonia-codec-alac", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-isomp4", - "symphonia-format-mkv", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", + "symphonia-bundle-flac 0.5.5", + "symphonia-bundle-mp3 0.5.5", + "symphonia-codec-aac 0.5.5", + "symphonia-codec-adpcm 0.5.5", + "symphonia-codec-alac 0.5.5", + "symphonia-codec-pcm 0.5.5", + "symphonia-codec-vorbis 0.5.5", + "symphonia-core 0.5.5", + "symphonia-format-isomp4 0.5.5", + "symphonia-format-mkv 0.5.5", + "symphonia-format-ogg 0.5.5", + "symphonia-format-riff 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "symphonia" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac 0.6.0", + "symphonia-bundle-mp3 0.6.0", + "symphonia-codec-aac 0.6.0", + "symphonia-codec-adpcm 0.6.0", + "symphonia-codec-alac 0.6.0", + "symphonia-codec-pcm 0.6.0", + "symphonia-codec-vorbis 0.6.0", + "symphonia-core 0.6.0", + "symphonia-format-caf", + "symphonia-format-isomp4 0.6.0", + "symphonia-format-mkv 0.6.0", + "symphonia-format-ogg 0.6.0", + "symphonia-format-riff 0.6.0", + "symphonia-metadata 0.6.0", ] [[package]] @@ -3989,11 +3939,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" dependencies = [ "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", "symphonia-utils-xiph", ] +[[package]] +name = "symphonia-bundle-flac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + [[package]] name = "symphonia-bundle-mp3" version = "0.5.5" @@ -4002,8 +3964,19 @@ checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" dependencies = [ "lazy_static", "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546" +dependencies = [ + "lazy_static", + "log", + "symphonia-core 0.6.0", ] [[package]] @@ -4014,7 +3987,19 @@ checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" dependencies = [ "lazy_static", "log", - "symphonia-core", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76" +dependencies = [ + "lazy_static", + "log", + "symphonia-common", + "symphonia-core 0.6.0", ] [[package]] @@ -4024,7 +4009,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f" +dependencies = [ + "log", + "symphonia-core 0.6.0", ] [[package]] @@ -4034,7 +4029,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", ] [[package]] @@ -4044,7 +4050,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328" +dependencies = [ + "log", + "symphonia-core 0.6.0", ] [[package]] @@ -4054,10 +4070,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" dependencies = [ "log", - "symphonia-core", + "symphonia-core 0.5.5", "symphonia-utils-xiph", ] +[[package]] +name = "symphonia-codec-vorbis" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-common" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55" +dependencies = [ + "log", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + [[package]] name = "symphonia-core" version = "0.5.5" @@ -4071,30 +4109,80 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "rustfft", + "smallvec", +] + +[[package]] +name = "symphonia-format-caf" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde3ca76633d3400ab57195456c09f8a58d775ff5452329f3f212b6efc8622f5" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", +] + +[[package]] +name = "symphonia-format-isomp4" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +dependencies = [ + "encoding_rs", + "log", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", + "symphonia-utils-xiph", +] + [[package]] name = "symphonia-format-isomp4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + +[[package]] +name = "symphonia-format-mkv" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" dependencies = [ - "encoding_rs", + "lazy_static", "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-mkv" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74" dependencies = [ "lazy_static", "log", - "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", + "symphonia-common", + "symphonia-core 0.6.0", ] [[package]] @@ -4104,11 +4192,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" dependencies = [ "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", "symphonia-utils-xiph", ] +[[package]] +name = "symphonia-format-ogg" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", +] + [[package]] name = "symphonia-format-riff" version = "0.5.5" @@ -4117,8 +4217,20 @@ checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" dependencies = [ "extended", "log", - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0" +dependencies = [ + "extended", + "log", + "symphonia-core 0.6.0", + "symphonia-metadata 0.6.0", ] [[package]] @@ -4130,7 +4242,20 @@ dependencies = [ "encoding_rs", "lazy_static", "log", - "symphonia-core", + "symphonia-core 0.5.5", +] + +[[package]] +name = "symphonia-metadata" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core 0.6.0", ] [[package]] @@ -4139,8 +4264,8 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" dependencies = [ - "symphonia-core", - "symphonia-metadata", + "symphonia-core 0.5.5", + "symphonia-metadata 0.5.5", ] [[package]] @@ -4150,7 +4275,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", - "quote", "unicode-ident", ] @@ -4200,15 +4324,16 @@ dependencies = [ [[package]] name = "tao" -version = "0.34.6" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2", - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.25.0", "crossbeam-channel", + "dbus", "dispatch2", "dlopen2", "dpi", @@ -4219,18 +4344,19 @@ dependencies = [ "libc", "log", "ndk", - "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -4255,9 +4381,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -4270,6 +4396,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", @@ -4301,7 +4428,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -4310,22 +4437,27 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "lofty", + "raw-window-handle", "serde", "serde_json", + "souvlaki", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-store", + "tokio", + "trash", "walkdir", "web-audio-api", ] [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -4339,22 +4471,21 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4372,9 +4503,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4386,9 +4517,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -4397,7 +4528,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -4439,15 +4569,30 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 1.0.7+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-opener" -version = "2.5.3" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", @@ -4461,7 +4606,7 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] @@ -4483,9 +4628,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", @@ -4503,14 +4648,14 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", @@ -4528,30 +4673,30 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf", + "plist", "proc-macro2", "quote", "regex", @@ -4563,7 +4708,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -4572,13 +4717,13 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -4594,17 +4739,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "tendril" version = "0.5.0" @@ -4688,19 +4822,34 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4713,9 +4862,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4753,9 +4902,9 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -4764,17 +4913,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 1.0.1+spec-1.1.0", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.0", + "winnow 1.0.3", ] [[package]] @@ -4797,9 +4946,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -4810,7 +4959,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -4821,7 +4970,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -4830,30 +4979,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.13.0", - "toml_datetime 1.0.1+spec-1.1.0", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.3", ] [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -4872,20 +5021,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4941,11 +5090,29 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs", @@ -4957,10 +5124,10 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4977,9 +5144,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -5041,9 +5208,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -5064,6 +5231,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -5090,9 +5263,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5160,12 +5333,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -5174,11 +5341,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -5187,14 +5354,14 @@ 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", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -5205,23 +5372,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5229,9 +5392,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -5242,9 +5405,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -5266,7 +5429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -5290,17 +5453,17 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] [[package]] name = "web-audio-api" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84af8a6acf0652c839297eebf360d25e6108c7376085cd446fc59289101110dd" +checksum = "aae1d42666fbdedf3c4504894e1b615b1a74e158f131cba7a6e0138321b6de1c" dependencies = [ "almost", "arc-swap", @@ -5323,15 +5486,15 @@ dependencies = [ "realfft", "rubato 0.16.2", "smallvec", - "symphonia", + "symphonia 0.6.0", "vecmath", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -5339,14 +5502,14 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -5401,10 +5564,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -5425,7 +5588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5475,17 +5638,48 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5497,14 +5691,35 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -5516,8 +5731,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -5531,7 +5746,29 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5545,6 +5782,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5578,6 +5826,25 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5707,6 +5974,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5868,15 +6144,12 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -5900,6 +6173,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +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" @@ -5919,7 +6198,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -5949,8 +6228,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", + "bitflags 2.11.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -5969,7 +6248,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", @@ -5981,15 +6260,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.54.4" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2", @@ -6023,7 +6302,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -6050,11 +6329,34 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6063,9 +6365,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -6075,9 +6377,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.14.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -6102,7 +6404,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -6110,9 +6412,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -6125,49 +6427,29 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant", ] -[[package]] -name = "zerocopy" -version = "0.8.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5030500cb2d66bdfbb4ebc9563be6ce7005a4b5d0f26be0c523870fe372ca6" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5f86989a046a79640b9d8867c823349a139367bda96549794fcc3313ce91f4e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -6177,9 +6459,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -6188,9 +6470,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -6199,9 +6481,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -6216,23 +6498,23 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.10.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", @@ -6243,13 +6525,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow 1.0.3", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7686823..7f9fe70 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,14 +18,19 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -web-audio-api = "1.4.0" +web-audio-api = "1.5.0" lofty = "0.24.0" base64 = "0.22.1" walkdir = "2.5.0" tauri-plugin-dialog = "2" tauri-plugin-store = "2" +trash = "5" +tokio = { version = "1", features = ["time"] } +souvlaki = "0.8.3" +raw-window-handle = "0.6" +tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index fd8ae86..2182660 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,18 +1,36 @@ use std::sync::{Mutex, OnceLock}; +use serde::{Deserialize, Serialize}; use web_audio_api::context::AudioContext; use web_audio_api::node::GainNode; use web_audio_api::MediaElement; +use crate::models::Track; + pub fn get_audio_context() -> &'static AudioContext { static CONTEXT: OnceLock = OnceLock::new(); CONTEXT.get_or_init(|| AudioContext::default()) } +#[derive(Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PlayMode { + List, + Single, + Shuffle, +} + pub struct AudioState { pub media: Option, pub gain_node: Option, pub volume: f32, + pub current_track_id: Option, + pub current_path: Option, + pub playing: bool, + pub playlist: Vec, + pub current_index: Option, + pub play_mode: PlayMode, + pub playback_history: Vec, } pub fn get_audio_state() -> &'static Mutex { @@ -23,6 +41,13 @@ pub fn get_audio_state() -> &'static Mutex { media: None, gain_node: None, volume: 0.8, + current_track_id: None, + current_path: None, + playing: false, + playlist: Vec::new(), + current_index: None, + play_mode: PlayMode::List, + playback_history: Vec::new(), }) }) } \ No newline at end of file diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs new file mode 100644 index 0000000..46cfb5b --- /dev/null +++ b/src-tauri/src/commands/config.rs @@ -0,0 +1,63 @@ +use std::fs; +use std::path::PathBuf; + +use tauri::{command, Manager}; + +use crate::models::AppConfig; + +const CONFIG_FILE: &str = "config.json"; + +fn config_path(app_handle: &tauri::AppHandle) -> Result { + let app_data_path = app_handle + .path() + .app_data_dir() + .map_err(|e| e.to_string())?; + + if !app_data_path.exists() { + fs::create_dir_all(&app_data_path).map_err(|e| e.to_string())?; + } + + Ok(app_data_path.join(CONFIG_FILE)) +} + +pub fn load_config_sync(app_handle: &tauri::AppHandle) -> Result { + let path = config_path(app_handle)?; + + if !path.exists() { + return save_config_sync(app_handle, AppConfig::default()); + } + + let file = fs::File::open(&path).map_err(|e| e.to_string())?; + + match serde_json::from_reader::<_, AppConfig>(file) { + Ok(config) => Ok(config), + Err(_) => save_config_sync(app_handle, AppConfig::default()), + } +} + +pub fn save_config_sync( + app_handle: &tauri::AppHandle, + config: AppConfig, +) -> Result { + let path = config_path(app_handle)?; + let temp_path = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + + fs::write(&temp_path, json).map_err(|e| e.to_string())?; + fs::rename(&temp_path, &path).map_err(|e| e.to_string())?; + + Ok(config) +} + +#[command] +pub async fn load_config(app_handle: tauri::AppHandle) -> Result { + load_config_sync(&app_handle) +} + +#[command] +pub async fn save_config( + app_handle: tauri::AppHandle, + config: AppConfig, +) -> Result { + save_config_sync(&app_handle, config) +} diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index 56a7188..6185fdb 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::fs::{self, File}; use std::path::Path; +use std::process::Command; use base64::{engine::general_purpose, Engine as _}; use lofty::prelude::*; @@ -8,6 +9,7 @@ use lofty::probe::Probe; use tauri::{command, Manager}; use walkdir::WalkDir; +use crate::commands::playback::{remove_track_from_backend_queue, PlaybackQueueChangedPayload}; use crate::commands::settings::load_settings; use crate::metadata::parse_single_track; use crate::models::settings::AppSettings; @@ -129,6 +131,102 @@ pub fn get_track_cover(full_path: String) -> Result, String> { Ok(cover) } +fn validate_audio_path(path: &str) -> Result<&Path, String> { + let file_path = Path::new(path); + + if !file_path.exists() { + return Err("音频文件不存在".into()); + } + + if !file_path.is_file() || !is_supported_audio_file(file_path) { + return Err(format!("拒绝操作非音频文件: {}", path)); + } + + Ok(file_path) +} + +#[command] +pub fn show_in_folder(path: String) -> Result<(), String> { + let file_path = Path::new(&path); + + if !file_path.exists() { + return Err("文件不存在".into()); + } + + #[cfg(target_os = "linux")] + let parent = file_path + .parent() + .ok_or_else(|| "无法定位文件所在目录".to_string())?; + + #[cfg(target_os = "windows")] + let status = Command::new("explorer.exe") + .arg(format!("/select,{}", file_path.display())) + .status(); + + #[cfg(target_os = "macos")] + let status = Command::new("open").arg("-R").arg(file_path).status(); + + #[cfg(target_os = "linux")] + let status = Command::new("xdg-open").arg(parent).status(); + + let status = status.map_err(|e| e.to_string())?; + + if status.success() { + Ok(()) + } else { + Err("打开文件所在目录失败".into()) + } +} + +#[command] +pub async fn delete_track_file( + app_handle: tauri::AppHandle, + path: String, + track_id: String, +) -> Result { + let file_path = validate_audio_path(&path)?; + trash::delete(file_path).map_err(|e| format!("移至回收站失败 {}: {}", path, e))?; + remove_track_from_backend_queue(app_handle, track_id).await +} + +#[command] +pub fn trash_music_files(paths: Vec) -> Result<(), String> { + for path in paths { + let file_path = Path::new(&path); + + if !file_path.exists() { + continue; + } + + if !file_path.is_file() || !is_supported_audio_file(file_path) { + return Err(format!("拒绝移至回收站非音频文件: {}", path)); + } + + trash::delete(file_path).map_err(|e| format!("移至回收站失败 {}: {}", path, e))?; + } + + Ok(()) +} + +#[command] +pub fn delete_music_files(paths: Vec) -> Result<(), String> { + for path in paths { + let file_path = Path::new(&path); + + if !file_path.exists() { + continue; + } + + if !file_path.is_file() || !is_supported_audio_file(file_path) { + return Err(format!("拒绝删除非音频文件: {}", path)); + } + + fs::remove_file(file_path).map_err(|e| format!("删除文件失败 {}: {}", path, e))?; + } + + Ok(()) +} + #[command] pub fn scan_music_directories( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/commands/menu.rs b/src-tauri/src/commands/menu.rs new file mode 100644 index 0000000..879f3ee --- /dev/null +++ b/src-tauri/src/commands/menu.rs @@ -0,0 +1,13 @@ +use tauri::{command, menu::MenuBuilder, Manager, Window}; + +#[command] +pub fn show_context_menu(window: Window) { + let handle = window.app_handle(); + let menu = MenuBuilder::new(handle) + .text("quit", "退出程序") + .separator() + .text("play", "播放") + .build() + .unwrap(); + let _ = window.popup_menu(&menu); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 68a7ac7..710a13a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,6 @@ +pub mod config; pub mod recent; pub mod settings; pub mod playback; pub mod library; +pub mod menu; \ No newline at end of file diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index aa76444..eff3c79 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -1,15 +1,249 @@ use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tauri::command; +use serde::Serialize; +use souvlaki::{MediaControlEvent, SeekDirection}; +use tauri::{command, AppHandle, Emitter}; use web_audio_api::context::BaseAudioContext; use web_audio_api::node::AudioNode; use web_audio_api::MediaElement; -use crate::audio::{get_audio_context, get_audio_state}; +use crate::audio::{get_audio_context, get_audio_state, PlayMode}; +use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; +use crate::models::Track; -#[command] -pub async fn play_music(full_path: &str) -> Result { - let file_path = Path::new(full_path); +#[derive(Clone)] +pub struct NativeTrackMetadata { + pub title: String, + pub album: String, + pub artist: String, + pub duration: Option, +} + +#[derive(Clone)] +pub enum NativePlaybackState { + Playing { current_time: f64 }, + Paused { current_time: f64 }, + Stopped, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackTrackSnapshot { + pub id: Option, + pub path: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackStatusSnapshot { + pub has_media: bool, + pub playing: bool, + pub current_time: f64, + pub track: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackProgressPayload { + pub playing: bool, + pub current_time: f64, + pub track_id: Option, + pub path: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackTrackStartedPayload { + pub track: Track, + pub index: usize, + pub playing: bool, + pub current_time: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackQueueChangedPayload { + pub playlist: Vec, + pub current_index: Option, + pub play_mode: PlayMode, + pub playback_history: Vec, +} + +fn metadata_from_track(track: &Track) -> NativeTrackMetadata { + NativeTrackMetadata { + title: track.title.clone(), + album: track.album.clone(), + artist: track.artist.clone(), + duration: Some(track.duration), + } +} + +fn minimal_track(full_path: String, track_id: Option) -> Track { + let title = Path::new(&full_path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("未知歌曲") + .to_string(); + + Track { + id: track_id.unwrap_or_else(|| full_path.clone()), + title, + artist: "未知歌手".to_string(), + album: "未知专辑".to_string(), + album_artist: "未知歌手".to_string(), + duration: 0.0, + sample_rate: None, + cover: None, + path: full_path, + } +} + +fn current_time_from_state() -> f64 { + let Ok(state) = get_audio_state().lock() else { + return 0.0; + }; + + if let Some(ref media) = state.media { + media.current_time() + } else { + 0.0 + } +} + +fn status_snapshot() -> Result { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + let playing = !media.paused(); + let current_time = media.current_time(); + let track = PlaybackTrackSnapshot { + id: state.current_track_id.clone(), + path: state.current_path.clone(), + }; + state.playing = playing; + + return Ok(PlaybackStatusSnapshot { + has_media: true, + playing, + current_time, + track: Some(track), + }); + } + + state.playing = false; + + Ok(PlaybackStatusSnapshot { + has_media: false, + playing: false, + current_time: 0.0, + track: None, + }) +} + +fn progress_payload() -> Option { + let Ok(mut state) = get_audio_state().lock() else { + return None; + }; + + let Some(ref media) = state.media else { + state.playing = false; + return None; + }; + + let playing = !media.paused(); + let current_time = media.current_time(); + let track_id = state.current_track_id.clone(); + let path = state.current_path.clone(); + state.playing = playing; + + Some(PlaybackProgressPayload { + playing, + current_time, + track_id, + path, + }) +} + +fn current_track_duration() -> Option { + let Ok(state) = get_audio_state().lock() else { + return None; + }; + + let index = state.current_index?; + state.playlist.get(index).map(|track| track.duration) +} + +fn should_advance_track() -> bool { + let Some(payload) = progress_payload() else { + return false; + }; + + if !payload.playing { + return false; + } + + let Some(duration) = current_track_duration() else { + return false; + }; + + duration > 0.0 && payload.current_time >= duration - 0.5 +} + +fn emit_track_started(app_handle: &AppHandle, track: Track, index: usize) { + let payload = PlaybackTrackStartedPayload { + track, + index, + playing: true, + current_time: 0.0, + }; + let _ = app_handle.emit("playback:track-started", payload); +} + +fn emit_progress(app_handle: &AppHandle) { + if let Some(payload) = progress_payload() { + let state = if payload.playing { + NativePlaybackState::Playing { + current_time: payload.current_time, + } + } else { + NativePlaybackState::Paused { + current_time: payload.current_time, + } + }; + update_media_controls_playback(state); + let _ = app_handle.emit("playback:progress", payload); + } +} + +fn queue_snapshot() -> Result { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + Ok(PlaybackQueueChangedPayload { + playlist: state.playlist.clone(), + current_index: state.current_index, + play_mode: state.play_mode, + playback_history: state.playback_history.clone(), + }) +} + +fn emit_queue_changed(app_handle: &AppHandle) -> Result { + let payload = queue_snapshot()?; + let _ = app_handle.emit("playback:queue-changed", payload.clone()); + Ok(payload) +} + +fn sanitize_track(mut track: Track) -> Track { + track.cover = None; + track +} + +fn play_track_internal(app_handle: &AppHandle, track: Track, index: Option) -> Result<(), String> { + let file_path = Path::new(&track.path); if !file_path.exists() { return Err("音频文件不存在或已被移动".into()); @@ -20,7 +254,6 @@ pub async fn play_music(full_path: &str) -> Result { let context = get_audio_context(); let src = context.create_media_element_source(&mut media); - let gain_node = context.create_gain(); src.connect(&gain_node); @@ -29,94 +262,516 @@ pub async fn play_music(full_path: &str) -> Result { media.set_loop(false); media.set_current_time(0.0); - let mut state = get_audio_state() - .lock() - .map_err(|_| "Failed to lock audio state")?; + { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; - gain_node.gain().set_value(state.volume); + gain_node.gain().set_value(state.volume); - if let Some(old_media) = state.media.replace(media) { - old_media.pause(); + if let Some(old_media) = state.media.replace(media) { + old_media.pause(); + } + + state.gain_node = Some(gain_node); + state.current_path = Some(track.path.clone()); + state.current_track_id = Some(track.id.clone()); + state.playing = true; + + if let Some(next_index) = index { + state.current_index = Some(next_index); + } + + if let Some(ref media) = state.media { + media.play(); + } } - state.gain_node = Some(gain_node); + update_media_controls_metadata(metadata_from_track(&track)); + update_media_controls_playback(NativePlaybackState::Playing { current_time: 0.0 }); - if let Some(ref media) = state.media { - media.play(); + if let Some(index) = index { + emit_track_started(app_handle, track, index); + } else { + emit_progress(app_handle); } - Ok(format!("Playing music: {}", full_path)) + Ok(()) } -#[command] -pub async fn resume_music() -> Result<(), String> { - let state = get_audio_state() - .lock() - .map_err(|_| "Failed to lock audio state")?; - if let Some(ref media) = state.media { - media.play(); - Ok(()) - } else { - Err("No media available".into()) +fn play_current_index_internal(app_handle: &AppHandle, index: usize) -> Result<(), String> { + let track = { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + state + .playlist + .get(index) + .cloned() + .ok_or_else(|| "Invalid queue index".to_string())? + }; + + play_track_internal(app_handle, track, Some(index)) +} + +fn next_index_from_state() -> Option { + let state = get_audio_state().lock().ok()?; + + if state.playlist.is_empty() { + return None; + } + + let current_index = state.current_index.unwrap_or(0); + + match state.play_mode { + PlayMode::Single => Some(current_index.min(state.playlist.len() - 1)), + PlayMode::List => Some(if current_index >= state.playlist.len() - 1 { + 0 + } else { + current_index + 1 + }), + PlayMode::Shuffle => { + if state.playlist.len() <= 1 { + return Some(0); + } + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .subsec_nanos() as usize; + let mut next = nanos % state.playlist.len(); + + if next == current_index { + next = (next + 1) % state.playlist.len(); + } + + Some(next) + } } } -#[command] -pub async fn pause_music() -> Result<(), String> { - let state = get_audio_state() - .lock() - .map_err(|_| "Failed to lock audio state")?; +fn previous_index_from_state() -> Option { + let state = get_audio_state().lock().ok()?; - if let Some(ref media) = state.media { - media.pause(); - Ok(()) + if state.playlist.is_empty() { + return None; + } + + if let Some(last_id) = state.playback_history.last() { + if let Some(index) = state.playlist.iter().position(|track| &track.id == last_id) { + return Some(index); + } + } + + let current_index = state.current_index.unwrap_or(0); + + Some(if current_index == 0 { + state.playlist.len() - 1 } else { - Err("No media available".into()) + current_index - 1 + }) +} + +fn push_current_to_history() { + if let Ok(mut state) = get_audio_state().lock() { + if let Some(index) = state.current_index { + if let Some(track) = state.playlist.get(index) { + let id = track.id.clone(); + state.playback_history.push(id); + } + } } } +fn pop_history_if_matches(index: usize) { + if let Ok(mut state) = get_audio_state().lock() { + if let Some(track) = state.playlist.get(index) { + if state.playback_history.last() == Some(&track.id) { + state.playback_history.pop(); + } + } + } +} -#[command] -pub async fn toggle_music() -> Result { - let state = get_audio_state() - .lock() - .map_err(|_| "Failed to lock audio state")?; +pub async fn play_next_internal(app_handle: AppHandle) -> Result<(), String> { + push_current_to_history(); + let index = next_index_from_state().ok_or_else(|| "Queue is empty".to_string())?; + play_current_index_internal(&app_handle, index) +} - if let Some(ref media) = state.media { - if media.paused() { +pub async fn play_previous_internal(app_handle: AppHandle) -> Result<(), String> { + let index = previous_index_from_state().ok_or_else(|| "Queue is empty".to_string())?; + pop_history_if_matches(index); + play_current_index_internal(&app_handle, index) +} + +pub async fn resume_internal(app_handle: AppHandle) -> Result<(), String> { + let current_time = { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { media.play(); - Ok(true) + let current_time = media.current_time(); + state.playing = true; + current_time } else { + return Err("No media available".into()); + } + }; + + update_media_controls_playback(NativePlaybackState::Playing { current_time }); + emit_progress(&app_handle); + Ok(()) +} + +pub async fn pause_internal(app_handle: AppHandle) -> Result<(), String> { + let current_time = { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { media.pause(); - Ok(false) + let current_time = media.current_time(); + state.playing = false; + current_time + } else { + return Err("No media available".into()); + } + }; + + update_media_controls_playback(NativePlaybackState::Paused { current_time }); + emit_progress(&app_handle); + Ok(()) +} + +pub async fn toggle_internal(app_handle: AppHandle) -> Result { + let playing = { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + if media.paused() { + media.play(); + state.playing = true; + true + } else { + media.pause(); + state.playing = false; + false + } + } else { + return Err("No media available".into()); } + }; + + let current_time = current_time_from_state(); + + if playing { + update_media_controls_playback(NativePlaybackState::Playing { current_time }); } else { - Err("No media available".into()) + update_media_controls_playback(NativePlaybackState::Paused { current_time }); } + + emit_progress(&app_handle); + Ok(playing) } -#[command] -pub async fn current_time() -> f64 { - let Ok(state) = get_audio_state().lock() else { - return 0.0; +pub async fn stop_internal(app_handle: AppHandle) -> Result<(), String> { + { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + media.pause(); + media.set_current_time(0.0); + } + + state.playing = false; + } + + update_media_controls_playback(NativePlaybackState::Stopped); + emit_progress(&app_handle); + Ok(()) +} + +pub async fn seek_internal(app_handle: AppHandle, time: f64) -> Result<(), String> { + let playing = { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + if let Some(ref media) = state.media { + media.set_current_time(time); + !media.paused() + } else { + false + } }; - if let Some(ref media) = state.media { - media.current_time() + if playing { + update_media_controls_playback(NativePlaybackState::Playing { current_time: time }); } else { - 0.0 + update_media_controls_playback(NativePlaybackState::Paused { current_time: time }); + } + + emit_progress(&app_handle); + Ok(()) +} + +pub async fn handle_media_control_event(app_handle: AppHandle, event: MediaControlEvent) { + match event { + MediaControlEvent::Play => { + let _ = resume_internal(app_handle).await; + } + MediaControlEvent::Pause => { + let _ = pause_internal(app_handle).await; + } + MediaControlEvent::Toggle => { + let _ = toggle_internal(app_handle).await; + } + MediaControlEvent::Next => { + let _ = play_next_internal(app_handle).await; + } + MediaControlEvent::Previous => { + let _ = play_previous_internal(app_handle).await; + } + MediaControlEvent::Stop => { + let _ = stop_internal(app_handle).await; + } + MediaControlEvent::SetPosition(position) => { + let _ = seek_internal(app_handle, position.0.as_secs_f64()).await; + } + MediaControlEvent::Seek(direction) => { + let current_time = current_time_from_state(); + let offset = match direction { + SeekDirection::Forward => 10.0, + SeekDirection::Backward => -10.0, + }; + let _ = seek_internal(app_handle, (current_time + offset).max(0.0)).await; + } + MediaControlEvent::SeekBy(direction, duration) => { + let current_time = current_time_from_state(); + let offset = duration.as_secs_f64(); + let next_time = match direction { + SeekDirection::Forward => current_time + offset, + SeekDirection::Backward => current_time - offset, + }; + let _ = seek_internal(app_handle, next_time.max(0.0)).await; + } + _ => {} } } +pub fn spawn_playback_progress_task(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + loop { + emit_progress(&app_handle); + + if should_advance_track() { + let _ = play_next_internal(app_handle.clone()).await; + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } + }); +} + #[command] -pub async fn set_current_time(time: f64) { - let Ok(state) = get_audio_state().lock() else { - return; - }; +pub async fn sync_playback_queue( + playlist: Vec, + current_index: Option, + play_mode: PlayMode, + playback_history: Vec, +) -> Result<(), String> { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; - if let Some(ref media) = state.media { - media.set_current_time(time); + state.playlist = playlist.into_iter().map(sanitize_track).collect(); + state.current_index = current_index.filter(|index| *index < state.playlist.len()); + state.play_mode = play_mode; + state.playback_history = playback_history; + + Ok(()) +} + +#[command] +pub async fn remove_track_from_backend_queue( + app_handle: AppHandle, + track_id: String, +) -> Result { + let mut play_index = None; + let mut should_stop = false; + + { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + let Some(remove_index) = state.playlist.iter().position(|track| track.id == track_id) else { + return Ok(PlaybackQueueChangedPayload { + playlist: state.playlist.clone(), + current_index: state.current_index, + play_mode: state.play_mode, + playback_history: state.playback_history.clone(), + }); + }; + + let was_current = state.current_index == Some(remove_index); + state.playlist.remove(remove_index); + state.playback_history.retain(|id| id != &track_id); + + if state.playlist.is_empty() { + state.current_index = None; + should_stop = true; + } else if was_current { + let next_index = remove_index.min(state.playlist.len() - 1); + state.current_index = Some(next_index); + play_index = Some(next_index); + } else if let Some(current_index) = state.current_index { + if remove_index < current_index { + state.current_index = Some(current_index - 1); + } + } + } + + let payload = emit_queue_changed(&app_handle)?; + + if should_stop { + let _ = stop_internal(app_handle).await; + } else if let Some(index) = play_index { + let _ = play_current_index_internal(&app_handle, index); + } + + Ok(payload) +} + +#[command] +pub async fn insert_track_as_next( + app_handle: AppHandle, + track: Track, +) -> Result { + { + let mut state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + let track = sanitize_track(track); + + if let Some(existing_index) = state.playlist.iter().position(|item| item.id == track.id) { + if state.current_index == Some(existing_index) { + return Ok(PlaybackQueueChangedPayload { + playlist: state.playlist.clone(), + current_index: state.current_index, + play_mode: state.play_mode, + playback_history: state.playback_history.clone(), + }); + } + + state.playlist.remove(existing_index); + + if let Some(current_index) = state.current_index { + if existing_index < current_index { + state.current_index = Some(current_index - 1); + } + } + } + + if state.playlist.is_empty() || state.current_index.is_none() { + state.playlist = vec![track]; + state.current_index = Some(0); + } else { + let insert_index = state.current_index.unwrap_or(0) + 1; + state.playlist.insert(insert_index, track); + } + } + + emit_queue_changed(&app_handle) +} + +#[command] +pub async fn play_queue_track(app_handle: AppHandle, index: usize) -> Result<(), String> { + play_current_index_internal(&app_handle, index) +} + +#[command] +pub async fn play_next_track(app_handle: AppHandle) -> Result<(), String> { + play_next_internal(app_handle).await +} + +#[command] +pub async fn play_previous_track(app_handle: AppHandle) -> Result<(), String> { + play_previous_internal(app_handle).await +} + +#[command] +pub async fn stop_music(app_handle: AppHandle) -> Result<(), String> { + stop_internal(app_handle).await +} + +#[command] +pub async fn play_music( + app_handle: AppHandle, + full_path: String, + track_id: Option, +) -> Result { + let track = { + let state = get_audio_state() + .lock() + .map_err(|_| "Failed to lock audio state")?; + + state + .playlist + .iter() + .find(|track| { + track_id + .as_ref() + .map(|id| track.id == *id) + .unwrap_or(false) + || track.path == full_path + }) + .cloned() } + .unwrap_or_else(|| minimal_track(full_path.clone(), track_id)); + + play_track_internal(&app_handle, track, None)?; + + Ok(format!("Playing music: {}", full_path)) +} + +#[command] +pub async fn resume_music(app_handle: AppHandle) -> Result<(), String> { + resume_internal(app_handle).await +} + +#[command] +pub async fn pause_music(app_handle: AppHandle) -> Result<(), String> { + pause_internal(app_handle).await +} + +#[command] +pub async fn toggle_music(app_handle: AppHandle) -> Result { + toggle_internal(app_handle).await +} + +#[command] +pub async fn current_time() -> f64 { + current_time_from_state() +} + +#[command] +pub async fn get_current_status() -> Result { + status_snapshot() +} + +#[command] +pub async fn set_current_time(app_handle: AppHandle, time: f64) { + let _ = seek_internal(app_handle, time).await; } #[command] @@ -135,4 +790,4 @@ pub async fn set_volume(volume: u8) -> Result<(), String> { } Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4cb30f..ecb8dff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,24 +1,39 @@ mod audio; mod commands; +mod media_controls; mod metadata; mod models; +mod shortcuts; use tauri::{ - menu::{Menu, MenuItem}, + menu::{Menu, MenuBuilder, MenuItem, SubmenuBuilder}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; +use commands::config::{load_config, save_config}; use commands::library::{ - execute_scan, get_track_cover, load_music_library, save_music_library, scan_music_directories, + delete_music_files, delete_track_file, execute_scan, get_track_cover, load_music_library, + save_music_library, scan_music_directories, show_in_folder, trash_music_files, }; -use commands::playback::{current_time, play_music, set_current_time, set_volume, resume_music, pause_music, toggle_music}; +use commands::playback::{ + current_time, get_current_status, insert_track_as_next, pause_music, play_music, play_next_track, + play_previous_track, play_queue_track, remove_track_from_backend_queue, resume_music, set_current_time, set_volume, + spawn_playback_progress_task, stop_music, sync_playback_queue, toggle_music, +}; use commands::recent::{add_recently_played, clear_recently_played, load_recently_played}; use commands::settings::{load_settings, save_settings}; +use commands::menu::show_context_menu; +use media_controls::init_media_controls; +use shortcuts::{ + get_shortcuts, handle_shortcut_event, init_shortcuts, reset_shortcuts, update_shortcut, + ShortcutRegistry, +}; + pub fn init_startup_scan(app_handle: tauri::AppHandle) { tauri::async_runtime::spawn(async move { if let Ok(settings) = crate::commands::settings::load_settings(app_handle.clone()).await { @@ -36,14 +51,29 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(handle_shortcut_event) + .build(), + ) + .manage(ShortcutRegistry::default()) .setup(|app| { + let _ = init_shortcuts(app.handle().clone()); + let _ = init_media_controls(app.handle().clone()); + spawn_playback_progress_task(app.handle().clone()); + let settings_item = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&settings_item, &quit_item])?; + let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&settings_item, &quit_item])?; + let file_menu = SubmenuBuilder::new(app, "文件") + .text("quit", "退出") + .build()?; + let app_menu = MenuBuilder::new(app).items(&[&file_menu]).build()?; + app.set_menu(app_menu)?; let _tray = TrayIconBuilder::new() .icon(app.default_window_icon().unwrap().clone()) - .menu(&menu) + .menu(&tray_menu) .show_menu_on_left_click(false) .on_menu_event(|app: &tauri::AppHandle, event| match event.id.as_ref() { "settings" => { @@ -63,7 +93,8 @@ pub fn run() { if let TrayIconEvent::Click { button: MouseButton::Left, .. - } = event { + } = event + { let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); @@ -89,17 +120,52 @@ pub fn run() { } } }); + app.on_menu_event(move |app_handle: &tauri::AppHandle, event| { + match event.id().0.as_str() { + "quit" => { + app_handle.exit(0); + } + "play" => { + let _ = app_handle.emit("menu-play", ()); + } + "next" => { + let _ = app_handle.emit("menu-next", ()); + } + "delete" => { + let _ = app_handle.emit("menu-delete", ()); + } + _ => {} + } + }); Ok(()) }) .invoke_handler(tauri::generate_handler![ + show_context_menu, + load_config, + save_config, + get_shortcuts, + update_shortcut, + reset_shortcuts, play_music, + play_queue_track, + insert_track_as_next, + remove_track_from_backend_queue, + play_next_track, + play_previous_track, + stop_music, + sync_playback_queue, resume_music, pause_music, toggle_music, current_time, + get_current_status, set_current_time, set_volume, scan_music_directories, + show_in_folder, + delete_track_file, + delete_music_files, + trash_music_files, load_music_library, get_track_cover, load_recently_played, diff --git a/src-tauri/src/media_controls.rs b/src-tauri/src/media_controls.rs new file mode 100644 index 0000000..2a498e5 --- /dev/null +++ b/src-tauri/src/media_controls.rs @@ -0,0 +1,112 @@ +use std::ffi::c_void; +use std::sync::mpsc::{self, Sender}; +use std::sync::OnceLock; +use std::thread; +use std::time::Duration; + +use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig}; +use tauri::{AppHandle, Manager, WebviewWindow}; + +use crate::commands::playback::{ + handle_media_control_event, NativePlaybackState, NativeTrackMetadata, +}; + +#[derive(Clone)] +enum MediaControlMessage { + Metadata(NativeTrackMetadata), + Playback(NativePlaybackState), +} + +static MEDIA_CONTROL_SENDER: OnceLock> = OnceLock::new(); + +#[cfg(target_os = "windows")] +fn window_handle(window: &WebviewWindow) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let handle = window.window_handle().ok()?.as_raw(); + + match handle { + RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()), + _ => None, + } +} + +#[cfg(not(target_os = "windows"))] +fn window_handle(_window: &WebviewWindow) -> Option { + None +} + +pub fn init_media_controls(app_handle: AppHandle) -> Result<(), String> { + let window = app_handle + .get_webview_window("main") + .ok_or_else(|| "main window not found".to_string())?; + + let hwnd = window_handle(&window); + let (tx, rx) = mpsc::channel::(); + let app_for_events = app_handle.clone(); + + thread::spawn(move || { + let config = PlatformConfig { + display_name: "RustEchoMusic", + dbus_name: "rust_echo_music", + hwnd: hwnd.map(|value| value as *mut c_void), + }; + + let Ok(mut controls) = MediaControls::new(config) else { + return; + }; + + let _ = controls.attach(move |event| { + let app = app_for_events.clone(); + tauri::async_runtime::spawn(async move { + handle_media_control_event(app, event).await; + }); + }); + + while let Ok(message) = rx.recv() { + match message { + MediaControlMessage::Metadata(track) => { + let duration = track.duration.map(Duration::from_secs_f64); + let metadata = MediaMetadata { + title: Some(track.title.as_str()), + album: Some(track.album.as_str()), + artist: Some(track.artist.as_str()), + cover_url: None, + duration, + }; + let _ = controls.set_metadata(metadata); + } + MediaControlMessage::Playback(state) => { + let playback = match state { + NativePlaybackState::Playing { current_time } => MediaPlayback::Playing { + progress: Some(MediaPosition(Duration::from_secs_f64(current_time))), + }, + NativePlaybackState::Paused { current_time } => MediaPlayback::Paused { + progress: Some(MediaPosition(Duration::from_secs_f64(current_time))), + }, + NativePlaybackState::Stopped => MediaPlayback::Stopped, + }; + let _ = controls.set_playback(playback); + } + } + } + }); + + let _ = MEDIA_CONTROL_SENDER.set(tx); + + Ok(()) +} + +fn send(message: MediaControlMessage) { + if let Some(sender) = MEDIA_CONTROL_SENDER.get() { + let _ = sender.send(message); + } +} + +pub fn update_media_controls_metadata(track: NativeTrackMetadata) { + send(MediaControlMessage::Metadata(track)); +} + +pub fn update_media_controls_playback(state: NativePlaybackState) { + send(MediaControlMessage::Playback(state)); +} diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs new file mode 100644 index 0000000..31cc9f3 --- /dev/null +++ b/src-tauri/src/models/config.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ShortcutAction { + PlayPause, + Stop, + Previous, + Next, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShortcutConfig { + pub play_pause: String, + pub stop: String, + pub previous: String, + pub next: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppConfig { + pub shortcuts: ShortcutConfig, +} + +impl Default for ShortcutConfig { + fn default() -> Self { + Self { + play_pause: "CommandOrControl+Alt+Space".to_string(), + stop: "CommandOrControl+Alt+S".to_string(), + previous: "CommandOrControl+Alt+ArrowLeft".to_string(), + next: "CommandOrControl+Alt+ArrowRight".to_string(), + } + } +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + shortcuts: ShortcutConfig::default(), + } + } +} + +impl ShortcutConfig { + pub fn set(&mut self, action: ShortcutAction, shortcut: String) { + match action { + ShortcutAction::PlayPause => self.play_pause = shortcut, + ShortcutAction::Stop => self.stop = shortcut, + ShortcutAction::Previous => self.previous = shortcut, + ShortcutAction::Next => self.next = shortcut, + } + } + + pub fn entries(&self) -> [(ShortcutAction, &str); 4] { + [ + (ShortcutAction::PlayPause, &self.play_pause), + (ShortcutAction::Stop, &self.stop), + (ShortcutAction::Previous, &self.previous), + (ShortcutAction::Next, &self.next), + ] + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index db7612e..7a2ab3b 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,4 +1,6 @@ +pub mod config; pub mod settings; pub mod track; +pub use config::{AppConfig, ShortcutAction, ShortcutConfig}; pub use track::{generate_track_id, Track}; \ No newline at end of file diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs new file mode 100644 index 0000000..18875fe --- /dev/null +++ b/src-tauri/src/shortcuts.rs @@ -0,0 +1,189 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Mutex; + +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutEvent, ShortcutState}; + +use crate::commands::config::{load_config_sync, save_config_sync}; +use crate::commands::playback::{ + play_next_internal, play_previous_internal, stop_internal, toggle_internal, +}; +use crate::models::{AppConfig, ShortcutAction, ShortcutConfig}; + +pub struct ShortcutRegistry { + shortcuts: Mutex>, +} + +impl Default for ShortcutRegistry { + fn default() -> Self { + Self { + shortcuts: Mutex::new(HashMap::new()), + } + } +} + +fn parse_shortcut(shortcut: &str) -> Result { + Shortcut::from_str(shortcut).map_err(|e| e.to_string()) +} + +fn shortcut_entries(config: &ShortcutConfig) -> [(ShortcutAction, &str); 4] { + config.entries() +} + +fn ensure_unique(config: &ShortcutConfig) -> Result<(), String> { + let mut seen = HashSet::new(); + + for (_, shortcut) in shortcut_entries(config) { + if !seen.insert(shortcut.to_string()) { + return Err("快捷键冲突".to_string()); + } + } + + Ok(()) +} + +fn register_config(app_handle: &AppHandle, config: &ShortcutConfig) -> Result<(), String> { + ensure_unique(config)?; + + for (_, shortcut) in shortcut_entries(config) { + parse_shortcut(shortcut)?; + app_handle + .global_shortcut() + .register(shortcut) + .map_err(|e| e.to_string())?; + } + + Ok(()) +} + +fn unregister_config(app_handle: &AppHandle, shortcuts: &HashMap) { + for shortcut in shortcuts.values() { + let _ = app_handle.global_shortcut().unregister(shortcut.as_str()); + } +} + +fn set_registry(state: &ShortcutRegistry, config: &ShortcutConfig) -> Result<(), String> { + let mut shortcuts = state.shortcuts.lock().map_err(|_| "快捷键状态锁定失败")?; + shortcuts.clear(); + + for (action, shortcut) in shortcut_entries(config) { + shortcuts.insert(action, shortcut.to_string()); + } + + Ok(()) +} + +pub fn init_shortcuts(app_handle: AppHandle) -> Result<(), String> { + let config = load_config_sync(&app_handle)?; + register_config(&app_handle, &config.shortcuts)?; + let state = app_handle.state::(); + set_registry(state.inner(), &config.shortcuts) +} + +pub fn handle_shortcut_event(app_handle: &AppHandle, shortcut: &Shortcut, event: ShortcutEvent) { + if event.state != ShortcutState::Pressed { + return; + } + + let shortcut_string = shortcut.into_string(); + let state = app_handle.state::(); + let Ok(shortcuts) = state.shortcuts.lock() else { + return; + }; + + let action = shortcuts + .iter() + .find_map(|(action, value)| (value == &shortcut_string).then_some(*action)); + + let Some(action) = action else { + return; + }; + + let app = app_handle.clone(); + + tauri::async_runtime::spawn(async move { + match action { + ShortcutAction::PlayPause => { + let _ = toggle_internal(app).await; + } + ShortcutAction::Stop => { + let _ = stop_internal(app).await; + } + ShortcutAction::Previous => { + let _ = play_previous_internal(app).await; + } + ShortcutAction::Next => { + let _ = play_next_internal(app).await; + } + } + }); +} + +#[tauri::command] +pub async fn get_shortcuts(app_handle: AppHandle) -> Result { + load_config_sync(&app_handle) +} + +#[tauri::command] +pub async fn update_shortcut( + app_handle: AppHandle, + state: State<'_, ShortcutRegistry>, + action: ShortcutAction, + shortcut: String, +) -> Result { + parse_shortcut(&shortcut)?; + + let mut config = load_config_sync(&app_handle)?; + config.shortcuts.set(action, shortcut.clone()); + ensure_unique(&config.shortcuts)?; + + let previous = state + .shortcuts + .lock() + .map_err(|_| "快捷键状态锁定失败")? + .clone(); + + unregister_config(&app_handle, &previous); + + if let Err(err) = register_config(&app_handle, &config.shortcuts) { + let _ = register_config(&app_handle, &config_from_map(&previous).shortcuts); + return Err(err); + } + + set_registry(state.inner(), &config.shortcuts)?; + save_config_sync(&app_handle, config) +} + +#[tauri::command] +pub async fn reset_shortcuts( + app_handle: AppHandle, + state: State<'_, ShortcutRegistry>, +) -> Result { + let config = AppConfig::default(); + let previous = state + .shortcuts + .lock() + .map_err(|_| "快捷键状态锁定失败")? + .clone(); + + unregister_config(&app_handle, &previous); + + if let Err(err) = register_config(&app_handle, &config.shortcuts) { + let _ = register_config(&app_handle, &config_from_map(&previous).shortcuts); + return Err(err); + } + + set_registry(state.inner(), &config.shortcuts)?; + save_config_sync(&app_handle, config) +} + +fn config_from_map(map: &HashMap) -> AppConfig { + let mut shortcuts = ShortcutConfig::default(); + + for (action, shortcut) in map { + shortcuts.set(*action, shortcut.clone()); + } + + AppConfig { shortcuts } +} diff --git a/src/lib/components/base/Slider.svelte b/src/lib/components/base/Slider.svelte index a521c6c..e4760da 100644 --- a/src/lib/components/base/Slider.svelte +++ b/src/lib/components/base/Slider.svelte @@ -114,6 +114,10 @@
+ +
+ {Math.round(value)} +
\ No newline at end of file diff --git a/src/lib/features/QueueDrawer.svelte b/src/lib/features/QueueDrawer.svelte index 3a8f6bb..8634565 100644 --- a/src/lib/features/QueueDrawer.svelte +++ b/src/lib/features/QueueDrawer.svelte @@ -1,126 +1,107 @@ + { player.queueOpen = false }} - class="mt-32 h-[calc(100vh-var(--spacing)*(24+14+20))] [&::part(panel)]:mt-20 [&::part(overlay)]:bg-transparent" + onclose={() => { + player.queueOpen = false + }} + class={[ + 'mt-32', + 'h-[calc(100vh-var(--spacing)*(24+14+20))]', + '[&::part(panel)]:mt-20', + '[&::part(overlay)]:bg-transparent', + ]} > -
-
- 播放队列 - - {player.playlist.length} - +
+
+
+ + 播放队列 + + + {player.playlist.length} + +
+
+ + { + player.queueOpen = false + }} + /> +
- { player.queueOpen = false }} /> -
-
- {#if player.playlist.length === 0} -
- 🎵 -

队列为空

-
- {:else} - {#each player.playlist as track, index (track.id)} - {@const isCurrent = player.currentIndex === index} +
+ {#if player.playlist.length === 0}
handlePlay(index)} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') handlePlay(index) }} - class="group flex items-center gap-3 p-2.5 rounded-lg cursor-pointer transition-all duration-200 select-none outline-none - {isCurrent - ? 'bg-[rgb(var(--mdui-color-secondary-container))] text-[rgb(var(--mdui-color-on-secondary-container))] border border-[rgb(var(--mdui-color-outline-variant))]' - : 'hover:bg-[rgb(var(--mdui-color-surface-container-high))] text-[rgb(var(--mdui-color-on-surface))] border border-transparent'}" + class="flex h-64 flex-col items-center justify-center p-6 text-center themed-text-secondary" > -
- cover - {#if isCurrent} -
- {#if player.playing} - - {:else} - - {/if} -
- {/if} -
- -
-
- {track.title} -
-
- {track.artist} -
-
- -
- - {formatDuration(track.duration)} - - handleRemove(track.id, e)} - /> -
+ 🎵 +

队列为空

- {/each} - {/if} + {:else} + + {/if} +
+ + diff --git a/src/lib/features/TrackList.svelte b/src/lib/features/TrackList.svelte index 8f8109c..eaf7242 100644 --- a/src/lib/features/TrackList.svelte +++ b/src/lib/features/TrackList.svelte @@ -1,9 +1,12 @@ + + {#snippet trackIndex(index: number, isCurrent: boolean)}
{/snippet} -{#snippet trackCover(track: Track, cover: string | null)} +{#snippet trackCover(track: Track, cover: string | null, isCurrent: boolean)}
{#if cover} {/if} + + {#if !columns.includes('index') && isCurrent} +
+ {#if player.playing} + + {:else} + + {/if} +
+ {/if}
{/snippet} {#snippet trackTitle(track: Track, cover: string | null, isCurrent: boolean)}
- {@render trackCover(track, cover)} + {@render trackCover(track, cover, isCurrent)}
{/snippet} -{#snippet trackDuration(track: Track)} -
- {formatDuration(track.duration)} +{#snippet trackDuration(track: Track, index: number)} +
+
+ {formatDuration(track.duration)} +
+ {#if onremovetrack} + { + e.stopPropagation() + onremovetrack?.(track, index) + }} + /> + {/if}
{/snippet} -{#snippet trackItem(track: Track, index: number)} - {@const isCurrent = player.currentTrack?.id === track.id} - {@const cover = trackCovers.get(track)} - - handlePlay(track)} - role="button" - tabindex="0" - > - - -
+ {#if !hideHeader} + + {#if selectable} +
+ {/if} + {#if columns.includes('index')} - {@render trackIndex(index, isCurrent)} +
#
{/if} {#if columns.includes('title')} - {@render trackTitle(track, cover, isCurrent)} +
标题
{/if} {#if columns.includes('album')} - {@render trackAlbum(track)} + {/if} {#if columns.includes('tags')} - {@render trackTags(track)} +
标签
{/if} {#if columns.includes('duration')} - {@render trackDuration(track)} +
时长
{/if} -
-
-{/snippet} - - - - {#if columns.includes('index')} -
#
- {/if} - - {#if columns.includes('title')} -
标题
- {/if} - - {#if columns.includes('album')} - - {/if} - - {#if columns.includes('tags')} -
标签
- {/if} - - {#if columns.includes('duration')} -
时长
- {/if} -
+ + {/if}
- {#each tracks as track, index (track.path)} - {@render trackItem(track, index)} + {#each tracks as track, index (track.id || index)} + {@const isCurrent = + currentTrackIndex !== undefined + ? currentTrackIndex === index + : player.currentTrack?.id === track.id} + {@const cover = trackCovers.get(track)} + + handleTrackClick(event, track)} + ondblclick={(event: MouseEvent) => + handleTrackDoubleClick(event, track, index)} + oncontextmenu={(event: MouseEvent) => + openTrackContextMenu(event, track, index)} + onkeydown={(event: KeyboardEvent) => + handleTrackKeydown(event, track, index)} + role="button" + tabindex="0" + > + + +
+ {#if selectable} +
+ + handleTrackToggle(event, track)} + > +
+ {/if} + + {#if columns.includes('index')} + {@render trackIndex(index, isCurrent)} + {/if} + + {#if columns.includes('title')} + {@render trackTitle(track, cover, isCurrent)} + {/if} + + {#if columns.includes('album')} + {@render trackAlbum(track)} + {/if} + + {#if columns.includes('tags')} + {@render trackTags(track)} + {/if} + + {#if columns.includes('duration')} + {@render trackDuration(track, index)} + {/if} +
+
{/each}
+{#if contextMenuOpen && contextMenuTrack} + +{/if} + diff --git a/src/lib/features/settings/ShortcutRecorder.svelte b/src/lib/features/settings/ShortcutRecorder.svelte deleted file mode 100644 index 0418daf..0000000 --- a/src/lib/features/settings/ShortcutRecorder.svelte +++ /dev/null @@ -1,151 +0,0 @@ - - - - - diff --git a/src/lib/features/settings/ShortcutSettingsPanel.svelte b/src/lib/features/settings/ShortcutSettingsPanel.svelte deleted file mode 100644 index 6f43a0a..0000000 --- a/src/lib/features/settings/ShortcutSettingsPanel.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -
- {#each rows as row (row.action)} - - handleRecord(row.action, shortcut)} - /> - - {/each} - -
- -
- - {#if config.error} -
- {config.error} -
- {/if} -
diff --git a/src/lib/features/shell/NavRail.svelte b/src/lib/features/shell/NavRail.svelte index b051f36..2b8824d 100644 --- a/src/lib/features/shell/NavRail.svelte +++ b/src/lib/features/shell/NavRail.svelte @@ -1,7 +1,7 @@ onclick()} - onkeydown={(e: KeyboardEvent) => - (e.key === 'Enter' || e.key === ' ') && onclick()} + onclick={(event: MouseEvent) => onclick(event)} + onkeydown={(event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + onclick(event) + } + }} role="button" tabindex="0" {...props} -> +> \ No newline at end of file diff --git a/src/lib/features/PlayerBar.svelte b/src/lib/features/PlayerBar.svelte index e9988b0..e8a6359 100644 --- a/src/lib/features/PlayerBar.svelte +++ b/src/lib/features/PlayerBar.svelte @@ -1,6 +1,6 @@ @@ -45,13 +45,13 @@ - {player.playlist.length} + {player.queue.tracks.length}
- {#if player.playlist.length === 0} + {#if player.queue.tracks.length === 0}
@@ -73,9 +73,9 @@
{:else} void ondblclicktrack?: (track: Track, index: number) => void @@ -53,7 +53,6 @@ columns = ['index', 'title', 'album', 'duration'], selectable = false, selectedIds = [], - currentTrackIndex, hideHeader = false, ontoggletrack, ondblclicktrack, @@ -322,7 +321,7 @@ { + onclick={(e: Event) => { e.stopPropagation() onremovetrack?.(track, index) }} @@ -364,9 +363,7 @@
{#each tracks as track, index (track.id)} {@const isCurrent = - currentTrackIndex !== undefined - ? currentTrackIndex === index - : player.currentTrack?.id === track.id} + player.currentTrack?.id === track.id} {@const cover = trackCovers.get(track)} - -const STORE_NAME = "player-state.json" - class Player { - playlist = $state([]) - currentIndex = $state(-1) - playing = $state(false) - currentTime = $state(0) - playMode = $state('list') - queueOpen = $state(false) - playbackHistory: number[] = [] + queue = $state({ + tracks: [], + currentIndex: null, + playMode: 'list', + history: [] + }) + + playing = $state(false) + currentTime = $state(0) + queueOpen = $state(false) #muted = $state(false) #volume = $state(80) #previousVolume = 80 - #pollTimer: any = null - #loadToken = 0 - #storePromise: ReturnType | null = null - #debounceTimer: any = null #progressUnlisten: UnlistenFn | null = null #trackStartedUnlisten: UnlistenFn | null = null #queueChangedUnlisten: UnlistenFn | null = null @@ -109,15 +69,6 @@ class Player { return track.duration / 1000 } - #isPersistedTrack(track: unknown): track is Track { - return ( - typeof track === 'object' && - track !== null && - typeof (track as Track).id === 'number' && - typeof (track as Track).path === 'string' - ) - } - #updatePlaybackState() { if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused' @@ -146,11 +97,18 @@ class Player { navigator.mediaSession.playbackState = 'none' } - get currentTrack(): Track | null { - if (this.currentIndex >= 0 && this.currentIndex < this.playlist.length) { - return this.playlist[this.currentIndex] + get currentTrack(): Track | undefined { + const index = this.queue.currentIndex + + if ( + index === null || + index < 0 || + index >= this.queue.tracks.length + ) { + return undefined } - return null + + return this.queue.tracks[index] } get volume() { return this.#volume } @@ -160,7 +118,6 @@ class Player { this.#muted = false } invoke('set_volume', { volume }).catch(console.error) - this.#saveDebounced() } get muted() { return this.#muted } @@ -174,254 +131,94 @@ class Player { this.#volume = this.#previousVolume invoke('set_volume', { volume: this.#previousVolume }).catch(console.error) } - this.#saveDebounced() - } - - async #getStore() { - if (!this.#storePromise) { - this.#storePromise = load(STORE_NAME) - } - return this.#storePromise - } - - #saveDebounced() { - if (this.#debounceTimer) clearTimeout(this.#debounceTimer) - this.#debounceTimer = setTimeout(() => { - void this.#persist() - }, 1000) - } - - #stripCoverData(track: Track): BackendQueueTrack { - return Object.fromEntries( - Object.entries(track).filter(([key]) => key !== 'cover'), - ) as BackendQueueTrack } - async #syncBackendQueue() { - await invoke('sync_playback_queue', { - playlist: this.playlist.map(track => this.#stripCoverData(track)), - currentIndex: this.currentIndex >= 0 ? this.currentIndex : null, - playMode: this.playMode, - playbackHistory: [...this.playbackHistory], - }) - } - - #applyBackendQueue(payload: PlaybackQueueChangedPayload) { - this.playlist = payload.playlist - this.currentIndex = payload.currentIndex ?? -1 - this.playMode = payload.playMode - this.playbackHistory = payload.playbackHistory - this.#syncMediaSession() - this.#saveDebounced() - } - - #playBackendQueueTrack(index: number) { - this.#loadToken++ - this.#stopPolling() - this.currentTime = 0 - this.playing = true - this.#syncMediaSession() - void this.#syncBackendQueue() - .then(() => invoke('play_queue_track', { index })) - .catch(err => { - this.playing = false - this.#syncMediaSession() - console.error(err) - }) - } - - replacePlaylistAndPlay(tracks: Track[], targetId: number) { - if (tracks.length === 0) { - this.clearQueue() - return - } - - const nextQueue = [...tracks] - const index = nextQueue.findIndex(t => t.id === targetId) - const nextIndex = index !== -1 ? index : 0 - this.playbackHistory = [] - this.playlist = nextQueue - this.currentIndex = nextIndex - this.#playBackendQueueTrack(nextIndex) - this.#saveDebounced() + #applyBackendQueue(queue: PlaybackQueue) { + this.queue = queue } - appendTrack(track: Track) { - this.insertNext(track) - } - - insertTracksNext(tracks: Track[]) { - const uniqueTracks = tracks.filter( - (track, index, list) => - list.findIndex(item => item.id === track.id) === index, - ) - - if (uniqueTracks.length === 0) return - - if (this.playlist.length === 0 || this.currentIndex === -1) { - this.playlist = uniqueTracks - this.currentIndex = 0 - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() - return - } - - const insertTracks = uniqueTracks.filter( - track => !this.playlist.some(item => item.id === track.id), + async replacePlaylistAndPlay( + tracks: Track[], + targetId: number + ) { + await invoke( + 'replace_playlist_and_play', + { + tracks, + targetId + } ) - - if (insertTracks.length === 0) return - - const nextQueue = [...this.playlist] - nextQueue.splice(this.currentIndex + 1, 0, ...insertTracks) - this.playlist = nextQueue - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() } - insertNext(track: Track) { - const existingIndex = this.playlist.findIndex(item => item.id === track.id) - if (existingIndex === this.currentIndex && this.currentIndex !== -1) return - - let nextQueue = [...this.playlist] - let nextIndex = this.currentIndex - - if (existingIndex !== -1) { - nextQueue.splice(existingIndex, 1) - if (existingIndex < nextIndex) { - nextIndex-- - } - } - - if (nextQueue.length === 0 || nextIndex === -1) { - this.playlist = [track] - this.currentIndex = 0 - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() - return - } + async insertTracksNext(tracks: Track[]) { + const payload = await invoke( + 'insert_tracks_as_next', + { tracks } + ) - nextQueue.splice(nextIndex + 1, 0, track) - this.playlist = nextQueue - this.currentIndex = nextIndex - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() + this.#applyBackendQueue(payload) } async insertTrackAsNext(track: Track) { - const payload = await invoke('insert_track_as_next', { track }) + const payload = await invoke('insert_track_as_next', { track }) this.#applyBackendQueue(payload) } - async removeTrackFromBackendQueue(trackId: number) { - const payload = await invoke('remove_track_from_backend_queue', { trackId }) + async removeTrack(trackId: number) { + const payload = await invoke( + 'remove_track_from_backend_queue', + { trackId } + ) + this.#applyBackendQueue(payload) } - removeTrack(id: number) { - const removeIndex = this.playlist.findIndex(t => t.id === id) - if (removeIndex === -1) return - - const isCurrent = removeIndex === this.currentIndex - const nextQueue = this.playlist.filter(t => t.id !== id) + async playTrackInQueue(index: number) { + await invoke( + 'play_queue_track', + { index } + ) + } - if (nextQueue.length === 0) { - this.playlist = [] - this.currentIndex = -1 - this.playing = false - this.currentTime = 0 - this.playbackHistory = [] - this.#clearMediaSession() - this.#stopPolling() - void this.#syncBackendQueue().catch(console.error) - invoke('stop_track').catch(console.error) - this.#saveDebounced() - return - } + async cyclePlayMode() { + const modes: PlayMode[] = [ + 'list', + 'single', + 'shuffle' + ] - if (isCurrent) { - const nextIndex = Math.min(removeIndex, nextQueue.length - 1) - this.playlist = nextQueue - this.currentIndex = nextIndex - this.#playBackendQueueTrack(nextIndex) - this.#saveDebounced() - return - } + const index = modes.indexOf( + this.queue.playMode + ) - let nextIndex = this.currentIndex - if (removeIndex < nextIndex) { - nextIndex-- - } - this.playlist = nextQueue - this.currentIndex = nextIndex - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() - } + const mode = + modes[(index + 1) % modes.length] - playTrackInQueue(index: number) { - if (index < 0 || index >= this.playlist.length) return - this.currentIndex = index - this.#playBackendQueueTrack(index) - this.#saveDebounced() - } + const queue = await invoke( + 'set_play_mode', + { mode } + ) - cyclePlayMode() { - const modes: PlayMode[] = ['list', 'single', 'shuffle'] - const currentModeIndex = modes.indexOf(this.playMode) - this.playMode = modes[(currentModeIndex + 1) % modes.length] - void this.#syncBackendQueue().catch(console.error) - this.#saveDebounced() + this.#applyBackendQueue(queue) } toggleQueue() { this.queueOpen = !this.queueOpen } - clearQueue() { - this.playlist = [] - this.currentIndex = -1 - this.playing = false - this.currentTime = 0 - this.playbackHistory = [] - this.#clearMediaSession() - this.#stopPolling() - void this.#syncBackendQueue().catch(console.error) - invoke('stop_track').catch(console.error) - this.#saveDebounced() - } - - #getNextIndex(): number { - if (this.playlist.length <= 1) return 0 - if (this.playMode === 'shuffle') { - let nextIndex = this.currentIndex - while (nextIndex === this.currentIndex) { - nextIndex = Math.floor(Math.random() * this.playlist.length) - } - return nextIndex - } - return this.currentIndex >= this.playlist.length - 1 ? 0 : this.currentIndex + 1 + async clearQueue() { + const queue = await invoke('clear_queue') + + this.#applyBackendQueue(queue) } - next() { - if (this.playlist.length === 0) return - const current = this.currentTrack - if (current) { - this.playbackHistory.push(current.id) - } - void this.#syncBackendQueue() - .then(() => invoke('play_next_track')) - .catch(console.error) - this.#saveDebounced() + async next() { + await invoke('play_next_track') } - prev() { - if (this.playlist.length === 0) return - void invoke('play_previous_track').catch(console.error) - if (this.playbackHistory.length > 0) { - this.playbackHistory.pop() - } - this.#saveDebounced() + async prev() { + await invoke('play_previous_track') } resume = async () => { @@ -429,7 +226,6 @@ class Player { await invoke('resume_track') this.playing = true this.#syncMediaSession() - this.#startPolling() } catch (err) { console.error(err) } @@ -440,7 +236,6 @@ class Player { await invoke('pause_track') this.playing = false this.#syncMediaSession() - this.#stopPolling() } catch (err) { console.error(err) } @@ -455,129 +250,59 @@ class Player { } seek = async (time: number) => { - this.#stopPolling() await invoke('set_current_time', { time }) this.currentTime = time this.#updatePositionState() - if (this.playing) { - this.#startPolling() - } } async loadState() { - try { - const store = await this.#getStore() - const playlist = await store.get("playlist") - const currentIndex = await store.get("currentIndex") - const playMode = await store.get("playMode") - const volume = await store.get("volume") - - if (playlist && playlist.length > 0) { - this.playlist = playlist.filter(this.#isPersistedTrack) - } - if (typeof currentIndex === "number" && currentIndex >= 0) { - this.currentIndex = currentIndex - } - if (this.playlist.length === 0) { - this.currentIndex = -1 - } - if (this.playlist.length > 0 && this.currentIndex >= this.playlist.length) { - this.currentIndex = this.playlist.length - 1 - } - if (playMode) { - this.playMode = playMode - } - if (typeof volume === "number") { - this.#volume = volume - invoke('set_volume', { volume }).catch(console.error) - } + await this.#setupPlaybackListeners() - await this.#syncBackendQueue() - await this.#setupPlaybackListeners() - const status = await invoke('get_current_status') - this.#applyBackendStatus(status) - } catch (err) { - console.error(err) - } - } + const queue = + await invoke( + 'get_playback_queue' + ) - #findTrackIndexByIdentity(id?: number | null, path?: string | null) { - if (typeof id === 'number') { - const index = this.playlist.findIndex(track => track.id === id) - if (index !== -1) return index - } + this.#applyBackendQueue(queue) - if (path) { - return this.playlist.findIndex(track => track.path === path) - } + const status = + await invoke( + 'get_current_status' + ) - return -1 + this.#applyBackendStatus(status) } #applyBackendStatus(status: PlaybackStatusSnapshot) { if (!status.hasMedia || !status.track) { this.playing = false this.currentTime = 0 - this.#stopPolling() this.#syncMediaSession() return } - const index = this.#findTrackIndexByIdentity(status.track.id, status.track.path) - if (index !== -1) { - this.currentIndex = index - } - this.currentTime = status.currentTime this.playing = status.playing this.#syncMediaSession() - - if (this.playing) { - this.#startPolling() - } else { - this.#stopPolling() - } } #handleBackendProgress(payload: PlaybackProgressPayload) { - const index = this.#findTrackIndexByIdentity(payload.trackId, payload.path) - if (index !== -1) { - this.currentIndex = index - } + this.currentTime = + payload.currentTime - this.currentTime = payload.currentTime - this.playing = payload.playing - this.#syncMediaSession() + this.playing = + payload.playing - if (this.currentTrack && this.currentTime >= this.#trackDurationSeconds(this.currentTrack) - 0.5) { - this.#handleTrackEnded() - } + this.#syncMediaSession() } #handleTrackStarted(payload: PlaybackTrackStartedPayload) { - if (payload.index >= 0 && payload.index < this.playlist.length) { - this.currentIndex = payload.index - } else { - const index = this.#findTrackIndexByIdentity(payload.track.id, payload.track.path) - if (index !== -1) { - this.currentIndex = index - } - } - this.currentTime = payload.currentTime this.playing = payload.playing + this.queue = { ...this.queue, currentIndex: payload.index } this.#syncMediaSession() - const track = this.currentTrack ?? payload.track - recentlyPlayed.add(track) - - if (this.playing) { - this.#startPolling() - } else { - this.#stopPolling() - } - - this.#saveDebounced() + recentlyPlayed.add(payload.track) } async #setupPlaybackListeners() { @@ -594,101 +319,20 @@ class Player { } if (!this.#queueChangedUnlisten) { - this.#queueChangedUnlisten = await listen('playback:queue-changed', event => { + this.#queueChangedUnlisten = await listen('playback:queue-changed', event => { this.#applyBackendQueue(event.payload) }) } } - #handleTrackEnded() { - if (!this.currentTrack) return - if (this.playMode === 'single') { - this.currentTime = 0 - this.#playBackendQueueTrack(this.currentIndex) - } else { - this.next() - } - } - - async #persist() { - try { - const store = await this.#getStore() - await store.set("playlist", this.playlist.map(t => ({ ...t, cover: null }))) - await store.set("currentIndex", this.currentIndex) - await store.set("playMode", this.playMode) - await store.set("volume", this.#volume) - await store.save() - } catch (err) { - console.error(err) - } - } - - #onTrackStarted(track: Track) { - this.playing = true - this.#syncMediaSession() - recentlyPlayed.add(track) - } - - async #loadAndPlay() { - const track = this.currentTrack - if (!track) return - - const currentToken = ++this.#loadToken - this.#stopPolling() - - try { - this.currentTime = 0 - await invoke('play_track', { fullPath: track.path, trackId: track.id }) - - if (currentToken !== this.#loadToken) return - - this.#onTrackStarted(track) - this.#startPolling() - } catch (err) { - if (currentToken === this.#loadToken) { - this.playing = false - console.error(err) - } - } - } - - #startPolling = () => { - this.#stopPolling() - this.#pollTimer = setInterval(async () => { - if (!this.playing) { - this.#stopPolling() - return - } - try { - this.currentTime = await invoke('current_time') - if (this.currentTrack && this.currentTime >= this.#trackDurationSeconds(this.currentTrack) - 0.5) { - this.#handleTrackEnded() - } - } catch (e) { - this.#stopPolling() - } - }, 250) - } - - #stopPolling = () => { - if (this.#pollTimer) { - clearInterval(this.#pollTimer) - this.#pollTimer = null - } - } - destroy() { - this.#stopPolling() this.#progressUnlisten?.() this.#progressUnlisten = null this.#trackStartedUnlisten?.() this.#trackStartedUnlisten = null this.#queueChangedUnlisten?.() this.#queueChangedUnlisten = null - if (this.#debounceTimer) { - clearTimeout(this.#debounceTimer) - } } } -export const player = new Player() +export const player = new Player() \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 7f20cfa..3bd7581 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -4,4 +4,5 @@ export type { ExtensionNavItem } from './extension' export type { Album, Artist, Playlist, Track } from './music' +export type { BackendQueueTrack, PlayMode, PlaybackProgressPayload, PlaybackStatusSnapshot, PlaybackTrackSnapshot, PlaybackTrackStartedPayload } from './playback' export type { AppSettings, ThemeMode } from './settings' diff --git a/src/lib/types/music.ts b/src/lib/types/music.ts index 6102b8f..4d32fc6 100644 --- a/src/lib/types/music.ts +++ b/src/lib/types/music.ts @@ -1,3 +1,5 @@ +import type { PlayMode } from "./playback" + export interface Track { id: number title: string @@ -36,4 +38,11 @@ export interface Album { tracks: Track[] trackCount: number representativeTrack: Track +} + +export interface PlaybackQueue { + tracks: Track[] + currentIndex: number | null + playMode: PlayMode + history: number[] } \ No newline at end of file diff --git a/src/lib/types/playback.ts b/src/lib/types/playback.ts new file mode 100644 index 0000000..fd216d4 --- /dev/null +++ b/src/lib/types/playback.ts @@ -0,0 +1,31 @@ +import type { Track } from "./music" + +export type PlayMode = 'list' | 'single' | 'shuffle' + +export type PlaybackTrackSnapshot = { + id?: number | null + path?: string | null +} + +export type PlaybackStatusSnapshot = { + hasMedia: boolean + playing: boolean + currentTime: number + track: PlaybackTrackSnapshot | null +} + +export type PlaybackProgressPayload = { + playing: boolean + currentTime: number + trackId?: number | null + path?: string | null +} + +export type PlaybackTrackStartedPayload = { + track: Track + index: number + playing: boolean + currentTime: number +} + +export type BackendQueueTrack = Omit \ No newline at end of file From 0dbb3f07c4e32ce0f9650d411f07f46e9113f747 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Wed, 3 Jun 2026 20:15:22 +0800 Subject: [PATCH 26/39] refactor: replace String error type with AppError across commands and core logic - Update all `#[command]` functions to return `Result<_, AppError>` instead of `Result<_, String>` - Refactor internal playback, file operation, and directory scanning utilities to use `AppError` - Simplify error propagation by leveraging the `?` operator with existing `From` trait implementations - Unify the error response structure sent to the frontend for consistent error handling --- src-tauri/src/audio/state.rs | 7 +-- src-tauri/src/commands/library.rs | 43 ++++++++-------- src-tauri/src/commands/playback.rs | 50 +++++++++++-------- src-tauri/src/commands/playback_queue.rs | 11 ++-- src-tauri/src/commands/playlists.rs | 11 ---- src-tauri/src/commands/recent.rs | 4 +- src-tauri/src/commands/tracks.rs | 17 +++---- src-tauri/src/db/manager.rs | 14 +++--- src-tauri/src/errors/app_error.rs | 12 ++++- src-tauri/src/lib.rs | 4 +- .../sqlite/playlist_repository.rs | 49 +++++++++--------- .../repositories/sqlite/recent_repository.rs | 21 ++++---- .../repositories/sqlite/track_repository.rs | 41 +++++++-------- src-tauri/src/services/playback_events.rs | 11 ++-- src-tauri/src/services/playback_service.rs | 29 +++++------ src-tauri/src/services/playlist_service.rs | 23 +++++---- src-tauri/src/services/recent_service.rs | 7 +-- src-tauri/src/services/track_service.rs | 21 ++++---- src-tauri/src/state/playback_state.rs | 5 +- src/lib/state/player.svelte.ts | 4 +- src/routes/library/+page.svelte | 6 +-- 21 files changed, 203 insertions(+), 187 deletions(-) diff --git a/src-tauri/src/audio/state.rs b/src-tauri/src/audio/state.rs index e9eb5e5..338d9d4 100644 --- a/src-tauri/src/audio/state.rs +++ b/src-tauri/src/audio/state.rs @@ -1,5 +1,6 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; use crate::audio::graph::AudioGraph; +use crate::errors::AppError; use crate::models::PlaybackQueue; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -37,10 +38,10 @@ pub fn init_audio_state(queue: PlaybackQueue) { }); } -pub fn lock_audio_state() -> Result, String> { +pub fn lock_audio_state() -> Result, AppError> { AUDIO_STATE .get() - .ok_or_else(|| "Audio state not initialized".to_string())? + .ok_or_else(|| AppError::from("Audio state not initialized"))? .lock() - .map_err(|_| "Failed to lock audio state".to_string()) + .map_err(|_| AppError::from("Failed to lock audio state")) } \ No newline at end of file diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index bca8e4a..9399bb0 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -9,7 +9,8 @@ use lofty::probe::Probe; use tauri::{command, State}; use walkdir::WalkDir; -use crate::commands::playback::remove_track_from_backend_queue; +use crate::commands::playback::remove_track_from_queue; +use crate::errors::AppError; use crate::metadata::parse_single_track; use crate::models::{NewTrack, PlaybackQueue, Track}; use crate::state::AppState; @@ -22,12 +23,11 @@ fn is_supported_audio_file(path: &Path) -> bool { .map(|ext| SUPPORTED_EXT.contains(&ext.to_lowercase().as_str())) .unwrap_or(false) } - -fn scan_single_directory(dir: &str) -> Result, String> { +fn scan_single_directory(dir: &str) -> Result, AppError> { let root_path = Path::new(dir); if !root_path.exists() || !root_path.is_dir() { - return Err(format!("无效目录: {}", dir)); + return Err(format!("无效目录: {}", dir).into()); } let mut tracks = Vec::new(); @@ -57,7 +57,7 @@ fn dedupe_new_tracks(tracks: &mut Vec) { tracks.retain(|track| seen.insert(track.path.clone())); } -pub fn execute_scan(dirs: Vec) -> Result, String> { +pub fn execute_scan(dirs: Vec) -> Result, AppError> { let mut all_tracks = Vec::new(); for dir in dirs { @@ -70,7 +70,7 @@ pub fn execute_scan(dirs: Vec) -> Result, String> { Ok(all_tracks) } -async fn upsert_scanned_tracks(state: &AppState, tracks: Vec) -> Result, String> { +async fn upsert_scanned_tracks(state: &AppState, tracks: Vec) -> Result, AppError> { let mut saved = Vec::with_capacity(tracks.len()); for track in tracks { @@ -81,7 +81,7 @@ async fn upsert_scanned_tracks(state: &AppState, tracks: Vec) -> Resul } #[command] -pub fn get_track_cover(full_path: String) -> Result, String> { +pub fn get_track_cover(full_path: String) -> Result, AppError> { let file_path = Path::new(&full_path); if !file_path.exists() { @@ -112,7 +112,7 @@ pub fn get_track_cover(full_path: String) -> Result, String> { Ok(cover) } -fn validate_audio_path(path: &str) -> Result<&Path, String> { +fn validate_audio_path(path: &str) -> Result<&Path, AppError> { let file_path = Path::new(path); if !file_path.exists() { @@ -120,14 +120,14 @@ fn validate_audio_path(path: &str) -> Result<&Path, String> { } if !file_path.is_file() || !is_supported_audio_file(file_path) { - return Err(format!("拒绝操作非音频文件: {}", path)); + return Err(format!("拒绝操作非音频文件: {}", path).into()); } Ok(file_path) } #[command] -pub fn show_in_folder(path: String) -> Result<(), String> { +pub fn show_in_folder(path: String) -> Result<(), AppError> { let file_path = Path::new(&path); if !file_path.exists() { @@ -137,7 +137,7 @@ pub fn show_in_folder(path: String) -> Result<(), String> { #[cfg(target_os = "linux")] let parent = file_path .parent() - .ok_or_else(|| "无法定位文件所在目录".to_string())?; + .ok_or_else(|| AppError::from("无法定位文件所在目录"))?; #[cfg(target_os = "windows")] let status = Command::new("explorer.exe") @@ -150,7 +150,7 @@ pub fn show_in_folder(path: String) -> Result<(), String> { #[cfg(target_os = "linux")] let status = Command::new("xdg-open").arg(parent).status(); - let status = status.map_err(|e| e.to_string())?; + let status = status?; if status.success() { Ok(()) @@ -164,15 +164,16 @@ pub async fn delete_track_file( app_handle: tauri::AppHandle, path: String, track_id: i64, -) -> Result { +) -> Result { let file_path = validate_audio_path(&path)?; trash::delete(file_path) .map_err(|e| format!("移至回收站失败 {}: {}", path, e))?; - remove_track_from_backend_queue(app_handle, track_id).await + let queue = remove_track_from_queue(app_handle, track_id).await?; + Ok(queue) } #[command] -pub fn trash_track_files(paths: Vec) -> Result<(), String> { +pub fn trash_track_files(paths: Vec) -> Result<(), AppError> { for path in paths { let file_path = Path::new(&path); @@ -181,7 +182,7 @@ pub fn trash_track_files(paths: Vec) -> Result<(), String> { } if !file_path.is_file() || !is_supported_audio_file(file_path) { - return Err(format!("拒绝移至回收站非音频文件: {}", path)); + return Err(format!("拒绝移至回收站非音频文件: {}", path).into()); } trash::delete(file_path).map_err(|e| format!("移至回收站失败 {}: {}", path, e))?; @@ -191,7 +192,7 @@ pub fn trash_track_files(paths: Vec) -> Result<(), String> { } #[command] -pub fn delete_track_files(paths: Vec) -> Result<(), String> { +pub fn delete_track_files(paths: Vec) -> Result<(), AppError> { for path in paths { let file_path = Path::new(&path); @@ -200,7 +201,7 @@ pub fn delete_track_files(paths: Vec) -> Result<(), String> { } if !file_path.is_file() || !is_supported_audio_file(file_path) { - return Err(format!("拒绝删除非音频文件: {}", path)); + return Err(format!("拒绝删除非音频文件: {}", path).into()); } fs::remove_file(file_path).map_err(|e| format!("删除文件失败 {}: {}", path, e))?; @@ -213,7 +214,7 @@ pub fn delete_track_files(paths: Vec) -> Result<(), String> { pub async fn scan_track_directories( state: State<'_, AppState>, dirs: Vec, -) -> Result, String> { +) -> Result, AppError> { let scanned = execute_scan(dirs)?; let tracks = upsert_scanned_tracks(&state, scanned).await?; Ok(tracks) @@ -222,7 +223,7 @@ pub async fn scan_track_directories( #[command] pub async fn load_track_library( state: State<'_, AppState>, -) -> Result, String> { +) -> Result, AppError> { let tracks = state.tracks.list_tracks().await?; if tracks.is_empty() { @@ -234,7 +235,7 @@ pub async fn load_track_library( async fn rebuild_and_get_library( state: &AppState, -) -> Result, String> { +) -> Result, AppError> { let settings = state.settings.get_settings().await.map_err(|error| error.to_string())?; let scanned = tauri::async_runtime::spawn_blocking(move || execute_scan(settings.library_dirs)) .await diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 322bc2f..6e62933 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -1,5 +1,6 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; +use crate::errors::AppError; use crate::models::playback::PlaybackStatusSnapshot; use crate::models::{PlaybackQueue, Track}; use crate::services::playback_events::{emit_queue_changed, status_snapshot}; @@ -24,7 +25,7 @@ pub async fn sync_playback_queue( current_index: Option, play_mode: PlayMode, history: Vec, -) -> Result<(), String> { +) -> Result<(), AppError> { let mut state = lock_audio_state()?; state.play_mode = play_mode; state.playback_queue.sync( @@ -37,10 +38,10 @@ pub async fn sync_playback_queue( } #[command] -pub async fn remove_track_from_backend_queue( +pub async fn remove_track_from_queue( app_handle: AppHandle, track_id: i64, -) -> Result { +) -> Result { let result = { let mut state = lock_audio_state()?; @@ -66,7 +67,7 @@ pub async fn remove_track_from_backend_queue( pub async fn insert_track_as_next( app_handle: AppHandle, track: Track, -) -> Result { +) -> Result { with_audio_state(|state| { state.playback_queue.insert_next(track); })?; @@ -75,23 +76,27 @@ pub async fn insert_track_as_next( } #[command] -pub async fn play_queue_track(app_handle: AppHandle, index: usize) -> Result<(), String> { - play_current_index_internal(&app_handle, index) +pub async fn play_queue_track(app_handle: AppHandle, index: usize) -> Result<(), AppError> { + play_current_index_internal(&app_handle, index)?; + Ok(()) } #[command] -pub async fn play_next_track(app_handle: AppHandle) -> Result<(), String> { - play_next_internal(app_handle).await +pub async fn play_next_track(app_handle: AppHandle) -> Result<(), AppError> { + play_next_internal(app_handle).await?; + Ok(()) } #[command] -pub async fn play_previous_track(app_handle: AppHandle) -> Result<(), String> { - play_previous_internal(app_handle).await +pub async fn play_previous_track(app_handle: AppHandle) -> Result<(), AppError> { + play_previous_internal(app_handle).await?; + Ok(()) } #[command] -pub async fn stop_track(app_handle: AppHandle) -> Result<(), String> { - stop_internal(app_handle).await +pub async fn stop_track(app_handle: AppHandle) -> Result<(), AppError> { + stop_internal(app_handle).await?; + Ok(()) } #[command] @@ -99,25 +104,28 @@ pub async fn play_track( app_handle: AppHandle, full_path: String, track_id: Option, -) -> Result { +) -> Result { let track = load_track_for_playback(&full_path, track_id); play_track_internal(&app_handle, track, None)?; Ok(format!("Playing track: {}", full_path)) } #[command] -pub async fn resume_track(app_handle: AppHandle) -> Result<(), String> { - resume_internal(app_handle).await +pub async fn resume_track(app_handle: AppHandle) -> Result<(), AppError> { + resume_internal(app_handle).await?; + Ok(()) } #[command] -pub async fn pause_track(app_handle: AppHandle) -> Result<(), String> { - pause_internal(app_handle).await +pub async fn pause_track(app_handle: AppHandle) -> Result<(), AppError> { + pause_internal(app_handle).await?; + Ok(()) } #[command] -pub async fn toggle_track(app_handle: AppHandle) -> Result { - toggle_internal(app_handle).await +pub async fn toggle_track(app_handle: AppHandle) -> Result { + let status = toggle_internal(app_handle).await?; + Ok(status) } #[command] @@ -126,7 +134,7 @@ pub async fn current_time() -> f64 { } #[command] -pub async fn get_current_status() -> Result { +pub async fn get_current_status() -> Result { status_snapshot().map(|s| s.status_payload()) } @@ -136,7 +144,7 @@ pub async fn set_current_time(app_handle: AppHandle, time: f64) { } #[command] -pub async fn set_volume(volume: u8) -> Result<(), String> { +pub async fn set_volume(volume: u8) -> Result<(), AppError> { let mut state = lock_audio_state()?; let volume_f32 = volume as f32 / 100.0; let safe_volume = volume_f32.clamp(0.0, 1.0); diff --git a/src-tauri/src/commands/playback_queue.rs b/src-tauri/src/commands/playback_queue.rs index 08821a7..c099fc4 100644 --- a/src-tauri/src/commands/playback_queue.rs +++ b/src-tauri/src/commands/playback_queue.rs @@ -1,17 +1,18 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; +use crate::errors::AppError; use crate::models::{PlaybackQueue, Track}; use crate::services::playback_events::{emit_queue_changed, queue_snapshot}; use crate::services::playback_service::{play_current_index_internal, stop_internal}; use crate::state::playback_state::{lock_audio_state, with_audio_state}; #[command] -pub fn get_playback_queue() -> Result { +pub fn get_playback_queue() -> Result { with_audio_state(|state| state.playback_queue.clone()) } #[command] -pub async fn clear_queue(app: AppHandle) -> Result { +pub async fn clear_queue(app: AppHandle) -> Result { { let mut state = lock_audio_state()?; state.playback_queue.clear(); @@ -22,7 +23,7 @@ pub async fn clear_queue(app: AppHandle) -> Result { } #[command] -pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result { +pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result { { let mut state = lock_audio_state()?; state.play_mode = mode; @@ -33,7 +34,7 @@ pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result) -> Result { +pub fn insert_tracks_as_next(app: AppHandle, tracks: Vec) -> Result { { let mut state = lock_audio_state()?; state.playback_queue.insert_tracks_next(tracks); @@ -47,7 +48,7 @@ pub async fn replace_playlist_and_play( app: AppHandle, tracks: Vec, target_id: i64, -) -> Result<(), String> { +) -> Result<(), AppError> { let play_index = { let mut state = lock_audio_state()?; state.playback_queue.replace_playlist(tracks, target_id)? diff --git a/src-tauri/src/commands/playlists.rs b/src-tauri/src/commands/playlists.rs index 8e678f1..3133fea 100644 --- a/src-tauri/src/commands/playlists.rs +++ b/src-tauri/src/commands/playlists.rs @@ -13,7 +13,6 @@ pub async fn create_playlist( .playlists .create_playlist(playlist) .await - .map_err(AppError::Command) } #[command] @@ -25,7 +24,6 @@ pub async fn rename_playlist( .playlists .rename_playlist(playlist) .await - .map_err(AppError::Command) } #[command] @@ -37,7 +35,6 @@ pub async fn delete_playlist( .playlists .delete_playlist(id) .await - .map_err(AppError::Command) } #[command] @@ -49,7 +46,6 @@ pub async fn get_playlist( .playlists .get_playlist(id) .await - .map_err(AppError::Command) } #[command] @@ -58,7 +54,6 @@ pub async fn list_playlists(state: State<'_, AppState>) -> Result, .playlists .list_playlists() .await - .map_err(AppError::Command) } #[command] @@ -69,7 +64,6 @@ pub async fn list_playlists_with_tracks( .playlists .list_playlists_with_tracks() .await - .map_err(AppError::Command) } #[command] @@ -81,7 +75,6 @@ pub async fn get_playlist_with_tracks( .playlists .get_playlist_with_tracks(id) .await - .map_err(AppError::Command) } #[command] @@ -93,7 +86,6 @@ pub async fn add_track_to_playlist( .playlists .add_track(track) .await - .map_err(AppError::Command) } #[command] @@ -106,7 +98,6 @@ pub async fn remove_track_from_playlist( .playlists .remove_track(playlist_id, track_id) .await - .map_err(AppError::Command) } #[command] @@ -118,7 +109,6 @@ pub async fn clear_playlist_tracks( .playlists .clear_tracks(playlist_id) .await - .map_err(AppError::Command) } #[command] @@ -132,5 +122,4 @@ pub async fn reorder_playlist_track( .playlists .reorder_track(playlist_id, track_id, position) .await - .map_err(AppError::Command) } diff --git a/src-tauri/src/commands/recent.rs b/src-tauri/src/commands/recent.rs index 2989de5..7b4b122 100644 --- a/src-tauri/src/commands/recent.rs +++ b/src-tauri/src/commands/recent.rs @@ -14,7 +14,6 @@ pub async fn load_recently_played( .recent .load(limit, offset) .await - .map_err(AppError::Command) } #[command] @@ -27,10 +26,9 @@ pub async fn add_recently_played( .recent .add(track_id, played_at) .await - .map_err(AppError::Command) } #[command] pub async fn clear_recently_played(state: State<'_, AppState>) -> Result<(), AppError> { - state.recent.clear().await.map_err(AppError::Command) + state.recent.clear().await } diff --git a/src-tauri/src/commands/tracks.rs b/src-tauri/src/commands/tracks.rs index b425b62..aabb56b 100644 --- a/src-tauri/src/commands/tracks.rs +++ b/src-tauri/src/commands/tracks.rs @@ -9,7 +9,7 @@ pub async fn create_track( state: State<'_, AppState>, track: NewTrack, ) -> Result { - state.tracks.create_track(track).await.map_err(AppError::Command) + state.tracks.create_track(track).await } #[command] @@ -17,7 +17,7 @@ pub async fn upsert_track( state: State<'_, AppState>, track: NewTrack, ) -> Result { - state.tracks.upsert_track(track).await.map_err(AppError::Command) + state.tracks.upsert_track(track).await } #[command] @@ -25,7 +25,7 @@ pub async fn update_track( state: State<'_, AppState>, track: UpdateTrack, ) -> Result { - state.tracks.update_track(track).await.map_err(AppError::Command) + state.tracks.update_track(track).await } #[command] @@ -33,7 +33,7 @@ pub async fn delete_track( state: State<'_, AppState>, id: i64, ) -> Result<(), AppError> { - state.tracks.delete_track(id).await.map_err(AppError::Command) + state.tracks.delete_track(id).await } #[command] @@ -45,7 +45,6 @@ pub async fn delete_track_by_path( .tracks .delete_track_by_path(&path) .await - .map_err(AppError::Command) } #[command] @@ -53,7 +52,7 @@ pub async fn get_track( state: State<'_, AppState>, id: i64, ) -> Result, AppError> { - state.tracks.get_track(id).await.map_err(AppError::Command) + state.tracks.get_track(id).await } #[command] @@ -65,12 +64,11 @@ pub async fn get_track_by_path( .tracks .get_track_by_path(&path) .await - .map_err(AppError::Command) } #[command] pub async fn list_tracks(state: State<'_, AppState>) -> Result, AppError> { - state.tracks.list_tracks().await.map_err(AppError::Command) + state.tracks.list_tracks().await } #[command] @@ -78,7 +76,7 @@ pub async fn search_tracks( state: State<'_, AppState>, query: TrackSearchQuery, ) -> Result, AppError> { - state.tracks.search_tracks(query).await.map_err(AppError::Command) + state.tracks.search_tracks(query).await } #[command] @@ -91,5 +89,4 @@ pub async fn mark_track_played( .tracks .mark_track_played(id, played_at) .await - .map_err(AppError::Command) } diff --git a/src-tauri/src/db/manager.rs b/src-tauri/src/db/manager.rs index 20a75d4..9c327f7 100644 --- a/src-tauri/src/db/manager.rs +++ b/src-tauri/src/db/manager.rs @@ -5,18 +5,20 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, S use sqlx::{ConnectOptions, SqlitePool}; use tauri::Manager; +use crate::errors::AppError; + #[derive(Clone)] pub struct DatabaseManager { pool: SqlitePool, } impl DatabaseManager { - pub async fn new(app_handle: &tauri::AppHandle) -> Result { + pub async fn new(app_handle: &tauri::AppHandle) -> Result { let database_path = database_path(app_handle)?; if let Some(parent) = database_path.parent() { if !parent.exists() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; + fs::create_dir_all(parent).map_err(AppError::from)?; } } @@ -32,12 +34,12 @@ impl DatabaseManager { .max_connections(5) .connect_with(options) .await - .map_err(|error| error.to_string())?; + .map_err(AppError::from)?; sqlx::migrate!("./migrations") .run(&pool) .await - .map_err(|error| error.to_string())?; + .map_err(AppError::from)?; Ok(Self { pool }) } @@ -47,11 +49,11 @@ impl DatabaseManager { } } -fn database_path(app_handle: &tauri::AppHandle) -> Result { +fn database_path(app_handle: &tauri::AppHandle) -> Result { let app_data_path = app_handle .path() .app_data_dir() - .map_err(|error| error.to_string())?; + .map_err(AppError::from)?; Ok(app_data_path.join("music.sqlite")) } diff --git a/src-tauri/src/errors/app_error.rs b/src-tauri/src/errors/app_error.rs index 99ed5f2..172fb6f 100644 --- a/src-tauri/src/errors/app_error.rs +++ b/src-tauri/src/errors/app_error.rs @@ -8,6 +8,8 @@ pub enum AppError { Migration(String), Service(String), Command(String), + Platform(String), + Domain(String), } impl std::fmt::Display for AppError { @@ -17,7 +19,9 @@ impl std::fmt::Display for AppError { | AppError::Io(message) | AppError::Migration(message) | AppError::Service(message) - | AppError::Command(message) => formatter.write_str(message), + | AppError::Command(message) + | AppError::Platform(message) + | AppError::Domain(message) => formatter.write_str(message), } } } @@ -53,3 +57,9 @@ impl From<&str> for AppError { AppError::Service(error.to_string()) } } + +impl From for AppError { + fn from(error: tauri::Error) -> Self { + AppError::Platform(error.to_string()) + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a04047..ad6ba1a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ use commands::library::{ use commands::playback::{ current_time, get_current_status, insert_track_as_next, pause_track, play_next_track, - play_previous_track, play_queue_track, play_track, remove_track_from_backend_queue, + play_previous_track, play_queue_track, play_track, remove_track_from_queue, resume_track, set_current_time, set_volume, spawn_playback_progress_task, stop_track, sync_playback_queue, toggle_track, }; @@ -203,7 +203,7 @@ pub fn run() { play_track, play_queue_track, insert_track_as_next, - remove_track_from_backend_queue, + remove_track_from_queue, play_next_track, play_previous_track, stop_track, diff --git a/src-tauri/src/repositories/sqlite/playlist_repository.rs b/src-tauri/src/repositories/sqlite/playlist_repository.rs index c1d49c8..b705148 100644 --- a/src-tauri/src/repositories/sqlite/playlist_repository.rs +++ b/src-tauri/src/repositories/sqlite/playlist_repository.rs @@ -1,5 +1,6 @@ use sqlx::SqlitePool; +use crate::errors::AppError; use crate::models::{AddPlaylistTrack, NewPlaylist, Playlist, PlaylistTrack, PlaylistWithTracks, RenamePlaylist, Track}; #[derive(Clone)] @@ -12,7 +13,7 @@ impl SqlitePlaylistRepository { Self { pool } } - pub async fn create(&self, playlist: NewPlaylist) -> Result { + pub async fn create(&self, playlist: NewPlaylist) -> Result { sqlx::query_as::<_, Playlist>( "INSERT INTO playlists (name, created_at) VALUES (?1, datetime('now')) @@ -21,10 +22,10 @@ impl SqlitePlaylistRepository { .bind(playlist.name) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn rename(&self, playlist: RenamePlaylist) -> Result { + pub async fn rename(&self, playlist: RenamePlaylist) -> Result { sqlx::query_as::<_, Playlist>( "UPDATE playlists SET name = ?1 WHERE id = ?2 RETURNING id, name, created_at", ) @@ -32,36 +33,36 @@ impl SqlitePlaylistRepository { .bind(playlist.id) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn delete(&self, id: i64) -> Result<(), String> { + pub async fn delete(&self, id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM playlists WHERE id = ?1") .bind(id) .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn find_by_id(&self, id: i64) -> Result, String> { + pub async fn find_by_id(&self, id: i64) -> Result, AppError> { sqlx::query_as::<_, Playlist>("SELECT id, name, created_at FROM playlists WHERE id = ?1") .bind(id) .fetch_optional(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn list_all(&self) -> Result, String> { + pub async fn list_all(&self) -> Result, AppError> { sqlx::query_as::<_, Playlist>( "SELECT id, name, created_at FROM playlists ORDER BY created_at DESC, id DESC", ) .fetch_all(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn list_with_tracks(&self) -> Result, String> { + pub async fn list_with_tracks(&self) -> Result, AppError> { let playlists = self.list_all().await?; let mut result = Vec::with_capacity(playlists.len()); @@ -73,7 +74,7 @@ impl SqlitePlaylistRepository { Ok(result) } - pub async fn get_with_tracks(&self, id: i64) -> Result, String> { + pub async fn get_with_tracks(&self, id: i64) -> Result, AppError> { let Some(playlist) = self.find_by_id(id).await? else { return Ok(None); }; @@ -82,7 +83,7 @@ impl SqlitePlaylistRepository { Ok(Some(PlaylistWithTracks { playlist, tracks })) } - pub async fn add_track(&self, track: AddPlaylistTrack) -> Result { + pub async fn add_track(&self, track: AddPlaylistTrack) -> Result { let position = match track.position { Some(position) => position, None => next_position(&self.pool, track.playlist_id).await?, @@ -99,29 +100,29 @@ impl SqlitePlaylistRepository { .bind(position) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn remove_track(&self, playlist_id: i64, track_id: i64) -> Result<(), String> { + pub async fn remove_track(&self, playlist_id: i64, track_id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND track_id = ?2") .bind(playlist_id) .bind(track_id) .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn clear_tracks(&self, playlist_id: i64) -> Result<(), String> { + pub async fn clear_tracks(&self, playlist_id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM playlist_tracks WHERE playlist_id = ?1") .bind(playlist_id) .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn reorder_track(&self, playlist_id: i64, track_id: i64, position: i64) -> Result<(), String> { + pub async fn reorder_track(&self, playlist_id: i64, track_id: i64, position: i64) -> Result<(), AppError> { sqlx::query("UPDATE playlist_tracks SET position = ?1 WHERE playlist_id = ?2 AND track_id = ?3") .bind(position) .bind(playlist_id) @@ -129,23 +130,23 @@ impl SqlitePlaylistRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } } -async fn next_position(pool: &SqlitePool, playlist_id: i64) -> Result { +async fn next_position(pool: &SqlitePool, playlist_id: i64) -> Result { let value: (i64,) = sqlx::query_as( "SELECT coalesce(max(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", ) .bind(playlist_id) .fetch_one(pool) .await - .map_err(|e| e.to_string())?; + .map_err(AppError::from)?; Ok(value.0) } -async fn tracks_for_playlist(pool: &SqlitePool, playlist_id: i64) -> Result, String> { +async fn tracks_for_playlist(pool: &SqlitePool, playlist_id: i64) -> Result, AppError> { sqlx::query_as::<_, Track>( "SELECT t.id, t.title, t.artist, t.album, t.duration, t.path, t.cover, t.file_size, t.play_count, t.last_played_at, t.created_at, t.updated_at FROM tracks t @@ -156,5 +157,5 @@ async fn tracks_for_playlist(pool: &SqlitePool, playlist_id: i64) -> Result Result, String> { + pub async fn list_with_tracks(&self, limit: i64, offset: i64) -> Result, AppError> { let rows = sqlx::query_as::<_, RecentRow>( "SELECT t.id, t.title, t.artist, t.album, t.duration, t.path, t.cover, t.file_size, t.play_count, t.last_played_at, t.created_at, t.updated_at, r.played_at FROM recent_played r @@ -24,7 +25,7 @@ impl SqliteRecentRepository { .bind(offset) .fetch_all(&self.pool) .await - .map_err(|e| e.to_string())?; + .map_err(AppError::from)?; Ok(rows.into_iter().map(|row| RecentPlayedWithTrack { track: Track { @@ -45,7 +46,7 @@ impl SqliteRecentRepository { }).collect()) } - pub async fn upsert(&self, track_id: i64, played_at: String) -> Result<(), String> { + pub async fn upsert(&self, track_id: i64, played_at: String) -> Result<(), AppError> { sqlx::query( "INSERT INTO recent_played (track_id, played_at) VALUES (?1, ?2) @@ -56,26 +57,26 @@ impl SqliteRecentRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn clear(&self) -> Result<(), String> { + pub async fn clear(&self) -> Result<(), AppError> { sqlx::query("DELETE FROM recent_played") .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn count(&self) -> Result { + pub async fn count(&self) -> Result { let row: (i64,) = sqlx::query_as("SELECT count(*) FROM recent_played") .fetch_one(&self.pool) .await - .map_err(|e| e.to_string())?; + .map_err(AppError::from)?; Ok(row.0) } - pub async fn remove_oldest(&self, keep: i64) -> Result<(), String> { + pub async fn remove_oldest(&self, keep: i64) -> Result<(), AppError> { sqlx::query( "DELETE FROM recent_played WHERE track_id NOT IN ( SELECT track_id FROM recent_played ORDER BY played_at DESC LIMIT ?1 @@ -85,7 +86,7 @@ impl SqliteRecentRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } } diff --git a/src-tauri/src/repositories/sqlite/track_repository.rs b/src-tauri/src/repositories/sqlite/track_repository.rs index b291352..4d3ac4a 100644 --- a/src-tauri/src/repositories/sqlite/track_repository.rs +++ b/src-tauri/src/repositories/sqlite/track_repository.rs @@ -1,5 +1,6 @@ use sqlx::{QueryBuilder, Sqlite, SqlitePool}; +use crate::errors::AppError; use crate::models::{NewTrack, SortDirection, Track, TrackSearchQuery, TrackSortBy, UpdateTrack}; #[derive(Clone)] @@ -12,7 +13,7 @@ impl SqliteTrackRepository { Self { pool } } - pub async fn create(&self, track: NewTrack) -> Result { + pub async fn create(&self, track: NewTrack) -> Result { sqlx::query_as::<_, Track>( "INSERT INTO tracks (title, artist, album, duration, path, cover, file_size, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), datetime('now')) @@ -27,10 +28,10 @@ impl SqliteTrackRepository { .bind(track.file_size) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn upsert_by_path(&self, track: NewTrack) -> Result { + pub async fn upsert_by_path(&self, track: NewTrack) -> Result { sqlx::query_as::<_, Track>( "INSERT INTO tracks (title, artist, album, duration, path, cover, file_size, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), datetime('now')) @@ -53,10 +54,10 @@ impl SqliteTrackRepository { .bind(track.file_size) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn update(&self, track: UpdateTrack) -> Result { + pub async fn update(&self, track: UpdateTrack) -> Result { sqlx::query_as::<_, Track>( "UPDATE tracks SET title = ?1, @@ -80,28 +81,28 @@ impl SqliteTrackRepository { .bind(track.id) .fetch_one(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn delete(&self, id: i64) -> Result<(), String> { + pub async fn delete(&self, id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM tracks WHERE id = ?1") .bind(id) .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn delete_by_path(&self, path: &str) -> Result<(), String> { + pub async fn delete_by_path(&self, path: &str) -> Result<(), AppError> { sqlx::query("DELETE FROM tracks WHERE path = ?1") .bind(path) .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn find_by_id(&self, id: i64) -> Result, String> { + pub async fn find_by_id(&self, id: i64) -> Result, AppError> { sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks WHERE id = ?1", @@ -109,10 +110,10 @@ impl SqliteTrackRepository { .bind(id) .fetch_optional(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn find_by_path(&self, path: &str) -> Result, String> { + pub async fn find_by_path(&self, path: &str) -> Result, AppError> { sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks WHERE path = ?1", @@ -120,20 +121,20 @@ impl SqliteTrackRepository { .bind(path) .fetch_optional(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn list_all(&self) -> Result, String> { + pub async fn list_all(&self) -> Result, AppError> { sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks ORDER BY title COLLATE NOCASE ASC", ) .fetch_all(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn search(&self, query: TrackSearchQuery) -> Result, String> { + pub async fn search(&self, query: TrackSearchQuery) -> Result, AppError> { let mut builder = QueryBuilder::::new( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks", ); @@ -174,10 +175,10 @@ impl SqliteTrackRepository { .build_query_as::() .fetch_all(&self.pool) .await - .map_err(|e| e.to_string()) + .map_err(AppError::from) } - pub async fn increment_play_count(&self, id: i64, played_at: String) -> Result<(), String> { + pub async fn increment_play_count(&self, id: i64, played_at: String) -> Result<(), AppError> { sqlx::query( "UPDATE tracks SET play_count = play_count + 1, last_played_at = ?1, updated_at = datetime('now') WHERE id = ?2", ) @@ -186,6 +187,6 @@ impl SqliteTrackRepository { .execute(&self.pool) .await .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(AppError::from) } } diff --git a/src-tauri/src/services/playback_events.rs b/src-tauri/src/services/playback_events.rs index 23070fd..8b5bdee 100644 --- a/src-tauri/src/services/playback_events.rs +++ b/src-tauri/src/services/playback_events.rs @@ -1,5 +1,6 @@ use tauri::{AppHandle, Emitter}; +use crate::errors::AppError; use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; use crate::models::playback::{ NativePlaybackState, PlaybackStateSnapshot, PlaybackTrackStartedPayload, @@ -7,17 +8,17 @@ use crate::models::playback::{ use crate::models::{PlaybackQueue, Track}; use crate::state::playback_state::{current_playback_snapshot, with_audio_state}; -pub fn status_snapshot() -> Result { - current_playback_snapshot() +pub fn status_snapshot() -> Result { + Ok(current_playback_snapshot()?) } -pub fn queue_snapshot() -> Result { - with_audio_state(|state| state.playback_queue.clone()) +pub fn queue_snapshot() -> Result { + Ok(with_audio_state(|state| state.playback_queue.clone())?) } pub fn emit_queue_changed( app_handle: &AppHandle, -) -> Result { +) -> Result { let payload = queue_snapshot()?; let _ = app_handle.emit( diff --git a/src-tauri/src/services/playback_service.rs b/src-tauri/src/services/playback_service.rs index 87a81e8..b013f83 100644 --- a/src-tauri/src/services/playback_service.rs +++ b/src-tauri/src/services/playback_service.rs @@ -1,4 +1,5 @@ use crate::audio::{get_audio_context, lock_audio_state, AudioGraph}; +use crate::errors::AppError; use crate::media_controls::update_media_controls_playback; use crate::models::playback::NativePlaybackState; use crate::models::Track; @@ -16,7 +17,7 @@ pub fn play_track_internal( app_handle: &AppHandle, track: Track, index: Option, -) -> Result<(), String> { +) -> Result<(), AppError> { let file_path = Path::new(&track.path); if !file_path.exists() { @@ -89,33 +90,33 @@ pub fn play_track_internal( Ok(()) } -pub fn play_current_index_internal(app_handle: &AppHandle, index: usize) -> Result<(), String> { +pub fn play_current_index_internal(app_handle: &AppHandle, index: usize) -> Result<(), AppError> { let track = with_audio_state(|state| state.playback_queue.require_track(index))??; play_track_internal(app_handle, track, Some(index)) } -pub async fn play_next_internal(app_handle: AppHandle) -> Result<(), String> { +pub async fn play_next_internal(app_handle: AppHandle) -> Result<(), AppError> { let index = with_audio_state(|state| state.playback_queue.move_next())? - .ok_or_else(|| "Queue is empty".to_string())?; + .ok_or_else(|| AppError::from("Queue is empty"))?; play_current_index_internal(&app_handle, index) } -pub async fn play_previous_internal(app_handle: AppHandle) -> Result<(), String> { +pub async fn play_previous_internal(app_handle: AppHandle) -> Result<(), AppError> { let index = with_audio_state(|state| state.playback_queue.move_previous())? - .ok_or_else(|| "Queue is empty".to_string())?; + .ok_or_else(|| AppError::from("Queue is empty"))?; play_current_index_internal(&app_handle, index) } -pub async fn resume_internal(app_handle: AppHandle) -> Result<(), String> { +pub async fn resume_internal(app_handle: AppHandle) -> Result<(), AppError> { let current_time = with_audio_state(|state| { if let Some(ref mut graph) = state.graph { graph.media.play(); state.playing = true; Ok(graph.media.current_time()) } else { - Err("No media available".to_string()) + Err(AppError::from("No media available")) } })??; @@ -123,14 +124,14 @@ pub async fn resume_internal(app_handle: AppHandle) -> Result<(), String> { Ok(()) } -pub async fn pause_internal(app_handle: AppHandle) -> Result<(), String> { +pub async fn pause_internal(app_handle: AppHandle) -> Result<(), AppError> { let current_time = with_audio_state(|state| { if let Some(ref mut graph) = state.graph { graph.media.pause(); state.playing = false; Ok(graph.media.current_time()) } else { - Err("No media available".to_string()) + Err(AppError::from("No media available")) } })??; @@ -138,7 +139,7 @@ pub async fn pause_internal(app_handle: AppHandle) -> Result<(), String> { Ok(()) } -pub async fn toggle_internal(app_handle: AppHandle) -> Result { +pub async fn toggle_internal(app_handle: AppHandle) -> Result { let (playing, current_time) = with_audio_state(|state| { if let Some(ref mut graph) = state.graph { let playing = if graph.media.paused() { @@ -151,7 +152,7 @@ pub async fn toggle_internal(app_handle: AppHandle) -> Result { state.playing = playing; Ok((playing, graph.media.current_time())) } else { - Err("No media available".to_string()) + Err(AppError::from("No media available")) } })??; @@ -165,7 +166,7 @@ pub async fn toggle_internal(app_handle: AppHandle) -> Result { Ok(playing) } -pub async fn stop_internal(app_handle: AppHandle) -> Result<(), String> { +pub async fn stop_internal(app_handle: AppHandle) -> Result<(), AppError> { with_audio_state(|state| { if let Some(ref mut graph) = state.graph { graph.media.pause(); @@ -178,7 +179,7 @@ pub async fn stop_internal(app_handle: AppHandle) -> Result<(), String> { Ok(()) } -pub async fn seek_internal(app_handle: AppHandle, time: f64) -> Result<(), String> { +pub async fn seek_internal(app_handle: AppHandle, time: f64) -> Result<(), AppError> { let playing = with_audio_state(|state| { let playing = if let Some(ref mut graph) = state.graph { graph.media.set_current_time(time); diff --git a/src-tauri/src/services/playlist_service.rs b/src-tauri/src/services/playlist_service.rs index 1f856b7..35c576f 100644 --- a/src-tauri/src/services/playlist_service.rs +++ b/src-tauri/src/services/playlist_service.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::errors::AppError; use crate::models::{AddPlaylistTrack, NewPlaylist, Playlist, PlaylistTrack, PlaylistWithTracks, RenamePlaylist}; use crate::repositories::sqlite::playlist_repository::SqlitePlaylistRepository; @@ -13,47 +14,47 @@ impl PlaylistService { Self { playlists } } - pub async fn create_playlist(&self, playlist: NewPlaylist) -> Result { + pub async fn create_playlist(&self, playlist: NewPlaylist) -> Result { self.playlists.create(playlist).await } - pub async fn rename_playlist(&self, playlist: RenamePlaylist) -> Result { + pub async fn rename_playlist(&self, playlist: RenamePlaylist) -> Result { self.playlists.rename(playlist).await } - pub async fn delete_playlist(&self, id: i64) -> Result<(), String> { + pub async fn delete_playlist(&self, id: i64) -> Result<(), AppError> { self.playlists.delete(id).await } - pub async fn get_playlist(&self, id: i64) -> Result, String> { + pub async fn get_playlist(&self, id: i64) -> Result, AppError> { self.playlists.find_by_id(id).await } - pub async fn list_playlists(&self) -> Result, String> { + pub async fn list_playlists(&self) -> Result, AppError> { self.playlists.list_all().await } - pub async fn list_playlists_with_tracks(&self) -> Result, String> { + pub async fn list_playlists_with_tracks(&self) -> Result, AppError> { self.playlists.list_with_tracks().await } - pub async fn get_playlist_with_tracks(&self, id: i64) -> Result, String> { + pub async fn get_playlist_with_tracks(&self, id: i64) -> Result, AppError> { self.playlists.get_with_tracks(id).await } - pub async fn add_track(&self, track: AddPlaylistTrack) -> Result { + pub async fn add_track(&self, track: AddPlaylistTrack) -> Result { self.playlists.add_track(track).await } - pub async fn remove_track(&self, playlist_id: i64, track_id: i64) -> Result<(), String> { + pub async fn remove_track(&self, playlist_id: i64, track_id: i64) -> Result<(), AppError> { self.playlists.remove_track(playlist_id, track_id).await } - pub async fn clear_tracks(&self, playlist_id: i64) -> Result<(), String> { + pub async fn clear_tracks(&self, playlist_id: i64) -> Result<(), AppError> { self.playlists.clear_tracks(playlist_id).await } - pub async fn reorder_track(&self, playlist_id: i64, track_id: i64, position: i64) -> Result<(), String> { + pub async fn reorder_track(&self, playlist_id: i64, track_id: i64, position: i64) -> Result<(), AppError> { self.playlists.reorder_track(playlist_id, track_id, position).await } } \ No newline at end of file diff --git a/src-tauri/src/services/recent_service.rs b/src-tauri/src/services/recent_service.rs index 6b9de69..5fca5be 100644 --- a/src-tauri/src/services/recent_service.rs +++ b/src-tauri/src/services/recent_service.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::errors::AppError; use crate::models::RecentPlayedWithTrack; use crate::repositories::sqlite::recent_repository::{SqliteRecentRepository}; @@ -15,11 +16,11 @@ impl RecentService { Self { recent } } - pub async fn load(&self, limit: i64, offset: i64) -> Result, String> { + pub async fn load(&self, limit: i64, offset: i64) -> Result, AppError> { self.recent.list_with_tracks(limit, offset).await } - pub async fn add(&self, track_id: i64, played_at: String) -> Result<(), String> { + pub async fn add(&self, track_id: i64, played_at: String) -> Result<(), AppError> { self.recent.upsert(track_id, played_at).await?; let count = self.recent.count().await?; @@ -31,7 +32,7 @@ impl RecentService { Ok(()) } - pub async fn clear(&self) -> Result<(), String> { + pub async fn clear(&self) -> Result<(), AppError> { self.recent.clear().await } } diff --git a/src-tauri/src/services/track_service.rs b/src-tauri/src/services/track_service.rs index 419e402..c456518 100644 --- a/src-tauri/src/services/track_service.rs +++ b/src-tauri/src/services/track_service.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::errors::AppError; use crate::models::{NewTrack, Track, TrackSearchQuery, UpdateTrack}; use crate::repositories::sqlite::track_repository::SqliteTrackRepository; @@ -13,43 +14,43 @@ impl TrackService { Self { tracks } } - pub async fn create_track(&self, track: NewTrack) -> Result { + pub async fn create_track(&self, track: NewTrack) -> Result { self.tracks.create(track).await } - pub async fn upsert_track(&self, track: NewTrack) -> Result { + pub async fn upsert_track(&self, track: NewTrack) -> Result { self.tracks.upsert_by_path(track).await } - pub async fn update_track(&self, track: UpdateTrack) -> Result { + pub async fn update_track(&self, track: UpdateTrack) -> Result { self.tracks.update(track).await } - pub async fn delete_track(&self, id: i64) -> Result<(), String> { + pub async fn delete_track(&self, id: i64) -> Result<(), AppError> { self.tracks.delete(id).await } - pub async fn delete_track_by_path(&self, path: &str) -> Result<(), String> { + pub async fn delete_track_by_path(&self, path: &str) -> Result<(), AppError> { self.tracks.delete_by_path(path).await } - pub async fn get_track(&self, id: i64) -> Result, String> { + pub async fn get_track(&self, id: i64) -> Result, AppError> { self.tracks.find_by_id(id).await } - pub async fn get_track_by_path(&self, path: &str) -> Result, String> { + pub async fn get_track_by_path(&self, path: &str) -> Result, AppError> { self.tracks.find_by_path(path).await } - pub async fn list_tracks(&self) -> Result, String> { + pub async fn list_tracks(&self) -> Result, AppError> { self.tracks.list_all().await } - pub async fn search_tracks(&self, query: TrackSearchQuery) -> Result, String> { + pub async fn search_tracks(&self, query: TrackSearchQuery) -> Result, AppError> { self.tracks.search(query).await } - pub async fn mark_track_played(&self, id: i64, played_at: String) -> Result<(), String> { + pub async fn mark_track_played(&self, id: i64, played_at: String) -> Result<(), AppError> { self.tracks.increment_play_count(id, played_at).await } } \ No newline at end of file diff --git a/src-tauri/src/state/playback_state.rs b/src-tauri/src/state/playback_state.rs index 4b8ab13..16dacbf 100644 --- a/src-tauri/src/state/playback_state.rs +++ b/src-tauri/src/state/playback_state.rs @@ -1,12 +1,13 @@ use std::path::Path; pub use crate::audio::lock_audio_state; use crate::audio::AudioState; +use crate::errors::AppError; use crate::models::playback::{ NativeTrackMetadata, PlaybackStateSnapshot, PlaybackTrackInfo, }; use crate::models::Track; -pub fn with_audio_state(f: impl FnOnce(&mut AudioState) -> T) -> Result { +pub fn with_audio_state(f: impl FnOnce(&mut AudioState) -> T) -> Result { let mut state = lock_audio_state()?; Ok(f(&mut state)) } @@ -41,7 +42,7 @@ pub fn current_playback_state(state: &mut AudioState) -> PlaybackStateSnapshot { } } -pub fn current_playback_snapshot() -> Result { +pub fn current_playback_snapshot() -> Result { with_audio_state(current_playback_state) } diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index 4209d62..63f948f 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -151,7 +151,7 @@ class Player { ) } - async insertTracksNext(tracks: Track[]) { + async insertTracksAsNext(tracks: Track[]) { const payload = await invoke( 'insert_tracks_as_next', { tracks } @@ -167,7 +167,7 @@ class Player { async removeTrack(trackId: number) { const payload = await invoke( - 'remove_track_from_backend_queue', + 'remove_track_from_queue', { trackId } ) diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index f8180d3..4e215ff 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -12,8 +12,8 @@ import SettingsRow from '$lib/features/settings/SettingsRow.svelte' import { settings } from '$lib/state/settings.svelte' import { - scanDirectory, removeMusicDirectory, + scanDirectory, } from '$lib/utils/library' import { invoke } from '@tauri-apps/api/core' @@ -80,7 +80,7 @@ ) function appendTracksToPlaylist(tracks: Track[]) { - player.insertTracksNext(tracks) + player.insertTracksAsNext(tracks) } function addAllToPlaylist() { @@ -186,7 +186,7 @@ async function removeContextTrackFromList(track: Track) { musicLibrary.tracks = musicLibrary.tracks.filter(item => item.id !== track.id) - await player.removeTrackFromBackendQueue(track.id) + await player.removeTrack(track.id) } async function deleteContextTrackFile(track: Track) { From 6c91cc25bb899ef30d3646586e73f118bba81b6f Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Wed, 3 Jun 2026 23:08:18 +0800 Subject: [PATCH 27/39] eat: Add an AudioEngine trait and a WebAudioEngine implementation - Replace legacy AudioGraph with trait-based AudioEngine and boxed engine storage in AudioState. - Introduce central event system (AppPayload, emit_event helpers) replacing playback_events module. - Refactor playback services to interact directly with engine methods (play, pause, seek). - Update command signatures to unit returns and move to event-driven state updates. - Standardize data models: rename PlaybackStateSnapshot to PlaybackStatusSnapshot and PlaybackTrackSnapshot to PlaybackTrackInfo. - Update frontend to consume unified global-app-event channel and streamline player state logic. - Perform widespread cleanup of unused modules and legacy code." --- src-tauri/src/audio/engine.rs | 11 ++ src-tauri/src/audio/graph.rs | 16 -- src-tauri/src/audio/mod.rs | 19 +- src-tauri/src/audio/state.rs | 19 +- src-tauri/src/audio/web_audio.rs | 84 +++++++++ src-tauri/src/commands/library.rs | 11 +- src-tauri/src/commands/playback.rs | 41 +++-- src-tauri/src/commands/playback_queue.rs | 15 +- src-tauri/src/commands/settings.rs | 22 ++- src-tauri/src/events.rs | 66 +++++++ src-tauri/src/lib.rs | 15 +- src-tauri/src/media_controls.rs | 20 +- src-tauri/src/models/playback.rs | 52 +----- src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/playback_events.rs | 65 ------- src-tauri/src/services/playback_service.rs | 154 +++++----------- src-tauri/src/state/playback_state.rs | 44 ++--- src/lib/state/player.svelte.ts | 205 ++++++--------------- src/lib/state/settings.svelte.ts | 22 ++- src/lib/types/events.ts | 10 + src/lib/types/index.ts | 3 +- src/lib/types/playback.ts | 4 +- src/routes/+layout.svelte | 20 +- 23 files changed, 425 insertions(+), 494 deletions(-) create mode 100644 src-tauri/src/audio/engine.rs delete mode 100644 src-tauri/src/audio/graph.rs create mode 100644 src-tauri/src/audio/web_audio.rs create mode 100644 src-tauri/src/events.rs delete mode 100644 src-tauri/src/services/playback_events.rs create mode 100644 src/lib/types/events.ts diff --git a/src-tauri/src/audio/engine.rs b/src-tauri/src/audio/engine.rs new file mode 100644 index 0000000..4392224 --- /dev/null +++ b/src-tauri/src/audio/engine.rs @@ -0,0 +1,11 @@ +use crate::errors::AppError; + +pub trait AudioEngine: Send { + fn play(&mut self) -> Result<(), AppError>; + fn pause(&mut self); + fn seek(&mut self, time: f64); + fn set_volume(&self, volume: f32); + fn set_pan(&self, pan: f32); + fn current_time(&self) -> f64; + fn paused(&self) -> bool; +} \ No newline at end of file diff --git a/src-tauri/src/audio/graph.rs b/src-tauri/src/audio/graph.rs deleted file mode 100644 index 4471ff0..0000000 --- a/src-tauri/src/audio/graph.rs +++ /dev/null @@ -1,16 +0,0 @@ -use web_audio_api::{MediaElement, node::{AudioNode, GainNode, MediaElementAudioSourceNode, StereoPannerNode}}; -pub struct AudioGraph { - pub media: MediaElement, - pub src: MediaElementAudioSourceNode, - pub gain: GainNode, - pub panner: StereoPannerNode, -} - -impl AudioGraph { - pub fn destroy(self) { - let _ = self.panner.disconnect(); - let _ = self.gain.disconnect(); - let _ = self.src.disconnect(); - self.media.pause(); - } -} \ No newline at end of file diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index eec73bc..57fd9e4 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,21 +1,12 @@ -pub mod graph; +pub mod engine; +pub mod web_audio; pub mod state; -pub use graph::AudioGraph; +pub use engine::AudioEngine; +pub use web_audio::WebAudioEngine; pub use state::{AudioState, init_audio_state, lock_audio_state}; -use web_audio_api::{MediaElement, context::AudioContext}; +use web_audio_api::context::AudioContext; pub fn get_audio_context() -> AudioContext { AudioContext::default() -} - -pub fn with_media(state: &mut AudioState, f: F) -> Result -where - F: FnOnce(&mut MediaElement) -> R, -{ - if let Some(ref mut graph) = state.graph { - Ok(f(&mut graph.media)) - } else { - Err("Audio graph is missing".into()) - } } \ No newline at end of file diff --git a/src-tauri/src/audio/state.rs b/src-tauri/src/audio/state.rs index 338d9d4..5e286b3 100644 --- a/src-tauri/src/audio/state.rs +++ b/src-tauri/src/audio/state.rs @@ -1,5 +1,5 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; -use crate::audio::graph::AudioGraph; +use crate::audio::engine::AudioEngine; use crate::errors::AppError; use crate::models::PlaybackQueue; @@ -11,7 +11,7 @@ pub enum PlayMode { } pub struct AudioState { - pub graph: Option, + pub engine: Option>, pub volume: f32, pub pan: f32, pub playing: bool, @@ -21,12 +21,25 @@ pub struct AudioState { pub play_mode: PlayMode, } +impl AudioState { + pub fn with_engine(&mut self, f: F) -> Result + where + F: FnOnce(&mut Box) -> R, + { + if let Some(ref mut engine) = self.engine { + Ok(f(engine)) + } else { + Err(AppError::from("Audio engine is missing")) + } + } +} + static AUDIO_STATE: OnceLock> = OnceLock::new(); pub fn init_audio_state(queue: PlaybackQueue) { AUDIO_STATE.get_or_init(|| { Mutex::new(AudioState { - graph: None, + engine: None, volume: 1.0, pan: 0.0, playing: false, diff --git a/src-tauri/src/audio/web_audio.rs b/src-tauri/src/audio/web_audio.rs new file mode 100644 index 0000000..c23ee7e --- /dev/null +++ b/src-tauri/src/audio/web_audio.rs @@ -0,0 +1,84 @@ +use std::path::Path; +use web_audio_api::{ + MediaElement, context::BaseAudioContext, node::{AudioNode, GainNode, MediaElementAudioSourceNode, StereoPannerNode} +}; +use crate::errors::AppError; +use crate::audio::engine::AudioEngine; +use crate::audio::get_audio_context; + +pub struct WebAudioEngine { + media: MediaElement, + src: MediaElementAudioSourceNode, + gain: GainNode, + panner: StereoPannerNode, +} + +impl WebAudioEngine { + pub fn new(file_path: &Path, volume: f32, pan: f32) -> Result { + let context = get_audio_context(); + let _ = context.resume(); + + let mut media = web_audio_api::MediaElement::new(file_path) + .map_err(|e| format!("Failed to create media element: {}", e))?; + + let src = context.create_media_element_source(&mut media); + let gain = context.create_gain(); + let panner = context.create_stereo_panner(); + + src.connect(&gain); + gain.connect(&panner); + panner.connect(&context.destination()); + + media.set_loop(false); + media.set_current_time(0.0); + gain.gain().set_value(volume); + panner.pan().set_value(pan); + + Ok(WebAudioEngine { + media, + src, + gain, + panner, + }) + } +} + +impl AudioEngine for WebAudioEngine { + fn play(&mut self) -> Result<(), AppError> { + self.media.play(); + Ok(()) + } + + fn pause(&mut self) { + self.media.pause(); + } + + fn seek(&mut self, time: f64) { + self.media.set_current_time(time); + } + + fn set_volume(&self, volume: f32) { + self.gain.gain().set_value(volume); + } + + fn set_pan(&self, pan: f32) { + self.panner.pan().set_value(pan); + } + + fn current_time(&self) -> f64 { + self.media.current_time() + } + + fn paused(&self) -> bool { + self.media.paused() + } +} + +impl Drop for WebAudioEngine { + fn drop(&mut self) { + let _ = self.panner.disconnect(); + let _ = self.gain.disconnect(); + let _ = self.src.disconnect(); + self.media.pause(); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index 9399bb0..a71b449 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -12,7 +12,7 @@ use walkdir::WalkDir; use crate::commands::playback::remove_track_from_queue; use crate::errors::AppError; use crate::metadata::parse_single_track; -use crate::models::{NewTrack, PlaybackQueue, Track}; +use crate::models::{NewTrack, Track}; use crate::state::AppState; const SUPPORTED_EXT: [&str; 5] = ["mp3", "flac", "m4a", "wav", "ogg"]; @@ -164,12 +164,15 @@ pub async fn delete_track_file( app_handle: tauri::AppHandle, path: String, track_id: i64, -) -> Result { +) -> Result<(), AppError> { + // 1. 验证并删除物理文件 let file_path = validate_audio_path(&path)?; trash::delete(file_path) .map_err(|e| format!("移至回收站失败 {}: {}", path, e))?; - let queue = remove_track_from_queue(app_handle, track_id).await?; - Ok(queue) + + remove_track_from_queue(app_handle, track_id).await?; + + Ok(()) } #[command] diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 6e62933..7672565 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -2,15 +2,15 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; use crate::errors::AppError; use crate::models::playback::PlaybackStatusSnapshot; -use crate::models::{PlaybackQueue, Track}; -use crate::services::playback_events::{emit_queue_changed, status_snapshot}; +use crate::models::Track; +use crate::events::emit_queue_changed; use crate::services::playback_service::{ handle_media_control_event_internal, load_track_for_playback, pause_internal, play_current_index_internal, play_next_internal, play_previous_internal, play_track_internal, resume_internal, seek_internal, stop_internal, toggle_internal, }; use crate::state::playback_state::{ - current_time_from_state, lock_audio_state, sanitize_track, with_audio_state, + current_playback_snapshot, current_time_from_state, lock_audio_state, sanitize_track, with_audio_state }; pub use crate::services::playback_service::spawn_playback_progress_task; @@ -41,38 +41,36 @@ pub async fn sync_playback_queue( pub async fn remove_track_from_queue( app_handle: AppHandle, track_id: i64, -) -> Result { +) -> Result<(), AppError> { let result = { let mut state = lock_audio_state()?; - + if !state.playback_queue.contains_track(track_id) { - return Ok(state.playback_queue.payload()); + return Ok(()); } - state.playback_queue.remove_track(track_id) }; - let payload = emit_queue_changed(&app_handle)?; - if result.should_stop { - let _ = stop_internal(app_handle).await; + stop_internal(app_handle.clone()).await?; } else if let Some(index) = result.play_index { - let _ = play_current_index_internal(&app_handle, index); + play_current_index_internal(&app_handle, index)?; } - Ok(payload) + emit_queue_changed(&app_handle)?; + + Ok(()) } #[command] pub async fn insert_track_as_next( - app_handle: AppHandle, + _app_handle: AppHandle, track: Track, -) -> Result { +) -> Result<(), AppError> { with_audio_state(|state| { state.playback_queue.insert_next(track); })?; - - emit_queue_changed(&app_handle) + Ok(()) } #[command] @@ -135,7 +133,7 @@ pub async fn current_time() -> f64 { #[command] pub async fn get_current_status() -> Result { - status_snapshot().map(|s| s.status_payload()) + current_playback_snapshot() } #[command] @@ -144,16 +142,19 @@ pub async fn set_current_time(app_handle: AppHandle, time: f64) { } #[command] -pub async fn set_volume(volume: u8) -> Result<(), AppError> { +pub async fn set_volume(app_handle: tauri::AppHandle, volume: f32) -> Result<(), AppError> { let mut state = lock_audio_state()?; let volume_f32 = volume as f32 / 100.0; let safe_volume = volume_f32.clamp(0.0, 1.0); state.volume = safe_volume; - if let Some(ref graph) = state.graph { - graph.gain.gain().set_value(safe_volume); + if let Some(ref engine) = state.engine { + engine.set_volume(safe_volume); } + crate::events::emit_event(&app_handle, crate::events::AppPayload::VolumeChanged(safe_volume)) + .map_err(|e| AppError::from(e.to_string()))?; + Ok(()) } \ No newline at end of file diff --git a/src-tauri/src/commands/playback_queue.rs b/src-tauri/src/commands/playback_queue.rs index c099fc4..a42a823 100644 --- a/src-tauri/src/commands/playback_queue.rs +++ b/src-tauri/src/commands/playback_queue.rs @@ -2,7 +2,7 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; use crate::errors::AppError; use crate::models::{PlaybackQueue, Track}; -use crate::services::playback_events::{emit_queue_changed, queue_snapshot}; +use crate::events::emit_queue_changed; use crate::services::playback_service::{play_current_index_internal, stop_internal}; use crate::state::playback_state::{lock_audio_state, with_audio_state}; @@ -12,35 +12,36 @@ pub fn get_playback_queue() -> Result { } #[command] -pub async fn clear_queue(app: AppHandle) -> Result { +pub async fn clear_queue(app: AppHandle) -> Result<(), AppError> { { let mut state = lock_audio_state()?; state.playback_queue.clear(); } + stop_internal(app.clone()).await?; emit_queue_changed(&app)?; - queue_snapshot() + Ok(()) } #[command] -pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result { +pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result<(), AppError> { { let mut state = lock_audio_state()?; state.play_mode = mode; state.playback_queue.play_mode = mode; } emit_queue_changed(&app)?; - queue_snapshot() + Ok(()) } #[command] -pub fn insert_tracks_as_next(app: AppHandle, tracks: Vec) -> Result { +pub fn insert_tracks_as_next(app: AppHandle, tracks: Vec) -> Result<(), AppError> { { let mut state = lock_audio_state()?; state.playback_queue.insert_tracks_next(tracks); } emit_queue_changed(&app)?; - queue_snapshot() + Ok(()) } #[command] diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 5203893..a7079c3 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,8 +1,8 @@ -use tauri::{State, command}; - +use tauri::{State, command, AppHandle}; use crate::errors::AppError; use crate::models::AppSettings; use crate::state::AppState; +use crate::events::{emit_event, AppPayload}; #[command] pub async fn get_settings(state: State<'_, AppState>) -> Result { @@ -11,10 +11,16 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result, settings: AppSettings, ) -> Result { - state.settings.update_settings(settings).await + let updated_settings = state.settings.update_settings(settings).await?; + + emit_event(&app_handle, AppPayload::SettingsChanged(updated_settings.clone())) + .map_err(|e| AppError::from(e.to_string()))?; + + Ok(updated_settings) } #[command] @@ -24,8 +30,14 @@ pub async fn load_settings(state: State<'_, AppState>) -> Result, settings: AppSettings, ) -> Result { - state.settings.update_settings(settings).await -} + let updated_settings = state.settings.update_settings(settings).await?; + + emit_event(&app_handle, AppPayload::SettingsChanged(updated_settings.clone())) + .map_err(|e| AppError::from(e.to_string()))?; + + Ok(updated_settings) +} \ No newline at end of file diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..c872d17 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,66 @@ +use tauri::Emitter; + +use crate::errors::AppError; +use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; +use crate::models::{AppSettings, PlaybackQueue, Track}; +use crate::state::playback_state::{current_playback_snapshot, metadata_from_track, with_audio_state}; + +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackStatePayload { + pub playing: bool, + pub current_time: f64, +} + +#[derive(Clone, serde::Serialize)] +#[serde(tag = "type", content = "payload")] +pub enum AppPayload { + VolumeChanged(f32), + SettingsChanged(AppSettings), + TrackStarted { track: Track, index: usize }, + PlaybackProgress { current_time: f64 }, + QueueChanged(PlaybackQueue), + PlaybackStateChanged(PlaybackStatePayload), +} + +pub fn emit_event(app_handle: &tauri::AppHandle, payload: AppPayload) -> Result<(), AppError> { + app_handle + .emit("global-app-event", payload) + .map_err(|e| AppError::from(e.to_string())) +} + +pub fn emit_queue_changed(app_handle: &tauri::AppHandle) -> Result<(), AppError> { + let queue = with_audio_state(|state| state.playback_queue.clone())?; + emit_event(app_handle, AppPayload::QueueChanged(queue)) +} + +pub fn emit_track_started( + app_handle: &tauri::AppHandle, + track: Track, + index: usize, +) -> Result<(), AppError> { + update_media_controls_metadata(metadata_from_track(&track)); + emit_event(app_handle, AppPayload::TrackStarted { track, index }) +} + +pub fn sync_playback_state( + app_handle: &tauri::AppHandle, + playing: bool, + current_time: f64, +) -> Result<(), AppError> { + let payload = PlaybackStatePayload { playing, current_time }; + update_media_controls_playback(payload.clone()); + emit_event(app_handle, AppPayload::PlaybackStateChanged(payload)) +} + +pub fn emit_progress(app_handle: &tauri::AppHandle) -> Result<(), AppError> { + if let Ok(snapshot) = current_playback_snapshot() { + emit_event( + app_handle, + AppPayload::PlaybackProgress { + current_time: snapshot.current_time, + }, + )?; + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad6ba1a..771d170 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod models; mod repositories; mod services; mod state; +mod events; use std::sync::Arc; @@ -28,6 +29,7 @@ use commands::playback::{ resume_track, set_current_time, set_volume, spawn_playback_progress_task, stop_track, sync_playback_queue, toggle_track, }; +use services::playback_service::{play_next_internal, toggle_internal}; use commands::recent::{add_recently_played, clear_recently_played, load_recently_played}; @@ -164,13 +166,16 @@ pub fn run() { app_handle.exit(0); } "play" => { - let _ = app_handle.emit("menu-play", ()); + let handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = toggle_internal(handle).await; + }); } "next" => { - let _ = app_handle.emit("menu-next", ()); - } - "delete" => { - let _ = app_handle.emit("menu-delete", ()); + let handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = play_next_internal(handle).await; + }); } _ => {} } diff --git a/src-tauri/src/media_controls.rs b/src-tauri/src/media_controls.rs index d041449..c7e98ba 100644 --- a/src-tauri/src/media_controls.rs +++ b/src-tauri/src/media_controls.rs @@ -8,12 +8,13 @@ use souvlaki::{MediaControls, MediaMetadata, MediaPlayback, MediaPosition, Platf use tauri::{AppHandle, Manager, WebviewWindow}; use crate::commands::playback::handle_media_control_event; -use crate::models::playback::{NativePlaybackState, NativeTrackMetadata}; +use crate::events::PlaybackStatePayload; +use crate::models::playback::NativeTrackMetadata; #[derive(Clone)] enum MediaControlMessage { Metadata(NativeTrackMetadata), - Playback(NativePlaybackState), + Playback(PlaybackStatePayload), } static MEDIA_CONTROL_SENDER: OnceLock> = OnceLock::new(); @@ -69,14 +70,11 @@ pub fn init_media_controls(app_handle: AppHandle) -> Result<(), String> { let _ = controls.set_metadata(metadata); } MediaControlMessage::Playback(state) => { - let playback = match state { - NativePlaybackState::Playing { current_time } => MediaPlayback::Playing { - progress: Some(MediaPosition(Duration::from_secs_f64(current_time))), - }, - NativePlaybackState::Paused { current_time } => MediaPlayback::Paused { - progress: Some(MediaPosition(Duration::from_secs_f64(current_time))), - }, - NativePlaybackState::Stopped => MediaPlayback::Stopped, + let progress = Some(MediaPosition(Duration::from_secs_f64(state.current_time))); + let playback = if state.playing { + MediaPlayback::Playing { progress } + } else { + MediaPlayback::Paused { progress } }; let _ = controls.set_playback(playback); } @@ -99,6 +97,6 @@ pub fn update_media_controls_metadata(track: NativeTrackMetadata) { send(MediaControlMessage::Metadata(track)); } -pub fn update_media_controls_playback(state: NativePlaybackState) { +pub fn update_media_controls_playback(state: PlaybackStatePayload) { send(MediaControlMessage::Playback(state)); } diff --git a/src-tauri/src/models/playback.rs b/src-tauri/src/models/playback.rs index 18d6c26..6ff4ce1 100644 --- a/src-tauri/src/models/playback.rs +++ b/src-tauri/src/models/playback.rs @@ -10,27 +10,13 @@ pub struct NativeTrackMetadata { pub duration: Option, } -#[derive(Clone)] -pub enum NativePlaybackState { - Playing { current_time: f64 }, - Paused { current_time: f64 }, - Stopped, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PlaybackTrackSnapshot { - pub id: Option, - pub path: Option, -} - #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlaybackStatusSnapshot { pub has_media: bool, pub playing: bool, pub current_time: f64, - pub track: Option, + pub track: Option, } #[derive(Clone, Serialize)] @@ -51,30 +37,14 @@ pub struct PlaybackTrackStartedPayload { pub current_time: f64, } -#[derive(Clone)] +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct PlaybackTrackInfo { pub id: Option, pub path: Option, } -#[derive(Clone)] -pub struct PlaybackStateSnapshot { - pub has_media: bool, - pub playing: bool, - pub current_time: f64, - pub track: Option, -} - -impl From for PlaybackTrackSnapshot { - fn from(value: PlaybackTrackInfo) -> Self { - Self { - id: value.id, - path: value.path, - } - } -} - -impl PlaybackStateSnapshot { +impl PlaybackStatusSnapshot { pub fn status_payload(self) -> PlaybackStatusSnapshot { PlaybackStatusSnapshot { has_media: self.has_media, @@ -94,18 +64,4 @@ impl PlaybackStateSnapshot { path: track.path.clone(), }) } - - pub fn native_playback_state(&self) -> NativePlaybackState { - if !self.has_media { - NativePlaybackState::Stopped - } else if self.playing { - NativePlaybackState::Playing { - current_time: self.current_time, - } - } else { - NativePlaybackState::Paused { - current_time: self.current_time, - } - } - } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 33676d0..3e1bfe4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,3 @@ -pub mod playback_events; pub mod playback_service; pub mod playlist_service; pub mod recent_service; diff --git a/src-tauri/src/services/playback_events.rs b/src-tauri/src/services/playback_events.rs deleted file mode 100644 index 8b5bdee..0000000 --- a/src-tauri/src/services/playback_events.rs +++ /dev/null @@ -1,65 +0,0 @@ -use tauri::{AppHandle, Emitter}; - -use crate::errors::AppError; -use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; -use crate::models::playback::{ - NativePlaybackState, PlaybackStateSnapshot, PlaybackTrackStartedPayload, -}; -use crate::models::{PlaybackQueue, Track}; -use crate::state::playback_state::{current_playback_snapshot, with_audio_state}; - -pub fn status_snapshot() -> Result { - Ok(current_playback_snapshot()?) -} - -pub fn queue_snapshot() -> Result { - Ok(with_audio_state(|state| state.playback_queue.clone())?) -} - -pub fn emit_queue_changed( - app_handle: &AppHandle, -) -> Result { - let payload = queue_snapshot()?; - - let _ = app_handle.emit( - "playback:queue-changed", - payload.clone(), - ); - - Ok(payload) -} - -pub fn emit_track_started(app_handle: &AppHandle, track: Track, index: usize) { - let payload = PlaybackTrackStartedPayload { - track, - index, - playing: true, - current_time: 0.0, - }; - let _ = app_handle.emit("playback:track-started", payload); -} - -pub fn sync_playback_snapshot( - app_handle: &AppHandle, - snapshot: PlaybackStateSnapshot, -) { - if let Some(payload) = snapshot.progress_payload() { - update_media_controls_playback(snapshot.native_playback_state()); - let _ = app_handle.emit("playback:progress", payload); - } -} - -pub fn sync_playback_state(app_handle: &AppHandle, state: NativePlaybackState) { - update_media_controls_playback(state); - emit_progress(app_handle); -} - -pub fn emit_progress(app_handle: &AppHandle) { - if let Ok(snapshot) = current_playback_snapshot() { - sync_playback_snapshot(app_handle, snapshot); - } -} - -pub fn notify_track_metadata(track: &Track) { - update_media_controls_metadata(crate::state::playback_state::metadata_from_track(track)); -} diff --git a/src-tauri/src/services/playback_service.rs b/src-tauri/src/services/playback_service.rs index b013f83..212760c 100644 --- a/src-tauri/src/services/playback_service.rs +++ b/src-tauri/src/services/playback_service.rs @@ -1,20 +1,14 @@ -use crate::audio::{get_audio_context, lock_audio_state, AudioGraph}; +use crate::audio::{AudioEngine, WebAudioEngine, lock_audio_state}; use crate::errors::AppError; -use crate::media_controls::update_media_controls_playback; -use crate::models::playback::NativePlaybackState; +use crate::events::{emit_progress, emit_track_started, sync_playback_state}; use crate::models::Track; -use crate::services::playback_events::{ - emit_progress, emit_track_started, notify_track_metadata, sync_playback_state, -}; use crate::state::playback_state::{current_time_from_state, minimal_track, with_audio_state}; use souvlaki::{MediaControlEvent, SeekDirection}; use std::path::Path; use tauri::AppHandle; -use web_audio_api::context::BaseAudioContext; -use web_audio_api::node::AudioNode; pub fn play_track_internal( - app_handle: &AppHandle, + app_handle: &tauri::AppHandle, track: Track, index: Option, ) -> Result<(), AppError> { @@ -24,40 +18,14 @@ pub fn play_track_internal( return Err("音频文件不存在或已被移动".into()); } - let context = get_audio_context(); - let _ = context.resume(); - let mut state = lock_audio_state()?; - if let Some(old_graph) = state.graph.take() { - old_graph.media.pause(); - old_graph.destroy(); - } - - let mut media = web_audio_api::MediaElement::new(file_path) - .map_err(|e| format!("Failed to create media element: {}", e))?; - - let src = context.create_media_element_source(&mut media); - let gain = context.create_gain(); - let panner = context.create_stereo_panner(); - - src.connect(&gain); - gain.connect(&panner); - panner.connect(&context.destination()); - - media.set_loop(false); - media.set_current_time(0.0); - gain.gain().set_value(state.volume); - panner.pan().set_value(state.pan); + state.engine = None; - let graph = AudioGraph { - media, - src, - gain, - panner, - }; + let mut engine = WebAudioEngine::new(file_path, state.volume, state.pan)?; + engine.play()?; - state.graph = Some(graph); + state.engine = Some(Box::new(engine)); state.current_path = Some(track.path.clone()); state.current_track_id = Some(track.id); @@ -65,27 +33,13 @@ pub fn play_track_internal( state.playback_queue.current_index = Some(next_index); } - let play_result = state.graph.as_mut().map(|g| g.media.play()); - - if play_result.is_none() { - state.playing = false; - if let Some(failed_graph) = state.graph.take() { - failed_graph.destroy(); - } - return Err("Playback failed to start because audio graph is missing".into()); - } - state.playing = true; let target_index = index.or(state.playback_queue.current_index).unwrap_or(0); drop(state); - notify_track_metadata(&track); - - update_media_controls_playback(NativePlaybackState::Playing { current_time: 0.0 }); - - emit_track_started(app_handle, track, target_index); + emit_track_started(app_handle, track, target_index)?; Ok(()) } @@ -111,93 +65,73 @@ pub async fn play_previous_internal(app_handle: AppHandle) -> Result<(), AppErro pub async fn resume_internal(app_handle: AppHandle) -> Result<(), AppError> { let current_time = with_audio_state(|state| { - if let Some(ref mut graph) = state.graph { - graph.media.play(); + let res = state.with_engine(|engine| { + engine.play()?; + Ok(engine.current_time()) + }).and_then(|inner| inner); + + if res.is_ok() { state.playing = true; - Ok(graph.media.current_time()) - } else { - Err(AppError::from("No media available")) } + res })??; - sync_playback_state(&app_handle, NativePlaybackState::Playing { current_time }); + sync_playback_state(&app_handle, true, current_time)?; Ok(()) } pub async fn pause_internal(app_handle: AppHandle) -> Result<(), AppError> { let current_time = with_audio_state(|state| { - if let Some(ref mut graph) = state.graph { - graph.media.pause(); - state.playing = false; - Ok(graph.media.current_time()) - } else { - Err(AppError::from("No media available")) - } + let res = state.with_engine(|engine| { + engine.pause(); + engine.current_time() + }); + if res.is_ok() { state.playing = false; } + res })??; - sync_playback_state(&app_handle, NativePlaybackState::Paused { current_time }); + sync_playback_state(&app_handle, false, current_time)?; Ok(()) } pub async fn toggle_internal(app_handle: AppHandle) -> Result { let (playing, current_time) = with_audio_state(|state| { - if let Some(ref mut graph) = state.graph { - let playing = if graph.media.paused() { - graph.media.play(); - true - } else { - graph.media.pause(); - false - }; - state.playing = playing; - Ok((playing, graph.media.current_time())) - } else { - Err(AppError::from("No media available")) - } + let res = state.with_engine(|engine| { + if engine.paused() { engine.play()?; Ok((true, engine.current_time())) } + else { engine.pause(); Ok((false, engine.current_time())) } + }).and_then(|i| i); + if let Ok((p, _)) = res { state.playing = p; } + res })??; - let state = if playing { - NativePlaybackState::Playing { current_time } - } else { - NativePlaybackState::Paused { current_time } - }; - - sync_playback_state(&app_handle, state); + sync_playback_state(&app_handle, playing, current_time)?; Ok(playing) } pub async fn stop_internal(app_handle: AppHandle) -> Result<(), AppError> { with_audio_state(|state| { - if let Some(ref mut graph) = state.graph { - graph.media.pause(); - graph.media.set_current_time(0.0); - } + let _ = state.with_engine(|engine| { + engine.pause(); + engine.seek(0.0); + }); state.playing = false; })?; - sync_playback_state(&app_handle, NativePlaybackState::Stopped); + sync_playback_state(&app_handle, false, 0.0)?; Ok(()) } pub async fn seek_internal(app_handle: AppHandle, time: f64) -> Result<(), AppError> { let playing = with_audio_state(|state| { - let playing = if let Some(ref mut graph) = state.graph { - graph.media.set_current_time(time); - !graph.media.paused() - } else { - false - }; - state.playing = playing; - playing - })?; - - let state = if playing { - NativePlaybackState::Playing { current_time: time } - } else { - NativePlaybackState::Paused { current_time: time } - }; + let res = state.with_engine(|engine| { + engine.seek(time); + !engine.paused() + }); + if let Ok(p) = res { state.playing = p; } + res + })??; - sync_playback_state(&app_handle, state); + sync_playback_state(&app_handle, playing, time)?; Ok(()) } @@ -223,7 +157,7 @@ pub fn load_track_for_playback(full_path: &str, track_id: Option) -> Track pub fn spawn_playback_progress_task(app_handle: AppHandle) { std::thread::spawn(move || loop { - emit_progress(&app_handle); + let _ = emit_progress(&app_handle); if crate::state::playback_state::should_advance_track() { let local_handle = app_handle.clone(); diff --git a/src-tauri/src/state/playback_state.rs b/src-tauri/src/state/playback_state.rs index 16dacbf..0af43c9 100644 --- a/src-tauri/src/state/playback_state.rs +++ b/src-tauri/src/state/playback_state.rs @@ -3,7 +3,7 @@ pub use crate::audio::lock_audio_state; use crate::audio::AudioState; use crate::errors::AppError; use crate::models::playback::{ - NativeTrackMetadata, PlaybackStateSnapshot, PlaybackTrackInfo, + NativeTrackMetadata, PlaybackStatusSnapshot, PlaybackTrackInfo }; use crate::models::Track; @@ -19,30 +19,30 @@ pub fn playback_track_info(state: &AudioState) -> PlaybackTrackInfo { } } -pub fn current_playback_state(state: &mut AudioState) -> PlaybackStateSnapshot { - let Some(ref graph) = state.graph else { - state.playing = false; - return PlaybackStateSnapshot { - has_media: false, - playing: false, - current_time: 0.0, - track: None, - }; - }; - - let playing = !graph.media.paused(); - let current_time = graph.media.current_time(); - state.playing = playing; - - PlaybackStateSnapshot { - has_media: true, - playing, - current_time, - track: Some(playback_track_info(state)), +pub fn current_playback_state(state: &mut AudioState) -> PlaybackStatusSnapshot { + match state.with_engine(|engine| (!engine.paused(), engine.current_time())) { + Ok((playing, current_time)) => { + state.playing = playing; + PlaybackStatusSnapshot { + has_media: true, + playing, + current_time, + track: Some(playback_track_info(state)), + } + } + Err(_) => { + state.playing = false; + PlaybackStatusSnapshot { + has_media: false, + playing: false, + current_time: 0.0, + track: None, + } + } } } -pub fn current_playback_snapshot() -> Result { +pub fn current_playback_snapshot() -> Result { with_audio_state(current_playback_state) } diff --git a/src/lib/state/player.svelte.ts b/src/lib/state/player.svelte.ts index 63f948f..9d34448 100644 --- a/src/lib/state/player.svelte.ts +++ b/src/lib/state/player.svelte.ts @@ -1,4 +1,4 @@ -import type { PlaybackProgressPayload, PlaybackStatusSnapshot, PlaybackTrackStartedPayload, PlayMode } from "$lib/types" +import type { GlobalAppEvent, PlaybackStatusSnapshot, PlayMode } from "$lib/types" import { invoke } from "@tauri-apps/api/core" import { listen, type UnlistenFn } from "@tauri-apps/api/event" import type { PlaybackQueue, Track } from "../types/music" @@ -18,9 +18,7 @@ class Player { #muted = $state(false) #volume = $state(80) #previousVolume = 80 - #progressUnlisten: UnlistenFn | null = null - #trackStartedUnlisten: UnlistenFn | null = null - #queueChangedUnlisten: UnlistenFn | null = null + #globalUnlisten: UnlistenFn | null = null constructor() { this.#setupMediaSession() @@ -99,36 +97,23 @@ class Player { get currentTrack(): Track | undefined { const index = this.queue.currentIndex - - if ( - index === null || - index < 0 || - index >= this.queue.tracks.length - ) { + if (index === null || index < 0 || index >= this.queue.tracks.length) { return undefined } - return this.queue.tracks[index] } get volume() { return this.#volume } set volume(volume: number) { - this.#volume = volume - if (volume > 0) { - this.#muted = false - } invoke('set_volume', { volume }).catch(console.error) } get muted() { return this.#muted } set muted(value: boolean) { - this.#muted = value if (value) { this.#previousVolume = this.#volume - this.#volume = 0 invoke('set_volume', { volume: 0 }).catch(console.error) } else { - this.#volume = this.#previousVolume invoke('set_volume', { volume: this.#previousVolume }).catch(console.error) } } @@ -138,79 +123,35 @@ class Player { this.queue = queue } - async replacePlaylistAndPlay( - tracks: Track[], - targetId: number - ) { - await invoke( - 'replace_playlist_and_play', - { - tracks, - targetId - } - ) + async replacePlaylistAndPlay(tracks: Track[], targetId: number) { + await invoke('replace_playlist_and_play', { tracks, targetId }) } async insertTracksAsNext(tracks: Track[]) { - const payload = await invoke( - 'insert_tracks_as_next', - { tracks } - ) - - this.#applyBackendQueue(payload) + await invoke('insert_tracks_as_next', { tracks }) } async insertTrackAsNext(track: Track) { - const payload = await invoke('insert_track_as_next', { track }) - this.#applyBackendQueue(payload) + await invoke('insert_track_as_next', { track }) } async removeTrack(trackId: number) { - const payload = await invoke( - 'remove_track_from_queue', - { trackId } - ) - - this.#applyBackendQueue(payload) + await invoke('remove_track_from_queue', { trackId }) } async playTrackInQueue(index: number) { - await invoke( - 'play_queue_track', - { index } - ) + await invoke('play_queue_track', { index }) } async cyclePlayMode() { - const modes: PlayMode[] = [ - 'list', - 'single', - 'shuffle' - ] - - const index = modes.indexOf( - this.queue.playMode - ) - - const mode = - modes[(index + 1) % modes.length] - - const queue = await invoke( - 'set_play_mode', - { mode } - ) - - this.#applyBackendQueue(queue) - } - - toggleQueue() { - this.queueOpen = !this.queueOpen + const modes: PlayMode[] = ['list', 'single', 'shuffle'] + const index = modes.indexOf(this.queue.playMode) + const mode = modes[(index + 1) % modes.length] + await invoke('set_play_mode', { mode }) } async clearQueue() { - const queue = await invoke('clear_queue') - - this.#applyBackendQueue(queue) + await invoke('clear_queue') } async next() { @@ -224,8 +165,6 @@ class Player { resume = async () => { try { await invoke('resume_track') - this.playing = true - this.#syncMediaSession() } catch (err) { console.error(err) } @@ -234,8 +173,6 @@ class Player { pause = async () => { try { await invoke('pause_track') - this.playing = false - this.#syncMediaSession() } catch (err) { console.error(err) } @@ -251,87 +188,67 @@ class Player { seek = async (time: number) => { await invoke('set_current_time', { time }) - this.currentTime = time - this.#updatePositionState() + } + + toggleQueue() { + this.queueOpen = !this.queueOpen } async loadState() { await this.#setupPlaybackListeners() - const queue = - await invoke( - 'get_playback_queue' - ) - - this.#applyBackendQueue(queue) - - const status = - await invoke( - 'get_current_status' - ) - - this.#applyBackendStatus(status) - } - - #applyBackendStatus(status: PlaybackStatusSnapshot) { - if (!status.hasMedia || !status.track) { - this.playing = false - this.currentTime = 0 - this.#syncMediaSession() - return - } + const queue = await invoke('get_playback_queue') + this.queue = queue - this.currentTime = status.currentTime + const status = await invoke('get_current_status') this.playing = status.playing + this.currentTime = status.currentTime this.#syncMediaSession() } - #handleBackendProgress(payload: PlaybackProgressPayload) { - this.currentTime = - payload.currentTime - - this.playing = - payload.playing - - this.#syncMediaSession() - } - - #handleTrackStarted(payload: PlaybackTrackStartedPayload) { - this.currentTime = payload.currentTime - this.playing = payload.playing - this.queue = { ...this.queue, currentIndex: payload.index } - this.#syncMediaSession() - - recentlyPlayed.add(payload.track) - } - async #setupPlaybackListeners() { - if (!this.#progressUnlisten) { - this.#progressUnlisten = await listen('playback:progress', event => { - this.#handleBackendProgress(event.payload) - }) - } - - if (!this.#trackStartedUnlisten) { - this.#trackStartedUnlisten = await listen('playback:track-started', event => { - this.#handleTrackStarted(event.payload) - }) - } - - if (!this.#queueChangedUnlisten) { - this.#queueChangedUnlisten = await listen('playback:queue-changed', event => { - this.#applyBackendQueue(event.payload) - }) - } + if (this.#globalUnlisten) return + + this.#globalUnlisten = await listen('global-app-event', event => { + const { type, payload } = event.payload + + switch (type) { + case 'PlaybackProgress': + this.currentTime = payload.current_time + this.#updatePositionState() + break + + case 'TrackStarted': + this.currentTime = 0 + this.playing = true + this.queue = { ...this.queue, currentIndex: payload.index } + this.#syncMediaSession() + recentlyPlayed.add(payload.track) + break + + case 'VolumeChanged': + this.#volume = Math.round(payload * 100) + this.#muted = this.#volume === 0 + break + + case 'QueueChanged': + this.queue = payload + break + + case 'PlaybackStateChanged': + this.playing = payload.playing + this.currentTime = payload.currentTime + this.#syncMediaSession() + break + } + }) } destroy() { - this.#progressUnlisten?.() - this.#progressUnlisten = null - this.#trackStartedUnlisten?.() - this.#trackStartedUnlisten = null - this.#queueChangedUnlisten?.() - this.#queueChangedUnlisten = null + if (this.#globalUnlisten) { + this.#globalUnlisten() + this.#globalUnlisten = null + } } } diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index d9ba1f8..e8bf611 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -33,36 +33,38 @@ class SettingsState { } } + updateLocalState(newSettings: AppSettings) { + this.data = newSettings + } + update(patch: Partial) { - this.data = { + const nextSettings = { ...this.data, ...patch, } - this.scheduleSave() + this.scheduleSave(nextSettings) } - async save(): Promise { + async save(targetSettings: AppSettings): Promise { this.error = null try { - this.data = await invoke('save_settings', { - settings: this.data, + await invoke('save_settings', { + settings: targetSettings, }) - return this.data } catch (error) { console.error(error) this.error = String(error) - return this.data } } - scheduleSave() { + scheduleSave(targetSettings: AppSettings) { if (this.#saveTimer) { clearTimeout(this.#saveTimer) } this.#saveTimer = setTimeout(() => { - void this.save() + void this.save(targetSettings) }, 300) } @@ -84,4 +86,4 @@ class SettingsState { } } -export const settings = new SettingsState() +export const settings = new SettingsState() \ No newline at end of file diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts new file mode 100644 index 0000000..8ed496f --- /dev/null +++ b/src/lib/types/events.ts @@ -0,0 +1,10 @@ +import type { PlaybackQueue, Track } from "./music" +import type { AppSettings } from "./settings" + +export type GlobalAppEvent = + | { type: 'VolumeChanged'; payload: number } + | { type: 'SettingsChanged'; payload: AppSettings } + | { type: 'TrackStarted'; payload: { track: Track; index: number } } + | { type: 'PlaybackProgress'; payload: { current_time: number } } + | { type: 'QueueChanged'; payload: PlaybackQueue } + | { type: 'PlaybackStateChanged'; payload: { playing: boolean; currentTime: number } } \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 3bd7581..dde6c71 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,8 +1,9 @@ +export type { GlobalAppEvent } from './events' export type { AppExtension, ExtensionCommand, ExtensionNavItem } from './extension' export type { Album, Artist, Playlist, Track } from './music' -export type { BackendQueueTrack, PlayMode, PlaybackProgressPayload, PlaybackStatusSnapshot, PlaybackTrackSnapshot, PlaybackTrackStartedPayload } from './playback' +export type { BackendQueueTrack, PlayMode, PlaybackProgressPayload, PlaybackStatusSnapshot, PlaybackTrackInfo, PlaybackTrackStartedPayload } from './playback' export type { AppSettings, ThemeMode } from './settings' diff --git a/src/lib/types/playback.ts b/src/lib/types/playback.ts index fd216d4..0314a17 100644 --- a/src/lib/types/playback.ts +++ b/src/lib/types/playback.ts @@ -2,7 +2,7 @@ import type { Track } from "./music" export type PlayMode = 'list' | 'single' | 'shuffle' -export type PlaybackTrackSnapshot = { +export type PlaybackTrackInfo = { id?: number | null path?: string | null } @@ -11,7 +11,7 @@ export type PlaybackStatusSnapshot = { hasMedia: boolean playing: boolean currentTime: number - track: PlaybackTrackSnapshot | null + track: PlaybackTrackInfo | null } export type PlaybackProgressPayload = { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 07afaa5..cb66f1b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,7 +3,6 @@ import QueueDrawer from '$lib/features/QueueDrawer.svelte' import Appbar from '$lib/features/shell/Appbar.svelte' import NavRail from '$lib/features/shell/NavRail.svelte' - import { musicLibrary } from '$lib/state/library.svelte' import { player } from '$lib/state/player.svelte' import { settings } from '$lib/state/settings.svelte' import 'mdui' @@ -16,19 +15,28 @@ let { children } = $props() - onMount(() => { +onMount(() => { void settings.load() - void musicLibrary.load() void player.loadState() - const unlisten = listen('tray:navigate', event => { + const trayUnlistenPromise = listen('tray:navigate', event => { if (event.payload === 'settings') { - goto('/settings') + void goto('/settings') + } + }) + + const globalEventUnlistenPromise = listen<{ type: string; payload: any }>('global-app-event', event => { + const { type, payload } = event.payload + + if (type === 'SettingsChanged') { + settings.updateLocalState(payload) } }) return () => { - unlisten.then(fn => fn()) + void trayUnlistenPromise.then(unlisten => unlisten()) + void globalEventUnlistenPromise.then(unlisten => unlisten()) + player.destroy() } }) From 1fd3fcfea00f462b3c037351b36567e0d7c56d48 Mon Sep 17 00:00:00 2001 From: kino <1491037864@qq.com> Date: Fri, 5 Jun 2026 21:31:10 +0800 Subject: [PATCH 28/39] Refactor playback, events, repos and async I/O - Introduce an EventBus and typed AppEvent payloads - Remove the old events module and replace direct emit_* calls with EventBus::emit - Refactor playback logic into a PlaybackService (methods for play/resume/pause/next/previous/toggle/seek) - Add a media_control_service to handle system media events - Make playback queue changes (versioning, insert_next behavior) and sanitize queue operations - Convert many repository DB calls to use ? and return Result (fixing awaits and error propagation) - Add tokio dependency and migrate file/dir operations and delete_track_files to async tokio fs - Update lib startup to initialize services, handle async scan on startup, and wire tray/menu events to the new services - Misc: adjust models/playback types, move media control handling, and export/consume PlaybackService across commands --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/audio/state.rs | 9 +- src-tauri/src/commands/library.rs | 8 +- src-tauri/src/commands/playback.rs | 86 ++-- src-tauri/src/commands/playback_queue.rs | 28 +- src-tauri/src/commands/settings.rs | 12 +- src-tauri/src/db/manager.rs | 8 +- src-tauri/src/events.rs | 66 --- src-tauri/src/events/bus.rs | 23 + src-tauri/src/events/mod.rs | 5 + src-tauri/src/events/payloads.rs | 43 ++ src-tauri/src/lib.rs | 77 ++-- src-tauri/src/models/playback.rs | 38 +- src-tauri/src/models/playback_queue.rs | 38 +- .../sqlite/playlist_repository.rs | 75 ++-- .../repositories/sqlite/recent_repository.rs | 27 +- .../sqlite/settings_repository.rs | 13 +- .../repositories/sqlite/track_repository.rs | 69 +-- .../src/services/media_control_service.rs | 50 +++ src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/playback_service.rs | 412 ++++++++++-------- src-tauri/src/state/playback_state.rs | 23 +- src/lib/components/base/MduiSlider.svelte | 12 +- src/lib/features/PlayerBar.svelte | 45 +- src/lib/features/TrackList.svelte | 43 +- src/lib/state/covers.svelte.ts | 6 - src/lib/state/library.svelte.ts | 37 +- src/lib/state/player.svelte.ts | 117 +++-- src/lib/state/recent.svelte.ts | 11 +- src/lib/types/events.ts | 9 +- src/lib/types/index.ts | 2 +- src/lib/types/music.ts | 1 + src/lib/types/playback.ts | 17 +- src/routes/+layout.svelte | 9 +- src/routes/album/+page.svelte | 13 +- 36 files changed, 801 insertions(+), 634 deletions(-) delete mode 100644 src-tauri/src/events.rs create mode 100644 src-tauri/src/events/bus.rs create mode 100644 src-tauri/src/events/mod.rs create mode 100644 src-tauri/src/events/payloads.rs create mode 100644 src-tauri/src/services/media_control_service.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index da1a0a4..41aea51 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4872,6 +4872,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-store", + "tokio", "trash", "walkdir", "web-audio-api", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8cac7dc..1450a21 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,4 +33,5 @@ souvlaki = "0.8.3" sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "macros", "migrate"] } async-trait = "0.1" rand = "0.10.1" +tokio = { version = "1.52.3", features = ["fs"] } diff --git a/src-tauri/src/audio/state.rs b/src-tauri/src/audio/state.rs index 5e286b3..c3ad44e 100644 --- a/src-tauri/src/audio/state.rs +++ b/src-tauri/src/audio/state.rs @@ -18,7 +18,6 @@ pub struct AudioState { pub current_track_id: Option, pub current_path: Option, pub playback_queue: PlaybackQueue, - pub play_mode: PlayMode, } impl AudioState { @@ -32,6 +31,13 @@ impl AudioState { Err(AppError::from("Audio engine is missing")) } } + + pub fn play_mode(&self) -> PlayMode { + self.playback_queue.play_mode + } + pub fn set_play_mode(&mut self, mode: PlayMode) { + self.playback_queue.play_mode = mode; + } } static AUDIO_STATE: OnceLock> = OnceLock::new(); @@ -46,7 +52,6 @@ pub fn init_audio_state(queue: PlaybackQueue) { current_track_id: None, current_path: None, playback_queue: queue, - play_mode: PlayMode::ListLoop, }) }); } diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index a71b449..c88ae47 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -1,5 +1,4 @@ use std::collections::HashSet; -use std::fs; use std::path::Path; use std::process::Command; @@ -23,6 +22,7 @@ fn is_supported_audio_file(path: &Path) -> bool { .map(|ext| SUPPORTED_EXT.contains(&ext.to_lowercase().as_str())) .unwrap_or(false) } + fn scan_single_directory(dir: &str) -> Result, AppError> { let root_path = Path::new(dir); @@ -195,7 +195,7 @@ pub fn trash_track_files(paths: Vec) -> Result<(), AppError> { } #[command] -pub fn delete_track_files(paths: Vec) -> Result<(), AppError> { +pub async fn delete_track_files(paths: Vec) -> Result<(), AppError> { for path in paths { let file_path = Path::new(&path); @@ -207,7 +207,9 @@ pub fn delete_track_files(paths: Vec) -> Result<(), AppError> { return Err(format!("拒绝删除非音频文件: {}", path).into()); } - fs::remove_file(file_path).map_err(|e| format!("删除文件失败 {}: {}", path, e))?; + tokio::fs::remove_file(file_path) + .await + .map_err(|e| format!("删除文件失败 {}: {}", path, e))?; } Ok(()) diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 7672565..421ba07 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -1,39 +1,37 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; use crate::errors::AppError; +use crate::events::{AppEvent, EventBus}; use crate::models::playback::PlaybackStatusSnapshot; use crate::models::Track; -use crate::events::emit_queue_changed; -use crate::services::playback_service::{ - handle_media_control_event_internal, load_track_for_playback, pause_internal, - play_current_index_internal, play_next_internal, play_previous_internal, play_track_internal, - resume_internal, seek_internal, stop_internal, toggle_internal, -}; +use crate::services::playback_service::PlaybackService; use crate::state::playback_state::{ current_playback_snapshot, current_time_from_state, lock_audio_state, sanitize_track, with_audio_state }; -pub use crate::services::playback_service::spawn_playback_progress_task; - -pub async fn handle_media_control_event(app_handle: AppHandle, event: souvlaki::MediaControlEvent) { - handle_media_control_event_internal(app_handle, event).await; -} +pub use crate::services::media_control_service::handle_media_control_event; #[command] pub async fn sync_playback_queue( + app_handle: AppHandle, playlist: Vec, current_index: Option, play_mode: PlayMode, history: Vec, ) -> Result<(), AppError> { - let mut state = lock_audio_state()?; - state.play_mode = play_mode; - state.playback_queue.sync( - playlist.into_iter().map(sanitize_track).collect(), - current_index, - play_mode, - history, - ); + { + let mut state = lock_audio_state()?; + + state.playback_queue.sync( + playlist.into_iter().map(sanitize_track).collect(), + current_index, + play_mode, + history, + ); + } + + PlaybackService::emit_queue_changed(&app_handle)?; + Ok(()) } @@ -42,24 +40,7 @@ pub async fn remove_track_from_queue( app_handle: AppHandle, track_id: i64, ) -> Result<(), AppError> { - let result = { - let mut state = lock_audio_state()?; - - if !state.playback_queue.contains_track(track_id) { - return Ok(()); - } - state.playback_queue.remove_track(track_id) - }; - - if result.should_stop { - stop_internal(app_handle.clone()).await?; - } else if let Some(index) = result.play_index { - play_current_index_internal(&app_handle, index)?; - } - - emit_queue_changed(&app_handle)?; - - Ok(()) + PlaybackService::new(app_handle).remove_track_from_queue(track_id).await } #[command] @@ -75,25 +56,25 @@ pub async fn insert_track_as_next( #[command] pub async fn play_queue_track(app_handle: AppHandle, index: usize) -> Result<(), AppError> { - play_current_index_internal(&app_handle, index)?; + PlaybackService::new(app_handle).play_queue_index(index)?; Ok(()) } #[command] pub async fn play_next_track(app_handle: AppHandle) -> Result<(), AppError> { - play_next_internal(app_handle).await?; + PlaybackService::new(app_handle).next().await?; Ok(()) } #[command] pub async fn play_previous_track(app_handle: AppHandle) -> Result<(), AppError> { - play_previous_internal(app_handle).await?; + PlaybackService::new(app_handle).previous().await?; Ok(()) } #[command] pub async fn stop_track(app_handle: AppHandle) -> Result<(), AppError> { - stop_internal(app_handle).await?; + PlaybackService::new(app_handle).stop().await?; Ok(()) } @@ -103,26 +84,27 @@ pub async fn play_track( full_path: String, track_id: Option, ) -> Result { - let track = load_track_for_playback(&full_path, track_id); - play_track_internal(&app_handle, track, None)?; + let service = PlaybackService::new(app_handle); + let track = service.load_track_for_playback(&full_path, track_id); + service.play_track(track, None)?; Ok(format!("Playing track: {}", full_path)) } #[command] pub async fn resume_track(app_handle: AppHandle) -> Result<(), AppError> { - resume_internal(app_handle).await?; + PlaybackService::new(app_handle).resume().await?; Ok(()) } #[command] pub async fn pause_track(app_handle: AppHandle) -> Result<(), AppError> { - pause_internal(app_handle).await?; + PlaybackService::new(app_handle).pause().await?; Ok(()) } #[command] pub async fn toggle_track(app_handle: AppHandle) -> Result { - let status = toggle_internal(app_handle).await?; + let status = PlaybackService::new(app_handle).toggle().await?; Ok(status) } @@ -137,15 +119,14 @@ pub async fn get_current_status() -> Result { } #[command] -pub async fn set_current_time(app_handle: AppHandle, time: f64) { - let _ = seek_internal(app_handle, time).await; +pub async fn set_current_time(app_handle: AppHandle, time: f64) -> Result<(), AppError> { + PlaybackService::new(app_handle).seek(time).await } #[command] pub async fn set_volume(app_handle: tauri::AppHandle, volume: f32) -> Result<(), AppError> { let mut state = lock_audio_state()?; - let volume_f32 = volume as f32 / 100.0; - let safe_volume = volume_f32.clamp(0.0, 1.0); + let safe_volume = (volume/ 100.0).clamp(0.0, 1.0); state.volume = safe_volume; @@ -153,8 +134,7 @@ pub async fn set_volume(app_handle: tauri::AppHandle, volume: f32) -> Result<(), engine.set_volume(safe_volume); } - crate::events::emit_event(&app_handle, crate::events::AppPayload::VolumeChanged(safe_volume)) - .map_err(|e| AppError::from(e.to_string()))?; + EventBus::emit(&app_handle, AppEvent::VolumeChanged(safe_volume))?; Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/commands/playback_queue.rs b/src-tauri/src/commands/playback_queue.rs index a42a823..a645443 100644 --- a/src-tauri/src/commands/playback_queue.rs +++ b/src-tauri/src/commands/playback_queue.rs @@ -2,8 +2,7 @@ use tauri::{command, AppHandle}; use crate::audio::state::PlayMode; use crate::errors::AppError; use crate::models::{PlaybackQueue, Track}; -use crate::events::emit_queue_changed; -use crate::services::playback_service::{play_current_index_internal, stop_internal}; +use crate::services::playback_service::PlaybackService; use crate::state::playback_state::{lock_audio_state, with_audio_state}; #[command] @@ -12,41 +11,40 @@ pub fn get_playback_queue() -> Result { } #[command] -pub async fn clear_queue(app: AppHandle) -> Result<(), AppError> { +pub async fn clear_queue(app_handle: AppHandle) -> Result<(), AppError> { { let mut state = lock_audio_state()?; state.playback_queue.clear(); } - - stop_internal(app.clone()).await?; - emit_queue_changed(&app)?; + + PlaybackService::new(app_handle.clone()).stop().await?; + PlaybackService::emit_queue_changed(&app_handle)?; Ok(()) } #[command] -pub fn set_play_mode(app: AppHandle, mode: PlayMode) -> Result<(), AppError> { +pub fn set_play_mode(app_handle: AppHandle, mode: PlayMode) -> Result<(), AppError> { { let mut state = lock_audio_state()?; - state.play_mode = mode; state.playback_queue.play_mode = mode; } - emit_queue_changed(&app)?; + PlaybackService::emit_queue_changed(&app_handle)?; Ok(()) } #[command] -pub fn insert_tracks_as_next(app: AppHandle, tracks: Vec) -> Result<(), AppError> { +pub fn insert_tracks_as_next(app_handle: AppHandle, tracks: Vec) -> Result<(), AppError> { { let mut state = lock_audio_state()?; - state.playback_queue.insert_tracks_next(tracks); + state.playback_queue.insert_tracks_as_next(tracks); } - emit_queue_changed(&app)?; + PlaybackService::emit_queue_changed(&app_handle)?; Ok(()) } #[command] pub async fn replace_playlist_and_play( - app: AppHandle, + app_handle: AppHandle, tracks: Vec, target_id: i64, ) -> Result<(), AppError> { @@ -54,7 +52,7 @@ pub async fn replace_playlist_and_play( let mut state = lock_audio_state()?; state.playback_queue.replace_playlist(tracks, target_id)? }; - emit_queue_changed(&app)?; - play_current_index_internal(&app, play_index)?; + PlaybackService::emit_queue_changed(&app_handle)?; + PlaybackService::new(app_handle).play_queue_index(play_index)?; Ok(()) } \ No newline at end of file diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index a7079c3..c5544b3 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,8 +1,8 @@ use tauri::{State, command, AppHandle}; use crate::errors::AppError; +use crate::events::{AppEvent, EventBus}; use crate::models::AppSettings; use crate::state::AppState; -use crate::events::{emit_event, AppPayload}; #[command] pub async fn get_settings(state: State<'_, AppState>) -> Result { @@ -17,9 +17,8 @@ pub async fn update_settings( ) -> Result { let updated_settings = state.settings.update_settings(settings).await?; - emit_event(&app_handle, AppPayload::SettingsChanged(updated_settings.clone())) - .map_err(|e| AppError::from(e.to_string()))?; - + EventBus::emit(&app_handle, AppEvent::SettingsChanged(updated_settings.clone()))?; + Ok(updated_settings) } @@ -36,8 +35,7 @@ pub async fn save_settings( ) -> Result { let updated_settings = state.settings.update_settings(settings).await?; - emit_event(&app_handle, AppPayload::SettingsChanged(updated_settings.clone())) - .map_err(|e| AppError::from(e.to_string()))?; - + EventBus::emit(&app_handle, AppEvent::SettingsChanged(updated_settings.clone()))?; + Ok(updated_settings) } \ No newline at end of file diff --git a/src-tauri/src/db/manager.rs b/src-tauri/src/db/manager.rs index 9c327f7..dfa4a46 100644 --- a/src-tauri/src/db/manager.rs +++ b/src-tauri/src/db/manager.rs @@ -1,4 +1,3 @@ -use std::fs; use std::path::PathBuf; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; @@ -17,8 +16,9 @@ impl DatabaseManager { let database_path = database_path(app_handle)?; if let Some(parent) = database_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent).map_err(AppError::from)?; + let exists = tokio::fs::try_exists(parent).await.unwrap_or(false); + if !exists { + tokio::fs::create_dir_all(parent).await.map_err(AppError::from)?; } } @@ -56,4 +56,4 @@ fn database_path(app_handle: &tauri::AppHandle) -> Result { .map_err(AppError::from)?; Ok(app_data_path.join("music.sqlite")) -} +} \ No newline at end of file diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs deleted file mode 100644 index c872d17..0000000 --- a/src-tauri/src/events.rs +++ /dev/null @@ -1,66 +0,0 @@ -use tauri::Emitter; - -use crate::errors::AppError; -use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; -use crate::models::{AppSettings, PlaybackQueue, Track}; -use crate::state::playback_state::{current_playback_snapshot, metadata_from_track, with_audio_state}; - -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PlaybackStatePayload { - pub playing: bool, - pub current_time: f64, -} - -#[derive(Clone, serde::Serialize)] -#[serde(tag = "type", content = "payload")] -pub enum AppPayload { - VolumeChanged(f32), - SettingsChanged(AppSettings), - TrackStarted { track: Track, index: usize }, - PlaybackProgress { current_time: f64 }, - QueueChanged(PlaybackQueue), - PlaybackStateChanged(PlaybackStatePayload), -} - -pub fn emit_event(app_handle: &tauri::AppHandle, payload: AppPayload) -> Result<(), AppError> { - app_handle - .emit("global-app-event", payload) - .map_err(|e| AppError::from(e.to_string())) -} - -pub fn emit_queue_changed(app_handle: &tauri::AppHandle) -> Result<(), AppError> { - let queue = with_audio_state(|state| state.playback_queue.clone())?; - emit_event(app_handle, AppPayload::QueueChanged(queue)) -} - -pub fn emit_track_started( - app_handle: &tauri::AppHandle, - track: Track, - index: usize, -) -> Result<(), AppError> { - update_media_controls_metadata(metadata_from_track(&track)); - emit_event(app_handle, AppPayload::TrackStarted { track, index }) -} - -pub fn sync_playback_state( - app_handle: &tauri::AppHandle, - playing: bool, - current_time: f64, -) -> Result<(), AppError> { - let payload = PlaybackStatePayload { playing, current_time }; - update_media_controls_playback(payload.clone()); - emit_event(app_handle, AppPayload::PlaybackStateChanged(payload)) -} - -pub fn emit_progress(app_handle: &tauri::AppHandle) -> Result<(), AppError> { - if let Ok(snapshot) = current_playback_snapshot() { - emit_event( - app_handle, - AppPayload::PlaybackProgress { - current_time: snapshot.current_time, - }, - )?; - } - Ok(()) -} diff --git a/src-tauri/src/events/bus.rs b/src-tauri/src/events/bus.rs new file mode 100644 index 0000000..4036c25 --- /dev/null +++ b/src-tauri/src/events/bus.rs @@ -0,0 +1,23 @@ +use tauri::{ + AppHandle, + Emitter, +}; + +use crate::{ + errors::AppError, + events::payloads::AppEvent, +}; + +pub struct EventBus; + +impl EventBus { + pub const CHANNEL: &'static str = "global-app-event"; + + pub fn emit( + app_handle: &AppHandle, + event: AppEvent, + ) -> Result<(), AppError> { + app_handle.emit(Self::CHANNEL, event)?; + Ok(()) + } +} \ No newline at end of file diff --git a/src-tauri/src/events/mod.rs b/src-tauri/src/events/mod.rs new file mode 100644 index 0000000..1acf885 --- /dev/null +++ b/src-tauri/src/events/mod.rs @@ -0,0 +1,5 @@ +pub mod bus; +pub mod payloads; + +pub use bus::*; +pub use payloads::*; \ No newline at end of file diff --git a/src-tauri/src/events/payloads.rs b/src-tauri/src/events/payloads.rs new file mode 100644 index 0000000..886fba3 --- /dev/null +++ b/src-tauri/src/events/payloads.rs @@ -0,0 +1,43 @@ +use serde::Serialize; + +use crate::models::{ + AppSettings, + PlaybackQueue, + Track, +}; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackStatePayload { + pub playing: bool, + pub current_time: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackProgressPayload { + pub current_time: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackStartedPayload { + pub track: Track, + pub index: usize, +} + +#[derive(Clone, Serialize)] +#[serde(tag = "type", content = "payload")] +pub enum AppEvent { + VolumeChanged(f32), + + SettingsChanged(AppSettings), + + QueueChanged(PlaybackQueue), + + PlaybackStateChanged(PlaybackStatePayload), + + PlaybackProgress(PlaybackProgressPayload), + + TrackStarted(TrackStartedPayload), +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 771d170..6c0eb46 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,17 +19,17 @@ use tauri::{ }; use commands::library::{ - delete_track_file, delete_track_files, get_track_cover, load_track_library, - scan_track_directories, show_in_folder, trash_track_files, execute_scan + delete_track_file, delete_track_files, execute_scan, get_track_cover, load_track_library, + scan_track_directories, show_in_folder, trash_track_files, }; use commands::playback::{ current_time, get_current_status, insert_track_as_next, pause_track, play_next_track, - play_previous_track, play_queue_track, play_track, remove_track_from_queue, - resume_track, set_current_time, set_volume, spawn_playback_progress_task, stop_track, - sync_playback_queue, toggle_track, + play_previous_track, play_queue_track, play_track, remove_track_from_queue, resume_track, + set_current_time, set_volume, stop_track, sync_playback_queue, + toggle_track, }; -use services::playback_service::{play_next_internal, toggle_internal}; +use services::playback_service::PlaybackService; use commands::recent::{add_recently_played, clear_recently_played, load_recently_played}; @@ -61,6 +61,8 @@ use commands::playback_queue::{ use audio::init_audio_state; +use crate::errors::AppError; + pub fn init_startup_scan(_app_handle: tauri::AppHandle) {} #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -70,24 +72,27 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { let app_handle = app.handle().clone(); - let database = tauri::async_runtime::block_on(DatabaseManager::new(&app_handle)) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; + + let database = tauri::async_runtime::block_on(DatabaseManager::new(&app_handle))?; let pool = database.pool().clone(); + let track_repository = Arc::new(SqliteTrackRepository::new(pool.clone())); let playlist_repository = Arc::new(SqlitePlaylistRepository::new(pool.clone())); let setting_repository = Arc::new(SqliteSettingsRepository::new(pool.clone())); let recent_repository = Arc::new(SqliteRecentRepository::new(pool)); let setting_service = Arc::new(SettingsService::new(setting_repository)); - tauri::async_runtime::block_on(setting_service.get_settings()).map_err(|error| { - std::io::Error::new(std::io::ErrorKind::Other, error.to_string()) - })?; + tauri::async_runtime::block_on(setting_service.get_settings())?; + + let track_service = Arc::new(TrackService::new(track_repository)); + let playlist_service = Arc::new(PlaylistService::new(playlist_repository)); + let recent_service = Arc::new(RecentService::new(recent_repository)); let app_state = AppState::new( - Arc::new(TrackService::new(track_repository)), - Arc::new(PlaylistService::new(playlist_repository)), + track_service, + playlist_service, setting_service, - Arc::new(RecentService::new(recent_repository)), + recent_service, ); app.manage(app_state); @@ -95,7 +100,7 @@ pub fn run() { init_audio_state(models::PlaybackQueue::default()); let _ = init_media_controls(app.handle().clone()); - spawn_playback_progress_task(app.handle().clone()); + PlaybackService::spawn_playback_progress_task(app.handle().clone()); let settings_item = MenuItem::with_id(app, "settings", "设置", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; @@ -106,13 +111,18 @@ pub fn run() { let app_menu = MenuBuilder::new(app).items(&[&file_menu]).build()?; app.set_menu(app_menu)?; + + let icon = app.default_window_icon() + .cloned() + .ok_or_else(|| AppError::from("Application default window icon is missing"))?; + let _tray = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) + .icon(icon) .menu(&tray_menu) .show_menu_on_left_click(false) - .on_menu_event(|app: &tauri::AppHandle, event| match event.id.as_ref() { + .on_menu_event(|app_handle: &tauri::AppHandle, event| match event.id.as_ref() { "settings" => { - if let Some(window) = app.get_webview_window("main") { + if let Some(window) = app_handle.get_webview_window("main") { let _ = window.emit("tray:navigate", "settings"); let _ = window.unminimize(); let _ = window.show(); @@ -120,7 +130,7 @@ pub fn run() { } } "quit" => { - app.exit(0); + app_handle.exit(0); } _ => {} }) @@ -142,12 +152,17 @@ pub fn run() { let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - if let Some(state) = handle.try_state::() { - if let Ok(settings) = state.settings.get_settings().await { - if settings.scan_on_startup { - if let Ok(scanned) = - execute_scan(settings.library_dirs) - { + let state = handle.state::(); + if let Ok(settings) = state.settings.get_settings().await { + if settings.scan_on_startup { + let library_dirs = settings.library_dirs; + let scanned_result = tauri::async_runtime::spawn_blocking(move || { + execute_scan(library_dirs) + }) + .await; + + match scanned_result { + Ok(Ok(scanned)) => { let mut tracks = Vec::with_capacity(scanned.len()); for track in scanned { if let Ok(saved) = state.tracks.upsert_track(track).await { @@ -156,10 +171,17 @@ pub fn run() { } let _ = handle.emit("library:refreshed", tracks); } + Ok(Err(error)) => { + eprintln!("{}", error); + } + Err(error) => { + eprintln!("{}", error); + } } } } }); + app.on_menu_event(move |app_handle: &tauri::AppHandle, event| { match event.id().0.as_str() { "quit" => { @@ -168,18 +190,19 @@ pub fn run() { "play" => { let handle = app_handle.clone(); tauri::async_runtime::spawn(async move { - let _ = toggle_internal(handle).await; + let _ = PlaybackService::new(handle).toggle().await; }); } "next" => { let handle = app_handle.clone(); tauri::async_runtime::spawn(async move { - let _ = play_next_internal(handle).await; + let _ = PlaybackService::new(handle).next().await; }); } _ => {} } }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/models/playback.rs b/src-tauri/src/models/playback.rs index 6ff4ce1..ded6ed5 100644 --- a/src-tauri/src/models/playback.rs +++ b/src-tauri/src/models/playback.rs @@ -13,19 +13,8 @@ pub struct NativeTrackMetadata { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlaybackStatusSnapshot { - pub has_media: bool, pub playing: bool, pub current_time: f64, - pub track: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PlaybackProgressPayload { - pub playing: bool, - pub current_time: f64, - pub track_id: Option, - pub path: Option, } #[derive(Clone, Serialize)] @@ -33,8 +22,7 @@ pub struct PlaybackProgressPayload { pub struct PlaybackTrackStartedPayload { pub track: Track, pub index: usize, - pub playing: bool, - pub current_time: f64, + pub version: u64, } #[derive(Clone, Serialize)] @@ -42,26 +30,4 @@ pub struct PlaybackTrackStartedPayload { pub struct PlaybackTrackInfo { pub id: Option, pub path: Option, -} - -impl PlaybackStatusSnapshot { - pub fn status_payload(self) -> PlaybackStatusSnapshot { - PlaybackStatusSnapshot { - has_media: self.has_media, - playing: self.playing, - current_time: self.current_time, - track: self.track.map(Into::into), - } - } - - pub fn progress_payload(&self) -> Option { - let track = self.track.as_ref()?; - - Some(PlaybackProgressPayload { - playing: self.playing, - current_time: self.current_time, - track_id: track.id, - path: track.path.clone(), - }) - } -} +} \ No newline at end of file diff --git a/src-tauri/src/models/playback_queue.rs b/src-tauri/src/models/playback_queue.rs index c1c55bf..f531f13 100644 --- a/src-tauri/src/models/playback_queue.rs +++ b/src-tauri/src/models/playback_queue.rs @@ -11,6 +11,7 @@ pub struct PlaybackQueue { pub current_index: Option, pub play_mode: PlayMode, pub history: Vec, + pub version: u64, } #[derive(Debug, Serialize)] @@ -27,6 +28,7 @@ impl Default for PlaybackQueue { current_index: None, play_mode: PlayMode::ListLoop, history: Vec::new(), + version: 0 } } } @@ -43,6 +45,7 @@ impl PlaybackQueue { self.current_index = current_index.filter(|i| *i < self.tracks.len()); self.play_mode = play_mode; self.history = history; + self.version += 1; } pub fn clear(&mut self) { @@ -106,19 +109,9 @@ impl PlaybackQueue { ) -> Option<&Track> { self.tracks.iter().find(|t| t.id == track_id) } - - pub fn remove_track( - &mut self, - track_id: i64, - ) -> QueueRemoveResult { - let Some(remove_index) = - self.tracks.iter().position(|track| track.id == track_id) - else { - return QueueRemoveResult { - play_index: None, - should_stop: false, - }; - }; + + pub fn remove_track(&mut self, track_id: i64) -> Option { + let remove_index = self.tracks.iter().position(|track| track.id == track_id)?; let was_current = self.current_index == Some(remove_index); @@ -127,22 +120,19 @@ impl PlaybackQueue { if self.tracks.is_empty() { self.current_index = None; - - return QueueRemoveResult { + return Some(QueueRemoveResult { play_index: None, should_stop: true, - }; + }); } if was_current { let next_index = remove_index.min(self.tracks.len() - 1); - self.current_index = Some(next_index); - - return QueueRemoveResult { + return Some(QueueRemoveResult { play_index: Some(next_index), should_stop: false, - }; + }); } if let Some(current_index) = self.current_index { @@ -151,10 +141,10 @@ impl PlaybackQueue { } } - QueueRemoveResult { + Some(QueueRemoveResult { play_index: None, should_stop: false, - } + }) } pub fn insert_next( @@ -190,11 +180,11 @@ impl PlaybackQueue { self.tracks.insert(insert_index, track); } - pub fn insert_tracks_next( + pub fn insert_tracks_as_next( &mut self, tracks: Vec, ) { - for track in tracks { + for track in tracks.into_iter().rev() { self.insert_next(track); } } diff --git a/src-tauri/src/repositories/sqlite/playlist_repository.rs b/src-tauri/src/repositories/sqlite/playlist_repository.rs index b705148..e2af51e 100644 --- a/src-tauri/src/repositories/sqlite/playlist_repository.rs +++ b/src-tauri/src/repositories/sqlite/playlist_repository.rs @@ -14,52 +14,56 @@ impl SqlitePlaylistRepository { } pub async fn create(&self, playlist: NewPlaylist) -> Result { - sqlx::query_as::<_, Playlist>( + let res = sqlx::query_as::<_, Playlist>( "INSERT INTO playlists (name, created_at) VALUES (?1, datetime('now')) RETURNING id, name, created_at", ) .bind(playlist.name) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn rename(&self, playlist: RenamePlaylist) -> Result { - sqlx::query_as::<_, Playlist>( + let res = sqlx::query_as::<_, Playlist>( "UPDATE playlists SET name = ?1 WHERE id = ?2 RETURNING id, name, created_at", ) .bind(playlist.name) .bind(playlist.id) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn delete(&self, id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM playlists WHERE id = ?1") .bind(id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn find_by_id(&self, id: i64) -> Result, AppError> { - sqlx::query_as::<_, Playlist>("SELECT id, name, created_at FROM playlists WHERE id = ?1") + let res = sqlx::query_as::<_, Playlist>("SELECT id, name, created_at FROM playlists WHERE id = ?1") .bind(id) .fetch_optional(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn list_all(&self) -> Result, AppError> { - sqlx::query_as::<_, Playlist>( + let rows = sqlx::query_as::<_, Playlist>( "SELECT id, name, created_at FROM playlists ORDER BY created_at DESC, id DESC", ) .fetch_all(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(rows) } pub async fn list_with_tracks(&self) -> Result, AppError> { @@ -89,7 +93,7 @@ impl SqlitePlaylistRepository { None => next_position(&self.pool, track.playlist_id).await?, }; - sqlx::query_as::<_, PlaylistTrack>( + let res = sqlx::query_as::<_, PlaylistTrack>( "INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?1, ?2, ?3) ON CONFLICT(playlist_id, track_id) DO UPDATE SET position = excluded.position @@ -99,8 +103,9 @@ impl SqlitePlaylistRepository { .bind(track.track_id) .bind(position) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn remove_track(&self, playlist_id: i64, track_id: i64) -> Result<(), AppError> { @@ -108,18 +113,18 @@ impl SqlitePlaylistRepository { .bind(playlist_id) .bind(track_id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn clear_tracks(&self, playlist_id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM playlist_tracks WHERE playlist_id = ?1") .bind(playlist_id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn reorder_track(&self, playlist_id: i64, track_id: i64, position: i64) -> Result<(), AppError> { @@ -128,26 +133,25 @@ impl SqlitePlaylistRepository { .bind(playlist_id) .bind(track_id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } } async fn next_position(pool: &SqlitePool, playlist_id: i64) -> Result { - let value: (i64,) = sqlx::query_as( + let row: (i64,) = sqlx::query_as( "SELECT coalesce(max(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1", ) .bind(playlist_id) .fetch_one(pool) - .await - .map_err(AppError::from)?; + .await?; - Ok(value.0) + Ok(row.0) } async fn tracks_for_playlist(pool: &SqlitePool, playlist_id: i64) -> Result, AppError> { - sqlx::query_as::<_, Track>( + let rows = sqlx::query_as::<_, Track>( "SELECT t.id, t.title, t.artist, t.album, t.duration, t.path, t.cover, t.file_size, t.play_count, t.last_played_at, t.created_at, t.updated_at FROM tracks t INNER JOIN playlist_tracks pt ON pt.track_id = t.id @@ -156,6 +160,7 @@ async fn tracks_for_playlist(pool: &SqlitePool, playlist_id: i64) -> Result Result<(), AppError> { sqlx::query("DELETE FROM recent_played") .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn count(&self) -> Result { let row: (i64,) = sqlx::query_as("SELECT count(*) FROM recent_played") .fetch_one(&self.pool) - .await - .map_err(AppError::from)?; + .await?; + Ok(row.0) } @@ -84,9 +83,9 @@ impl SqliteRecentRepository { ) .bind(keep) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } } @@ -105,4 +104,4 @@ struct RecentRow { created_at: String, updated_at: String, played_at: String, -} \ No newline at end of file +} diff --git a/src-tauri/src/repositories/sqlite/settings_repository.rs b/src-tauri/src/repositories/sqlite/settings_repository.rs index 82aa5b2..ff96aca 100644 --- a/src-tauri/src/repositories/sqlite/settings_repository.rs +++ b/src-tauri/src/repositories/sqlite/settings_repository.rs @@ -14,12 +14,13 @@ impl SqliteSettingsRepository { } pub async fn get(&self) -> Result, AppError> { - sqlx::query_as::<_, SettingRow>( + let res = sqlx::query_as::<_, SettingRow>( "SELECT id, theme, volume, scan_on_startup, reduce_motion, library_dirs, created_at, updated_at FROM settings WHERE id = 1", ) .fetch_optional(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn update(&self, row: SettingRow) -> Result<(), AppError> { @@ -41,8 +42,8 @@ impl SqliteSettingsRepository { .bind(row.library_dirs) .bind(row.created_at) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } } diff --git a/src-tauri/src/repositories/sqlite/track_repository.rs b/src-tauri/src/repositories/sqlite/track_repository.rs index 4d3ac4a..26fc5eb 100644 --- a/src-tauri/src/repositories/sqlite/track_repository.rs +++ b/src-tauri/src/repositories/sqlite/track_repository.rs @@ -12,9 +12,9 @@ impl SqliteTrackRepository { pub fn new(pool: SqlitePool) -> Self { Self { pool } } - + pub async fn create(&self, track: NewTrack) -> Result { - sqlx::query_as::<_, Track>( + let res = sqlx::query_as::<_, Track>( "INSERT INTO tracks (title, artist, album, duration, path, cover, file_size, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), datetime('now')) RETURNING id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at", @@ -27,12 +27,13 @@ impl SqliteTrackRepository { .bind(track.cover) .bind(track.file_size) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn upsert_by_path(&self, track: NewTrack) -> Result { - sqlx::query_as::<_, Track>( + let res = sqlx::query_as::<_, Track>( "INSERT INTO tracks (title, artist, album, duration, path, cover, file_size, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), datetime('now')) ON CONFLICT(path) DO UPDATE SET @@ -53,12 +54,13 @@ impl SqliteTrackRepository { .bind(track.cover) .bind(track.file_size) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn update(&self, track: UpdateTrack) -> Result { - sqlx::query_as::<_, Track>( + let res = sqlx::query_as::<_, Track>( "UPDATE tracks SET title = ?1, artist = ?2, @@ -80,58 +82,62 @@ impl SqliteTrackRepository { .bind(track.file_size) .bind(track.id) .fetch_one(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn delete(&self, id: i64) -> Result<(), AppError> { sqlx::query("DELETE FROM tracks WHERE id = ?1") .bind(id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn delete_by_path(&self, path: &str) -> Result<(), AppError> { sqlx::query("DELETE FROM tracks WHERE path = ?1") .bind(path) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } pub async fn find_by_id(&self, id: i64) -> Result, AppError> { - sqlx::query_as::<_, Track>( + let res = sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks WHERE id = ?1", ) .bind(id) .fetch_optional(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn find_by_path(&self, path: &str) -> Result, AppError> { - sqlx::query_as::<_, Track>( + let res = sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks WHERE path = ?1", ) .bind(path) .fetch_optional(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(res) } pub async fn list_all(&self) -> Result, AppError> { - sqlx::query_as::<_, Track>( + let rows = sqlx::query_as::<_, Track>( "SELECT id, title, artist, album, duration, path, cover, file_size, play_count, last_played_at, created_at, updated_at FROM tracks ORDER BY title COLLATE NOCASE ASC", ) .fetch_all(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(rows) } pub async fn search(&self, query: TrackSearchQuery) -> Result, AppError> { @@ -171,11 +177,12 @@ impl SqliteTrackRepository { builder.push(" OFFSET "); builder.push_bind(query.offset.unwrap_or(0).max(0)); - builder + let rows = builder .build_query_as::() .fetch_all(&self.pool) - .await - .map_err(AppError::from) + .await?; + + Ok(rows) } pub async fn increment_play_count(&self, id: i64, played_at: String) -> Result<(), AppError> { @@ -185,8 +192,8 @@ impl SqliteTrackRepository { .bind(played_at) .bind(id) .execute(&self.pool) - .await - .map(|_| ()) - .map_err(AppError::from) + .await?; + + Ok(()) } } diff --git a/src-tauri/src/services/media_control_service.rs b/src-tauri/src/services/media_control_service.rs new file mode 100644 index 0000000..e1db4dc --- /dev/null +++ b/src-tauri/src/services/media_control_service.rs @@ -0,0 +1,50 @@ +use crate::services::playback_service::PlaybackService; +use crate::state::playback_state::current_time_from_state; +use souvlaki::{MediaControlEvent, SeekDirection}; +use tauri::AppHandle; + +pub async fn handle_media_control_event(app_handle: AppHandle, event: MediaControlEvent) { + let service = PlaybackService::new(app_handle); + + match event { + MediaControlEvent::Play => { + let _ = service.resume().await; + } + MediaControlEvent::Pause => { + let _ = service.pause().await; + } + MediaControlEvent::Toggle => { + let _ = service.toggle().await; + } + MediaControlEvent::Next => { + let _ = service.next().await; + } + MediaControlEvent::Previous => { + let _ = service.previous().await; + } + MediaControlEvent::Stop => { + let _ = service.stop().await; + } + MediaControlEvent::SetPosition(position) => { + let _ = service.seek(position.0.as_secs_f64()).await; + } + MediaControlEvent::Seek(direction) => { + let current_time = current_time_from_state(); + let offset = match direction { + SeekDirection::Forward => 10.0, + SeekDirection::Backward => -10.0, + }; + let _ = service.seek((current_time + offset).max(0.0)).await; + } + MediaControlEvent::SeekBy(direction, duration) => { + let current_time = current_time_from_state(); + let offset = duration.as_secs_f64(); + let next_time = match direction { + SeekDirection::Forward => current_time + offset, + SeekDirection::Backward => current_time - offset, + }; + let _ = service.seek(next_time.max(0.0)).await; + } + _ => {} + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 3e1bfe4..e43d44b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod media_control_service; pub mod playback_service; pub mod playlist_service; pub mod recent_service; diff --git a/src-tauri/src/services/playback_service.rs b/src-tauri/src/services/playback_service.rs index 212760c..938f1dd 100644 --- a/src-tauri/src/services/playback_service.rs +++ b/src-tauri/src/services/playback_service.rs @@ -1,215 +1,281 @@ -use crate::audio::{AudioEngine, WebAudioEngine, lock_audio_state}; +use crate::audio::{lock_audio_state, AudioEngine, WebAudioEngine}; use crate::errors::AppError; -use crate::events::{emit_progress, emit_track_started, sync_playback_state}; +use crate::events::{AppEvent, EventBus, PlaybackProgressPayload, PlaybackStatePayload, TrackStartedPayload}; +use crate::media_controls::{update_media_controls_metadata, update_media_controls_playback}; use crate::models::Track; -use crate::state::playback_state::{current_time_from_state, minimal_track, with_audio_state}; -use souvlaki::{MediaControlEvent, SeekDirection}; +use crate::state::playback_state::{current_playback_snapshot, metadata_from_track, minimal_track, should_advance_track, with_audio_state}; use std::path::Path; +use std::time::Duration; use tauri::AppHandle; -pub fn play_track_internal( - app_handle: &tauri::AppHandle, - track: Track, - index: Option, -) -> Result<(), AppError> { - let file_path = Path::new(&track.path); +pub struct PlaybackService { + app_handle: AppHandle, +} - if !file_path.exists() { - return Err("音频文件不存在或已被移动".into()); +impl PlaybackService { + pub fn new(app_handle: AppHandle) -> Self { + Self { app_handle } } - let mut state = lock_audio_state()?; + pub fn play_track(&self, track: Track, index: Option) -> Result<(), AppError> { + let file_path = Path::new(&track.path); + + if !file_path.exists() { + return Err("音频文件不存在或已被移动".into()); + } + + let mut state = lock_audio_state()?; + + let volume = state.volume; + let pan = state.pan; + state.engine = None; + + let mut engine = match WebAudioEngine::new(file_path, volume, pan) { + Ok(eng) => eng, + Err(e) => { + state.current_path = None; + state.current_track_id = None; + state.playing = false; + return Err(e); + } + }; + + if let Err(e) = engine.play() { + state.current_path = None; + state.current_track_id = None; + state.playing = false; + return Err(e); + } + + state.engine = Some(Box::new(engine)); + state.current_path = Some(track.path.clone()); + state.current_track_id = Some(track.id); + + if let Some(next_index) = index { + state.playback_queue.current_index = Some(next_index); + } - state.engine = None; + state.playing = true; + let target_index = index.or(state.playback_queue.current_index).unwrap_or(0); - let mut engine = WebAudioEngine::new(file_path, state.volume, state.pan)?; - engine.play()?; + drop(state); - state.engine = Some(Box::new(engine)); - state.current_path = Some(track.path.clone()); - state.current_track_id = Some(track.id); + Self::emit_track_started(&self.app_handle, track, target_index)?; - if let Some(next_index) = index { - state.playback_queue.current_index = Some(next_index); + Ok(()) } - state.playing = true; + pub fn play_queue_index(&self, index: usize) -> Result<(), AppError> { + let track = with_audio_state(|state| state.playback_queue.require_track(index))??; + self.play_track(track, Some(index)) + } - let target_index = index.or(state.playback_queue.current_index).unwrap_or(0); + pub async fn next(&self) -> Result<(), AppError> { + let (track, index) = with_audio_state(|state| { + let index = state.playback_queue.move_next() + .ok_or_else(|| AppError::from("Queue is empty"))?; + + let track = state.playback_queue.require_track(index)?; + + Ok::<(Track, usize), AppError>((track, index)) + })??; + + self.play_track(track, Some(index)) + } - drop(state); + pub async fn previous(&self) -> Result<(), AppError> { + let (track, index) = with_audio_state(|state| { + let index = state.playback_queue.move_previous() + .ok_or_else(|| AppError::from("Queue is empty"))?; + + let track = state.playback_queue.require_track(index)?; + + Ok::<(Track, usize), AppError>((track, index)) + })??; + + self.play_track(track, Some(index)) + } - emit_track_started(app_handle, track, target_index)?; + pub async fn resume(&self) -> Result<(), AppError> { + let current_time = with_audio_state(|state| { + let res = state + .with_engine(|engine| { + engine.play()?; + Ok(engine.current_time()) + }) + .and_then(|inner| inner); + + if res.is_ok() { + state.playing = true; + } + res + })??; + + Self::sync_playback_state(&self.app_handle, true, current_time)?; + Ok(()) + } - Ok(()) -} + pub async fn pause(&self) -> Result<(), AppError> { + let current_time = with_audio_state(|state| { + let res = state.with_engine(|engine| { + engine.pause(); + engine.current_time() + }); + if res.is_ok() { + state.playing = false; + } + res + })??; + + Self::sync_playback_state(&self.app_handle, false, current_time)?; + Ok(()) + } -pub fn play_current_index_internal(app_handle: &AppHandle, index: usize) -> Result<(), AppError> { - let track = with_audio_state(|state| state.playback_queue.require_track(index))??; - play_track_internal(app_handle, track, Some(index)) -} + pub async fn toggle(&self) -> Result { + let (playing, current_time) = with_audio_state(|state| { + let res = state + .with_engine(|engine| { + if engine.paused() { + engine.play()?; + Ok((true, engine.current_time())) + } else { + engine.pause(); + Ok((false, engine.current_time())) + } + }) + .and_then(|i| i); + if let Ok((p, _)) = res { + state.playing = p; + } + res + })??; + + Self::sync_playback_state(&self.app_handle, playing, current_time)?; + Ok(playing) + } -pub async fn play_next_internal(app_handle: AppHandle) -> Result<(), AppError> { - let index = with_audio_state(|state| state.playback_queue.move_next())? - .ok_or_else(|| AppError::from("Queue is empty"))?; + pub async fn stop(&self) -> Result<(), AppError> { + with_audio_state(|state| { + let _ = state.with_engine(|engine| { + engine.pause(); + engine.seek(0.0); + }); + state.playing = false; + })?; - play_current_index_internal(&app_handle, index) -} + Self::sync_playback_state(&self.app_handle, false, 0.0)?; + Ok(()) + } -pub async fn play_previous_internal(app_handle: AppHandle) -> Result<(), AppError> { - let index = with_audio_state(|state| state.playback_queue.move_previous())? - .ok_or_else(|| AppError::from("Queue is empty"))?; + pub async fn seek(&self, time: f64) -> Result<(), AppError> { + let playing = with_audio_state(|state| { + let res = state.with_engine(|engine| { + engine.seek(time); + !engine.paused() + }); + if let Ok(p) = res { + state.playing = p; + } + res + })??; + + Self::sync_playback_state(&self.app_handle, playing, time)?; + Ok(()) + } - play_current_index_internal(&app_handle, index) -} + pub fn load_track_for_playback(&self, full_path: &str, track_id: Option) -> Track { + with_audio_state(|state| { + state + .playback_queue + .tracks + .iter() + .find(|track| { + track_id.as_ref().map(|id| track.id == *id).unwrap_or(false) + || track.path == full_path + }) + .cloned() + }) + .ok() + .flatten() + .unwrap_or_else(|| minimal_track(full_path.to_string())) + } -pub async fn resume_internal(app_handle: AppHandle) -> Result<(), AppError> { - let current_time = with_audio_state(|state| { - let res = state.with_engine(|engine| { - engine.play()?; - Ok(engine.current_time()) - }).and_then(|inner| inner); + pub fn emit_track_started(app_handle: &AppHandle, track: Track, index: usize) -> Result<(), AppError> { + update_media_controls_metadata(metadata_from_track(&track)); - if res.is_ok() { - state.playing = true; - } - res - })??; + EventBus::emit( + app_handle, + AppEvent::TrackStarted(TrackStartedPayload { track, index }), + ) + } - sync_playback_state(&app_handle, true, current_time)?; - Ok(()) -} + pub fn sync_playback_state( + app_handle: &AppHandle, + playing: bool, + current_time: f64, + ) -> Result<(), AppError> { + let payload = PlaybackStatePayload { + playing, + current_time, + }; -pub async fn pause_internal(app_handle: AppHandle) -> Result<(), AppError> { - let current_time = with_audio_state(|state| { - let res = state.with_engine(|engine| { - engine.pause(); - engine.current_time() - }); - if res.is_ok() { state.playing = false; } - res - })??; + update_media_controls_playback(payload.clone()); - sync_playback_state(&app_handle, false, current_time)?; - Ok(()) -} + EventBus::emit(app_handle, AppEvent::PlaybackStateChanged(payload)) + } -pub async fn toggle_internal(app_handle: AppHandle) -> Result { - let (playing, current_time) = with_audio_state(|state| { - let res = state.with_engine(|engine| { - if engine.paused() { engine.play()?; Ok((true, engine.current_time())) } - else { engine.pause(); Ok((false, engine.current_time())) } - }).and_then(|i| i); - if let Ok((p, _)) = res { state.playing = p; } - res - })??; - - sync_playback_state(&app_handle, playing, current_time)?; - Ok(playing) -} + pub fn emit_progress(app_handle: &AppHandle) -> Result<(), AppError> { + if let Ok(snapshot) = current_playback_snapshot() { + EventBus::emit( + app_handle, + AppEvent::PlaybackProgress(PlaybackProgressPayload { + current_time: snapshot.current_time, + }), + )?; + } + Ok(()) + } -pub async fn stop_internal(app_handle: AppHandle) -> Result<(), AppError> { - with_audio_state(|state| { - let _ = state.with_engine(|engine| { - engine.pause(); - engine.seek(0.0); - }); - state.playing = false; - })?; + pub fn spawn_playback_progress_task(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(500)); - sync_playback_state(&app_handle, false, 0.0)?; - Ok(()) -} + loop { + interval.tick().await; + + let _ = Self::emit_progress(&app_handle); -pub async fn seek_internal(app_handle: AppHandle, time: f64) -> Result<(), AppError> { - let playing = with_audio_state(|state| { - let res = state.with_engine(|engine| { - engine.seek(time); - !engine.paused() + if should_advance_track() { + let _ = PlaybackService::new(app_handle.clone()).next(); + } + } }); - if let Ok(p) = res { state.playing = p; } - res - })??; + } - sync_playback_state(&app_handle, playing, time)?; - Ok(()) -} + pub(crate) async fn remove_track_from_queue(&self, track_id: i64) -> Result<(), AppError> { + let result = { + let mut state = lock_audio_state()?; + state.playback_queue.remove_track(track_id) + }; -pub fn find_track_in_queue(full_path: &str, track_id: Option) -> Option { - with_audio_state(|state| { - state - .playback_queue - .tracks - .iter() - .find(|track| { - track_id.as_ref().map(|id| track.id == *id).unwrap_or(false) - || track.path == full_path - }) - .cloned() - }) - .ok() - .flatten() -} + let Some(remove_result) = result else { + return Ok(()); + }; -pub fn load_track_for_playback(full_path: &str, track_id: Option) -> Track { - find_track_in_queue(full_path, track_id).unwrap_or_else(|| minimal_track(full_path.to_string())) -} + if remove_result.should_stop { + self.stop().await?; + } else if let Some(index) = remove_result.play_index { + self.play_queue_index(index)?; + } -pub fn spawn_playback_progress_task(app_handle: AppHandle) { - std::thread::spawn(move || loop { - let _ = emit_progress(&app_handle); + Self::emit_queue_changed(&self.app_handle)?; - if crate::state::playback_state::should_advance_track() { - let local_handle = app_handle.clone(); - tauri::async_runtime::block_on(async move { - let _ = play_next_internal(local_handle).await; - }); - } + Ok(()) + } - std::thread::sleep(std::time::Duration::from_millis(500)); - }); -} + pub(crate) fn emit_queue_changed(app_handle: &tauri::AppHandle) -> Result<(), AppError> { + let queue = with_audio_state(|state| state.playback_queue.clone())?; -pub async fn handle_media_control_event_internal(app_handle: AppHandle, event: MediaControlEvent) { - match event { - MediaControlEvent::Play => { - let _ = resume_internal(app_handle).await; - } - MediaControlEvent::Pause => { - let _ = pause_internal(app_handle).await; - } - MediaControlEvent::Toggle => { - let _ = toggle_internal(app_handle).await; - } - MediaControlEvent::Next => { - let _ = play_next_internal(app_handle).await; - } - MediaControlEvent::Previous => { - let _ = play_previous_internal(app_handle).await; - } - MediaControlEvent::Stop => { - let _ = stop_internal(app_handle).await; - } - MediaControlEvent::SetPosition(position) => { - let _ = seek_internal(app_handle, position.0.as_secs_f64()).await; - } - MediaControlEvent::Seek(direction) => { - let current_time = current_time_from_state(); - let offset = match direction { - SeekDirection::Forward => 10.0, - SeekDirection::Backward => -10.0, - }; - let _ = seek_internal(app_handle, (current_time + offset).max(0.0)).await; - } - MediaControlEvent::SeekBy(direction, duration) => { - let current_time = current_time_from_state(); - let offset = duration.as_secs_f64(); - let next_time = match direction { - SeekDirection::Forward => current_time + offset, - SeekDirection::Backward => current_time - offset, - }; - let _ = seek_internal(app_handle, next_time.max(0.0)).await; - } - _ => {} + EventBus::emit(app_handle, AppEvent::QueueChanged(queue)) } } diff --git a/src-tauri/src/state/playback_state.rs b/src-tauri/src/state/playback_state.rs index 0af43c9..d534e02 100644 --- a/src-tauri/src/state/playback_state.rs +++ b/src-tauri/src/state/playback_state.rs @@ -8,8 +8,12 @@ use crate::models::playback::{ use crate::models::Track; pub fn with_audio_state(f: impl FnOnce(&mut AudioState) -> T) -> Result { - let mut state = lock_audio_state()?; - Ok(f(&mut state)) + let value = { + let mut state = lock_audio_state()?; + f(&mut state) + }; + + Ok(value) } pub fn playback_track_info(state: &AudioState) -> PlaybackTrackInfo { @@ -19,24 +23,27 @@ pub fn playback_track_info(state: &AudioState) -> PlaybackTrackInfo { } } -pub fn current_playback_state(state: &mut AudioState) -> PlaybackStatusSnapshot { - match state.with_engine(|engine| (!engine.paused(), engine.current_time())) { +pub fn current_playback_state( + state: &mut AudioState, +) -> PlaybackStatusSnapshot { + match state.with_engine(|engine| { + (!engine.paused(), engine.current_time()) + }) { Ok((playing, current_time)) => { state.playing = playing; + PlaybackStatusSnapshot { - has_media: true, playing, current_time, - track: Some(playback_track_info(state)), } } + Err(_) => { state.playing = false; + PlaybackStatusSnapshot { - has_media: false, playing: false, current_time: 0.0, - track: None, } } } diff --git a/src/lib/components/base/MduiSlider.svelte b/src/lib/components/base/MduiSlider.svelte index c70e732..673e1bd 100644 --- a/src/lib/components/base/MduiSlider.svelte +++ b/src/lib/components/base/MduiSlider.svelte @@ -1,27 +1,32 @@ (null) + let slider = $state(null) + $effect(() => { + if (slider) { + slider.labelFormatter = (value: number) => formatTime(value) + } + }) + + let isDragging = $state(false) + let localProgress = $state(0) let currentCover = $derived( player.currentTrack?.cover ?? trackCovers.get(player.currentTrack), ) let volumeIcon = $derived(getVolumeIcon(player.volume, player.muted)) let playModeIcon = $derived.by(() => { - if (player.queue.playMode === 'single') return 'repeat_one--rounded' - if (player.queue.playMode === 'shuffle') return 'shuffle--rounded' + if (player.queue.playMode === 'SingleLoop') return 'repeat_one--rounded' + if (player.queue.playMode === 'Shuffle') return 'shuffle--rounded' return 'repeat--rounded' }) @@ -23,12 +32,23 @@ if (volume === 0) return 'volume_mute--rounded' return volume > 50 ? 'volume_up--rounded' : 'volume_down--rounded' } - function handleSeekInput(e: Event): void { - const target = e.currentTarget as HTMLElement & { - value: number | string - } - player.seek(Number(target.value)) + function handleSliderInput(e: Event): void { + const target = e.currentTarget as HTMLElement & { value: number | string } + + isDragging = true + + localProgress = Number(target.value) + } + + async function handleSliderChange(e: Event): Promise { + const target = e.currentTarget as HTMLElement & { value: number | string } + const finalValue = Number(target.value) + + player.currentTime = finalValue + await player.seek(finalValue) + + isDragging = false } @@ -40,12 +60,13 @@ {formatTime(player.currentTime)} -
+
diff --git a/src/lib/features/TrackList.svelte b/src/lib/features/TrackList.svelte index 1a4667b..c01ff23 100644 --- a/src/lib/features/TrackList.svelte +++ b/src/lib/features/TrackList.svelte @@ -78,15 +78,16 @@ .filter(Boolean) .join(' '), ) - function lazyCover(track: Track) { return (node: HTMLElement) => { + const load = () => { + void trackCovers.load(track) + } + const observer = new IntersectionObserver( entries => { - const entry = entries[0] - - if (entry.isIntersecting) { - void trackCovers.load(track) + if (entries[0].isIntersecting) { + load() observer.disconnect() } }, @@ -95,7 +96,15 @@ }, ) - observer.observe(node) + const rect = node.getBoundingClientRect() + const isVisible = + rect.top < window.innerHeight + 300 && rect.bottom > -300 + + if (isVisible) { + load() + } else { + observer.observe(node) + } return () => { observer.disconnect() @@ -228,9 +237,7 @@ {#snippet trackIndex(index: number, isCurrent: boolean)} -
+
{#if isCurrent && player.playing} {:else} @@ -267,7 +274,7 @@ {#if !columns.includes('index') && isCurrent}
{#if player.playing} +
{@render trackCover(track, cover, isCurrent)}
@@ -305,15 +312,13 @@ {/snippet} {#snippet trackAlbum(track: Track)} -