diff --git a/CHANGELOG.md b/CHANGELOG.md index 7352798a..88fc1a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [0.34.0] - 2025-12-10 + +### Added + +- **MPRIS D-Bus Integration (Linux)**: Desktop media control support for Linux users + - Control spotatui via media keys (play/pause, next, previous) + - Compatible with `playerctl` command-line tool + - Desktop environment integration (GNOME, KDE media widgets) + - Track metadata exposed (title, artist, album, duration) + - Playback status and volume synced to D-Bus + - Requires native streaming feature (enabled by default on Linux) + +- **Multi-Page Playback Support**: Enhanced playback functionality for large playlists and saved tracks + - Play seamlessly across all loaded pages, not just the current page + - Automatically calculates correct track offset across multiple pages + - Supports both saved tracks (Liked Songs) and playlists + +- **Background Prefetching**: Intelligent prefetching system for improved performance + - Automatically loads additional tracks in the background when viewing playlists or saved tracks + - Prefetches up to 500 tracks (~10 pages) for seamless playback + - Non-blocking implementation - prefetching runs in separate async tasks + - Prefetched tracks are immediately available for playback without delay + +### Fixed + +- **First Song Playback Delay**: Fixed 5-10 second delay when playing the first song after startup + - Root cause: Prefetch operations were blocking the network thread, preventing playback events from being processed + - Fix: Converted prefetch operations to spawn as independent async tasks using `tokio::spawn()` + - Result: Playback starts instantly while prefetching happens in the background + +- **Track Skip Metadata Sync**: Implemented retry mechanism for track skip operations + - Ensures Spotify API returns updated track metadata after skipping + - Prevents showing stale track information in the UI + - Improves reliability of track transitions + + ## [0.33.8] - 2025-12-09 ### Changed diff --git a/Cargo.lock b/Cargo.lock index e2cd7ded..f6cd9cb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,131 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -215,6 +340,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -304,6 +435,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -507,6 +651,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -966,6 +1119,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -978,6 +1137,27 @@ dependencies = [ "syn", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1000,6 +1180,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1159,6 +1360,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1364,6 +1578,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -2021,7 +2247,7 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix", + "nix 0.30.1", "nom 8.0.0", "system-deps", ] @@ -2105,6 +2331,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2170,6 +2405,19 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mpris-server" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058bc2227727af394f34aa51da3e36aeecf2c808f39315d35f754872660750ae" +dependencies = [ + "async-channel", + "futures-channel", + "serde", + "trait-variant", + "zbus", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2239,6 +2487,19 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.30.1" @@ -2306,7 +2567,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.4", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -2425,7 +2686,7 @@ dependencies = [ "chrono", "getrandom 0.2.16", "http", - "rand 0.8.4", + "rand 0.8.5", "reqwest", "serde", "serde_json", @@ -2667,6 +2928,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2739,6 +3016,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pipewire" version = "0.9.2" @@ -2750,7 +3038,7 @@ dependencies = [ "libc", "libspa", "libspa-sys", - "nix", + "nix 0.30.1", "once_cell", "pipewire-sys", "thiserror 2.0.17", @@ -2807,6 +3095,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -2988,14 +3290,13 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.0", "rand_core 0.6.4", - "rand_hc", ] [[package]] @@ -3056,15 +3357,6 @@ dependencies = [ "rand 0.9.2", ] -[[package]] -name = "rand_hc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" -dependencies = [ - "rand_core 0.6.4", -] - [[package]] name = "ratatui" version = "0.26.3" @@ -3502,6 +3794,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -3674,7 +3977,7 @@ dependencies = [ [[package]] name = "spotatui" -version = "0.33.8" +version = "0.34.0" dependencies = [ "anyhow", "arboard", @@ -3690,11 +3993,12 @@ dependencies = [ "librespot-metadata", "librespot-oauth", "librespot-playback", + "mpris-server", "open", "openssl", "openssl-sys", "pipewire", - "rand 0.8.4", + "rand 0.8.5", "ratatui", "realfft", "reqwest", @@ -4251,9 +4555,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.34" @@ -4263,6 +4579,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "transpose" version = "0.2.3" @@ -4303,6 +4630,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -5155,6 +5493,16 @@ dependencies = [ "rustix 1.1.2", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.1" @@ -5178,6 +5526,68 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -5313,3 +5723,40 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 3aade00c..4f7b3ffd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ documentation = "https://github.com/LargeModGames/spotatui" repository = "https://github.com/LargeModGames/spotatui" keywords = ["spotify", "tui", "cli", "terminal"] categories = ["command-line-utilities"] -version = "0.33.8" +version = "0.34.0" authors = ["LargeModGames "] edition = "2021" license = "MIT" @@ -60,12 +60,17 @@ librespot-playback = { version = "0.8", optional = true, default-features = fals [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["rodio-backend"] } +# MPRIS D-Bus support (Linux only) +[target.'cfg(target_os = "linux")'.dependencies] +mpris-server = { version = "0.8", optional = true } + [features] -default = ["telemetry", "streaming", "audio-viz-cpal"] +default = ["telemetry", "streaming", "audio-viz-cpal", "mpris"] telemetry = ["reqwest", "reqwest/native-tls"] streaming = ["librespot-core", "librespot-playback", "librespot-connect", "librespot-oauth", "librespot-metadata"] audio-viz = ["realfft", "pipewire"] audio-viz-cpal = ["realfft", "cpal"] # Alternative for Windows/macOS or if pipewire issues +mpris = ["mpris-server", "streaming"] # MPRIS D-Bus integration (Linux only, requires streaming) [target.'cfg(target_env = "musl")'.dependencies] openssl-sys = { version = "0.9", features = ["vendored"] } diff --git a/README.md b/README.md index 5b9f3292..8737a53d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ We respect your privacy. This is purely a fun community metric with zero trackin - [Setup](#setup) - [How It Works](#how-it-works) - [Notes](#notes) + - [MPRIS D-Bus Integration (Linux)](#mpris-d-bus-integration-linux) - [Configuration](#configuration) - [In-App Settings](#in-app-settings) - [Settings Categories](#settings-categories) @@ -263,6 +264,30 @@ The native streaming feature uses a separate authentication flow. On first run: - Premium account is required for playback - The streaming authentication uses a different client than the main app's API controls +### MPRIS D-Bus Integration (Linux) + +When using native streaming on Linux, spotatui automatically registers with the [MPRIS D-Bus interface](https://specifications.freedesktop.org/mpris-spec/latest/), enabling: + +- **Media key support** - Play/pause, next, previous via keyboard media keys +- **Desktop integration** - Track info appears in GNOME/KDE media widgets +- **playerctl compatibility** - Control spotatui from the command line: + +```bash +# Check available players +playerctl -l +# Should show: spotatui + +# Control playback +playerctl -p spotatui play-pause +playerctl -p spotatui next +playerctl -p spotatui previous + +# View current track metadata +playerctl -p spotatui metadata +``` + +MPRIS is enabled by default on Linux builds with native streaming. + # Configuration A configuration file is located at `${HOME}/.config/spotatui/config.yml`. diff --git a/src/app.rs b/src/app.rs index 7e2da7ef..4a9f1943 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,7 +47,7 @@ const DEFAULT_ROUTE: Route = Route { #[derive(Clone)] pub struct ScrollableResultPages { - index: usize, + pub index: usize, pub pages: Vec, } @@ -452,6 +452,9 @@ pub struct App { pub native_track_info: Option, /// Whether native streaming is active (disables API-based progress calculation) pub is_streaming_active: bool, + /// Native playback state - updated by player events, used when streaming is active + /// This is more reliable than current_playback_context.is_playing during native streaming + pub native_is_playing: Option, } impl Default for App { @@ -553,6 +556,7 @@ impl Default for App { settings_edit_buffer: String::new(), native_track_info: None, is_streaming_active: false, + native_is_playing: None, } } } diff --git a/src/handlers/library.rs b/src/handlers/library.rs index 0e38b5d6..7c74f5f8 100644 --- a/src/handlers/library.rs +++ b/src/handlers/library.rs @@ -48,6 +48,8 @@ pub fn handler(key: Key, app: &mut App) { // Liked Songs, 2 => { app.dispatch(IoEvent::GetCurrentSavedTracks(None)); + // Pre-fetch more pages in background for seamless playback + app.dispatch(IoEvent::PreFetchAllSavedTracks); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); } // Albums, diff --git a/src/handlers/playlist.rs b/src/handlers/playlist.rs index 696620cd..619b3db2 100644 --- a/src/handlers/playlist.rs +++ b/src/handlers/playlist.rs @@ -52,7 +52,12 @@ pub fn handler(key: Key, app: &mut App) { app.playlist_offset = 0; if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { let playlist_id = selected_playlist.id.clone().into_static(); - app.dispatch(IoEvent::GetPlaylistItems(playlist_id, app.playlist_offset)); + app.dispatch(IoEvent::GetPlaylistItems( + playlist_id.clone(), + app.playlist_offset, + )); + // Pre-fetch more pages in background for seamless playback + app.dispatch(IoEvent::PreFetchAllPlaylistTracks(playlist_id)); } }; } diff --git a/src/handlers/track_table.rs b/src/handlers/track_table.rs index 858860cb..9856133a 100644 --- a/src/handlers/track_table.rs +++ b/src/handlers/track_table.rs @@ -62,6 +62,19 @@ pub fn handler(key: Key, app: &mut App) { } } } + Some(TrackTableContext::SavedTracks) => { + // Check if there are more saved tracks to load + if let Some(saved_tracks) = app.library.saved_tracks.get_results(None) { + let current_offset = saved_tracks.offset; + let limit = saved_tracks.limit; + // If there are more tracks beyond current page + if current_offset + limit < saved_tracks.total { + app.get_current_user_saved_tracks_next(); + app.track_table.selected_index = 0; + return; + } + } + } _ => {} } } @@ -116,6 +129,15 @@ pub fn handler(key: Key, app: &mut App) { } } } + Some(TrackTableContext::SavedTracks) => { + // Check if there are previous saved tracks to load + if app.library.saved_tracks.index > 0 { + app.get_current_user_saved_tracks_previous(); + // Set selection to last track of the loaded page + app.track_table.selected_index = app.large_search_limit.saturating_sub(1) as usize; + return; + } + } _ => {} } } @@ -450,21 +472,40 @@ fn on_enter(app: &mut App) { } } TrackTableContext::SavedTracks => { - if let Some(saved_tracks) = &app.library.saved_tracks.get_results(None) { - let playable_ids: Vec> = saved_tracks - .items - .iter() - .filter_map(|item| track_playable_id(item.track.id.clone())) - .collect(); + // Collect tracks from ALL loaded pages (not just current page) + // This gives us a larger playback range as the user browses + let mut all_playable_ids: Vec> = Vec::new(); + let current_page_index = app.library.saved_tracks.index; - if !playable_ids.is_empty() { - app.dispatch(IoEvent::StartPlayback( - None, - Some(playable_ids), - Some(app.track_table.selected_index), - )); + // Iterate through all loaded pages + for (page_idx, page) in app.library.saved_tracks.pages.iter().enumerate() { + for item in &page.items { + if let Some(id) = track_playable_id(item.track.id.clone()) { + all_playable_ids.push(id); + } } - }; + // If this is the current page, calculate the absolute offset for the selected track + if page_idx == current_page_index { + // This is handled below by calculating from page sizes + } + } + + if !all_playable_ids.is_empty() { + // Calculate absolute offset: (sum of previous page sizes) + selected index in current page + let mut absolute_offset = 0; + for page_idx in 0..current_page_index { + if let Some(page) = app.library.saved_tracks.pages.get(page_idx) { + absolute_offset += page.items.len(); + } + } + absolute_offset += app.track_table.selected_index; + + app.dispatch(IoEvent::StartPlayback( + None, + Some(all_playable_ids), + Some(absolute_offset), + )); + } } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => { diff --git a/src/main.rs b/src/main.rs index b7067635..bebd407e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,8 @@ mod cli; mod config; mod event; mod handlers; +#[cfg(feature = "mpris")] +mod mpris; mod network; #[cfg(feature = "streaming")] mod player; @@ -496,13 +498,80 @@ of the app. Beware that this comes at a CPU cost!", #[cfg(feature = "streaming")] let shared_position_for_ui = Arc::clone(&shared_position); + // Create shared atomic for playing state (lock-free for MPRIS toggle) + #[cfg(feature = "streaming")] + let shared_is_playing = Arc::new(std::sync::atomic::AtomicBool::new(false)); + #[cfg(feature = "streaming")] + let shared_is_playing_for_events = Arc::clone(&shared_is_playing); + #[cfg(feature = "mpris")] + let shared_is_playing_for_mpris = Arc::clone(&shared_is_playing); + + // Initialize MPRIS D-Bus integration for desktop media control + // This registers spotatui as a controllable media player on the session bus + #[cfg(feature = "mpris")] + let mpris_manager: Option> = if streaming_player.is_some() { + match mpris::MprisManager::new() { + Ok(mgr) => { + println!("MPRIS D-Bus interface registered - media keys and playerctl enabled"); + Some(Arc::new(mgr)) + } + Err(e) => { + println!( + "Failed to initialize MPRIS: {} - media key control disabled", + e + ); + None + } + } + } else { + None + }; + + // Spawn MPRIS event handler to process external control requests (media keys, playerctl) + #[cfg(feature = "mpris")] + if let Some(ref mpris) = mpris_manager { + if let Some(event_rx) = mpris.take_event_rx() { + let streaming_player_for_mpris = streaming_player.clone(); + tokio::spawn(async move { + handle_mpris_events( + event_rx, + streaming_player_for_mpris, + shared_is_playing_for_mpris, + ) + .await; + }); + } + } + + // Clone MPRIS manager for player event handler + #[cfg(feature = "mpris")] + let mpris_for_events = mpris_manager.clone(); + // Spawn player event listener (updates app state from native player events) #[cfg(feature = "streaming")] if let Some(ref player) = streaming_player { let event_rx = player.get_event_channel(); let app_for_events = Arc::clone(&app); + #[cfg(feature = "mpris")] + tokio::spawn(async move { + handle_player_events( + event_rx, + app_for_events, + shared_position_for_events, + shared_is_playing_for_events, + mpris_for_events, + ) + .await; + }); + #[cfg(not(feature = "mpris"))] tokio::spawn(async move { - handle_player_events(event_rx, app_for_events, shared_position_for_events).await; + handle_player_events( + event_rx, + app_for_events, + shared_position_for_events, + shared_is_playing_for_events, + ) + .await; }); } @@ -546,14 +615,17 @@ async fn start_tokio(io_rx: std::sync::mpsc::Receiver, network: &mut Ne /// Handle player events from librespot and update app state directly /// This bypasses the Spotify Web API for instant UI updates -#[cfg(feature = "streaming")] +#[cfg(all(feature = "streaming", feature = "mpris"))] async fn handle_player_events( mut event_rx: librespot_playback::player::PlayerEventChannel, app: Arc>, shared_position: Arc, + shared_is_playing: Arc, + mpris_manager: Option>, ) { use chrono::TimeDelta; use player::PlayerEvent; + use std::sync::atomic::Ordering; while let Some(event) = event_rx.recv().await { // Use try_lock() to avoid blocking when the UI thread is busy @@ -564,7 +636,22 @@ async fn handle_player_events( track_id, position_ms, } => { - // Try to get lock - skip if busy + // Always update atomic - this never fails (lock-free for MPRIS) + shared_is_playing.store(true, Ordering::Relaxed); + + // Update MPRIS playback status + if let Some(ref mpris) = mpris_manager { + mpris.set_playback_status(true); + } + + // Always update native_is_playing - this is critical for UI state + // Use blocking lock since this is a brief operation + { + let mut app_lock = app.lock().await; + app_lock.native_is_playing = Some(true); + } + + // Try to get lock for other updates - skip if busy if let Ok(mut app) = app.try_lock() { app.song_progress_ms = position_ms as u128; @@ -590,6 +677,22 @@ async fn handle_player_events( track_id: _, position_ms, } => { + // Always update atomic - this never fails (lock-free for MPRIS) + shared_is_playing.store(false, Ordering::Relaxed); + + // Update MPRIS playback status + if let Some(ref mpris) = mpris_manager { + mpris.set_playback_status(false); + } + + // Always update native_is_playing - this is critical for UI state + // Use blocking lock since this is a brief operation + { + let mut app_lock = app.lock().await; + app_lock.native_is_playing = Some(false); + } + + // Try to get lock for other updates - skip if busy if let Ok(mut app) = app.try_lock() { app.song_progress_ms = position_ms as u128; @@ -617,13 +720,180 @@ async fn handle_player_events( } PlayerEvent::TrackChanged { audio_item } => { // Track metadata changed - extract immediate info for instant UI updates + use librespot_metadata::audio::UniqueFields; + + // Extract artist names and album from UniqueFields + let (artists, album) = match &audio_item.unique_fields { + UniqueFields::Track { artists, album, .. } => { + // Extract artist names from ArtistsWithRole + let artist_names: Vec = artists.0.iter().map(|a| a.name.clone()).collect(); + (artist_names, album.clone()) + } + UniqueFields::Episode { show_name, .. } => (vec![show_name.clone()], String::new()), + UniqueFields::Local { artists, album, .. } => { + let artist_vec = artists + .as_ref() + .map(|a| vec![a.clone()]) + .unwrap_or_default(); + let album_str = album.clone().unwrap_or_default(); + (artist_vec, album_str) + } + }; + + // Update MPRIS metadata + if let Some(ref mpris) = mpris_manager { + mpris.set_metadata(&audio_item.name, &artists, &album, audio_item.duration_ms); + } + if let Ok(mut app) = app.try_lock() { - use librespot_metadata::audio::UniqueFields; + // Store immediate track info for instant UI display + app.native_track_info = Some(app::NativeTrackInfo { + name: audio_item.name.clone(), + artists: artists.clone(), + album: album.clone(), + duration_ms: audio_item.duration_ms, + }); + + app.song_progress_ms = 0; + app.last_track_id = Some(audio_item.track_id.to_string()); + // Reset the poll timer so we don't immediately overwrite with stale API data + app.instant_since_last_current_playback_poll = std::time::Instant::now(); + app.dispatch(IoEvent::GetCurrentPlayback); + } + } + PlayerEvent::Stopped { .. } | PlayerEvent::EndOfTrack { .. } => { + // Update MPRIS status + if let Some(ref mpris) = mpris_manager { + mpris.set_stopped(); + } + + // When a track ends naturally, pre-fetch the next track's info immediately + if let Ok(mut app) = app.try_lock() { + if let Some(ref mut ctx) = app.current_playback_context { + ctx.is_playing = false; + } + app.song_progress_ms = 0; + // Clear the last track ID so the next Playing event will trigger a full refresh + app.last_track_id = None; + } + + // Small delay to let Spotify's backend transition to the next track + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Try to dispatch - skip if busy + if let Ok(mut app) = app.try_lock() { + app.dispatch(IoEvent::GetCurrentPlayback); + } + } + PlayerEvent::VolumeChanged { volume } => { + // Update MPRIS volume + let volume_percent = ((volume as f64 / 65535.0) * 100.0).round() as u8; + if let Some(ref mpris) = mpris_manager { + mpris.set_volume(volume_percent); + } + + if let Ok(mut app) = app.try_lock() { + if let Some(ref mut ctx) = app.current_playback_context { + ctx.device.volume_percent = Some(volume_percent as u32); + } + // Persist the latest volume so it is restored on next launch + app.user_config.behavior.volume_percent = volume_percent.min(100); + let _ = app.user_config.save_config(); + } + } + PlayerEvent::PositionChanged { + play_request_id: _, + track_id: _, + position_ms, + } => { + // Use atomic store for lock-free position updates + // This never blocks or fails, ensuring every position update is captured + shared_position.store(position_ms as u64, Ordering::Relaxed); + } + _ => { + // Ignore other events + } + } + } +} - // Extract artist names and album from UniqueFields +/// Handle player events from librespot and update app state directly +/// This bypasses the Spotify Web API for instant UI updates +#[cfg(all(feature = "streaming", not(feature = "mpris")))] +async fn handle_player_events( + mut event_rx: librespot_playback::player::PlayerEventChannel, + app: Arc>, + shared_position: Arc, + shared_is_playing: Arc, +) { + use chrono::TimeDelta; + use player::PlayerEvent; + use std::sync::atomic::Ordering; + + while let Some(event) = event_rx.recv().await { + match event { + PlayerEvent::Playing { + play_request_id: _, + track_id, + position_ms, + } => { + shared_is_playing.store(true, Ordering::Relaxed); + { + let mut app_lock = app.lock().await; + app_lock.native_is_playing = Some(true); + } + if let Ok(mut app) = app.try_lock() { + app.song_progress_ms = position_ms as u128; + if let Some(ref mut ctx) = app.current_playback_context { + ctx.is_playing = true; + ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); + } + app.instant_since_last_current_playback_poll = std::time::Instant::now(); + let track_id_str = track_id.to_string(); + if app.last_track_id.as_ref() != Some(&track_id_str) { + app.last_track_id = Some(track_id_str); + app.dispatch(IoEvent::GetCurrentPlayback); + } + } + } + PlayerEvent::Paused { + play_request_id: _, + track_id: _, + position_ms, + } => { + shared_is_playing.store(false, Ordering::Relaxed); + { + let mut app_lock = app.lock().await; + app_lock.native_is_playing = Some(false); + } + if let Ok(mut app) = app.try_lock() { + app.song_progress_ms = position_ms as u128; + if let Some(ref mut ctx) = app.current_playback_context { + ctx.is_playing = false; + ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); + } + app.instant_since_last_current_playback_poll = std::time::Instant::now(); + } + } + PlayerEvent::Seeked { + play_request_id: _, + track_id: _, + position_ms, + } => { + if let Ok(mut app) = app.try_lock() { + app.song_progress_ms = position_ms as u128; + app.seek_ms = None; + if let Some(ref mut ctx) = app.current_playback_context { + ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64)); + } + app.instant_since_last_current_playback_poll = std::time::Instant::now(); + } + } + PlayerEvent::TrackChanged { audio_item } => { + if let Ok(mut app) = app.try_lock() { + use librespot_metadata::audio::UniqueFields; let (artists, album) = match &audio_item.unique_fields { UniqueFields::Track { artists, album, .. } => { - // Extract artist names from ArtistsWithRole let artist_names: Vec = artists.0.iter().map(|a| a.name.clone()).collect(); (artist_names, album.clone()) } @@ -637,49 +907,37 @@ async fn handle_player_events( (artist_vec, album_str) } }; - - // Store immediate track info for instant UI display app.native_track_info = Some(app::NativeTrackInfo { name: audio_item.name.clone(), artists, album, duration_ms: audio_item.duration_ms, }); - app.song_progress_ms = 0; app.last_track_id = Some(audio_item.track_id.to_string()); - // Reset the poll timer so we don't immediately overwrite with stale API data app.instant_since_last_current_playback_poll = std::time::Instant::now(); app.dispatch(IoEvent::GetCurrentPlayback); } } PlayerEvent::Stopped { .. } | PlayerEvent::EndOfTrack { .. } => { - // When a track ends naturally, pre-fetch the next track's info immediately if let Ok(mut app) = app.try_lock() { if let Some(ref mut ctx) = app.current_playback_context { ctx.is_playing = false; } app.song_progress_ms = 0; - // Clear the last track ID so the next Playing event will trigger a full refresh app.last_track_id = None; } - - // Small delay to let Spotify's backend transition to the next track tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // Try to dispatch - skip if busy if let Ok(mut app) = app.try_lock() { app.dispatch(IoEvent::GetCurrentPlayback); } } PlayerEvent::VolumeChanged { volume } => { if let Ok(mut app) = app.try_lock() { - // Convert from 0-65535 to 0-100 let volume_percent = ((volume as f64 / 65535.0) * 100.0).round() as u32; if let Some(ref mut ctx) = app.current_playback_context { ctx.device.volume_percent = Some(volume_percent); } - // Persist the latest volume so it is restored on next launch app.user_config.behavior.volume_percent = volume_percent.min(100) as u8; let _ = app.user_config.save_config(); } @@ -689,12 +947,65 @@ async fn handle_player_events( track_id: _, position_ms, } => { - // Use atomic store for lock-free position updates - // This never blocks or fails, ensuring every position update is captured shared_position.store(position_ms as u64, Ordering::Relaxed); } - _ => { - // Ignore other events + _ => {} + } + } +} + +/// Handle MPRIS events from external clients (media keys, playerctl, etc.) +/// Routes control requests to the native streaming player +#[cfg(feature = "mpris")] +async fn handle_mpris_events( + mut event_rx: tokio::sync::mpsc::UnboundedReceiver, + streaming_player: Option>, + shared_is_playing: Arc, +) { + use mpris::MprisEvent; + use std::sync::atomic::Ordering; + + let Some(player) = streaming_player else { + // No streaming player, nothing to control + return; + }; + + while let Some(event) = event_rx.recv().await { + match event { + MprisEvent::PlayPause => { + // Toggle based on atomic state (lock-free, always up-to-date) + if shared_is_playing.load(Ordering::Relaxed) { + player.pause(); + } else { + player.play(); + } + } + MprisEvent::Play => { + player.play(); + } + MprisEvent::Pause => { + player.pause(); + } + MprisEvent::Next => { + player.next(); + // Ensure playback continues after skip + player.play(); + } + MprisEvent::Previous => { + player.prev(); + // Ensure playback continues after skip + player.play(); + } + MprisEvent::Stop => { + player.stop(); + } + MprisEvent::Seek(offset_micros) => { + // Seek by offset - convert from microseconds to milliseconds + // Note: This is a relative seek, not absolute position + let offset_ms = (offset_micros / 1000) as u32; + // Since we don't have the current position here easily, + // this is a simplified implementation + player.seek(offset_ms); } } } diff --git a/src/mpris.rs b/src/mpris.rs new file mode 100644 index 00000000..20b60eb5 --- /dev/null +++ b/src/mpris.rs @@ -0,0 +1,227 @@ +//! MPRIS D-Bus interface for desktop media control integration +//! +//! Exposes spotatui as a controllable media player via D-Bus, enabling: +//! - Media key support (play/pause, next, previous) +//! - Desktop environment integration (GNOME, KDE, etc.) +//! - playerctl command-line control +//! +//! This module is only available on Linux with the `mpris` feature enabled. + +use anyhow::Result; +use mpris_server::{Metadata, PlaybackStatus, Player, Time}; +use std::thread; +use tokio::sync::mpsc; + +/// Events that can be received from external MPRIS clients (e.g., media keys, playerctl) +#[derive(Debug, Clone)] +pub enum MprisEvent { + PlayPause, + Play, + Pause, + Next, + Previous, + Stop, + Seek(i64), // Offset in microseconds +} + +/// Commands to send TO the MPRIS server to update its state +#[derive(Debug, Clone)] +#[allow(dead_code)] // SetPosition kept for future use +pub enum MprisCommand { + SetMetadata { + title: String, + artists: Vec, + album: String, + duration_ms: u32, + }, + SetPlaybackStatus(bool), // true = playing, false = paused + SetPosition(u64), // position in milliseconds (for future use) + SetVolume(u8), // 0-100 + SetStopped, +} + +/// Manager for the MPRIS D-Bus server +pub struct MprisManager { + event_rx: std::sync::Mutex>>, + command_tx: mpsc::UnboundedSender, +} + +impl MprisManager { + /// Create and start the MPRIS server + /// + /// Registers spotatui as `org.mpris.MediaPlayer2.spotatui` on D-Bus + /// The MPRIS server runs in a dedicated thread with its own runtime + /// because player.run() returns a !Send future that requires LocalSet + pub fn new() -> Result { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let (command_tx, mut command_rx) = mpsc::unbounded_channel::(); + + // Spawn MPRIS server in a dedicated thread with its own LocalSet runtime + // This is required because mpris_server::Player uses Rc internally (not Send) + thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create MPRIS runtime"); + + let local = tokio::task::LocalSet::new(); + local.block_on(&rt, async move { + // Build the MPRIS player + let player = match Player::builder("spotatui") + .identity("spotatui") + .desktop_entry("spotatui") + .can_play(true) + .can_pause(true) + .can_go_next(true) + .can_go_previous(true) + .can_seek(true) + .can_control(true) + .can_quit(false) + .can_raise(false) + .can_set_fullscreen(false) + .build() + .await + { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to build MPRIS player: {}", e); + return; + } + }; + + // Set up event handlers for external control requests + let tx = event_tx.clone(); + player.connect_play_pause(move |_player| { + let _ = tx.send(MprisEvent::PlayPause); + }); + + let tx = event_tx.clone(); + player.connect_play(move |_player| { + let _ = tx.send(MprisEvent::Play); + }); + + let tx = event_tx.clone(); + player.connect_pause(move |_player| { + let _ = tx.send(MprisEvent::Pause); + }); + + let tx = event_tx.clone(); + player.connect_next(move |_player| { + let _ = tx.send(MprisEvent::Next); + }); + + let tx = event_tx.clone(); + player.connect_previous(move |_player| { + let _ = tx.send(MprisEvent::Previous); + }); + + let tx = event_tx.clone(); + player.connect_stop(move |_player| { + let _ = tx.send(MprisEvent::Stop); + }); + + let tx = event_tx.clone(); + player.connect_seek(move |_player, offset| { + let _ = tx.send(MprisEvent::Seek(offset.as_micros())); + }); + + // Spawn the player event loop + tokio::task::spawn_local(player.run()); + + // Handle commands from the main application + while let Some(cmd) = command_rx.recv().await { + match cmd { + MprisCommand::SetMetadata { + title, + artists, + album, + duration_ms, + } => { + let metadata = Metadata::builder() + .title(&title) + .artist(artists.iter().map(|s| s.as_str()).collect::>()) + .album(&album) + .length(Time::from_millis(duration_ms as i64)) + .build(); + + if let Err(e) = player.set_metadata(metadata).await { + eprintln!("MPRIS: Failed to set metadata: {}", e); + } + } + MprisCommand::SetPlaybackStatus(is_playing) => { + let status = if is_playing { + PlaybackStatus::Playing + } else { + PlaybackStatus::Paused + }; + if let Err(e) = player.set_playback_status(status).await { + eprintln!("MPRIS: Failed to set playback status: {}", e); + } + } + MprisCommand::SetPosition(position_ms) => { + player.set_position(Time::from_millis(position_ms as i64)); + } + MprisCommand::SetVolume(volume_percent) => { + let volume = (volume_percent as f64) / 100.0; + if let Err(e) = player.set_volume(volume).await { + eprintln!("MPRIS: Failed to set volume: {}", e); + } + } + MprisCommand::SetStopped => { + if let Err(e) = player.set_playback_status(PlaybackStatus::Stopped).await { + eprintln!("MPRIS: Failed to set stopped status: {}", e); + } + } + } + } + }); + }); + + Ok(Self { + event_rx: std::sync::Mutex::new(Some(event_rx)), + command_tx, + }) + } + + /// Take the event receiver for handling external control requests + /// + /// This can only be called once; subsequent calls return None + pub fn take_event_rx(&self) -> Option> { + self.event_rx.lock().ok()?.take() + } + + /// Update track metadata + pub fn set_metadata(&self, title: &str, artists: &[String], album: &str, duration_ms: u32) { + let _ = self.command_tx.send(MprisCommand::SetMetadata { + title: title.to_string(), + artists: artists.to_vec(), + album: album.to_string(), + duration_ms, + }); + } + + /// Update playback status + pub fn set_playback_status(&self, is_playing: bool) { + let _ = self + .command_tx + .send(MprisCommand::SetPlaybackStatus(is_playing)); + } + + /// Update playback position + #[allow(dead_code)] // Kept for future use + pub fn set_position(&self, position_ms: u64) { + let _ = self.command_tx.send(MprisCommand::SetPosition(position_ms)); + } + + /// Update volume (0-100) + pub fn set_volume(&self, volume_percent: u8) { + let _ = self + .command_tx + .send(MprisCommand::SetVolume(volume_percent)); + } + + /// Mark playback as stopped + pub fn set_stopped(&self) { + let _ = self.command_tx.send(MprisCommand::SetStopped); + } +} diff --git a/src/network.rs b/src/network.rs index a18baf09..5552709f 100644 --- a/src/network.rs +++ b/src/network.rs @@ -98,6 +98,16 @@ pub enum IoEvent { AddItemToQueue(PlayableId<'static>), IncrementGlobalSongCount, GetLyrics(String, String, f64), + /// Start playback from the user's saved tracks collection (Liked Songs) + /// Takes the absolute position in the collection to start from + /// NOTE: Currently unused - Spotify Web API doesn't support collection context URI + /// Keeping for potential future use if Spotify adds support + #[allow(dead_code)] + StartCollectionPlayback(usize), + /// Pre-fetch all saved tracks pages in background for seamless playback + PreFetchAllSavedTracks, + /// Pre-fetch all tracks from a playlist in background + PreFetchAllPlaylistTracks(PlaylistId<'static>), } pub struct Network { @@ -323,6 +333,28 @@ impl Network { IoEvent::GetLyrics(track, artist, duration) => { self.get_lyrics(track, artist, duration).await; } + IoEvent::StartCollectionPlayback(offset) => { + self.start_collection_playback(offset).await; + } + IoEvent::PreFetchAllSavedTracks => { + // Spawn prefetch as a separate task to avoid blocking playback + let spotify = self.spotify.clone(); + let app = self.app.clone(); + let large_search_limit = self.large_search_limit; + tokio::spawn(async move { + Self::prefetch_all_saved_tracks_task(spotify, app, large_search_limit).await; + }); + } + IoEvent::PreFetchAllPlaylistTracks(playlist_id) => { + // Spawn prefetch as a separate task to avoid blocking playback + let spotify = self.spotify.clone(); + let app = self.app.clone(); + let large_search_limit = self.large_search_limit; + tokio::spawn(async move { + Self::prefetch_all_playlist_tracks_task(spotify, app, large_search_limit, playlist_id) + .await; + }); + } }; let mut app = self.app.lock().await; @@ -1006,6 +1038,222 @@ impl Network { } } + /// Start playback from the user's saved tracks collection (Liked Songs) + /// Uses a direct HTTP call since rspotify doesn't support the collection context URI + async fn start_collection_playback(&mut self, offset: usize) { + // Get user ID to construct collection context URI + let user_id = { + let app = self.app.lock().await; + app.user.as_ref().map(|u| u.id.to_string()) + }; + + let user_id = match user_id { + Some(id) => id, + None => { + self.handle_error(anyhow!("User not logged in")).await; + return; + } + }; + + // Get access token from rspotify client + let token = { + let token_lock = self + .spotify + .token + .lock() + .await + .expect("Failed to lock token"); + token_lock.as_ref().map(|t| t.access_token.clone()) + }; + + let access_token = match token { + Some(t) => t, + None => { + self + .handle_error(anyhow!("No access token available")) + .await; + return; + } + }; + + // Construct the collection context URI: spotify:user:{user_id}:collection + let context_uri = format!("spotify:user:{}:collection", user_id); + + // Build the request body + let mut body = serde_json::json!({ + "context_uri": context_uri, + "offset": { "position": offset } + }); + + // Add device_id if configured + if let Some(ref device_id) = self.client_config.device_id { + body["device_id"] = serde_json::json!(device_id); + } + + // Make the API request using reqwest + let client = reqwest::Client::new(); + let url = match self.client_config.device_id.as_ref() { + Some(device_id) => format!( + "https://api.spotify.com/v1/me/player/play?device_id={}", + device_id + ), + None => "https://api.spotify.com/v1/me/player/play".to_string(), + }; + + let result = client + .put(&url) + .header("Authorization", format!("Bearer {}", access_token)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await; + + match result { + Ok(response) => { + if response.status().is_success() { + // Reset progress and update playing state immediately + { + let mut app = self.app.lock().await; + app.song_progress_ms = 0; + if let Some(ctx) = &mut app.current_playback_context { + ctx.is_playing = true; + } + } + + // Wait for Spotify's API to sync before fetching updated state + tokio::time::sleep(tokio::time::Duration::from_millis(300)).await; + self.get_current_playback().await; + } else { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + self + .handle_error(anyhow!( + "Failed to start collection playback: {}", + error_text + )) + .await; + } + } + Err(e) => { + self + .handle_error(anyhow!("HTTP request failed: {}", e)) + .await; + } + } + } + + /// Pre-fetch all saved tracks pages in background for seamless playback + /// This loads all remaining pages that haven't been loaded yet + /// Runs as a separate async task to avoid blocking other operations + async fn prefetch_all_saved_tracks_task( + spotify: AuthCodeSpotify, + app: Arc>, + large_search_limit: u32, + ) { + // Get current state + let (current_total, pages_loaded) = { + let app = app.lock().await; + if let Some(saved_tracks) = app.library.saved_tracks.get_results(Some(0)) { + ( + saved_tracks.total, + app.library.saved_tracks.pages.len() as u32, + ) + } else { + return; // No saved tracks loaded yet + } + }; + + // Calculate how many tracks we already have + let tracks_loaded = pages_loaded * large_search_limit; + + // Fetch remaining pages (limit to reasonable amount to avoid memory issues) + let max_tracks_to_prefetch = 500; // ~10 pages + let mut offset = tracks_loaded; + + while offset < current_total && offset < tracks_loaded + max_tracks_to_prefetch { + match spotify + .current_user_saved_tracks_manual(None, Some(large_search_limit), Some(offset)) + .await + { + Ok(saved_tracks) => { + let mut app = app.lock().await; + // Add liked song IDs to the set + saved_tracks.items.iter().for_each(|item| { + if let Some(track_id) = &item.track.id { + app.liked_song_ids_set.insert(track_id.to_string()); + } + }); + // Add page to the saved tracks + app.library.saved_tracks.pages.push(saved_tracks); + } + Err(_e) => { + // Silently fail in background task - don't show errors to user + break; + } + } + offset += large_search_limit; + } + } + + /// Pre-fetch all tracks from a playlist in background + /// Runs as a separate async task to avoid blocking other operations + async fn prefetch_all_playlist_tracks_task( + spotify: AuthCodeSpotify, + app: Arc>, + large_search_limit: u32, + playlist_id: PlaylistId<'static>, + ) { + // Get current playlist state + let current_total = { + let app = app.lock().await; + if let Some(playlist_tracks) = &app.playlist_tracks { + playlist_tracks.total + } else { + return; + } + }; + + // Get current offset + let current_offset = { + let app = app.lock().await; + app.playlist_offset + }; + + // Fetch remaining pages (limit to avoid memory issues) + let max_tracks_to_prefetch = 500; // ~10 pages + let mut offset = current_offset + large_search_limit; + + while offset < current_total && offset < current_offset + max_tracks_to_prefetch { + match spotify + .playlist_items_manual( + playlist_id.clone(), + None, + None, + Some(large_search_limit), + Some(offset), + ) + .await + { + Ok(playlist_page) => { + // Store the fetched tracks + let mut app = app.lock().await; + // Extend playlist tracks items + if let Some(ref mut existing) = app.playlist_tracks { + existing.items.extend(playlist_page.items); + existing.total = playlist_page.total; // Update total in case it changed + } + } + Err(_e) => { + // Silently fail in background task - don't show errors to user + break; + } + } + offset += large_search_limit; + } + } + async fn seek(&mut self, position_ms: u32) { // Use native streaming player for instant seek (no network delay) #[cfg(feature = "streaming")] @@ -1050,6 +1298,14 @@ impl Network { } } + // Store the current track ID before skipping + let old_track_id = { + let mut app = self.app.lock().await; + // Reset progress immediately for instant UI feedback + app.song_progress_ms = 0; + app.last_track_id.clone() + }; + // Fallback to API-based skip match self .spotify @@ -1057,8 +1313,21 @@ impl Network { .await { Ok(()) => { - tokio::time::sleep(Duration::from_millis(100)).await; - self.get_current_playback().await; + // Retry mechanism: Poll multiple times until we get updated metadata + // Spotify's API can be slow to update after a skip command + for attempt in 0..5 { + let delay = if attempt == 0 { 100 } else { 200 * attempt }; // 100ms, 200ms, 400ms, 600ms, 800ms + tokio::time::sleep(Duration::from_millis(delay as u64)).await; + self.get_current_playback().await; + + // Check if we got the new track - if so, stop retrying + let app = self.app.lock().await; + let current_track_id = app.last_track_id.clone(); + if current_track_id != old_track_id { + // Successfully got new track metadata + break; + } + } } Err(e) => { self.handle_error(anyhow!(e)).await; @@ -1080,6 +1349,14 @@ impl Network { } } + // Store the current track ID before skipping + let old_track_id = { + let mut app = self.app.lock().await; + // Reset progress immediately for instant UI feedback + app.song_progress_ms = 0; + app.last_track_id.clone() + }; + // Fallback to API-based skip match self .spotify @@ -1087,8 +1364,21 @@ impl Network { .await { Ok(()) => { - tokio::time::sleep(Duration::from_millis(100)).await; - self.get_current_playback().await; + // Retry mechanism: Poll multiple times until we get updated metadata + // Spotify's API can be slow to update after a skip command + for attempt in 0..5 { + let delay = if attempt == 0 { 100 } else { 200 * attempt }; // 100ms, 200ms, 400ms, 600ms, 800ms + tokio::time::sleep(Duration::from_millis(delay as u64)).await; + self.get_current_playback().await; + + // Check if we got the new track - if so, stop retrying + let app = self.app.lock().await; + let current_track_id = app.last_track_id.clone(); + if current_track_id != old_track_id { + // Successfully got new track metadata + break; + } + } } Err(e) => { self.handle_error(anyhow!(e)).await; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d0f8cf96..d73827cc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1010,11 +1010,13 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { // give hint to choose a device if let Some(current_playback_context) = &app.current_playback_context { if let Some(track_item) = ¤t_playback_context.item { - let play_title = if current_playback_context.is_playing { - "Playing" - } else { - "Paused" - }; + // Use native playing state when streaming is active (more reliable for MPRIS controls) + let is_playing = app + .native_is_playing + .filter(|_| app.is_streaming_active) + .unwrap_or(current_playback_context.is_playing); + + let play_title = if is_playing { "Playing" } else { "Paused" }; let shuffle_text = if current_playback_context.shuffle_state { "On"