diff --git a/Cargo.lock b/Cargo.lock index 78ba632..c34412e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -343,6 +349,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -598,6 +610,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -729,6 +750,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -840,12 +867,31 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1299,6 +1345,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" + [[package]] name = "ident_case" version = "1.0.1" @@ -1326,6 +1378,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1572,6 +1639,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1603,6 +1679,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1615,6 +1701,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "ndk" version = "0.8.0" @@ -1918,6 +2014,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -1968,6 +2077,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quinn" version = "0.11.11" @@ -2122,6 +2237,21 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ratatui-image" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb525a8d7c50ca224a238ae3bb3caf5ec357292ff91fcac28748a27b66dcacb" +dependencies = [ + "base64 0.21.7", + "dyn-clone", + "icy_sixel", + "image", + "rand 0.8.7", + "ratatui", + "rustix 0.38.44", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2250,6 +2380,7 @@ dependencies = [ name = "riptune-cache" version = "0.1.0" dependencies = [ + "futures-util", "riptune-core", "sqlx", "thiserror 1.0.69", @@ -2302,7 +2433,9 @@ dependencies = [ "anyhow", "crossterm", "futures-util", + "image", "ratatui", + "ratatui-image", "riptune-cache", "riptune-core", "riptune-types", @@ -2313,6 +2446,9 @@ dependencies = [ [[package]] name = "riptune-types" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "rodio" @@ -2600,6 +2736,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -3415,10 +3557,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] @@ -4198,6 +4344,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index c657716..5f737d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str anyhow = "1" thiserror = "1" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [package] name = "riptune" @@ -60,4 +60,4 @@ rodio = { version = "=0.20.1", features = ["symphonia-all"] } # default-features = false + rustls-tls). Used only by the audio thread's # synchronous fetch -- see the comment in src/audio.rs for why blocking is # actually the right call there, not a shortcut. -reqwest = { workspace = true, features = ["blocking"] } \ No newline at end of file +reqwest = { workspace = true, features = ["blocking"] } diff --git a/config.example.toml b/config.example.toml index 40d16b0..4c27a85 100644 --- a/config.example.toml +++ b/config.example.toml @@ -9,3 +9,13 @@ workspace_notifications = true [cache] path = "~/.cache/riptune/library.sqlite" + +# Optional. Every key here is optional too -- anything left out keeps +# riptune's built-in default for that color. Values are "#rrggbb" hex. +# [theme] +# accent = "#89b4fa" +# now_playing = "#a6e3a1" +# error = "#f38ba8" +# border_focused = "#89b4fa" +# border_unfocused = "#585b70" +# selection_bg = "#313244" diff --git a/crates/riptune-cache/Cargo.toml b/crates/riptune-cache/Cargo.toml index 2217434..36feb0e 100644 --- a/crates/riptune-cache/Cargo.toml +++ b/crates/riptune-cache/Cargo.toml @@ -11,3 +11,4 @@ tokio = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } +futures-util = "0.3" diff --git a/crates/riptune-cache/src/lib.rs b/crates/riptune-cache/src/lib.rs index 36a92cf..44bfd25 100644 --- a/crates/riptune-cache/src/lib.rs +++ b/crates/riptune-cache/src/lib.rs @@ -5,6 +5,7 @@ //! instances with 50k+ tracks). We index artists/albums/tracks locally and treat //! the network as a background sync source, not the read path for rendering[cite: 13]. +use futures_util::stream::{self, StreamExt}; use riptune_core::SubsonicClient; use riptune_core::models::{Artist, Track, AlbumSummary}; use sqlx::sqlite::SqlitePoolOptions; @@ -77,12 +78,25 @@ impl Cache { .execute(&pool) .await?; + // This is a rebuildable cache, not source-of-truth data, so schema + // evolution is a best-effort ALTER rather than a real migration -- + // ignore the error if a column already exists. SQLite only allows + // one ADD COLUMN per statement. + for alter in [ + "ALTER TABLE tracks ADD COLUMN cover_art TEXT", + "ALTER TABLE tracks ADD COLUMN track_number INTEGER", + "ALTER TABLE tracks ADD COLUMN disc_number INTEGER", + "ALTER TABLE albums ADD COLUMN year INTEGER", + ] { + let _ = sqlx::query(alter).execute(&pool).await; + } + Ok(Self { pool }) } pub async fn get_albums_by_artist(&self, artist_id: &str) -> Result, CacheError> { let rows = sqlx::query( - "SELECT id, name, song_count FROM albums WHERE artist_id = ? ORDER BY name ASC" + "SELECT id, name, song_count, year FROM albums WHERE artist_id = ? ORDER BY name ASC" ) .bind(artist_id) .fetch_all(&self.pool) @@ -90,36 +104,33 @@ impl Cache { let records = rows.into_iter().map(|row| { let count: i64 = row.get("song_count"); + let year: Option = row.get("year"); AlbumSummary { id: row.get("id"), name: row.get("name"), song_count: count as u32, + year: year.map(|y| y as u32), } }).collect(); Ok(records) } + /// Tracklist for an album, in soundtrack order (disc, then track number) + /// rather than alphabetically -- falls back to title only for the rare + /// track missing a number, so it still sorts predictably. pub async fn get_tracks_by_album(&self, album_name: &str) -> Result, CacheError> { // FIXED: Added CAST protection to match what we did in the search query! let rows = sqlx::query( - "SELECT id, title, artist, album, duration_secs, CAST(starred AS TEXT) as starred FROM tracks WHERE album = ? ORDER BY title ASC" + "SELECT id, title, artist, album, duration_secs, CAST(starred AS TEXT) as starred, cover_art, track_number, disc_number \ + FROM tracks WHERE album = ? \ + ORDER BY COALESCE(disc_number, 1) ASC, COALESCE(track_number, 999999) ASC, title ASC" ) .bind(album_name) .fetch_all(&self.pool) .await?; - let records = rows.into_iter().map(|row| { - let duration: Option = row.get("duration_secs"); - Track { - id: row.get("id"), - title: row.get("title"), - artist: row.get("artist"), - album: row.get("album"), - duration_secs: duration.map(|d| d as u32), - starred: row.get("starred"), - } - }).collect(); + let records = rows.into_iter().map(row_to_track).collect(); Ok(records) } @@ -149,7 +160,7 @@ impl Cache { pub async fn search_local_albums(&self, query: &str) -> Result, CacheError> { let sql_pattern = format!("%{}%", query); let rows = sqlx::query( - "SELECT id, name, song_count FROM albums WHERE name LIKE ? COLLATE NOCASE ORDER BY name ASC" + "SELECT id, name, song_count, year FROM albums WHERE name LIKE ? COLLATE NOCASE ORDER BY name ASC" ) .bind(sql_pattern) .fetch_all(&self.pool) @@ -157,10 +168,12 @@ impl Cache { let records = rows.into_iter().map(|row| { let count: i64 = row.get("song_count"); + let year: Option = row.get("year"); AlbumSummary { id: row.get("id"), name: row.get("name"), song_count: count as u32, + year: year.map(|y| y as u32), } }).collect(); @@ -169,39 +182,75 @@ impl Cache { pub async fn search_local_tracks(&self, query: &str) -> Result, CacheError> { let sql_pattern = format!("%{}%", query); - // FIXED: Using CAST forces SQLite to evaluate the column as a string primitive + // FIXED: Using CAST forces SQLite to evaluate the column as a string primitive // regardless of whether the internal row data was stored as an integer or text. let rows = sqlx::query( - "SELECT id, title, artist, album, duration_secs, CAST(starred AS TEXT) as starred FROM tracks WHERE title LIKE ? COLLATE NOCASE ORDER BY title ASC" + "SELECT id, title, artist, album, duration_secs, CAST(starred AS TEXT) as starred, cover_art, track_number, disc_number \ + FROM tracks WHERE title LIKE ? COLLATE NOCASE ORDER BY title ASC" ) .bind(sql_pattern) .fetch_all(&self.pool) .await?; - let records = rows.into_iter().map(|row| { - let duration: Option = row.get("duration_secs"); - Track { - id: row.get("id"), - title: row.get("title"), - artist: row.get("artist"), - album: row.get("album"), - duration_secs: duration.map(|d| d as u32), - starred: row.get("starred"), - } - }).collect(); + let records = rows.into_iter().map(row_to_track).collect(); Ok(records) } } +fn row_to_track(row: sqlx::sqlite::SqliteRow) -> Track { + let duration: Option = row.get("duration_secs"); + let track_number: Option = row.get("track_number"); + let disc_number: Option = row.get("disc_number"); + Track { + id: row.get("id"), + title: row.get("title"), + artist: row.get("artist"), + album: row.get("album"), + duration_secs: duration.map(|d| d as u32), + starred: row.get("starred"), + cover_art: row.get("cover_art"), + track_number: track_number.map(|n| n as u32), + disc_number: disc_number.map(|n| n as u32), + } +} + +// How many artist/album fetches run concurrently during a sync. Bounded so a +// large library doesn't fire hundreds of simultaneous requests at once -- +// this is a request-count/latency tradeoff, not a hard server limit. +const SYNC_CONCURRENCY: usize = 8; + /// Pulls the full artist/album/track tree from the server and upserts it into /// the local cache. Intended to run as a background task on startup, and /// periodically thereafter — never blocks the TUI's first paint[cite: 13]. +/// +/// Fetching is done concurrently (bounded by `SYNC_CONCURRENCY`) since the +/// Subsonic API requires one request per artist for albums and one more per +/// album for full track data -- doing those serially is what made sync slow +/// on real libraries. Writes stay sequential below: SQLite only has one +/// writer anyway, and it's cheap compared to the network round trips. pub async fn sync_library(client: &SubsonicClient, cache: &Cache) -> Result<(), CacheError> { let remote_artists = client.get_artists().await .map_err(|e| CacheError::Client(e.to_string()))?; - for artist in remote_artists { + let fetched = stream::iter(remote_artists) + .map(|artist| async move { + let albums = client.get_artist_albums(&artist.id).await.unwrap_or_default(); + let albums = stream::iter(albums) + .map(|album| async move { + let songs = client.get_album(&album.id).await.map(|a| a.song).unwrap_or_default(); + (album, songs) + }) + .buffer_unordered(SYNC_CONCURRENCY) + .collect::>() + .await; + (artist, albums) + }) + .buffer_unordered(SYNC_CONCURRENCY) + .collect::>() + .await; + + for (artist, albums) in fetched { let mut tx = cache.pool.begin().await?; sqlx::query("INSERT OR REPLACE INTO artists (id, name, album_count) VALUES (?, ?, ?)") @@ -211,29 +260,29 @@ pub async fn sync_library(client: &SubsonicClient, cache: &Cache) -> Result<(), .execute(&mut *tx) .await?; - if let Ok(albums) = client.get_artist_albums(&artist.id).await { - for album in albums { - sqlx::query("INSERT OR REPLACE INTO albums (id, name, song_count, artist_id) VALUES (?, ?, ?, ?)") - .bind(&album.id) - .bind(&album.name) - .bind(album.song_count) - .bind(&artist.id) + for (album, songs) in albums { + sqlx::query("INSERT OR REPLACE INTO albums (id, name, song_count, artist_id, year) VALUES (?, ?, ?, ?, ?)") + .bind(&album.id) + .bind(&album.name) + .bind(album.song_count) + .bind(&artist.id) + .bind(album.year) + .execute(&mut *tx) + .await?; + + for song in songs { + sqlx::query("INSERT OR REPLACE INTO tracks (id, title, artist, album, duration_secs, starred, cover_art, track_number, disc_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(&song.id) + .bind(&song.title) + .bind(&song.artist) + .bind(&song.album) + .bind(song.duration_secs) + .bind(&song.starred) // Safely saves text strings directly into SQLite schema rows[cite: 14] + .bind(&song.cover_art) + .bind(song.track_number) + .bind(song.disc_number) .execute(&mut *tx) .await?; - - if let Ok(full_album) = client.get_album(&album.id).await { - for song in full_album.song { - sqlx::query("INSERT OR REPLACE INTO tracks (id, title, artist, album, duration_secs, starred) VALUES (?, ?, ?, ?, ?, ?)") - .bind(&song.id) - .bind(&song.title) - .bind(&song.artist) - .bind(&song.album) - .bind(song.duration_secs) - .bind(&song.starred) // Safely saves text strings directly into SQLite schema rows[cite: 14] - .execute(&mut *tx) - .await?; - } - } } } diff --git a/crates/riptune-core/src/lib.rs b/crates/riptune-core/src/lib.rs index 45fc293..e557acb 100644 --- a/crates/riptune-core/src/lib.rs +++ b/crates/riptune-core/src/lib.rs @@ -35,7 +35,9 @@ pub struct SubsonicClient { impl SubsonicClient { pub fn new(base_url: &str, username: &str, password: &str) -> Result { Ok(Self { - http: reqwest::Client::new(), + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?, base_url: url::Url::parse(base_url)?, username: username.to_string(), password: password.to_string(), @@ -77,6 +79,30 @@ impl SubsonicClient { Ok(url.to_string()) } + /// Returns a fully-authenticated cover art URL for an id (album, artist, + /// or track -- whatever Navidrome/Subsonic returned as `coverArt`). + pub fn cover_art_url(&self, cover_art_id: &str) -> Result { + let mut url = self.base_url.join("rest/getCoverArt")?; + { + let mut qp = url.query_pairs_mut(); + for (k, v) in self.auth_params() { + qp.append_pair(&k, &v); + } + qp.append_pair("id", cover_art_id); + qp.append_pair("size", "300"); + } + Ok(url.to_string()) + } + + /// Fetches the raw image bytes for a cover art id. Unlike every other + /// endpoint here, `getCoverArt` returns the image directly rather than a + /// `subsonic-response` JSON envelope, so it bypasses `call`. + pub async fn get_cover_art(&self, cover_art_id: &str) -> Result, SubsonicError> { + let url = self.cover_art_url(cover_art_id)?; + let bytes = self.http.get(url).send().await?.error_for_status()?.bytes().await?; + Ok(bytes.to_vec()) + } + /// Sends an authenticated GET to `rest/{endpoint}`, unwraps the /// `subsonic-response` envelope, and either deserializes the payload into /// `T` or converts a server-side error into `SubsonicError::Api`. @@ -222,4 +248,16 @@ mod tests { let url = client.stream_url("track-123").unwrap(); assert!(!url.contains("hunter2"), "raw password must never appear in the URL: {url}"); } + + #[test] + fn cover_art_url_includes_required_auth_params() { + let client = SubsonicClient::new("https://music.example.com", "alice", "hunter2").unwrap(); + let url = client.cover_art_url("cover-123").unwrap(); + + assert!(url.starts_with("https://music.example.com/rest/getCoverArt")); + for param in ["u=alice", "t=", "s=", "v=", "c=riptune", "f=json", "id=cover-123"] { + assert!(url.contains(param), "expected `{param}` in {url}"); + } + assert!(!url.contains("hunter2"), "raw password must never appear in the URL: {url}"); + } } diff --git a/crates/riptune-core/src/models.rs b/crates/riptune-core/src/models.rs index 9321caa..f0f8b74 100644 --- a/crates/riptune-core/src/models.rs +++ b/crates/riptune-core/src/models.rs @@ -16,6 +16,8 @@ pub struct AlbumSummary { pub name: String, #[serde(rename = "songCount", default)] pub song_count: u32, + #[serde(default)] + pub year: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -41,4 +43,10 @@ pub struct Track { pub duration_secs: Option, #[serde(default)] pub starred: Option, + #[serde(rename = "coverArt", default)] + pub cover_art: Option, + #[serde(rename = "track", default)] + pub track_number: Option, + #[serde(rename = "discNumber", default)] + pub disc_number: Option, } diff --git a/crates/riptune-tui/Cargo.toml b/crates/riptune-tui/Cargo.toml index 10012a4..873de35 100644 --- a/crates/riptune-tui/Cargo.toml +++ b/crates/riptune-tui/Cargo.toml @@ -15,3 +15,15 @@ tracing = { workspace = true } ratatui = "0.28" crossterm = { version = "0.28", features = ["event-stream"] } futures-util = "0.3" +# "image-defaults" (a default feature) would otherwise force-enable the full +# `image` codec set (tiff/webp/av1/...) via feature unification with our own +# trimmed `image` dep below -- disabled in favor of just the two codecs we +# actually need. "rustix" is kept for accurate termios-based terminal +# capability detection; "crossterm" matches our own ratatui backend. +ratatui-image = { version = "2.0.1", default-features = false, features = ["crossterm", "rustix"] } +# Subsonic/Navidrome cover art is effectively always PNG or JPEG -- trimmed +# to those two codecs rather than pulling in image's full default set +# (AV1, TIFF, WebP, ...), keeping the binary lean. Must match the `image` +# version ratatui-image itself resolves to (see Cargo.lock) so DynamicImage +# is the same type on both sides. +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg"] } diff --git a/crates/riptune-tui/src/lib.rs b/crates/riptune-tui/src/lib.rs index 7d4dc66..ad14e05 100644 --- a/crates/riptune-tui/src/lib.rs +++ b/crates/riptune-tui/src/lib.rs @@ -1,17 +1,27 @@ +mod theme; + use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use futures_util::StreamExt; +use image::DynamicImage; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style, Color}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Gauge, Clear}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, +}; use ratatui::Terminal; +use ratatui_image::picker::Picker; +use ratatui_image::protocol::StatefulProtocol; +use ratatui_image::StatefulImage; use riptune_cache::Cache; use riptune_core::models::{AlbumSummary, Artist, Track}; use riptune_core::SubsonicClient; use riptune_types::{AudioCommand, AudioEvent}; +use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, Sender}; use std::time::Duration; @@ -53,11 +63,13 @@ enum InputMode { struct NowPlaying { title: String, artist: String, + album: String, position: Duration, duration_secs: Option, status: PlaybackStatus, current_track_id: String, playlist_context: Vec, + cover_art_id: Option, } pub struct App { @@ -89,10 +101,45 @@ pub struct App { // Playback Engine Mods shuffle: bool, repeat: RepeatMode, + + // List scroll state, persisted across frames (see `draw`) so the + // viewport doesn't reset to the top on every render. + artists_state: ListState, + albums_state: ListState, + tracks_state: ListState, + + // Cover art. `cover_picker` is None if the terminal doesn't support (or + // detection of) an image protocol failed -- the art panel is then just + // skipped rather than erroring. Fetches run on the tokio runtime and + // report back over `cover_art_rx`; `cover_in_flight` stops the same id + // from being fetched twice while a request is outstanding. + cover_picker: Option, + cover_cache: HashMap>, + cover_in_flight: HashSet, + cover_art_tx: tokio::sync::mpsc::UnboundedSender<(String, Option)>, + cover_art_rx: tokio::sync::mpsc::UnboundedReceiver<(String, Option)>, + + theme: theme::Theme, +} + +struct TerminalCleanup; + +impl Drop for TerminalCleanup { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(std::io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); + } } impl App { - pub fn new(client: SubsonicClient, cache: Cache, audio_tx: Sender, audio_events: Receiver) -> Self { + pub fn new( + client: SubsonicClient, + cache: Cache, + audio_tx: Sender, + audio_events: Receiver, + theme_colors: riptune_types::ThemeColors, + ) -> Self { + let (cover_art_tx, cover_art_rx) = tokio::sync::mpsc::unbounded_channel(); Self { client, cache, @@ -107,25 +154,48 @@ impl App { selected_idx: 0, now_playing: None, status_line: Some("Initializing Offline Cache...".into()), - volume: 0.8, + volume: 0.8, input_mode: InputMode::Normal, search_query: String::new(), search_scope: SearchScope::Song, show_help: false, shuffle: false, repeat: RepeatMode::Off, + artists_state: ListState::default(), + albums_state: ListState::default(), + tracks_state: ListState::default(), + cover_picker: None, + cover_cache: HashMap::new(), + cover_in_flight: HashSet::new(), + cover_art_tx, + cover_art_rx, + theme: theme::Theme::from_config(&theme_colors), } } pub async fn run(mut self) -> Result<()> { enable_raw_mode()?; + + // Best-effort: query the terminal for an image graphics protocol + // (Kitty/iTerm2/Sixel, falling back to unicode half-blocks). If + // detection fails outright, cover art is simply not shown. + self.cover_picker = Picker::from_termios() + .ok() + .map(|mut picker| { + picker.guess_protocol(); + picker + }); + let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + let _cleanup_guard = TerminalCleanup; + let result = self.event_loop(&mut terminal).await; + // Explicit cleanup before returning disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; @@ -165,6 +235,12 @@ impl App { } } _ = ticker.tick() => {} + Some((cover_id, image)) = self.cover_art_rx.recv() => { + self.cover_in_flight.remove(&cover_id); + if let (Some(image), Some(picker)) = (image, &mut self.cover_picker) { + self.cover_cache.insert(cover_id, picker.new_resize_protocol(image)); + } + } } } @@ -362,14 +438,19 @@ impl App { match self.client.stream_url(&track.id) { Ok(stream_url) => { let _ = self.audio_tx.send(AudioCommand::Play { stream_url }); + if let Some(cover_id) = &track.cover_art { + self.request_cover_art(cover_id.clone()); + } self.now_playing = Some(NowPlaying { title: track.title.clone(), artist: track.artist.clone().unwrap_or_default(), + album: track.album.clone().unwrap_or_default(), position: Duration::ZERO, duration_secs: track.duration_secs, status: PlaybackStatus::Buffering, current_track_id: track.id.clone(), playlist_context, + cover_art_id: track.cover_art.clone(), }); self.notify_desktop_environment("Track Started"); } @@ -379,6 +460,26 @@ impl App { } } + /// Kicks off a background fetch+decode for a cover art id, unless it's + /// already cached or a fetch for it is already in flight. Errors are + /// swallowed -- missing art just means the panel stays empty. + fn request_cover_art(&mut self, cover_id: String) { + if self.cover_picker.is_none() || self.cover_cache.contains_key(&cover_id) || self.cover_in_flight.contains(&cover_id) { + return; + } + self.cover_in_flight.insert(cover_id.clone()); + + let client = self.client.clone(); + let tx = self.cover_art_tx.clone(); + tokio::spawn(async move { + let image = match client.get_cover_art(&cover_id).await { + Ok(bytes) => image::load_from_memory(&bytes).ok(), + Err(_) => None, + }; + let _ = tx.send((cover_id, image)); + }); + } + fn toggle_play_pause(&mut self) { if let Some(now_playing) = &mut self.now_playing { match &now_playing.status { @@ -456,7 +557,7 @@ impl App { } } - fn draw(&self, f: &mut ratatui::Frame) { + fn draw(&mut self, f: &mut ratatui::Frame) { let area = f.area(); // --- 1. Layout Resizing Shield --- @@ -465,7 +566,7 @@ impl App { let emergency_msg = Paragraph::new( "\n\n ⚠️ Terminal Window Too Small!\n ===============================\n Please enlarge your screen layout dimensions\n to display Riptune's multi-tier grids safely." ) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Red))); + .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(self.theme.error))); f.render_widget(emergency_msg, area); return; } @@ -473,95 +574,219 @@ impl App { let main_layout = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(3), - Constraint::Length(3), - Constraint::Length(3), + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(7), + Constraint::Length(1), ]) .split(area); + // Give the focused pane the room -- with a single artist and a + // handful of albums, an equal three-way split leaves the two + // breadcrumb panes mostly empty while Tracks (denser, especially + // once titles carry track numbers + durations) is cramped. + let (artists_pct, albums_pct, tracks_pct) = match self.focus { + FocusPane::Artists => (50, 30, 20), + FocusPane::Albums => (25, 50, 25), + FocusPane::Tracks => (20, 20, 60), + }; let columns_layout = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), + Constraint::Percentage(artists_pct), + Constraint::Percentage(albums_pct), + Constraint::Percentage(tracks_pct), ]) .split(main_layout[0]); - let artists_list: Vec = self.artists.iter().map(|a| ListItem::new(format!(" {}", a.name))).collect(); - let albums_list: Vec = self.albums.iter().map(|a| ListItem::new(format!(" {}", a.name))).collect(); - let tracks_list: Vec = self.tracks.iter().map(|t| ListItem::new(format!(" {}", t.title))).collect(); + let now_playing_id = self.now_playing.as_ref().map(|np| np.current_track_id.as_str()); + + // Whether each pane's contents are currently the result of an + // active search in that pane's scope -- drives both the "Matching: + // N" title phrasing and per-row substring highlighting below. The + // pane's list itself is already filtered to matches by + // `execute_db_search`; this only affects how it's *labeled/shown*. + let searching = matches!(self.input_mode, InputMode::Search) && !self.search_query.is_empty(); + let searching_artists = searching && self.search_scope == SearchScope::Artist; + let searching_albums = searching && self.search_scope == SearchScope::Album; + let searching_tracks = searching && self.search_scope == SearchScope::Song; + let match_style = Style::default().fg(self.theme.accent).add_modifier(Modifier::BOLD | Modifier::UNDERLINED); + + let artists_col_width = columns_layout[0].width.saturating_sub(4) as usize; + let artists_list: Vec = self.artists.iter().map(|a| { + let query = searching_artists.then_some(self.search_query.as_str()); + let mut spans = vec![Span::raw(" ")]; + spans.extend(highlighted_spans(&a.name, artists_col_width, query, Style::default(), match_style)); + ListItem::new(Line::from(spans)) + }).collect(); + + let albums_col_width = columns_layout[1].width.saturating_sub(4) as usize; + let albums_list: Vec = self.albums.iter().map(|a| { + let query = searching_albums.then_some(self.search_query.as_str()); + let suffix = album_suffix(a); + let name_budget = albums_col_width.saturating_sub(suffix.chars().count()).max(3); + let mut spans = vec![Span::raw(" ")]; + spans.extend(highlighted_spans(&a.name, name_budget, query, Style::default(), match_style)); + spans.push(Span::styled(suffix, Style::default().fg(self.theme.text_muted))); + ListItem::new(Line::from(spans)) + }).collect(); + + // Duration right-aligned within the column, so it reads as a + // consistent right rail rather than trailing the title unevenly. + let tracks_col_width = columns_layout[2].width.saturating_sub(2) as usize; + let tracks_list: Vec = self.tracks.iter().map(|t| { + let is_now_playing = Some(t.id.as_str()) == now_playing_id; + let icon = if is_now_playing { " \u{266a} " } else { " " }; + let base_style = if is_now_playing { + Style::default().fg(self.theme.now_playing).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let duration = t.duration_secs.map(|d| format!("{}:{:02}", d / 60, d % 60)).unwrap_or_else(|| "--:--".into()); + let title_budget = tracks_col_width.saturating_sub(icon.chars().count() + duration.chars().count() + 1).max(3); + let query = searching_tracks.then_some(self.search_query.as_str()); + let title_spans = highlighted_spans(&t.title, title_budget, query, base_style, match_style); + let title_len: usize = title_spans.iter().map(|s| s.content.chars().count()).sum(); + + let mut spans = vec![Span::styled(icon, base_style)]; + spans.extend(title_spans); + let pad = tracks_col_width.saturating_sub(icon.chars().count() + title_len + duration.chars().count()).max(1); + spans.push(Span::styled(" ".repeat(pad), base_style)); + spans.push(Span::styled(duration, base_style)); + + ListItem::new(Line::from(spans)) + }).collect(); let get_border_style = |pane: FocusPane| { - if self.focus == pane { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::DarkGray) } + if self.focus == pane { Style::default().fg(self.theme.border_focused) } else { Style::default().fg(self.theme.border_unfocused) } + }; + // A background band for the keyboard cursor -- kept distinct from + // NOW_PLAYING's foreground-color-plus-icon treatment so the two + // states stay legible whether they land on the same row or not. + let cursor_highlight = Style::default().bg(self.theme.selection_bg).add_modifier(Modifier::BOLD); + + self.artists_state.select(if self.focus == FocusPane::Artists { Some(self.selected_idx) } else { None }); + self.albums_state.select(if self.focus == FocusPane::Albums { Some(self.selected_idx) } else { None }); + self.tracks_state.select(if self.focus == FocusPane::Tracks { Some(self.selected_idx) } else { None }); + + let pane_title = |label: &str, len: usize, matching: bool| { + if matching { format!(" {label} (Matching: {len}) ") } else { format!(" {label} ({len}) ") } }; - let mut left_state = ListState::default(); - let mut mid_state = ListState::default(); - let mut right_state = ListState::default(); + f.render_stateful_widget(List::new(artists_list).block(Block::default().borders(Borders::ALL).title(pane_title(":: Artists", self.artists.len(), searching_artists)).border_style(get_border_style(FocusPane::Artists))).highlight_style(cursor_highlight), columns_layout[0], &mut self.artists_state); + f.render_stateful_widget(List::new(albums_list).block(Block::default().borders(Borders::ALL).title(pane_title("<> Albums", self.albums.len(), searching_albums)).border_style(get_border_style(FocusPane::Albums))).highlight_style(cursor_highlight), columns_layout[1], &mut self.albums_state); + f.render_stateful_widget(List::new(tracks_list).block(Block::default().borders(Borders::ALL).title(pane_title(">> Tracks", self.tracks.len(), searching_tracks)).border_style(get_border_style(FocusPane::Tracks))).highlight_style(cursor_highlight), columns_layout[2], &mut self.tracks_state); - match self.focus { - FocusPane::Artists => left_state.select(Some(self.selected_idx)), - FocusPane::Albums => mid_state.select(Some(self.selected_idx)), - FocusPane::Tracks => right_state.select(Some(self.selected_idx)), + render_scrollbar(f, columns_layout[0], self.artists.len(), self.artists_state.offset(), self.theme.border_unfocused); + render_scrollbar(f, columns_layout[1], self.albums.len(), self.albums_state.offset(), self.theme.border_unfocused); + render_scrollbar(f, columns_layout[2], self.tracks.len(), self.tracks_state.offset(), self.theme.border_unfocused); + + let search_border = match self.input_mode { + InputMode::Search => self.theme.accent, + InputMode::Normal => self.theme.border_unfocused, + }; + let (search_line, search_style) = match self.input_mode { + InputMode::Search => { + let scope_name = match self.search_scope { + SearchScope::Artist => "Artist", + SearchScope::Album => "Album", + SearchScope::Song => "Song", + }; + let left = format!(" $ search: {}\u{2588}", self.search_query); + let right = format!("[Target: {scope_name}] (TAB to cycle target) "); + let width = main_layout[1].width.saturating_sub(2) as usize; + let pad = width.saturating_sub(left.chars().count() + right.chars().count()).max(1); + (format!("{left}{}{right}", " ".repeat(pad)), Style::default()) + } + InputMode::Normal => (" Press / to search".to_string(), Style::default().fg(self.theme.text_muted)), + }; + f.render_widget( + Paragraph::new(search_line).style(search_style) + .block(Block::default().borders(Borders::ALL).title(" Search ").border_style(Style::default().fg(search_border))), + main_layout[1], + ); + + // --- Now Playing deck: title/artist/album/progress/volume unified + // into one panel instead of four separate blocks, so the elapsed + // time, the bar, and the volume/shuffle/repeat state read as one + // cohesive status rather than scattered fragments. + let deck_block = Block::default().borders(Borders::ALL).title(" Now Playing "); + let deck_inner = deck_block.inner(main_layout[2]); + f.render_widget(deck_block, main_layout[2]); + + let cover_protocol = self.now_playing.as_ref() + .and_then(|np| np.cover_art_id.as_ref()) + .and_then(|id| self.cover_cache.get_mut(id)); + let art_width = if cover_protocol.is_some() { 14 } else { 0 }; + let deck_split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(art_width), Constraint::Min(20)]) + .split(deck_inner); + + if let Some(protocol) = cover_protocol { + f.render_stateful_widget(StatefulImage::new(None), deck_split[0], protocol); } - f.render_stateful_widget(List::new(artists_list).block(Block::default().borders(Borders::ALL).title(" :: Artists ").border_style(get_border_style(FocusPane::Artists))).highlight_style(Style::default().add_modifier(Modifier::REVERSED)), columns_layout[0], &mut left_state); - f.render_stateful_widget(List::new(albums_list).block(Block::default().borders(Borders::ALL).title(" <> Albums ").border_style(get_border_style(FocusPane::Albums))).highlight_style(Style::default().add_modifier(Modifier::REVERSED)), columns_layout[1], &mut mid_state); - f.render_stateful_widget(List::new(tracks_list).block(Block::default().borders(Borders::ALL).title(" >> Tracks ").border_style(get_border_style(FocusPane::Tracks))).highlight_style(Style::default().add_modifier(Modifier::REVERSED)), columns_layout[2], &mut right_state); - - let scope_indicator = match self.search_scope { - SearchScope::Artist => "[ARTIST] > Album > Song", - SearchScope::Album => "Artist > [ALBUM] > Song", - SearchScope::Song => "Artist > Album > [SONG]", - }; - let search_title = match self.input_mode { - InputMode::Normal => " / Search (Press '/' to search globally, '?' for help) ".to_string(), - InputMode::Search => format!(" $ Searching (TAB switches query target) -- Target: {} ", scope_indicator), - }; - f.render_widget(Paragraph::new(format!(" {}", self.search_query)).block(Block::default().borders(Borders::ALL).title(search_title)), main_layout[1]); - - let dashboard_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(45), Constraint::Percentage(20), Constraint::Percentage(20), Constraint::Percentage(15)]) - .split(main_layout[2]); - - let now_playing_text = if let Some(np) = &self.now_playing { - let secs = np.position.as_secs(); - format!(" {} -- {} [{}:{:02}]", np.title, np.artist, secs / 60, secs % 60) + let title_line = if let Some(np) = &self.now_playing { + Line::from(vec![ + Span::styled(format!("{} ", playback_label(&np.status)), Style::default().fg(self.theme.accent)), + Span::styled(np.title.clone(), Style::default().add_modifier(Modifier::BOLD)), + ]) } else { - " Idle . Highlight a track and press Space or Enter".to_string() + Line::from(Span::styled( + "Idle -- highlight a track and press Space or Enter", + Style::default().fg(self.theme.text_muted), + )) }; - let dashboard_title = if let Some(np) = &self.now_playing { format!(" {} ", playback_label(&np.status)) } else { " Player Status ".to_string() }; - f.render_widget(Paragraph::new(now_playing_text).block(Block::default().borders(Borders::ALL).title(dashboard_title)), dashboard_layout[0]); + let artist_line = Line::from(format!("Artist: {}", self.now_playing.as_ref().map(|np| np.artist.as_str()).unwrap_or(""))); + let album_line = Line::from(format!("Album: {}", self.now_playing.as_ref().map(|np| np.album.as_str()).unwrap_or(""))); + + let (elapsed_str, total_str, progress_ratio) = if let Some(np) = &self.now_playing { + let elapsed = np.position.as_secs(); + let total = np.duration_secs.map(|d| format!("{}:{:02}", d / 60, d % 60)).unwrap_or_else(|| "--:--".into()); + let ratio = np.duration_secs + .filter(|&d| d > 0) + .map(|d| (np.position.as_secs() as f64 / d as f64).min(1.0)) + .unwrap_or(0.0); + (format!("{}:{:02}", elapsed / 60, elapsed % 60), total, ratio) + } else { + ("--:--".to_string(), "--:--".to_string(), 0.0) + }; + let text_width = deck_split[1].width as usize; + let progress_bar_width = text_width + .saturating_sub("Progress: [] ".len() + elapsed_str.len() + total_str.len() + 3) + .clamp(10, 60); + let progress_line = Line::from(vec![ + Span::styled("Progress: ", Style::default().fg(self.theme.text_muted)), + Span::raw("["), + Span::styled(ascii_bar(progress_ratio, progress_bar_width, '=', '.', Some('>')), Style::default().fg(self.theme.progress)), + Span::raw(format!("] {elapsed_str} / {total_str}")), + ]); - // --- 2. Shuffle & Repeat Status Indicators --- let shuffle_str = if self.shuffle { "ON" } else { "OFF" }; let repeat_str = match self.repeat { RepeatMode::Off => "OFF", RepeatMode::One => "TRACK", RepeatMode::All => "QUEUE", }; - let modes_text = format!(" Shuffle: {} | Repeat: {}", shuffle_str, repeat_str); - f.render_widget(Paragraph::new(modes_text).block(Block::default().borders(Borders::ALL).title(" Play Modes ([s]/[r]) ")), dashboard_layout[1]); + let volume_line = Line::from(vec![ + Span::styled("Volume: \u{1f50a} ", Style::default().fg(self.theme.text_muted)), + Span::styled(ascii_bar(self.volume as f64, 10, '\u{2588}', '\u{2591}', None), Style::default().fg(self.theme.volume)), + Span::raw(format!( + " {}% | Shuffle: {shuffle_str} Repeat: {repeat_str}", + (self.volume * 100.0) as u16 + )), + ]); - let progress_ratio = if let Some(np) = &self.now_playing { - if let Some(total) = np.duration_secs { - if total > 0 { (np.position.as_secs() as f64 / total as f64).min(1.0) } else { 0.0 } - } else { 0.0 } - } else { 0.0 }; + f.render_widget( + Paragraph::new(vec![title_line, artist_line, album_line, progress_line, volume_line]), + deck_split[1], + ); - let progress_gauge = Gauge::default() - .block(Block::default().borders(Borders::ALL).title(" Progression ")) - .gauge_style(Style::default().fg(Color::Cyan)) - .ratio(progress_ratio); - f.render_widget(progress_gauge, dashboard_layout[2]); - - let volume_gauge = Gauge::default() - .block(Block::default().borders(Borders::ALL).title(" Vol ([-]/[+]) ")) - .gauge_style(Style::default().fg(Color::Green)) - .percent((self.volume * 100.0) as u16); - f.render_widget(volume_gauge, dashboard_layout[3]); + // --- Hotkey legend: one line at the very bottom instead of hints + // scattered across pane titles. + let legend = " [Space] Play/Pause [/] Search [h/l] Pane [j/k] Move [s] Shuffle [r] Repeat [+/-] Vol [?] Help [q] Quit"; + f.render_widget(Paragraph::new(legend).style(Style::default().fg(self.theme.text_muted)), main_layout[3]); if self.show_help { let help_text = r#" @@ -571,39 +796,153 @@ impl App { [l] / [Right] : Focus right column / Expand directory [j] / [Down] : Move selection cursor down [k] / [Up] : Move selection cursor up - + [Enter] : Expand selection or Play track [Space] : Toggle Play / Pause state [Esc] : Clear query context or escape layout back - + [/] : Open universal search context box [TAB] : Cycle target filtering scope ([SONG], [ARTIST]) - + [s] : Toggle Shuffle playback order [r] : Cycle Repeat Mode (Off -> Queue -> Track) [-] / [[] : Decrease system volume output [+] / []] : Increase system volume output - + [?] : Toggle this floating instructions overview overlay [q] : Exit riptune application safely "#; - let block = Block::default().title(" Help Overlay Menu ").borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta)); + let block = Block::default().title(" Help Overlay Menu ").borders(Borders::ALL).border_style(Style::default().fg(self.theme.help_border)); let paragraph = Paragraph::new(help_text).block(block); - + let popup_area = Rect { x: area.width / 4, y: area.height / 5, width: area.width / 2, height: 3 * area.height / 5, }; - - f.render_widget(Clear, popup_area); + + f.render_widget(Clear, popup_area); f.render_widget(paragraph, popup_area); } } } +/// Renders a vertical scrollbar along the right edge of `area`, inset to sit +/// inside a bordered block's margins. A no-op when everything already fits. +fn render_scrollbar(f: &mut ratatui::Frame, area: Rect, content_len: usize, offset: usize, color: ratatui::style::Color) { + if content_len <= area.height.saturating_sub(2) as usize { + return; + } + let mut state = ScrollbarState::new(content_len).position(offset); + let scrollbar = Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .style(Style::default().fg(color)); + f.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }), + &mut state, + ); +} + +/// Track count + year suffix for an album row (`" — 18 tracks, 2025"`), so +/// near-identical entries (duplicate imports, case-variant folder names, +/// etc.) are distinguishable before the user commits to opening one. +fn album_suffix(album: &AlbumSummary) -> String { + let tracks = format!("{} track{}", album.song_count, if album.song_count == 1 { "" } else { "s" }); + match album.year { + Some(year) => format!(" — {tracks}, {year}"), + None => format!(" — {tracks}"), + } +} + +/// Renders a horizontal `[===>....]`-style bar (or solid blocks when `tip` +/// is `None`, for the volume meter) instead of a filled rectangle, so it +/// reads as a line-based indicator consistent with the rest of the UI +/// rather than a clashing solid block of color. +fn ascii_bar(ratio: f64, width: usize, filled_char: char, empty_char: char, tip: Option) -> String { + if width == 0 { + return String::new(); + } + let ratio = ratio.clamp(0.0, 1.0); + let filled = ((ratio * width as f64).round() as usize).min(width); + match tip { + Some(tip_char) if filled > 0 && filled < width => { + let body = filled_char.to_string().repeat(filled - 1); + let rest = empty_char.to_string().repeat(width - filled); + format!("{body}{tip_char}{rest}") + } + _ => { + let body = filled_char.to_string().repeat(filled); + let rest = empty_char.to_string().repeat(width - filled); + format!("{body}{rest}") + } + } +} + +/// Truncates to `max_chars`, appending an ellipsis when content was cut, +/// instead of letting long names clip abruptly at the pane border. +fn truncate_ellipsis(s: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + let count = s.chars().count(); + if count <= max_chars { + return s.to_string(); + } + if max_chars == 1 { + return "\u{2026}".to_string(); + } + let truncated: String = s.chars().take(max_chars - 1).collect(); + format!("{truncated}\u{2026}") +} + +/// Truncates `text` to `budget` characters, then -- if `query` is active and +/// found (case-insensitive) within the truncated text -- splits it into +/// styled spans so the matched substring pops instead of leaving the reader +/// to guess why a row matched. +fn highlighted_spans(text: &str, budget: usize, query: Option<&str>, base: Style, match_style: Style) -> Vec> { + let text = truncate_ellipsis(text, budget); + let query = match query.filter(|q| !q.is_empty()) { + Some(q) => q, + None => return vec![Span::styled(text, base)], + }; + + let chars: Vec = text.chars().collect(); + let query_chars: Vec = query.chars().collect(); + if query_chars.is_empty() || query_chars.len() > chars.len() { + return vec![Span::styled(text, base)]; + } + + let match_start = (0..=chars.len() - query_chars.len()).find(|&i| { + chars[i..i + query_chars.len()] + .iter() + .zip(&query_chars) + .all(|(a, b)| a.to_lowercase().eq(b.to_lowercase())) + }); + + match match_start { + Some(start) => { + let before: String = chars[..start].iter().collect(); + let matched: String = chars[start..start + query_chars.len()].iter().collect(); + let after: String = chars[start + query_chars.len()..].iter().collect(); + let mut spans = Vec::new(); + if !before.is_empty() { + spans.push(Span::styled(before, base)); + } + spans.push(Span::styled(matched, match_style)); + if !after.is_empty() { + spans.push(Span::styled(after, base)); + } + spans + } + None => vec![Span::styled(text, base)], + } +} + fn playback_label(status: &PlaybackStatus) -> &str { match status { PlaybackStatus::Buffering => "[...] buffering...", diff --git a/crates/riptune-tui/src/theme.rs b/crates/riptune-tui/src/theme.rs new file mode 100644 index 0000000..3d45d96 --- /dev/null +++ b/crates/riptune-tui/src/theme.rs @@ -0,0 +1,72 @@ +//! The TUI's color choices, centralized so panes, gauges, and the help +//! overlay read as a single deliberate scheme instead of independent picks. +//! Some colors are user-overridable via config.toml's `[theme]` table (see +//! `riptune_types::ThemeColors`); the rest are fixed. + +use ratatui::style::Color; +use riptune_types::ThemeColors; + +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub border_focused: Color, + pub border_unfocused: Color, + pub accent: Color, + pub now_playing: Color, + pub progress: Color, + pub volume: Color, + pub text_muted: Color, + pub error: Color, + pub help_border: Color, + // Keyboard-cursor selection is a background band, deliberately distinct + // from `now_playing`'s foreground-color-plus-icon treatment -- the two + // need to layer legibly when they land on the same row, not collide the + // way an inverted/REVERSED highlight over colored text does. + pub selection_bg: Color, +} + +impl Default for Theme { + fn default() -> Self { + Self { + border_focused: Color::Rgb(137, 180, 250), + border_unfocused: Color::Rgb(88, 91, 112), + accent: Color::Rgb(137, 180, 250), + now_playing: Color::Rgb(166, 227, 161), + progress: Color::Rgb(137, 180, 250), + volume: Color::Rgb(166, 227, 161), + text_muted: Color::Rgb(127, 132, 156), + error: Color::Rgb(243, 139, 168), + help_border: Color::Rgb(203, 166, 247), + selection_bg: Color::Rgb(49, 50, 68), + } + } +} + +impl Theme { + /// Builds a theme from config.toml overrides, falling back to the + /// built-in default for any color that's absent or fails to parse as a + /// `#rrggbb` hex string. + pub fn from_config(colors: &ThemeColors) -> Self { + let default = Self::default(); + Self { + accent: parse_hex(&colors.accent).unwrap_or(default.accent), + now_playing: parse_hex(&colors.now_playing).unwrap_or(default.now_playing), + error: parse_hex(&colors.error).unwrap_or(default.error), + border_focused: parse_hex(&colors.border_focused).unwrap_or(default.border_focused), + border_unfocused: parse_hex(&colors.border_unfocused).unwrap_or(default.border_unfocused), + selection_bg: parse_hex(&colors.selection_bg).unwrap_or(default.selection_bg), + ..default + } + } +} + +fn parse_hex(value: &Option) -> Option { + let s = value.as_ref()?.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) +} diff --git a/crates/riptune-types/Cargo.toml b/crates/riptune-types/Cargo.toml index a3d54bc..bd27b27 100644 --- a/crates/riptune-types/Cargo.toml +++ b/crates/riptune-types/Cargo.toml @@ -6,3 +6,4 @@ license.workspace = true description = "Shared command/event types crossing thread and crate boundaries (audio, MPRIS, niri, TUI)" [dependencies] +serde = { workspace = true } diff --git a/crates/riptune-types/src/lib.rs b/crates/riptune-types/src/lib.rs index 3b20e50..0df3529 100644 --- a/crates/riptune-types/src/lib.rs +++ b/crates/riptune-types/src/lib.rs @@ -27,6 +27,27 @@ pub enum AudioEvent { Error(String), } +/// User-overridable TUI colors, loaded from config.toml's `[theme]` table as +/// `#rrggbb` hex strings. Lives here (not in riptune-tui) so the main crate's +/// config loader can deserialize it directly and hand it to the TUI without +/// a dependency cycle. Any field left unset, or that fails to parse, falls +/// back to riptune-tui's built-in default for that color. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct ThemeColors { + #[serde(default)] + pub accent: Option, + #[serde(default)] + pub now_playing: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub border_focused: Option, + #[serde(default)] + pub border_unfocused: Option, + #[serde(default)] + pub selection_bg: Option, +} + #[derive(Debug, Clone)] pub enum AppEvent { /// User favorited/unfavorited a track (mirrors Subsonic star/unstar + can be diff --git a/src/app.rs b/src/app.rs index f6d6c3e..ac545b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,36 +1,54 @@ use crate::audio::{AudioCommand, AudioEvent}; -use crate::config::Config; +use crate::config::Config; use anyhow::Result; use std::sync::mpsc::{Receiver, Sender}; -/// Wires together everything that lives on the tokio runtime +/// Wires together everything that lives on the tokio runtime: +/// - riptune_core::SubsonicClient (REST API + streaming URLs) +/// - riptune_cache::Cache (SQLite index, warmed on startup) +/// - riptune_mpris::Server (D-Bus MPRIS2 interface) +/// - riptune_niri::EventListener (niri IPC event-stream socket) +/// - riptune_tui::App (ratatui render loop) +/// +/// The TUI is the only piece that technically needs to run on the main thread on +/// some terminal backends, so it's driven from here rather than spawned as a task. pub async fn run(cfg: Config, audio_tx: Sender, audio_events: Receiver) -> Result<()> { let client = riptune_core::SubsonicClient::new(&cfg.server.url, &cfg.server.username, &cfg.server.password)?; let cache = riptune_cache::Cache::open(&cfg.cache.path).await?; - // Background: sync library index into the local cache + // Background: sync library index into the local cache so the TUI opens instantly + // on subsequent runs, and refreshes lazily rather than blocking startup. let sync_client = client.clone(); let sync_cache = cache.clone(); - tokio::spawn(async move { + let sync_task = tokio::spawn(async move { if let Err(e) = riptune_cache::sync_library(&sync_client, &sync_cache).await { tracing::warn!(error = %e, "library sync failed"); } }); - if cfg.niri.enabled { + // Kept so we can abort this task once the TUI quits. + let niri_task = if cfg.niri.enabled { let audio_tx = audio_tx.clone(); - tokio::spawn(async move { + Some(tokio::spawn(async move { if let Err(e) = riptune_niri::listen(audio_tx).await { tracing::warn!(error = %e, "niri IPC listener exited"); } - }); - } + })) + } else { + None + }; let mpris_handle = riptune_mpris::spawn(audio_tx.clone()).await?; - // This invokes the actual TUI implementation from crates/riptune-tui - riptune_tui::App::new(client, cache, audio_tx, audio_events).run().await?; + riptune_tui::App::new(client, cache, audio_tx.clone(), audio_events, cfg.theme.clone()).run().await?; + sync_task.abort(); + if let Some(task) = niri_task { + task.abort(); + } mpris_handle.shutdown().await; + + let _ = audio_tx.send(AudioCommand::Stop); + Ok(()) } \ No newline at end of file diff --git a/src/audio.rs b/src/audio.rs index 1999c64..b54de32 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -54,10 +54,22 @@ fn run_audio_loop(cmd_rx: Receiver, event_tx: Sender) loop { // Short timeout so we still get to check on playback progress // (position/finished) even when no new command has arrived. - match cmd_rx.recv_timeout(Duration::from_millis(250)) { - Ok(command) => handle_command(command, &stream_handle, &mut sink, &http, &event_tx, &mut current_volume), + match cmd_rx.recv_timeout(Duration::from_millis(100)) { + Ok(AudioCommand::Stop) => { + // Drop sink explicitly to free audio hardware resources + drop(sink.take()); + break; // Exit the audio thread loop! + } + Ok(command) => handle_command( + command, + &stream_handle, + &mut sink, + &http, + &event_tx, + &mut current_volume, + ), + Err(RecvTimeoutError::Disconnected) => break, Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => break, // sender dropped -> app is shutting down } if let Some(active) = &sink { diff --git a/src/config.rs b/src/config.rs index 4a4580e..8f76c40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub niri: NiriConfig, #[serde(default)] pub cache: CacheConfig, + #[serde(default)] + pub theme: riptune_types::ThemeColors, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/main.rs b/src/main.rs index cafe300..425bc24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,4 @@ //! riptune — a niri-native TUI music client for Subsonic servers. -//! -//! Threading model (see README.md#architecture for the diagram): -//! - Tokio runtime: Subsonic API calls, streaming downloads, MPRIS D-Bus server, -//! niri IPC event listener. All async, all non-blocking. -//! - Audio thread: owns the cpal/symphonia decode+playback pipeline. Talks to the -//! rest of the app only via a bounded mpsc channel (commands in, position/state out). -//! - TUI thread (main): ratatui render loop. Never blocks on I/O — reads from -//! channels/shared state populated by the other two. mod app; mod audio; @@ -37,34 +29,64 @@ enum Command { } fn main() -> Result<()> { - tracing_subscriber::fmt::init(); + init_logging()?; let cli = Cli::parse(); - let cfg = crate::config::Config::load(cli.config)?; + let cfg = config::Config::load(cli.config)?; - // Quick one-shot commands skip the audio thread, MPRIS, and TUI entirely — - // they exist to smoke-test the Subsonic client in isolation. if let Some(command) = cli.command { let runtime = tokio::runtime::Runtime::new()?; return runtime.block_on(run_command(command, cfg)); } - // Audio runs on its own OS thread — decode/playback must not share a runtime - // with network I/O, or a slow server response can stutter playback. - let (audio_tx, audio_events, audio_handle) = audio::spawn(cfg.clone())?; + let (audio_tx, audio_events, _audio_handle) = audio::spawn(cfg.clone())?; - // Everything async (Subsonic client, MPRIS, niri IPC) shares one tokio runtime. let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; - runtime.block_on(app::run(cfg, audio_tx, audio_events))?; + runtime.block_on(async { + tokio::select! { + res = app::run(cfg, audio_tx, audio_events) => res, + _ = tokio::signal::ctrl_c() => { + // When Ctrl+C is pressed, returning Ok(()) drops the TUI instance, + // triggering the `TerminalCleanup` guard in riptune-tui automatically! + Ok(()) + } + } + }) +} + +// Logs must never land on stdout/stderr: the TUI owns the terminal's +// alternate screen, and any interleaved write (e.g. symphonia's demuxer +// warnings) punches straight through the ratatui frame. +fn init_logging() -> Result<()> { + let dir = std::env::var_os("XDG_STATE_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var_os("HOME").unwrap_or_default(); + std::path::PathBuf::from(home).join(".local/state") + }) + .join("riptune"); + std::fs::create_dir_all(&dir)?; + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("riptune.log"))?; + + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,symphonia=warn,reqwest=warn,zbus=warn")); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::sync::Mutex::new(file)) + .with_ansi(false) + .init(); - audio_handle.join().ok(); Ok(()) } -async fn run_command(command: Command, cfg: crate::config::Config) -> Result<()> { +async fn run_command(command: Command, cfg: config::Config) -> Result<()> { let client = riptune_core::SubsonicClient::new(&cfg.server.url, &cfg.server.username, &cfg.server.password)?; match command { @@ -81,4 +103,4 @@ async fn run_command(command: Command, cfg: crate::config::Config) -> Result<()> } } Ok(()) -} +} \ No newline at end of file