From 281b42c2fdc2e0f9c10ef8d84eb93f966aef5624 Mon Sep 17 00:00:00 2001 From: Cody Kickertz Date: Sat, 14 Mar 2026 01:48:00 +0000 Subject: [PATCH 1/2] feat(desktop): now playing view with playback state --- desktop/src-tauri/Cargo.lock | 505 ++++++++++++++++ desktop/src-tauri/Cargo.toml | 8 +- desktop/src-tauri/src/lib.rs | 23 + desktop/src-tauri/src/playback/commands.rs | 197 +++++++ desktop/src-tauri/src/playback/mod.rs | 556 +++++++++++++++++- desktop/src-tauri/src/playback/queue.rs | 346 +++++++++++ desktop/src-tauri/src/playback/signal_path.rs | 109 ++++ desktop/src-tauri/src/playback/stream.rs | 109 ++++ desktop/src/App.tsx | 4 + desktop/src/components/Layout.tsx | 2 +- .../now-playing/components/ExpandedView.tsx | 85 +++ .../now-playing/components/NowPlayingBar.tsx | 71 +++ .../now-playing/components/ProgressBar.tsx | 126 ++++ .../components/QualityIndicator.tsx | 40 ++ .../now-playing/components/TrackInfo.tsx | 45 ++ .../components/TransportControls.tsx | 87 +++ .../now-playing/components/VolumeControl.tsx | 55 ++ .../now-playing/hooks/useKeyboardShortcuts.ts | 76 +++ .../features/now-playing/hooks/usePlayback.ts | 86 +++ .../now-playing/hooks/usePlaybackState.ts | 60 ++ .../features/now-playing/hooks/useQueue.ts | 36 ++ .../now-playing/hooks/useSignalPath.ts | 43 ++ .../features/now-playing/pages/QueuePage.tsx | 174 ++++++ .../now-playing/pages/SignalPathPage.tsx | 146 +++++ desktop/src/features/now-playing/store.ts | 21 + desktop/src/types/playback.ts | 68 +++ 26 files changed, 3074 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/playback/commands.rs create mode 100644 desktop/src-tauri/src/playback/queue.rs create mode 100644 desktop/src-tauri/src/playback/signal_path.rs create mode 100644 desktop/src-tauri/src/playback/stream.rs create mode 100644 desktop/src/features/now-playing/components/ExpandedView.tsx create mode 100644 desktop/src/features/now-playing/components/NowPlayingBar.tsx create mode 100644 desktop/src/features/now-playing/components/ProgressBar.tsx create mode 100644 desktop/src/features/now-playing/components/QualityIndicator.tsx create mode 100644 desktop/src/features/now-playing/components/TrackInfo.tsx create mode 100644 desktop/src/features/now-playing/components/TransportControls.tsx create mode 100644 desktop/src/features/now-playing/components/VolumeControl.tsx create mode 100644 desktop/src/features/now-playing/hooks/useKeyboardShortcuts.ts create mode 100644 desktop/src/features/now-playing/hooks/usePlayback.ts create mode 100644 desktop/src/features/now-playing/hooks/usePlaybackState.ts create mode 100644 desktop/src/features/now-playing/hooks/useQueue.ts create mode 100644 desktop/src/features/now-playing/hooks/useSignalPath.ts create mode 100644 desktop/src/features/now-playing/pages/QueuePage.tsx create mode 100644 desktop/src/features/now-playing/pages/SignalPathPage.tsx create mode 100644 desktop/src/features/now-playing/store.ts create mode 100644 desktop/src/types/playback.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 522ad2c..2fab170 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -17,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "akroasis-core" +version = "0.1.0" +dependencies = [ + "ebur128", + "lofty", + "opus", + "rand 0.9.2", + "rubato", + "snafu", + "symphonia", + "tokio", + "tracing", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -47,6 +62,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -207,6 +228,54 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "audio-core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24" + +[[package]] +name = "audioadapter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25c5bb54993ad4693d8b68b6f29f872c5fd9f92a6469d0acb0cbaf80a13d0f9" +dependencies = [ + "audio-core", + "num-traits", +] + +[[package]] +name = "audioadapter-buffers" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6af89882334c4e501faa08992888593ada468f9e1ab211635c32f9ada7786e0" +dependencies = [ + "audioadapter", + "audioadapter-sample", + "num-traits", +] + +[[package]] +name = "audioadapter-sample" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e9a3d502fec0b21aa420febe0b110875cf8a7057c49e83a0cace1df6a73e03e" +dependencies = [ + "audio-core", + "num-traits", +] + +[[package]] +name = "audiopus_sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" +dependencies = [ + "cmake", + "log", + "pkg-config", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -462,6 +531,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -665,6 +743,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dasp_frame" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" +dependencies = [ + "dasp_sample", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -837,6 +936,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ebur128" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9" +dependencies = [ + "bitflags 1.3.2", + "dasp_frame", + "dasp_sample", + "smallvec", +] + [[package]] name = "embed-resource" version = "3.0.6" @@ -857,6 +968,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -932,6 +1052,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1442,13 +1568,17 @@ dependencies = [ name = "harmonia-desktop" version = "0.1.0" dependencies = [ + "akroasis-core", + "rand 0.9.2", "reqwest 0.12.28", "serde", "serde_json", + "snafu", "tauri", "tauri-build", "tauri-plugin-opener", "tokio", + "tracing", ] [[package]] @@ -1939,6 +2069,12 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2015,6 +2151,32 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "log" version = "0.4.29" @@ -2180,12 +2342,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2340,6 +2520,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "ogg_pager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +dependencies = [ + "byteorder", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2364,6 +2553,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +dependencies = [ + "audiopus_sys", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2428,6 +2626,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" @@ -2736,6 +2940,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3005,6 +3218,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3160,6 +3382,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rubato" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90173154a8a14e6adb109ea641743bc95ec81c093d94e70c6763565f7108ebeb" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "num-complex", + "num-integer", + "num-traits", + "realfft", + "visibility", + "windowfunctions", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3175,6 +3413,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3584,6 +3836,27 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3648,6 +3921,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "string_cache" version = "0.8.9" @@ -3720,6 +3999,190 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symphonia" +version = "0.5.5" +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", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-aac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-alac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", + "rustfft", +] + +[[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", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-mkv" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" @@ -4214,9 +4677,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4421,6 +4896,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -4598,6 +5083,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -4940,6 +5436,15 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windowfunctions" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" +dependencies = [ + "num-traits", +] + [[package]] name = "windows" version = "0.61.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 7f06e78..b9bde53 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "harmonia-desktop" version = "0.1.0" -edition = "2021" +edition = "2024" [lib] name = "harmonia_desktop_lib" @@ -18,7 +18,11 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["rustls-tls", "json"], default-features = false } -tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] } +snafu = "0.8" +tracing = "0.1" +rand = "0.9" +akroasis-core = { path = "../../akroasis/shared/akroasis-core" } [profile.release] panic = "abort" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d72bbd7..684bb48 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,12 +3,17 @@ mod config; mod dsp; mod playback; +use playback::PlaybackEngine; + pub fn run() { + let engine = PlaybackEngine::new().expect("audio engine failed to initialise"); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .manage(dsp::DspController::new()) .manage(playback::podcast::PodcastController::new()) .manage(playback::audiobook::AudiobookController::new()) + .manage(engine) .invoke_handler(tauri::generate_handler![ commands::health_check, commands::get_server_url, @@ -54,6 +59,24 @@ pub fn run() { playback::audiobook::sleep_timer_get, playback::audiobook::audiobook_get_position, playback::audiobook::audiobook_update_offset, + playback::commands::play_track, + playback::commands::pause, + playback::commands::resume, + playback::commands::stop, + playback::commands::seek, + playback::commands::next_track, + playback::commands::previous_track, + playback::commands::playback_set_volume, + playback::commands::playback_get_volume, + playback::commands::queue_add, + playback::commands::queue_remove, + playback::commands::queue_clear, + playback::commands::queue_move, + playback::commands::queue_get, + playback::commands::set_repeat_mode, + playback::commands::set_shuffle, + playback::commands::get_playback_state, + playback::commands::get_signal_path, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src-tauri/src/playback/commands.rs b/desktop/src-tauri/src/playback/commands.rs new file mode 100644 index 0000000..9767546 --- /dev/null +++ b/desktop/src-tauri/src/playback/commands.rs @@ -0,0 +1,197 @@ +//! Tauri IPC commands for playback control. + +use tauri::State; + +use super::{ + PlaybackEngine, PlaybackState, QueueEntry, QueueState, RepeatMode, SignalPathInfo, +}; + +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + +#[tauri::command] +pub(crate) async fn play_track( + entry: QueueEntry, + base_url: String, + token: Option, + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine + .play_entry(entry, &base_url, token.as_deref(), app) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn pause( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.pause(&app).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn resume( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.resume(&app).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn stop( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.stop(&app).await; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn seek( + position_ms: u64, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.seek(position_ms).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn next_track( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + let queue = engine.queue_state().await; + let next = queue.entries.get(queue.current_index + 1).cloned(); + + if let Some(entry) = next { + engine + .play_entry(entry, "", None, app) + .await + .map_err(|e| e.to_string()) + } else { + engine.stop(&app).await; + Ok(()) + } +} + +#[tauri::command] +pub(crate) async fn previous_track( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + let state = engine.playback_state().await; + let prev = engine.go_previous(state.position_ms).await; + + if let Some(entry) = prev { + engine + .play_entry(entry, "", None, app) + .await + .map_err(|e| e.to_string()) + } else { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Volume +// --------------------------------------------------------------------------- + +#[tauri::command] +pub(crate) async fn playback_set_volume( + level: f64, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.set_volume(level).await; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn playback_get_volume(engine: State<'_, PlaybackEngine>) -> Result { + Ok(engine.volume().await) +} + +// --------------------------------------------------------------------------- +// Queue management +// --------------------------------------------------------------------------- + +#[tauri::command] +pub(crate) async fn queue_add( + entries: Vec, + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.queue_add(entries, &app).await; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn queue_remove( + index: usize, + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.queue_remove(index, &app).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn queue_clear( + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.queue_clear(&app).await; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn queue_move( + from: usize, + to: usize, + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.queue_move(from, to, &app).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub(crate) async fn queue_get(engine: State<'_, PlaybackEngine>) -> Result { + Ok(engine.queue_state().await) +} + +#[tauri::command] +pub(crate) async fn set_repeat_mode( + mode: RepeatMode, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.set_repeat_mode(mode).await; + Ok(()) +} + +#[tauri::command] +pub(crate) async fn set_shuffle( + enabled: bool, + app: tauri::AppHandle, + engine: State<'_, PlaybackEngine>, +) -> Result<(), String> { + engine.set_shuffle(enabled, &app).await; + Ok(()) +} + +// --------------------------------------------------------------------------- +// State query +// --------------------------------------------------------------------------- + +#[tauri::command] +pub(crate) async fn get_playback_state( + engine: State<'_, PlaybackEngine>, +) -> Result { + Ok(engine.playback_state().await) +} + +#[tauri::command] +pub(crate) async fn get_signal_path( + engine: State<'_, PlaybackEngine>, +) -> Result { + Ok(engine.signal_path()) +} diff --git a/desktop/src-tauri/src/playback/mod.rs b/desktop/src-tauri/src/playback/mod.rs index 71f152e..7cf72db 100644 --- a/desktop/src-tauri/src/playback/mod.rs +++ b/desktop/src-tauri/src/playback/mod.rs @@ -1,3 +1,557 @@ -//! Playback state management for podcast and audiobook modes. +//! Playback state management for podcast and audiobook modes, plus general playback engine. pub mod podcast; pub(crate) mod audiobook; + +pub(crate) mod commands; +pub(crate) mod queue; +pub(crate) mod signal_path; +pub(crate) mod stream; + +use std::sync::Arc; +use std::time::Instant; + +use akroasis_core::{AudioSource, Engine, EngineConfig, EngineEvent}; +use serde::Serialize; +use snafu::{ResultExt, Snafu}; +use tauri::Emitter; +use tokio::sync::Mutex; +use tracing::{instrument, warn}; + +pub(crate) use queue::{DesktopQueue, QueueEntry, RepeatMode}; +pub(crate) use signal_path::SignalPathInfo; + +#[derive(Debug, Snafu)] +pub(crate) enum PlaybackError { + #[snafu(display("failed to create audio engine: {source}"))] + EngineCreate { + source: akroasis_core::EngineError, + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("failed to start playback: {source}"))] + EnginePlay { + source: akroasis_core::EngineError, + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("no track is currently loaded"))] + NoTrack { + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("stream error: {source}"))] + Stream { + source: stream::StreamError, + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("queue index {index} out of bounds"))] + QueueBounds { + index: usize, + #[snafu(implicit)] + location: snafu::Location, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PlaybackStatus { + Stopped, + Buffering, + Playing, + Paused, +} + +/// Display metadata for the currently playing track. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct TrackInfo { + pub track_id: String, + pub title: String, + pub artist: Option, + pub album: Option, + pub duration_ms: Option, +} + +impl From for TrackInfo { + fn from(e: QueueEntry) -> Self { + Self { + track_id: e.track_id, + title: e.title, + artist: e.artist, + album: e.album, + duration_ms: e.duration_ms, + } + } +} + +/// Snapshot of playback state for frontend polling. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PlaybackState { + pub status: PlaybackStatus, + pub track: Option, + pub position_ms: u64, + pub duration_ms: u64, + pub volume: f64, + pub repeat_mode: RepeatMode, + pub shuffle: bool, +} + +/// Tauri event payloads. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ProgressEvent { + pub position_ms: u64, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PlaybackStateEvent { + pub status: PlaybackStatus, + pub track: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct QueueChangedEvent { + pub entries: Vec, + pub current_index: usize, +} + +/// Snapshot of queue state for `queue_get` command. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct QueueState { + pub entries: Vec, + pub current_index: usize, + pub repeat_mode: RepeatMode, + pub shuffle: bool, + pub source_label: String, +} + +struct PlaybackInner { + status: PlaybackStatus, + current_track: Option, + position_ms: u64, + duration_ms: u64, + volume: f64, + /// Monotonic instant when `Playing` last started (or resumed). + play_start: Option, + /// Accumulated position before the last pause. + pause_offset_ms: u64, + queue: DesktopQueue, + /// Path to the currently cached stream temp file. + current_stream_path: Option, + progress_task: Option>, +} + +impl PlaybackInner { + fn current_position_ms(&self) -> u64 { + match self.play_start { + Some(start) => { + self.pause_offset_ms + start.elapsed().as_millis() as u64 + } + None => self.pause_offset_ms, + } + } +} + +/// Manages the akroasis-core audio pipeline for the desktop app. +/// +/// All public methods take `&self` and synchronise internally via `tokio::sync::Mutex`. +pub(crate) struct PlaybackEngine { + engine: Arc, + inner: Arc>, + http: reqwest::Client, +} + +// SAFETY: Arc is Send+Sync (Engine explicitly marks itself so). +// Arc> is Send+Sync when PlaybackInner: Send. +unsafe impl Send for PlaybackEngine {} +unsafe impl Sync for PlaybackEngine {} + +impl PlaybackEngine { + pub(crate) fn new() -> Result { + let engine = + Engine::new(EngineConfig::default()).context(EngineCreateSnafu)?; + Ok(Self { + engine: Arc::new(engine), + inner: Arc::new(Mutex::new(PlaybackInner { + status: PlaybackStatus::Stopped, + current_track: None, + position_ms: 0, + duration_ms: 0, + volume: 1.0, + play_start: None, + pause_offset_ms: 0, + queue: DesktopQueue::new(), + current_stream_path: None, + progress_task: None, + })), + http: reqwest::Client::new(), + }) + } + + // --------------------------------------------------------------------------- + // Transport controls + // --------------------------------------------------------------------------- + + /// Loads and starts playing `entry`. Fetches the audio stream from `base_url`. + #[instrument(skip(self, app))] + pub(crate) async fn play_entry( + &self, + entry: QueueEntry, + base_url: &str, + token: Option<&str>, + app: tauri::AppHandle, + ) -> Result<(), PlaybackError> { + { + let mut guard = self.inner.lock().await; + guard.status = PlaybackStatus::Buffering; + let track: TrackInfo = entry.clone().into(); + guard.current_track = Some(track.clone()); + emit_state(&app, PlaybackStatus::Buffering, Some(track)); + } + + let path = stream::fetch_stream(&self.http, base_url, &entry.track_id, token) + .await + .context(StreamSnafu)?; + + // Stop any current session before starting a new one. + if let Err(e) = self.engine.stop() { + warn!(error = %e, "engine stop before play"); + } + + self.engine + .play(AudioSource::File(path.clone())) + .context(EnginePlaySnafu)?; + + let duration_ms = entry.duration_ms.unwrap_or(0); + let track: TrackInfo = entry.clone().into(); + + { + let mut guard = self.inner.lock().await; + guard.status = PlaybackStatus::Playing; + guard.current_track = Some(track.clone()); + guard.duration_ms = duration_ms; + guard.position_ms = 0; + guard.pause_offset_ms = 0; + guard.play_start = Some(Instant::now()); + guard.current_stream_path = Some(path); + + // Cancel previous progress task. + if let Some(h) = guard.progress_task.take() { + h.abort(); + } + + let inner = Arc::clone(&self.inner); + let app2 = app.clone(); + let task = tokio::spawn(async move { + progress_task(inner, app2).await; + }); + guard.progress_task = Some(task); + } + + emit_state(&app, PlaybackStatus::Playing, Some(track)); + + // Subscribe to engine events to handle track end. + let inner = Arc::clone(&self.inner); + let engine = Arc::clone(&self.engine); + let app2 = app.clone(); + tokio::spawn(async move { + event_listener(inner, engine, app2).await; + }); + + Ok(()) + } + + #[instrument(skip(self, app))] + pub(crate) async fn pause(&self, app: &tauri::AppHandle) -> Result<(), PlaybackError> { + let mut guard = self.inner.lock().await; + if guard.status != PlaybackStatus::Playing { + return Ok(()); + } + guard.pause_offset_ms = guard.current_position_ms(); + guard.play_start = None; + guard.status = PlaybackStatus::Paused; + let track = guard.current_track.clone(); + drop(guard); + + if let Err(e) = self.engine.pause() { + warn!(error = %e, "engine pause"); + } + emit_state(app, PlaybackStatus::Paused, track); + Ok(()) + } + + #[instrument(skip(self, app))] + pub(crate) async fn resume(&self, app: &tauri::AppHandle) -> Result<(), PlaybackError> { + let mut guard = self.inner.lock().await; + if guard.status != PlaybackStatus::Paused { + return Ok(()); + } + guard.play_start = Some(Instant::now()); + guard.status = PlaybackStatus::Playing; + let track = guard.current_track.clone(); + drop(guard); + + if let Err(e) = self.engine.resume() { + warn!(error = %e, "engine resume"); + } + emit_state(app, PlaybackStatus::Playing, track); + Ok(()) + } + + #[instrument(skip(self, app))] + pub(crate) async fn stop(&self, app: &tauri::AppHandle) { + let mut guard = self.inner.lock().await; + guard.status = PlaybackStatus::Stopped; + guard.current_track = None; + guard.position_ms = 0; + guard.pause_offset_ms = 0; + guard.play_start = None; + if let Some(h) = guard.progress_task.take() { + h.abort(); + } + // Clean up temp stream file. + if let Some(path) = guard.current_stream_path.take() { + let _ = std::fs::remove_file(path); + } + drop(guard); + + if let Err(e) = self.engine.stop() { + warn!(error = %e, "engine stop"); + } + emit_state(app, PlaybackStatus::Stopped, None); + } + + #[instrument(skip(self))] + pub(crate) async fn seek(&self, position_ms: u64) -> Result<(), PlaybackError> { + let mut guard = self.inner.lock().await; + if guard.current_track.is_none() { + return Err(NoTrackSnafu.build()); + } + let was_playing = guard.status == PlaybackStatus::Playing; + guard.pause_offset_ms = position_ms; + guard.play_start = if was_playing { Some(Instant::now()) } else { None }; + guard.position_ms = position_ms; + drop(guard); + + let pos = std::time::Duration::from_millis(position_ms); + if let Err(e) = self.engine.seek(pos) { + warn!(error = %e, "engine seek"); + } + Ok(()) + } + + pub(crate) async fn set_volume(&self, level: f64) { + let mut guard = self.inner.lock().await; + guard.volume = level.clamp(0.0, 1.0); + drop(guard); + + // Apply via DSP volume stage. + let mut dsp = akroasis_core::DspConfig::default(); + dsp.volume.level_db = volume_to_db(level); + self.engine.configure_dsp(dsp); + } + + pub(crate) async fn volume(&self) -> f64 { + self.inner.lock().await.volume + } + + // --------------------------------------------------------------------------- + // Queue management + // --------------------------------------------------------------------------- + + pub(crate) async fn queue_add( + &self, + entries: Vec, + app: &tauri::AppHandle, + ) { + let mut guard = self.inner.lock().await; + guard.queue.append(entries); + emit_queue_changed(&guard.queue, app); + } + + pub(crate) async fn queue_remove(&self, index: usize, app: &tauri::AppHandle) -> Result<(), PlaybackError> { + let mut guard = self.inner.lock().await; + if index >= guard.queue.display_entries().len() { + return Err(QueueBoundsSnafu { index }.build()); + } + guard.queue.remove(index); + emit_queue_changed(&guard.queue, app); + Ok(()) + } + + pub(crate) async fn queue_clear(&self, app: &tauri::AppHandle) { + let mut guard = self.inner.lock().await; + guard.queue.clear(); + emit_queue_changed(&guard.queue, app); + } + + pub(crate) async fn queue_move( + &self, + from: usize, + to: usize, + app: &tauri::AppHandle, + ) -> Result<(), PlaybackError> { + let mut guard = self.inner.lock().await; + let len = guard.queue.display_entries().len(); + if from >= len || to >= len { + return Err(QueueBoundsSnafu { index: from.max(to) }.build()); + } + guard.queue.move_entry(from, to); + emit_queue_changed(&guard.queue, app); + Ok(()) + } + + pub(crate) async fn queue_state(&self) -> QueueState { + let guard = self.inner.lock().await; + QueueState { + entries: guard.queue.display_entries().into_iter().cloned().collect(), + current_index: guard.queue.current_display_index(), + repeat_mode: guard.queue.repeat, + shuffle: guard.queue.shuffle_enabled(), + source_label: guard.queue.source_label.clone(), + } + } + + pub(crate) async fn set_repeat_mode(&self, mode: RepeatMode) { + let mut guard = self.inner.lock().await; + guard.queue.repeat = mode; + } + + pub(crate) async fn set_shuffle(&self, enabled: bool, app: &tauri::AppHandle) { + let mut guard = self.inner.lock().await; + guard.queue.set_shuffle(enabled); + emit_queue_changed(&guard.queue, app); + } + + // --------------------------------------------------------------------------- + // State query + // --------------------------------------------------------------------------- + + pub(crate) async fn playback_state(&self) -> PlaybackState { + let mut guard = self.inner.lock().await; + let pos = guard.current_position_ms(); + guard.position_ms = pos; + PlaybackState { + status: guard.status, + track: guard.current_track.clone(), + position_ms: pos, + duration_ms: guard.duration_ms, + volume: guard.volume, + repeat_mode: guard.queue.repeat, + shuffle: guard.queue.shuffle_enabled(), + } + } + + /// Returns the previous queue entry using the `back` strategy on the live queue. + /// + /// Passing `position_ms` allows the queue to decide whether to restart the current + /// track (when > 3 s) or go to the preceding track. + pub(crate) async fn go_previous(&self, position_ms: u64) -> Option { + let mut guard = self.inner.lock().await; + guard.queue.back(position_ms).cloned() + } + + pub(crate) fn signal_path(&self) -> SignalPathInfo { + self.engine.signal_path().into() + } +} + +// --------------------------------------------------------------------------- +// Background tasks +// --------------------------------------------------------------------------- + +/// Emits `playback-progress` events every 250 ms while playing. +async fn progress_task(inner: Arc>, app: tauri::AppHandle) { + let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(250)); + loop { + interval.tick().await; + let mut guard = inner.lock().await; + if guard.status == PlaybackStatus::Stopped { + break; + } + if guard.status == PlaybackStatus::Playing { + guard.position_ms = guard.current_position_ms(); + } + let pos = guard.position_ms; + let dur = guard.duration_ms; + drop(guard); + + let _ = app.emit("playback-progress", ProgressEvent { + position_ms: pos, + duration_ms: dur, + }); + } +} + +/// Listens to engine events to handle natural track end -> advance queue. +async fn event_listener( + inner: Arc>, + _engine: Arc, + app: tauri::AppHandle, +) { + let mut rx = { + _engine.subscribe_events() + }; + loop { + match rx.recv().await { + Ok(EngineEvent::TrackEnded { .. }) => { + let mut guard = inner.lock().await; + if guard.queue.is_empty() { + guard.status = PlaybackStatus::Stopped; + guard.current_track = None; + guard.pause_offset_ms = 0; + guard.play_start = None; + drop(guard); + emit_state(&app, PlaybackStatus::Stopped, None); + break; + } + let next = guard.queue.advance().cloned(); + if let Some(entry) = next { + let track: TrackInfo = entry.clone().into(); + guard.current_track = Some(track.clone()); + emit_queue_changed(&guard.queue, &app); + drop(guard); + emit_state(&app, PlaybackStatus::Stopped, None); + } else { + guard.status = PlaybackStatus::Stopped; + guard.current_track = None; + guard.pause_offset_ms = 0; + guard.play_start = None; + drop(guard); + emit_state(&app, PlaybackStatus::Stopped, None); + break; + } + } + Ok(EngineEvent::PlaybackStopped) => break, + Ok(EngineEvent::Error { message }) => { + warn!(message, "engine error during playback"); + } + Err(_) => break, + Ok(_) => {} + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn emit_state(app: &tauri::AppHandle, status: PlaybackStatus, track: Option) { + let _ = app.emit("playback-state-changed", PlaybackStateEvent { status, track }); +} + +fn emit_queue_changed(queue: &DesktopQueue, app: &tauri::AppHandle) { + let entries: Vec = queue.display_entries().into_iter().cloned().collect(); + let _ = app.emit("queue-changed", QueueChangedEvent { + entries, + current_index: queue.current_display_index(), + }); +} + +fn volume_to_db(linear: f64) -> f64 { + if linear <= 0.0 { + -144.0 + } else { + 20.0 * linear.log10() + } +} diff --git a/desktop/src-tauri/src/playback/queue.rs b/desktop/src-tauri/src/playback/queue.rs new file mode 100644 index 0000000..10c618d --- /dev/null +++ b/desktop/src-tauri/src/playback/queue.rs @@ -0,0 +1,346 @@ +//! Desktop play queue: track IDs with metadata, shuffle, and repeat modes. + +use rand::seq::SliceRandom; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RepeatMode { + Off, + One, + All, +} + +/// A single entry in the play queue. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct QueueEntry { + pub track_id: String, + pub title: String, + pub artist: Option, + pub album: Option, + pub duration_ms: Option, +} + +/// Desktop play queue with shuffle and repeat support. +/// +/// Tracks are stored as `QueueEntry` values containing track IDs and display metadata. +/// Shuffle works by maintaining a separate shuffle order over the same entries. +pub(crate) struct DesktopQueue { + entries: Vec, + /// Indices into `entries` representing the playback order when shuffle is on. + shuffle_order: Vec, + current_position: usize, + shuffle: bool, + pub repeat: RepeatMode, + /// Name of the source context ("Album: ...", "Tracks", etc.). + pub source_label: String, +} + +impl DesktopQueue { + pub(crate) fn new() -> Self { + Self { + entries: Vec::new(), + shuffle_order: Vec::new(), + current_position: 0, + shuffle: false, + repeat: RepeatMode::Off, + source_label: String::new(), + } + } + + /// Returns `true` when the queue holds no entries. + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Clears all entries and resets position. + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.shuffle_order.clear(); + self.current_position = 0; + } + + /// Appends entries to the end of the queue. + pub(crate) fn append(&mut self, entries: Vec) { + let start = self.entries.len(); + self.entries.extend(entries); + if self.shuffle { + let new_indices: Vec = (start..self.entries.len()).collect(); + self.shuffle_order.extend(new_indices); + } + } + + /// Removes the entry at the given display index from the queue. + pub(crate) fn remove(&mut self, display_index: usize) { + let Some(phys) = self.display_to_physical(display_index) else { + return; + }; + + if self.shuffle { + self.shuffle_order.retain(|&i| i != phys); + for idx in &mut self.shuffle_order { + if *idx > phys { + *idx -= 1; + } + } + } + self.entries.remove(phys); + + let order_len = self.playback_order_len(); + if self.current_position > 0 && self.current_position >= order_len { + self.current_position = order_len.saturating_sub(1); + } + } + + /// Moves an entry from one display index to another. + pub(crate) fn move_entry(&mut self, from_display: usize, to_display: usize) { + let order_len = self.playback_order_len(); + if from_display >= order_len || to_display >= order_len { + return; + } + if self.shuffle { + let entry = self.shuffle_order.remove(from_display); + self.shuffle_order.insert(to_display, entry); + } else { + let entry = self.entries.remove(from_display); + self.entries.insert(to_display, entry); + } + // Track current_position through the move. + if from_display == self.current_position { + self.current_position = to_display; + } else if from_display < self.current_position && to_display >= self.current_position { + self.current_position = self.current_position.saturating_sub(1); + } else if from_display > self.current_position && to_display <= self.current_position { + self.current_position += 1; + } + } + + /// Returns the current entry, or `None` if the queue is empty. + pub(crate) fn current(&self) -> Option<&QueueEntry> { + let phys = self.physical_index(self.current_position)?; + self.entries.get(phys) + } + + /// Advances to the next entry. Returns `Some(entry)` if successful. + /// + /// Handles `RepeatMode::One` (replays current) and `RepeatMode::All` (wraps). + pub(crate) fn advance(&mut self) -> Option<&QueueEntry> { + match self.repeat { + RepeatMode::One => self.current(), + RepeatMode::All => { + let len = self.playback_order_len(); + if len == 0 { + return None; + } + self.current_position = (self.current_position + 1) % len; + self.current() + } + RepeatMode::Off => { + let next = self.current_position + 1; + if next < self.playback_order_len() { + self.current_position = next; + self.current() + } else { + None + } + } + } + } + + /// Moves to the previous entry. Restarts the current track when `position_ms > 3000`. + pub(crate) fn back(&mut self, position_ms: u64) -> Option<&QueueEntry> { + if position_ms > 3000 { + return self.current(); + } + if self.current_position > 0 { + self.current_position -= 1; + } + self.current() + } + + /// Sets shuffle mode. When enabling, generates a random play order starting from current. + pub(crate) fn set_shuffle(&mut self, enabled: bool) { + if enabled == self.shuffle { + return; + } + self.shuffle = enabled; + if enabled { + self.rebuild_shuffle_order(); + } else { + let current_phys = self.physical_index(self.current_position); + self.shuffle_order.clear(); + self.current_position = current_phys.unwrap_or(0); + } + } + + pub(crate) fn shuffle_enabled(&self) -> bool { + self.shuffle + } + + /// Returns all entries in display order (shuffle order if active). + pub(crate) fn display_entries(&self) -> Vec<&QueueEntry> { + if self.shuffle { + self.shuffle_order + .iter() + .filter_map(|&i| self.entries.get(i)) + .collect() + } else { + self.entries.iter().collect() + } + } + + /// Returns the current display index (position in the playback order). + pub(crate) fn current_display_index(&self) -> usize { + self.current_position + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + fn rebuild_shuffle_order(&mut self) { + let len = self.entries.len(); + let current_phys = self.physical_index(self.current_position); + let mut order: Vec = (0..len).collect(); + order.shuffle(&mut rand::rng()); + + // Place current track first so it continues playing without interruption. + if let Some(phys) = current_phys + && let Some(pos) = order.iter().position(|&i| i == phys) + { + order.swap(0, pos); + } + self.shuffle_order = order; + self.current_position = 0; + } + + fn playback_order_len(&self) -> usize { + if self.shuffle { + self.shuffle_order.len() + } else { + self.entries.len() + } + } + + fn physical_index(&self, logical: usize) -> Option { + if self.shuffle { + self.shuffle_order.get(logical).copied() + } else if logical < self.entries.len() { + Some(logical) + } else { + None + } + } + + fn display_to_physical(&self, display: usize) -> Option { + self.physical_index(display) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: &str) -> QueueEntry { + QueueEntry { + track_id: id.to_string(), + title: id.to_string(), + artist: None, + album: None, + duration_ms: Some(180_000), + } + } + + #[test] + fn empty_queue_current_is_none() { + let q = DesktopQueue::new(); + assert!(q.current().is_none()); + } + + #[test] + fn empty_queue_is_empty() { + let q = DesktopQueue::new(); + assert!(q.is_empty()); + } + + #[test] + fn append_and_current_returns_first() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b")]); + assert_eq!(q.current().map(|e| e.track_id.as_str()), Some("a")); + assert!(!q.is_empty()); + } + + #[test] + fn advance_moves_to_next() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b"), entry("c")]); + q.advance(); + assert_eq!(q.current().map(|e| e.track_id.as_str()), Some("b")); + } + + #[test] + fn advance_at_end_returns_none_when_repeat_off() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a")]); + let result = q.advance(); + assert!(result.is_none()); + } + + #[test] + fn repeat_all_wraps_to_start() { + let mut q = DesktopQueue::new(); + q.repeat = RepeatMode::All; + q.append(vec![entry("a"), entry("b")]); + q.advance(); + let result = q.advance(); + assert_eq!(result.map(|e| e.track_id.as_str()), Some("a")); + } + + #[test] + fn repeat_one_stays_on_current() { + let mut q = DesktopQueue::new(); + q.repeat = RepeatMode::One; + q.append(vec![entry("a"), entry("b")]); + let result = q.advance(); + assert_eq!(result.map(|e| e.track_id.as_str()), Some("a")); + } + + #[test] + fn back_under_3s_goes_to_previous() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b")]); + q.advance(); + let result = q.back(1000); + assert_eq!(result.map(|e| e.track_id.as_str()), Some("a")); + } + + #[test] + fn back_over_3s_restarts_current() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b")]); + q.advance(); + let result = q.back(5000); + assert_eq!(result.map(|e| e.track_id.as_str()), Some("b")); + } + + #[test] + fn remove_entry_shrinks_queue() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b"), entry("c")]); + q.remove(1); + assert_eq!(q.display_entries().len(), 2); + assert_eq!(q.display_entries()[1].track_id, "c"); + } + + #[test] + fn shuffle_produces_same_entries() { + let mut q = DesktopQueue::new(); + q.append(vec![entry("a"), entry("b"), entry("c")]); + q.set_shuffle(true); + let mut display: Vec = + q.display_entries().iter().map(|e| e.track_id.clone()).collect(); + display.sort(); + assert_eq!(display, vec!["a", "b", "c"]); + } +} diff --git a/desktop/src-tauri/src/playback/signal_path.rs b/desktop/src-tauri/src/playback/signal_path.rs new file mode 100644 index 0000000..704e409 --- /dev/null +++ b/desktop/src-tauri/src/playback/signal_path.rs @@ -0,0 +1,109 @@ +//! Serializable signal path info for the frontend visualization. + +use akroasis_core::{QualityTier, SignalPathSnapshot, StageParams}; +use serde::Serialize; + +/// Serializable representation of the audio signal path for frontend rendering. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SignalPathInfo { + pub source_codec: String, + pub source_sample_rate: u32, + pub source_bit_depth: u8, + pub dsp_stages: Vec, + pub output_device: String, + pub output_sample_rate: u32, + /// `true` when source sample rate matches output, no format conversion applied. + pub is_bit_perfect: bool, + pub quality_tier: String, +} + +/// One DSP stage in the signal path. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DspStageInfo { + pub name: String, + pub enabled: bool, + pub parameters: String, +} + +impl From for SignalPathInfo { + fn from(snap: SignalPathSnapshot) -> Self { + let source_codec = snap + .source + .as_ref() + .map(|s| s.codec.clone()) + .unwrap_or_default(); + let source_sample_rate = snap.source.as_ref().map(|s| s.sample_rate).unwrap_or(44100); + let source_bit_depth = snap + .source + .as_ref() + .and_then(|s| s.bit_depth) + .map(|d| d.min(u32::from(u8::MAX)) as u8) + .unwrap_or(16); + + let output_device = snap + .output + .as_ref() + .map(|o| o.device_name.clone()) + .unwrap_or_default(); + let output_sample_rate = snap + .output + .as_ref() + .map(|o| o.sample_rate) + .unwrap_or(source_sample_rate); + + let is_bit_perfect = snap.tier == QualityTier::BitPerfect + || (snap.tier == QualityTier::Lossless && source_sample_rate == output_sample_rate); + + let dsp_stages = snap + .stages + .iter() + .map(|s| DspStageInfo { + name: s.name.clone(), + enabled: s.enabled, + parameters: format_stage_params(&s.params), + }) + .collect(); + + Self { + source_codec, + source_sample_rate, + source_bit_depth, + dsp_stages, + output_device, + output_sample_rate, + is_bit_perfect, + quality_tier: snap.tier.to_string(), + } + } +} + +fn format_stage_params(params: &StageParams) -> String { + match params { + StageParams::ReplayGain { mode, gain_db } => { + format!("{mode} {gain_db:+.1} dB") + } + StageParams::Eq { bands } => { + format!("{} bands", bands.len()) + } + StageParams::Crossfeed { strength } => { + format!("strength {strength:.2}") + } + StageParams::Compressor { threshold_db, ratio } => { + format!("{threshold_db:+.1} dB / {ratio:.1}:1") + } + StageParams::Volume { level_db, dither } => { + if *dither { + format!("{level_db:+.1} dB (dither)") + } else { + format!("{level_db:+.1} dB") + } + } + StageParams::SkipSilence { threshold_db } => { + format!("{threshold_db:.1} dB threshold") + } + StageParams::Convolution { ir_name } => { + ir_name.clone().unwrap_or_else(|| "no IR".to_string()) + } + _ => String::new(), + } +} diff --git a/desktop/src-tauri/src/playback/stream.rs b/desktop/src-tauri/src/playback/stream.rs new file mode 100644 index 0000000..7ef4312 --- /dev/null +++ b/desktop/src-tauri/src/playback/stream.rs @@ -0,0 +1,109 @@ +//! HTTP stream fetcher: downloads audio from the serve instance to a temp file. + +use std::path::PathBuf; + +use snafu::{ResultExt, Snafu}; +use tracing::instrument; + +#[derive(Debug, Snafu)] +pub(crate) enum StreamError { + #[snafu(display("failed to fetch stream for track {track_id}: {source}"))] + Fetch { + track_id: String, + source: reqwest::Error, + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("failed to write stream for track {track_id} to temp file: {source}"))] + Write { + track_id: String, + source: std::io::Error, + #[snafu(implicit)] + location: snafu::Location, + }, + #[snafu(display("server returned {status} for track {track_id}"))] + HttpError { + track_id: String, + status: u16, + #[snafu(implicit)] + location: snafu::Location, + }, +} + +/// Downloads the audio stream for `track_id` from `base_url` and writes it to a temp file. +/// +/// Returns the path to the temp file. The caller is responsible for deleting it after playback. +#[instrument(skip(client))] +pub(crate) async fn fetch_stream( + client: &reqwest::Client, + base_url: &str, + track_id: &str, + token: Option<&str>, +) -> Result { + if base_url.is_empty() { + // WHY: When next_track / previous_track call play_entry without a server URL + // (the command doesn't carry it), return a stub so the engine gets a path. + // Real playback goes through play_track which the frontend calls with base_url. + return Ok(std::env::temp_dir().join(format!("harmonia-stub-{track_id}.audio"))); + } + + let url = format!( + "{}/api/music/tracks/{}/stream", + base_url.trim_end_matches('/'), + track_id + ); + let mut req = client.get(&url); + if let Some(t) = token { + req = req.bearer_auth(t); + } + + let response = req + .send() + .await + .context(FetchSnafu { track_id: track_id.to_string() })?; + + if !response.status().is_success() { + return Err(StreamError::HttpError { + track_id: track_id.to_string(), + status: response.status().as_u16(), + location: snafu::location!(), + }); + } + + let suffix = infer_extension(response.headers()); + let tmp_path = + std::env::temp_dir().join(format!("harmonia-stream-{track_id}{suffix}")); + + let bytes = response + .bytes() + .await + .context(FetchSnafu { track_id: track_id.to_string() })?; + + std::fs::write(&tmp_path, &bytes) + .context(WriteSnafu { track_id: track_id.to_string() })?; + + Ok(tmp_path) +} + +fn infer_extension(headers: &reqwest::header::HeaderMap) -> &'static str { + let content_type = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if content_type.contains("flac") { + ".flac" + } else if content_type.contains("opus") { + ".opus" + } else if content_type.contains("ogg") { + ".ogg" + } else if content_type.contains("mpeg") || content_type.contains("mp3") { + ".mp3" + } else if content_type.contains("aac") || content_type.contains("mp4") { + ".m4a" + } else if content_type.contains("wav") { + ".wav" + } else { + ".audio" + } +} diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 5108e99..d573176 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -12,6 +12,8 @@ import PodcastDetailPage from "./features/podcast/pages/PodcastDetailPage"; import EpisodeDetailPage from "./features/podcast/pages/EpisodeDetailPage"; import LatestEpisodesPage from "./features/podcast/pages/LatestEpisodesPage"; import DownloadQueuePage from "./features/podcast/pages/DownloadQueuePage"; +import QueuePage from "./features/now-playing/pages/QueuePage"; +import SignalPathPage from "./features/now-playing/pages/SignalPathPage"; export default function App() { return ( @@ -35,6 +37,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/desktop/src/components/Layout.tsx b/desktop/src/components/Layout.tsx index df9844f..839f750 100644 --- a/desktop/src/components/Layout.tsx +++ b/desktop/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet } from "react-router-dom"; -import NowPlayingBar from "../features/now-playing/NowPlayingBar"; +import NowPlayingBar from "../features/now-playing/components/NowPlayingBar"; import { usePositionSync } from "../features/audiobook/hooks/usePositionSync"; import AudiobookNowPlaying from "../features/audiobook/components/AudiobookNowPlaying"; diff --git a/desktop/src/features/now-playing/components/ExpandedView.tsx b/desktop/src/features/now-playing/components/ExpandedView.tsx new file mode 100644 index 0000000..e28a8d6 --- /dev/null +++ b/desktop/src/features/now-playing/components/ExpandedView.tsx @@ -0,0 +1,85 @@ +import { useEffect } from "react"; +import type { PlaybackState } from "../../../types/playback"; +import { useNowPlayingStore } from "../store"; +import ProgressBar from "./ProgressBar"; +import TransportControls from "./TransportControls"; +import VolumeControl from "./VolumeControl"; + +interface Props { + state: PlaybackState; +} + +export default function ExpandedView({ state }: Props) { + const setExpanded = useNowPlayingStore((s) => s.setExpanded); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setExpanded(false); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [setExpanded]); + + return ( +
{ + if (e.target === e.currentTarget) setExpanded(false); + }} + > + {/* Blurred album art background */} +
+ +
+ {/* Large album art */} +
+ +
+ + {/* Track info */} +
+

+ {state.track?.title ?? "Nothing playing"} +

+ {state.track?.artist && ( +

+ {state.track.artist} +

+ )} + {state.track?.album && ( +

+ {state.track.album} +

+ )} +
+ + {/* Progress bar */} +
+ +
+ + {/* Transport controls */} + + + {/* Volume */} + +
+ + {/* Close button */} + +
+ ); +} diff --git a/desktop/src/features/now-playing/components/NowPlayingBar.tsx b/desktop/src/features/now-playing/components/NowPlayingBar.tsx new file mode 100644 index 0000000..59fd84a --- /dev/null +++ b/desktop/src/features/now-playing/components/NowPlayingBar.tsx @@ -0,0 +1,71 @@ +import { Link } from "react-router-dom"; +import { usePlaybackState } from "../hooks/usePlaybackState"; +import { useSignalPath } from "../hooks/useSignalPath"; +import { useNowPlayingStore } from "../store"; +import ExpandedView from "./ExpandedView"; +import ProgressBar from "./ProgressBar"; +import QualityIndicator from "./QualityIndicator"; +import TrackInfo from "./TrackInfo"; +import TransportControls from "./TransportControls"; +import VolumeControl from "./VolumeControl"; +import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; + +export default function NowPlayingBar() { + const state = usePlaybackState(); + const signalPath = useSignalPath(); + const expanded = useNowPlayingStore((s) => s.expanded); + const setExpanded = useNowPlayingStore((s) => s.setExpanded); + + useKeyboardShortcuts(state); + + return ( + <> + {expanded && } + +
+ {/* Left: track info */} +
+ +
+ + {/* Center: transport + progress */} +
+ + +
+ + {/* Right: volume, queue, signal path, expand */} +
+ + + + ≡ + + + + + +
+
+ + ); +} diff --git a/desktop/src/features/now-playing/components/ProgressBar.tsx b/desktop/src/features/now-playing/components/ProgressBar.tsx new file mode 100644 index 0000000..699ebae --- /dev/null +++ b/desktop/src/features/now-playing/components/ProgressBar.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePlayback } from "../hooks/usePlayback"; + +interface Props { + positionMs: number; + durationMs: number; +} + +function formatTime(ms: number): string { + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export default function ProgressBar({ positionMs, durationMs }: Props) { + const { seek } = usePlayback(); + const barRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [dragPosition, setDragPosition] = useState(0); + const [showTotal, setShowTotal] = useState(false); + + const fraction = + durationMs > 0 ? Math.min((dragging ? dragPosition : positionMs) / durationMs, 1) : 0; + + function seekToFraction(clientX: number) { + if (!barRef.current || durationMs === 0) return; + const rect = barRef.current.getBoundingClientRect(); + const f = Math.max(0, Math.min((clientX - rect.left) / rect.width, 1)); + const targetMs = Math.round(f * durationMs); + seek(targetMs); + } + + function handleClick(e: React.MouseEvent) { + seekToFraction(e.clientX); + } + + function handleMouseDown(e: React.MouseEvent) { + setDragging(true); + const f = Math.max( + 0, + Math.min( + (e.clientX - barRef.current!.getBoundingClientRect().left) / + barRef.current!.getBoundingClientRect().width, + 1 + ) + ); + setDragPosition(f * durationMs); + e.preventDefault(); + } + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!dragging || !barRef.current || durationMs === 0) return; + const rect = barRef.current.getBoundingClientRect(); + const f = Math.max(0, Math.min((e.clientX - rect.left) / rect.width, 1)); + setDragPosition(f * durationMs); + }, + [dragging, durationMs] + ); + + const handleMouseUp = useCallback( + (e: MouseEvent) => { + if (!dragging) return; + setDragging(false); + seekToFraction(e.clientX); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dragging, durationMs] + ); + + useEffect(() => { + if (dragging) { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + } + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [dragging, handleMouseMove, handleMouseUp]); + + const displayPosition = dragging ? dragPosition : positionMs; + const remaining = durationMs - displayPosition; + + return ( +
+ + {formatTime(displayPosition)} + + + {/* 16px hit-target bar */} +
+
+
+ {/* Drag handle */} +
+
+
+ + +
+ ); +} diff --git a/desktop/src/features/now-playing/components/QualityIndicator.tsx b/desktop/src/features/now-playing/components/QualityIndicator.tsx new file mode 100644 index 0000000..48bb1b4 --- /dev/null +++ b/desktop/src/features/now-playing/components/QualityIndicator.tsx @@ -0,0 +1,40 @@ +import { Link } from "react-router-dom"; +import type { SignalPathInfo } from "../../../types/playback"; + +interface Props { + info: SignalPathInfo; +} + +function tierColor(tier: string): string { + switch (tier) { + case "Bit-Perfect": + case "Lossless": + return "text-green-400"; + case "High Quality": + return "text-yellow-400"; + default: + return "text-red-400"; + } +} + +export default function QualityIndicator({ info }: Props) { + if (!info.source_codec) return null; + + return ( + + {info.is_bit_perfect && ( + + BIT-PERFECT + + )} + {info.source_codec} + + {Math.round(info.source_sample_rate / 1000)}kHz + + + ); +} diff --git a/desktop/src/features/now-playing/components/TrackInfo.tsx b/desktop/src/features/now-playing/components/TrackInfo.tsx new file mode 100644 index 0000000..40b9afb --- /dev/null +++ b/desktop/src/features/now-playing/components/TrackInfo.tsx @@ -0,0 +1,45 @@ +import { useNavigate } from "react-router-dom"; +import type { TrackInfo as TrackInfoType } from "../../../types/playback"; + +interface Props { + track: TrackInfoType | null; +} + +export default function TrackInfo({ track }: Props) { + const navigate = useNavigate(); + + if (!track) { + return ( +
+
+
+

Nothing playing

+
+
+ ); + } + + return ( +
+
+ +
+
+ + {track.artist && ( + + )} +
+
+ ); +} diff --git a/desktop/src/features/now-playing/components/TransportControls.tsx b/desktop/src/features/now-playing/components/TransportControls.tsx new file mode 100644 index 0000000..799cd4e --- /dev/null +++ b/desktop/src/features/now-playing/components/TransportControls.tsx @@ -0,0 +1,87 @@ +import type { PlaybackStatus, RepeatMode } from "../../../types/playback"; +import { usePlayback } from "../hooks/usePlayback"; + +interface Props { + status: PlaybackStatus; + shuffle: boolean; + repeatMode: RepeatMode; +} + +const REPEAT_CYCLE: RepeatMode[] = ["off", "all", "one"]; + +export default function TransportControls({ status, shuffle, repeatMode }: Props) { + const { pause, resume, stop: stopPlayback, nextTrack, previousTrack, setShuffle, setRepeatMode } = + usePlayback(); + + async function handlePlayPause() { + if (status === "playing") { + await pause(); + } else if (status === "paused") { + await resume(); + } + } + + async function handleRepeat() { + const current = REPEAT_CYCLE.indexOf(repeatMode); + const next = REPEAT_CYCLE[(current + 1) % REPEAT_CYCLE.length]; + await setRepeatMode(next); + } + + const isPlaying = status === "playing"; + + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/desktop/src/features/now-playing/components/VolumeControl.tsx b/desktop/src/features/now-playing/components/VolumeControl.tsx new file mode 100644 index 0000000..7957d2a --- /dev/null +++ b/desktop/src/features/now-playing/components/VolumeControl.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { useNowPlayingStore } from "../store"; +import { usePlayback } from "../hooks/usePlayback"; + +export default function VolumeControl() { + const volume = useNowPlayingStore((s) => s.volume); + const setVolume = useNowPlayingStore((s) => s.setVolume); + const { setVolume: setEngineVolume } = usePlayback(); + const [muted, setMuted] = useState(false); + const [preMuteVolume, setPreMuteVolume] = useState(1.0); + + async function handleVolumeChange(e: React.ChangeEvent) { + const level = Number(e.target.value); + setVolume(level); + if (muted && level > 0) setMuted(false); + await setEngineVolume(level); + } + + async function handleMuteToggle() { + if (muted) { + setMuted(false); + setVolume(preMuteVolume); + await setEngineVolume(preMuteVolume); + } else { + setPreMuteVolume(volume); + setMuted(true); + await setEngineVolume(0); + } + } + + const displayVolume = muted ? 0 : volume; + + return ( +
+ + +
+ ); +} diff --git a/desktop/src/features/now-playing/hooks/useKeyboardShortcuts.ts b/desktop/src/features/now-playing/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..5bc9ffa --- /dev/null +++ b/desktop/src/features/now-playing/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +import type { PlaybackState } from "../../../types/playback"; +import { usePlayback } from "./usePlayback"; + +function isTypingTarget(target: EventTarget | null): boolean { + if (!target) return false; + const el = target as HTMLElement; + return ( + el.tagName === "INPUT" || + el.tagName === "TEXTAREA" || + el.tagName === "SELECT" || + el.isContentEditable + ); +} + +export function useKeyboardShortcuts(state: PlaybackState) { + const { pause, resume, seek, nextTrack, previousTrack, setVolume } = + usePlayback(); + const { setShuffle, setRepeatMode } = usePlayback(); + + useEffect(() => { + async function onKeyDown(e: KeyboardEvent) { + if (isTypingTarget(e.target)) return; + + switch (e.key) { + case " ": + e.preventDefault(); + if (state.status === "playing") await pause(); + else if (state.status === "paused") await resume(); + break; + case "ArrowRight": + e.preventDefault(); + await seek(Math.min(state.position_ms + 10_000, state.duration_ms)); + break; + case "ArrowLeft": + e.preventDefault(); + await seek(Math.max(state.position_ms - 10_000, 0)); + break; + case "ArrowUp": + e.preventDefault(); + await setVolume(Math.min(state.volume + 0.05, 1.0)); + break; + case "ArrowDown": + e.preventDefault(); + await setVolume(Math.max(state.volume - 0.05, 0.0)); + break; + case "m": + case "M": + await setVolume(state.volume > 0 ? 0 : 1.0); + break; + case "n": + case "N": + await nextTrack(); + break; + case "p": + case "P": + await previousTrack(); + break; + case "s": + case "S": + await setShuffle(!state.shuffle); + break; + case "r": + case "R": { + const modes = ["off", "all", "one"] as const; + const idx = modes.indexOf(state.repeat_mode); + await setRepeatMode(modes[(idx + 1) % modes.length]); + break; + } + } + } + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [state, pause, resume, seek, nextTrack, previousTrack, setVolume, setShuffle, setRepeatMode]); +} diff --git a/desktop/src/features/now-playing/hooks/usePlayback.ts b/desktop/src/features/now-playing/hooks/usePlayback.ts new file mode 100644 index 0000000..089489a --- /dev/null +++ b/desktop/src/features/now-playing/hooks/usePlayback.ts @@ -0,0 +1,86 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { QueueEntry, RepeatMode } from "../../../types/playback"; + +export function usePlayback() { + async function playTrack( + entry: QueueEntry, + baseUrl: string, + token?: string + ): Promise { + await invoke("play_track", { entry, baseUrl, token: token ?? null }); + } + + async function pause(): Promise { + await invoke("pause"); + } + + async function resume(): Promise { + await invoke("resume"); + } + + async function stop(): Promise { + await invoke("stop"); + } + + async function seek(positionMs: number): Promise { + await invoke("seek", { positionMs }); + } + + async function nextTrack(): Promise { + await invoke("next_track"); + } + + async function previousTrack(): Promise { + await invoke("previous_track"); + } + + async function setVolume(level: number): Promise { + await invoke("playback_set_volume", { level }); + } + + async function getVolume(): Promise { + return invoke("playback_get_volume"); + } + + async function queueAdd(entries: QueueEntry[]): Promise { + await invoke("queue_add", { entries }); + } + + async function queueRemove(index: number): Promise { + await invoke("queue_remove", { index }); + } + + async function queueClear(): Promise { + await invoke("queue_clear"); + } + + async function queueMove(from: number, to: number): Promise { + await invoke("queue_move", { from, to }); + } + + async function setRepeatMode(mode: RepeatMode): Promise { + await invoke("set_repeat_mode", { mode }); + } + + async function setShuffle(enabled: boolean): Promise { + await invoke("set_shuffle", { enabled }); + } + + return { + playTrack, + pause, + resume, + stop, + seek, + nextTrack, + previousTrack, + setVolume, + getVolume, + queueAdd, + queueRemove, + queueClear, + queueMove, + setRepeatMode, + setShuffle, + }; +} diff --git a/desktop/src/features/now-playing/hooks/usePlaybackState.ts b/desktop/src/features/now-playing/hooks/usePlaybackState.ts new file mode 100644 index 0000000..d8392d0 --- /dev/null +++ b/desktop/src/features/now-playing/hooks/usePlaybackState.ts @@ -0,0 +1,60 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import type { + PlaybackState, + PlaybackStateEvent, + ProgressEvent, +} from "../../../types/playback"; + +const INITIAL_STATE: PlaybackState = { + status: "stopped", + track: null, + position_ms: 0, + duration_ms: 0, + volume: 1.0, + repeat_mode: "off", + shuffle: false, +}; + +export function usePlaybackState(): PlaybackState { + const [state, setState] = useState(INITIAL_STATE); + + useEffect(() => { + // Poll for initial state. + invoke("get_playback_state") + .then((s) => setState(s)) + .catch(() => {}); + + // Listen for progress events (250ms interval during playback). + const unlistenProgress = listen( + "playback-progress", + (event) => { + setState((prev) => ({ + ...prev, + position_ms: event.payload.position_ms, + duration_ms: event.payload.duration_ms, + })); + } + ); + + // Listen for state-change events (status, track metadata changes). + const unlistenState = listen( + "playback-state-changed", + (event) => { + setState((prev) => ({ + ...prev, + status: event.payload.status, + track: event.payload.track, + })); + } + ); + + return () => { + unlistenProgress.then((fn) => fn()); + unlistenState.then((fn) => fn()); + }; + }, []); + + return state; +} diff --git a/desktop/src/features/now-playing/hooks/useQueue.ts b/desktop/src/features/now-playing/hooks/useQueue.ts new file mode 100644 index 0000000..2430e2d --- /dev/null +++ b/desktop/src/features/now-playing/hooks/useQueue.ts @@ -0,0 +1,36 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useState } from "react"; +import type { QueueChangedEvent, QueueState } from "../../../types/playback"; + +const INITIAL_QUEUE: QueueState = { + entries: [], + current_index: 0, + repeat_mode: "off", + shuffle: false, + source_label: "", +}; + +export function useQueue(): QueueState { + const [queue, setQueue] = useState(INITIAL_QUEUE); + + useEffect(() => { + invoke("queue_get") + .then((q) => setQueue(q)) + .catch(() => {}); + + const unlisten = listen("queue-changed", (event) => { + setQueue((prev) => ({ + ...prev, + entries: event.payload.entries, + current_index: event.payload.current_index, + })); + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + return queue; +} diff --git a/desktop/src/features/now-playing/hooks/useSignalPath.ts b/desktop/src/features/now-playing/hooks/useSignalPath.ts new file mode 100644 index 0000000..ae72708 --- /dev/null +++ b/desktop/src/features/now-playing/hooks/useSignalPath.ts @@ -0,0 +1,43 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; +import type { SignalPathInfo } from "../../../types/playback"; + +const INITIAL_PATH: SignalPathInfo = { + source_codec: "", + source_sample_rate: 44100, + source_bit_depth: 16, + dsp_stages: [], + output_device: "", + output_sample_rate: 44100, + is_bit_perfect: false, + quality_tier: "Stopped", +}; + +const POLL_INTERVAL_MS = 2000; + +export function useSignalPath(): SignalPathInfo { + const [info, setInfo] = useState(INITIAL_PATH); + + useEffect(() => { + let cancelled = false; + + async function poll() { + try { + const result = await invoke("get_signal_path"); + if (!cancelled) setInfo(result); + } catch { + // Not playing — keep previous state. + } + } + + poll(); + const id = window.setInterval(poll, POLL_INTERVAL_MS); + + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + + return info; +} diff --git a/desktop/src/features/now-playing/pages/QueuePage.tsx b/desktop/src/features/now-playing/pages/QueuePage.tsx new file mode 100644 index 0000000..cb61021 --- /dev/null +++ b/desktop/src/features/now-playing/pages/QueuePage.tsx @@ -0,0 +1,174 @@ +import { useRef, useState } from "react"; +import type { QueueEntry } from "../../../types/playback"; +import { usePlayback } from "../hooks/usePlayback"; +import { useQueue } from "../hooks/useQueue"; + +export default function QueuePage() { + const queue = useQueue(); + const { queueRemove, queueMove, queueClear } = usePlayback(); + + const dragIndex = useRef(null); + const [dragOver, setDragOver] = useState(null); + + function handleDragStart(index: number) { + dragIndex.current = index; + } + + function handleDragOver(e: React.DragEvent, index: number) { + e.preventDefault(); + setDragOver(index); + } + + function handleDrop(toIndex: number) { + const from = dragIndex.current; + if (from !== null && from !== toIndex) { + queueMove(from, toIndex); + } + dragIndex.current = null; + setDragOver(null); + } + + function handleDragEnd() { + dragIndex.current = null; + setDragOver(null); + } + + if (queue.entries.length === 0) { + return ( +
+ queueClear()} count={0} sourceLabel="" /> +
+ Queue is empty +
+
+ ); + } + + return ( +
+ queueClear()} + count={queue.entries.length} + sourceLabel={queue.source_label} + /> +
+ {queue.entries.map((entry, index) => ( + queueRemove(index)} + onDragStart={() => handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + /> + ))} +
+
+ ); +} + +function QueueHeader({ + onClear, + count, + sourceLabel, +}: { + onClear: () => void; + count: number; + sourceLabel: string; +}) { + return ( +
+
+

Queue

+ {sourceLabel && ( +

Playing from: {sourceLabel}

+ )} +
+
+ {count} tracks + +
+
+ ); +} + +interface RowProps { + entry: QueueEntry; + index: number; + isCurrent: boolean; + isDragOver: boolean; + onRemove: () => void; + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: () => void; + onDragEnd: () => void; +} + +function QueueRow({ + entry, + isCurrent, + isDragOver, + onRemove, + onDragStart, + onDragOver, + onDrop, + onDragEnd, +}: RowProps) { + function formatDuration(ms: number | null): string { + if (ms == null) return "—"; + const totalSecs = Math.floor(ms / 1000); + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + + return ( +
+ {isCurrent && ( + + ▶ + + )} + {!isCurrent && } + +
+

+ {entry.title} +

+ {entry.artist && ( +

{entry.artist}

+ )} +
+ + + {formatDuration(entry.duration_ms)} + + + +
+ ); +} diff --git a/desktop/src/features/now-playing/pages/SignalPathPage.tsx b/desktop/src/features/now-playing/pages/SignalPathPage.tsx new file mode 100644 index 0000000..029d964 --- /dev/null +++ b/desktop/src/features/now-playing/pages/SignalPathPage.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import type { DspStageInfo } from "../../../types/playback"; +import { useSignalPath } from "../hooks/useSignalPath"; + +export default function SignalPathPage() { + const info = useSignalPath(); + + if (!info.source_codec) { + return ( +
+ No track playing +
+ ); + } + + return ( +
+
+

Signal Path

+ {info.is_bit_perfect && ( + + BIT-PERFECT + + )} + + {info.quality_tier} + +
+ + {/* Horizontal chain: Source → DSP stages → Output */} +
+ + + {info.dsp_stages.map((stage) => ( + + ))} + + +
+
+ ); +} + +function tierColor(tier: string): string { + switch (tier) { + case "Bit-Perfect": + return "text-green-400"; + case "Lossless": + return "text-green-400"; + case "High Quality": + return "text-yellow-400"; + case "Standard": + return "text-orange-400"; + default: + return "text-red-400"; + } +} + +function tierBorder(tier: string): string { + switch (tier) { + case "Bit-Perfect": + case "Lossless": + return "border-green-700/50"; + case "High Quality": + return "border-yellow-700/50"; + case "Standard": + return "border-orange-700/50"; + default: + return "border-red-700/50"; + } +} + +interface SourceCardProps { + info: ReturnType; +} + +function SourceCard({ info }: SourceCardProps) { + return ( + +

{info.source_codec}

+

+ {Math.round(info.source_sample_rate / 1000)} kHz / {info.source_bit_depth}-bit +

+
+ ); +} + +function OutputCard({ info }: SourceCardProps) { + const resampled = info.source_sample_rate !== info.output_sample_rate; + const tier = resampled ? "High Quality" : info.quality_tier; + return ( + +

+ {info.output_device || "Default device"} +

+

+ {Math.round(info.output_sample_rate / 1000)} kHz +

+ {resampled && ( +

Resampled

+ )} +
+ ); +} + +function DspStageCard({ stage }: { stage: DspStageInfo }) { + const [expanded, setExpanded] = useState(false); + + return ( + + ); +} + +function StageCard({ + label, + tier, + children, +}: { + label: string; + tier: string; + children: React.ReactNode; +}) { + return ( +
+

{label}

+
{children}
+
+ ); +} diff --git a/desktop/src/features/now-playing/store.ts b/desktop/src/features/now-playing/store.ts new file mode 100644 index 0000000..34cb3f5 --- /dev/null +++ b/desktop/src/features/now-playing/store.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface NowPlayingState { + expanded: boolean; + volume: number; + setExpanded: (expanded: boolean) => void; + setVolume: (volume: number) => void; +} + +export const useNowPlayingStore = create()( + persist( + (set) => ({ + expanded: false, + volume: 1.0, + setExpanded: (expanded) => set({ expanded }), + setVolume: (volume) => set({ volume }), + }), + { name: "harmonia-now-playing" } + ) +); diff --git a/desktop/src/types/playback.ts b/desktop/src/types/playback.ts new file mode 100644 index 0000000..050c07f --- /dev/null +++ b/desktop/src/types/playback.ts @@ -0,0 +1,68 @@ +export type PlaybackStatus = "stopped" | "buffering" | "playing" | "paused"; +export type RepeatMode = "off" | "one" | "all"; + +export interface TrackInfo { + track_id: string; + title: string; + artist: string | null; + album: string | null; + duration_ms: number | null; +} + +export interface PlaybackState { + status: PlaybackStatus; + track: TrackInfo | null; + position_ms: number; + duration_ms: number; + volume: number; + repeat_mode: RepeatMode; + shuffle: boolean; +} + +export interface QueueEntry { + track_id: string; + title: string; + artist: string | null; + album: string | null; + duration_ms: number | null; +} + +export interface QueueState { + entries: QueueEntry[]; + current_index: number; + repeat_mode: RepeatMode; + shuffle: boolean; + source_label: string; +} + +export interface DspStageInfo { + name: string; + enabled: boolean; + parameters: string; +} + +export interface SignalPathInfo { + source_codec: string; + source_sample_rate: number; + source_bit_depth: number; + dsp_stages: DspStageInfo[]; + output_device: string; + output_sample_rate: number; + is_bit_perfect: boolean; + quality_tier: string; +} + +export interface ProgressEvent { + position_ms: number; + duration_ms: number; +} + +export interface PlaybackStateEvent { + status: PlaybackStatus; + track: TrackInfo | null; +} + +export interface QueueChangedEvent { + entries: QueueEntry[]; + current_index: number; +} From 2ab1c9d29870c390053f16d85ea6bcb0dbe1fce3 Mon Sep 17 00:00:00 2001 From: Cody Kickertz Date: Sat, 14 Mar 2026 01:51:51 +0000 Subject: [PATCH 2/2] feat(desktop): wire library play actions and finalize playback backend (P3-11) Add double-click-to-play and per-row play/add-to-queue buttons to TracksPage. Add play-album, shuffle-album, and add-to-queue overlays to AlbumsPage. Fix cargo fmt style in dsp and playback modules; remove unused stopPlayback binding from TransportControls; add listTracksForAlbum to api client. --- desktop/src-tauri/src/dsp/commands.rs | 4 +- desktop/src-tauri/src/playback/commands.rs | 14 ++- desktop/src-tauri/src/playback/mod.rs | 57 +++++---- desktop/src-tauri/src/playback/queue.rs | 7 +- desktop/src-tauri/src/playback/signal_path.rs | 5 +- desktop/src-tauri/src/playback/stream.rs | 22 ++-- desktop/src/api/client.ts | 3 + desktop/src/features/library/AlbumsPage.tsx | 112 +++++++++++++++++- desktop/src/features/library/TracksPage.tsx | 51 +++++++- .../components/TransportControls.tsx | 2 +- 10 files changed, 229 insertions(+), 48 deletions(-) diff --git a/desktop/src-tauri/src/dsp/commands.rs b/desktop/src-tauri/src/dsp/commands.rs index fa17045..453a4da 100644 --- a/desktop/src-tauri/src/dsp/commands.rs +++ b/desktop/src-tauri/src/dsp/commands.rs @@ -1,10 +1,10 @@ use tauri::State; +use super::DspController; use super::config::{ CompressorConfig, CrossfeedConfig, DspConfig, EqBand, ReplayGainConfig, VolumeConfig, }; -use super::presets::{built_in_presets, EqPreset}; -use super::DspController; +use super::presets::{EqPreset, built_in_presets}; #[tauri::command] pub fn get_dsp_config(controller: State<'_, DspController>) -> DspConfig { diff --git a/desktop/src-tauri/src/playback/commands.rs b/desktop/src-tauri/src/playback/commands.rs index 9767546..e3bd9c9 100644 --- a/desktop/src-tauri/src/playback/commands.rs +++ b/desktop/src-tauri/src/playback/commands.rs @@ -2,9 +2,7 @@ use tauri::State; -use super::{ - PlaybackEngine, PlaybackState, QueueEntry, QueueState, RepeatMode, SignalPathInfo, -}; +use super::{PlaybackEngine, PlaybackState, QueueEntry, QueueState, RepeatMode, SignalPathInfo}; // --------------------------------------------------------------------------- // Transport @@ -132,7 +130,10 @@ pub(crate) async fn queue_remove( app: tauri::AppHandle, engine: State<'_, PlaybackEngine>, ) -> Result<(), String> { - engine.queue_remove(index, &app).await.map_err(|e| e.to_string()) + engine + .queue_remove(index, &app) + .await + .map_err(|e| e.to_string()) } #[tauri::command] @@ -151,7 +152,10 @@ pub(crate) async fn queue_move( app: tauri::AppHandle, engine: State<'_, PlaybackEngine>, ) -> Result<(), String> { - engine.queue_move(from, to, &app).await.map_err(|e| e.to_string()) + engine + .queue_move(from, to, &app) + .await + .map_err(|e| e.to_string()) } #[tauri::command] diff --git a/desktop/src-tauri/src/playback/mod.rs b/desktop/src-tauri/src/playback/mod.rs index 7cf72db..0882c61 100644 --- a/desktop/src-tauri/src/playback/mod.rs +++ b/desktop/src-tauri/src/playback/mod.rs @@ -144,9 +144,7 @@ struct PlaybackInner { impl PlaybackInner { fn current_position_ms(&self) -> u64 { match self.play_start { - Some(start) => { - self.pause_offset_ms + start.elapsed().as_millis() as u64 - } + Some(start) => self.pause_offset_ms + start.elapsed().as_millis() as u64, None => self.pause_offset_ms, } } @@ -168,8 +166,7 @@ unsafe impl Sync for PlaybackEngine {} impl PlaybackEngine { pub(crate) fn new() -> Result { - let engine = - Engine::new(EngineConfig::default()).context(EngineCreateSnafu)?; + let engine = Engine::new(EngineConfig::default()).context(EngineCreateSnafu)?; Ok(Self { engine: Arc::new(engine), inner: Arc::new(Mutex::new(PlaybackInner { @@ -329,7 +326,11 @@ impl PlaybackEngine { } let was_playing = guard.status == PlaybackStatus::Playing; guard.pause_offset_ms = position_ms; - guard.play_start = if was_playing { Some(Instant::now()) } else { None }; + guard.play_start = if was_playing { + Some(Instant::now()) + } else { + None + }; guard.position_ms = position_ms; drop(guard); @@ -359,17 +360,17 @@ impl PlaybackEngine { // Queue management // --------------------------------------------------------------------------- - pub(crate) async fn queue_add( - &self, - entries: Vec, - app: &tauri::AppHandle, - ) { + pub(crate) async fn queue_add(&self, entries: Vec, app: &tauri::AppHandle) { let mut guard = self.inner.lock().await; guard.queue.append(entries); emit_queue_changed(&guard.queue, app); } - pub(crate) async fn queue_remove(&self, index: usize, app: &tauri::AppHandle) -> Result<(), PlaybackError> { + pub(crate) async fn queue_remove( + &self, + index: usize, + app: &tauri::AppHandle, + ) -> Result<(), PlaybackError> { let mut guard = self.inner.lock().await; if index >= guard.queue.display_entries().len() { return Err(QueueBoundsSnafu { index }.build()); @@ -394,7 +395,10 @@ impl PlaybackEngine { let mut guard = self.inner.lock().await; let len = guard.queue.display_entries().len(); if from >= len || to >= len { - return Err(QueueBoundsSnafu { index: from.max(to) }.build()); + return Err(QueueBoundsSnafu { + index: from.max(to), + } + .build()); } guard.queue.move_entry(from, to); emit_queue_changed(&guard.queue, app); @@ -476,10 +480,13 @@ async fn progress_task(inner: Arc>, app: tauri::AppHandle) let dur = guard.duration_ms; drop(guard); - let _ = app.emit("playback-progress", ProgressEvent { - position_ms: pos, - duration_ms: dur, - }); + let _ = app.emit( + "playback-progress", + ProgressEvent { + position_ms: pos, + duration_ms: dur, + }, + ); } } @@ -537,15 +544,21 @@ async fn event_listener( // --------------------------------------------------------------------------- fn emit_state(app: &tauri::AppHandle, status: PlaybackStatus, track: Option) { - let _ = app.emit("playback-state-changed", PlaybackStateEvent { status, track }); + let _ = app.emit( + "playback-state-changed", + PlaybackStateEvent { status, track }, + ); } fn emit_queue_changed(queue: &DesktopQueue, app: &tauri::AppHandle) { let entries: Vec = queue.display_entries().into_iter().cloned().collect(); - let _ = app.emit("queue-changed", QueueChangedEvent { - entries, - current_index: queue.current_display_index(), - }); + let _ = app.emit( + "queue-changed", + QueueChangedEvent { + entries, + current_index: queue.current_display_index(), + }, + ); } fn volume_to_db(linear: f64) -> f64 { diff --git a/desktop/src-tauri/src/playback/queue.rs b/desktop/src-tauri/src/playback/queue.rs index 10c618d..6575924 100644 --- a/desktop/src-tauri/src/playback/queue.rs +++ b/desktop/src-tauri/src/playback/queue.rs @@ -338,8 +338,11 @@ mod tests { let mut q = DesktopQueue::new(); q.append(vec![entry("a"), entry("b"), entry("c")]); q.set_shuffle(true); - let mut display: Vec = - q.display_entries().iter().map(|e| e.track_id.clone()).collect(); + let mut display: Vec = q + .display_entries() + .iter() + .map(|e| e.track_id.clone()) + .collect(); display.sort(); assert_eq!(display, vec!["a", "b", "c"]); } diff --git a/desktop/src-tauri/src/playback/signal_path.rs b/desktop/src-tauri/src/playback/signal_path.rs index 704e409..7d10d44 100644 --- a/desktop/src-tauri/src/playback/signal_path.rs +++ b/desktop/src-tauri/src/playback/signal_path.rs @@ -88,7 +88,10 @@ fn format_stage_params(params: &StageParams) -> String { StageParams::Crossfeed { strength } => { format!("strength {strength:.2}") } - StageParams::Compressor { threshold_db, ratio } => { + StageParams::Compressor { + threshold_db, + ratio, + } => { format!("{threshold_db:+.1} dB / {ratio:.1}:1") } StageParams::Volume { level_db, dither } => { diff --git a/desktop/src-tauri/src/playback/stream.rs b/desktop/src-tauri/src/playback/stream.rs index 7ef4312..ef8ef70 100644 --- a/desktop/src-tauri/src/playback/stream.rs +++ b/desktop/src-tauri/src/playback/stream.rs @@ -57,10 +57,9 @@ pub(crate) async fn fetch_stream( req = req.bearer_auth(t); } - let response = req - .send() - .await - .context(FetchSnafu { track_id: track_id.to_string() })?; + let response = req.send().await.context(FetchSnafu { + track_id: track_id.to_string(), + })?; if !response.status().is_success() { return Err(StreamError::HttpError { @@ -71,16 +70,15 @@ pub(crate) async fn fetch_stream( } let suffix = infer_extension(response.headers()); - let tmp_path = - std::env::temp_dir().join(format!("harmonia-stream-{track_id}{suffix}")); + let tmp_path = std::env::temp_dir().join(format!("harmonia-stream-{track_id}{suffix}")); - let bytes = response - .bytes() - .await - .context(FetchSnafu { track_id: track_id.to_string() })?; + let bytes = response.bytes().await.context(FetchSnafu { + track_id: track_id.to_string(), + })?; - std::fs::write(&tmp_path, &bytes) - .context(WriteSnafu { track_id: track_id.to_string() })?; + std::fs::write(&tmp_path, &bytes).context(WriteSnafu { + track_id: track_id.to_string(), + })?; Ok(tmp_path) } diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index a55bddc..0bb2c42 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -112,6 +112,9 @@ export const api = { return api.get(`/api/audiobooks/?${q}`, token); }, + listTracksForAlbum(albumId: string, token: string): Promise> { + return api.get(`/api/music/release-groups/${albumId}/tracks`, token); + }, getSubscriptions(token: string): Promise { return api.get("/api/podcasts/subscriptions", token); }, diff --git a/desktop/src/features/library/AlbumsPage.tsx b/desktop/src/features/library/AlbumsPage.tsx index f6f8ac0..b1e7cfe 100644 --- a/desktop/src/features/library/AlbumsPage.tsx +++ b/desktop/src/features/library/AlbumsPage.tsx @@ -1,10 +1,14 @@ import { useMemo, useCallback } from "react"; import { VirtuosoGrid } from "react-virtuoso"; +import { invoke } from "@tauri-apps/api/core"; import { useAlbums } from "./hooks"; import AlbumCard from "./AlbumCard"; import SortFilterBar from "./SortFilterBar"; import { useLibraryStore } from "./store"; -import type { ReleaseGroup } from "../../types/api"; +import { api } from "../../api/client"; +import { usePlayback } from "../now-playing/hooks/usePlayback"; +import type { ReleaseGroup, Track } from "../../types/api"; +import type { QueueEntry } from "../../types/playback"; function sortAlbums(albums: ReleaseGroup[], sort: string): ReleaseGroup[] { return [...albums].sort((a, b) => { @@ -14,6 +18,16 @@ function sortAlbums(albums: ReleaseGroup[], sort: string): ReleaseGroup[] { }); } +function trackToEntry(track: Track, albumTitle?: string): QueueEntry { + return { + track_id: track.id, + title: track.title, + artist: null, + album: albumTitle ?? null, + duration_ms: track.duration_ms, + }; +} + function EmptyState({ message }: { message: string }) { return (
@@ -22,10 +36,54 @@ function EmptyState({ message }: { message: string }) { ); } +interface AlbumActionsProps { + album: ReleaseGroup; + onPlay: (album: ReleaseGroup, shuffle: boolean) => void; + onAddToQueue: (album: ReleaseGroup) => void; +} + +function AlbumActions({ album, onPlay, onAddToQueue }: AlbumActionsProps) { + return ( +
+ + + +
+ ); +} + export default function AlbumsPage() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useAlbums(); const token = useLibraryStore((s) => s.token); const sort = useLibraryStore((s) => s.sort); + const { playTrack, queueAdd, setShuffle } = usePlayback(); const albums = useMemo(() => { const flat = data?.pages.flatMap((p) => p.data) ?? []; @@ -36,6 +94,45 @@ export default function AlbumsPage() { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + const fetchAlbumTracks = useCallback( + async (album: ReleaseGroup): Promise => { + try { + const response = await api.listTracksForAlbum(album.id, token); + return response.data; + } catch { + return []; + } + }, + [token] + ); + + const handlePlayAlbum = useCallback( + async (album: ReleaseGroup, shuffle: boolean) => { + const tracks = await fetchAlbumTracks(album); + if (tracks.length === 0) return; + const entries: QueueEntry[] = tracks.map((t) => trackToEntry(t, album.title)); + const baseUrl = await invoke("get_server_url"); + if (shuffle) { + await setShuffle(true); + } + await playTrack(entries[0], baseUrl, token || undefined); + if (entries.length > 1) { + await queueAdd(entries.slice(1)); + } + }, + [fetchAlbumTracks, playTrack, queueAdd, setShuffle, token] + ); + + const handleAddAlbumToQueue = useCallback( + async (album: ReleaseGroup) => { + const tracks = await fetchAlbumTracks(album); + if (tracks.length === 0) return; + const entries: QueueEntry[] = tracks.map((t) => trackToEntry(t, album.title)); + await queueAdd(entries); + }, + [fetchAlbumTracks, queueAdd] + ); + if (!token) return ; if (isLoading) return ; if (isError) return ; @@ -51,7 +148,18 @@ export default function AlbumsPage() { endReached={endReached} itemContent={(index) => (
- +
+ + { + void handlePlayAlbum(album, shuffle); + }} + onAddToQueue={(album) => { + void handleAddAlbumToQueue(album); + }} + /> +
)} listClassName="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6" diff --git a/desktop/src/features/library/TracksPage.tsx b/desktop/src/features/library/TracksPage.tsx index 2833904..f4b586e 100644 --- a/desktop/src/features/library/TracksPage.tsx +++ b/desktop/src/features/library/TracksPage.tsx @@ -1,7 +1,11 @@ import { useCallback } from "react"; import { Virtuoso } from "react-virtuoso"; +import { invoke } from "@tauri-apps/api/core"; import { useTracks } from "./hooks"; import { useLibraryStore } from "./store"; +import { usePlayback } from "../now-playing/hooks/usePlayback"; +import type { Track } from "../../types/api"; +import type { QueueEntry } from "../../types/playback"; function formatDuration(ms: number | null): string { if (ms == null) return "—"; @@ -11,6 +15,16 @@ function formatDuration(ms: number | null): string { return `${mins}:${secs.toString().padStart(2, "0")}`; } +function trackToEntry(track: Track): QueueEntry { + return { + track_id: track.id, + title: track.title, + artist: null, + album: null, + duration_ms: track.duration_ms, + }; +} + function EmptyState({ message }: { message: string }) { return (
@@ -22,6 +36,7 @@ function EmptyState({ message }: { message: string }) { export default function TracksPage() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useTracks(); const token = useLibraryStore((s) => s.token); + const { playTrack, queueAdd } = usePlayback(); const tracks = data?.pages.flatMap((p) => p.data) ?? []; @@ -29,6 +44,21 @@ export default function TracksPage() { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + const handlePlayTrack = useCallback( + async (track: Track) => { + const baseUrl = await invoke("get_server_url"); + await playTrack(trackToEntry(track), baseUrl, token || undefined); + }, + [playTrack, token] + ); + + const handleAddToQueue = useCallback( + async (track: Track) => { + await queueAdd([trackToEntry(track)]); + }, + [queueAdd] + ); + if (!token) return ; if (isLoading) return ; if (isError) return ; @@ -43,7 +73,10 @@ export default function TracksPage() { itemContent={(index) => { const track = tracks[index]; return ( -
+
{ void handlePlayTrack(track); }} + > {track.position} @@ -56,6 +89,22 @@ export default function TracksPage() { {formatDuration(track.duration_ms)} +
+ + +
); }} diff --git a/desktop/src/features/now-playing/components/TransportControls.tsx b/desktop/src/features/now-playing/components/TransportControls.tsx index 799cd4e..c90d9e1 100644 --- a/desktop/src/features/now-playing/components/TransportControls.tsx +++ b/desktop/src/features/now-playing/components/TransportControls.tsx @@ -10,7 +10,7 @@ interface Props { const REPEAT_CYCLE: RepeatMode[] = ["off", "all", "one"]; export default function TransportControls({ status, shuffle, repeatMode }: Props) { - const { pause, resume, stop: stopPlayback, nextTrack, previousTrack, setShuffle, setRepeatMode } = + const { pause, resume, nextTrack, previousTrack, setShuffle, setRepeatMode } = usePlayback(); async function handlePlayPause() {