Fixed several riptune TUI bugs and did a full player UI redesign with cover art, theming, and a unified now-playing deck
This commit is contained in:
Generated
+161
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -343,6 +349,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.12.1"
|
||||||
@@ -598,6 +610,15 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.3.13"
|
version = "0.3.13"
|
||||||
@@ -729,6 +750,12 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dyn-clone"
|
||||||
|
version = "1.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -840,12 +867,31 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1299,6 +1345,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icy_sixel"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1326,6 +1378,21 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -1572,6 +1639,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -1603,6 +1679,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1615,6 +1701,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1918,6 +2014,19 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
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]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -1968,6 +2077,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.11"
|
version = "0.11.11"
|
||||||
@@ -2122,6 +2237,21 @@ dependencies = [
|
|||||||
"unicode-width",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2250,6 +2380,7 @@ dependencies = [
|
|||||||
name = "riptune-cache"
|
name = "riptune-cache"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
"riptune-core",
|
"riptune-core",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@@ -2302,7 +2433,9 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"image",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"ratatui-image",
|
||||||
"riptune-cache",
|
"riptune-cache",
|
||||||
"riptune-core",
|
"riptune-core",
|
||||||
"riptune-types",
|
"riptune-types",
|
||||||
@@ -2313,6 +2446,9 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "riptune-types"
|
name = "riptune-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rodio"
|
name = "rodio"
|
||||||
@@ -2600,6 +2736,12 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -3415,10 +3557,14 @@ version = "0.3.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
"nu-ansi-term",
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
]
|
]
|
||||||
@@ -4198,6 +4344,21 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
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]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "4.2.0"
|
version = "4.2.0"
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "riptune"
|
name = "riptune"
|
||||||
|
|||||||
@@ -9,3 +9,13 @@ workspace_notifications = true
|
|||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
path = "~/.cache/riptune/library.sqlite"
|
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"
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ tokio = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! instances with 50k+ tracks). We index artists/albums/tracks locally and treat
|
//! 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].
|
//! 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::SubsonicClient;
|
||||||
use riptune_core::models::{Artist, Track, AlbumSummary};
|
use riptune_core::models::{Artist, Track, AlbumSummary};
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use sqlx::sqlite::SqlitePoolOptions;
|
||||||
@@ -77,12 +78,25 @@ impl Cache {
|
|||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await?;
|
.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 })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_albums_by_artist(&self, artist_id: &str) -> Result<Vec<AlbumSummary>, CacheError> {
|
pub async fn get_albums_by_artist(&self, artist_id: &str) -> Result<Vec<AlbumSummary>, CacheError> {
|
||||||
let rows = sqlx::query(
|
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)
|
.bind(artist_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -90,36 +104,33 @@ impl Cache {
|
|||||||
|
|
||||||
let records = rows.into_iter().map(|row| {
|
let records = rows.into_iter().map(|row| {
|
||||||
let count: i64 = row.get("song_count");
|
let count: i64 = row.get("song_count");
|
||||||
|
let year: Option<i64> = row.get("year");
|
||||||
AlbumSummary {
|
AlbumSummary {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
song_count: count as u32,
|
song_count: count as u32,
|
||||||
|
year: year.map(|y| y as u32),
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
Ok(records)
|
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<Vec<Track>, CacheError> {
|
pub async fn get_tracks_by_album(&self, album_name: &str) -> Result<Vec<Track>, CacheError> {
|
||||||
// FIXED: Added CAST protection to match what we did in the search query!
|
// FIXED: Added CAST protection to match what we did in the search query!
|
||||||
let rows = sqlx::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)
|
.bind(album_name)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let records = rows.into_iter().map(|row| {
|
let records = rows.into_iter().map(row_to_track).collect();
|
||||||
let duration: Option<i64> = 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();
|
|
||||||
|
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
@@ -149,7 +160,7 @@ impl Cache {
|
|||||||
pub async fn search_local_albums(&self, query: &str) -> Result<Vec<AlbumSummary>, CacheError> {
|
pub async fn search_local_albums(&self, query: &str) -> Result<Vec<AlbumSummary>, CacheError> {
|
||||||
let sql_pattern = format!("%{}%", query);
|
let sql_pattern = format!("%{}%", query);
|
||||||
let rows = sqlx::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)
|
.bind(sql_pattern)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
@@ -157,10 +168,12 @@ impl Cache {
|
|||||||
|
|
||||||
let records = rows.into_iter().map(|row| {
|
let records = rows.into_iter().map(|row| {
|
||||||
let count: i64 = row.get("song_count");
|
let count: i64 = row.get("song_count");
|
||||||
|
let year: Option<i64> = row.get("year");
|
||||||
AlbumSummary {
|
AlbumSummary {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
name: row.get("name"),
|
name: row.get("name"),
|
||||||
song_count: count as u32,
|
song_count: count as u32,
|
||||||
|
year: year.map(|y| y as u32),
|
||||||
}
|
}
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
@@ -172,14 +185,23 @@ impl Cache {
|
|||||||
// 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.
|
// regardless of whether the internal row data was stored as an integer or text.
|
||||||
let rows = sqlx::query(
|
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)
|
.bind(sql_pattern)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let records = rows.into_iter().map(|row| {
|
let records = rows.into_iter().map(row_to_track).collect();
|
||||||
|
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_track(row: sqlx::sqlite::SqliteRow) -> Track {
|
||||||
let duration: Option<i64> = row.get("duration_secs");
|
let duration: Option<i64> = row.get("duration_secs");
|
||||||
|
let track_number: Option<i64> = row.get("track_number");
|
||||||
|
let disc_number: Option<i64> = row.get("disc_number");
|
||||||
Track {
|
Track {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
@@ -187,21 +209,48 @@ impl Cache {
|
|||||||
album: row.get("album"),
|
album: row.get("album"),
|
||||||
duration_secs: duration.map(|d| d as u32),
|
duration_secs: duration.map(|d| d as u32),
|
||||||
starred: row.get("starred"),
|
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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Ok(records)
|
// 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
|
/// 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
|
/// the local cache. Intended to run as a background task on startup, and
|
||||||
/// periodically thereafter — never blocks the TUI's first paint[cite: 13].
|
/// 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> {
|
pub async fn sync_library(client: &SubsonicClient, cache: &Cache) -> Result<(), CacheError> {
|
||||||
let remote_artists = client.get_artists().await
|
let remote_artists = client.get_artists().await
|
||||||
.map_err(|e| CacheError::Client(e.to_string()))?;
|
.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::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
(artist, albums)
|
||||||
|
})
|
||||||
|
.buffer_unordered(SYNC_CONCURRENCY)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (artist, albums) in fetched {
|
||||||
let mut tx = cache.pool.begin().await?;
|
let mut tx = cache.pool.begin().await?;
|
||||||
|
|
||||||
sqlx::query("INSERT OR REPLACE INTO artists (id, name, album_count) VALUES (?, ?, ?)")
|
sqlx::query("INSERT OR REPLACE INTO artists (id, name, album_count) VALUES (?, ?, ?)")
|
||||||
@@ -211,31 +260,31 @@ pub async fn sync_library(client: &SubsonicClient, cache: &Cache) -> Result<(),
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Ok(albums) = client.get_artist_albums(&artist.id).await {
|
for (album, songs) in albums {
|
||||||
for album in albums {
|
sqlx::query("INSERT OR REPLACE INTO albums (id, name, song_count, artist_id, year) VALUES (?, ?, ?, ?, ?)")
|
||||||
sqlx::query("INSERT OR REPLACE INTO albums (id, name, song_count, artist_id) VALUES (?, ?, ?, ?)")
|
|
||||||
.bind(&album.id)
|
.bind(&album.id)
|
||||||
.bind(&album.name)
|
.bind(&album.name)
|
||||||
.bind(album.song_count)
|
.bind(album.song_count)
|
||||||
.bind(&artist.id)
|
.bind(&artist.id)
|
||||||
|
.bind(album.year)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Ok(full_album) = client.get_album(&album.id).await {
|
for song in songs {
|
||||||
for song in full_album.song {
|
sqlx::query("INSERT OR REPLACE INTO tracks (id, title, artist, album, duration_secs, starred, cover_art, track_number, disc_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
sqlx::query("INSERT OR REPLACE INTO tracks (id, title, artist, album, duration_secs, starred) VALUES (?, ?, ?, ?, ?, ?)")
|
|
||||||
.bind(&song.id)
|
.bind(&song.id)
|
||||||
.bind(&song.title)
|
.bind(&song.title)
|
||||||
.bind(&song.artist)
|
.bind(&song.artist)
|
||||||
.bind(&song.album)
|
.bind(&song.album)
|
||||||
.bind(song.duration_secs)
|
.bind(song.duration_secs)
|
||||||
.bind(&song.starred) // Safely saves text strings directly into SQLite schema rows[cite: 14]
|
.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)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ pub struct SubsonicClient {
|
|||||||
impl SubsonicClient {
|
impl SubsonicClient {
|
||||||
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
|
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
|
||||||
Ok(Self {
|
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)?,
|
base_url: url::Url::parse(base_url)?,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
@@ -77,6 +79,30 @@ impl SubsonicClient {
|
|||||||
Ok(url.to_string())
|
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<String, SubsonicError> {
|
||||||
|
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<T>`.
|
||||||
|
pub async fn get_cover_art(&self, cover_art_id: &str) -> Result<Vec<u8>, 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
|
/// Sends an authenticated GET to `rest/{endpoint}`, unwraps the
|
||||||
/// `subsonic-response` envelope, and either deserializes the payload into
|
/// `subsonic-response` envelope, and either deserializes the payload into
|
||||||
/// `T` or converts a server-side error into `SubsonicError::Api`.
|
/// `T` or converts a server-side error into `SubsonicError::Api`.
|
||||||
@@ -222,4 +248,16 @@ mod tests {
|
|||||||
let url = client.stream_url("track-123").unwrap();
|
let url = client.stream_url("track-123").unwrap();
|
||||||
assert!(!url.contains("hunter2"), "raw password must never appear in the URL: {url}");
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ pub struct AlbumSummary {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(rename = "songCount", default)]
|
#[serde(rename = "songCount", default)]
|
||||||
pub song_count: u32,
|
pub song_count: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -41,4 +43,10 @@ pub struct Track {
|
|||||||
pub duration_secs: Option<u32>,
|
pub duration_secs: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub starred: Option<String>,
|
pub starred: Option<String>,
|
||||||
|
#[serde(rename = "coverArt", default)]
|
||||||
|
pub cover_art: Option<String>,
|
||||||
|
#[serde(rename = "track", default)]
|
||||||
|
pub track_number: Option<u32>,
|
||||||
|
#[serde(rename = "discNumber", default)]
|
||||||
|
pub disc_number: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,15 @@ tracing = { workspace = true }
|
|||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
futures-util = "0.3"
|
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"] }
|
||||||
|
|||||||
+405
-66
@@ -1,17 +1,27 @@
|
|||||||
|
mod theme;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind};
|
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use image::DynamicImage;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style, Color};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Gauge, Clear};
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{
|
||||||
|
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
|
||||||
|
};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
use ratatui_image::picker::Picker;
|
||||||
|
use ratatui_image::protocol::StatefulProtocol;
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
use riptune_cache::Cache;
|
use riptune_cache::Cache;
|
||||||
use riptune_core::models::{AlbumSummary, Artist, Track};
|
use riptune_core::models::{AlbumSummary, Artist, Track};
|
||||||
use riptune_core::SubsonicClient;
|
use riptune_core::SubsonicClient;
|
||||||
use riptune_types::{AudioCommand, AudioEvent};
|
use riptune_types::{AudioCommand, AudioEvent};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -53,11 +63,13 @@ enum InputMode {
|
|||||||
struct NowPlaying {
|
struct NowPlaying {
|
||||||
title: String,
|
title: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
|
album: String,
|
||||||
position: Duration,
|
position: Duration,
|
||||||
duration_secs: Option<u32>,
|
duration_secs: Option<u32>,
|
||||||
status: PlaybackStatus,
|
status: PlaybackStatus,
|
||||||
current_track_id: String,
|
current_track_id: String,
|
||||||
playlist_context: Vec<Track>,
|
playlist_context: Vec<Track>,
|
||||||
|
cover_art_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -89,10 +101,45 @@ pub struct App {
|
|||||||
// Playback Engine Mods
|
// Playback Engine Mods
|
||||||
shuffle: bool,
|
shuffle: bool,
|
||||||
repeat: RepeatMode,
|
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<Picker>,
|
||||||
|
cover_cache: HashMap<String, Box<dyn StatefulProtocol>>,
|
||||||
|
cover_in_flight: HashSet<String>,
|
||||||
|
cover_art_tx: tokio::sync::mpsc::UnboundedSender<(String, Option<DynamicImage>)>,
|
||||||
|
cover_art_rx: tokio::sync::mpsc::UnboundedReceiver<(String, Option<DynamicImage>)>,
|
||||||
|
|
||||||
|
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 {
|
impl App {
|
||||||
pub fn new(client: SubsonicClient, cache: Cache, audio_tx: Sender<AudioCommand>, audio_events: Receiver<AudioEvent>) -> Self {
|
pub fn new(
|
||||||
|
client: SubsonicClient,
|
||||||
|
cache: Cache,
|
||||||
|
audio_tx: Sender<AudioCommand>,
|
||||||
|
audio_events: Receiver<AudioEvent>,
|
||||||
|
theme_colors: riptune_types::ThemeColors,
|
||||||
|
) -> Self {
|
||||||
|
let (cover_art_tx, cover_art_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
cache,
|
cache,
|
||||||
@@ -114,18 +161,41 @@ impl App {
|
|||||||
show_help: false,
|
show_help: false,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat: RepeatMode::Off,
|
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<()> {
|
pub async fn run(mut self) -> Result<()> {
|
||||||
enable_raw_mode()?;
|
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();
|
let mut stdout = std::io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let _cleanup_guard = TerminalCleanup;
|
||||||
|
|
||||||
let result = self.event_loop(&mut terminal).await;
|
let result = self.event_loop(&mut terminal).await;
|
||||||
|
|
||||||
|
// Explicit cleanup before returning
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
@@ -165,6 +235,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = ticker.tick() => {}
|
_ = 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) {
|
match self.client.stream_url(&track.id) {
|
||||||
Ok(stream_url) => {
|
Ok(stream_url) => {
|
||||||
let _ = self.audio_tx.send(AudioCommand::Play { 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 {
|
self.now_playing = Some(NowPlaying {
|
||||||
title: track.title.clone(),
|
title: track.title.clone(),
|
||||||
artist: track.artist.clone().unwrap_or_default(),
|
artist: track.artist.clone().unwrap_or_default(),
|
||||||
|
album: track.album.clone().unwrap_or_default(),
|
||||||
position: Duration::ZERO,
|
position: Duration::ZERO,
|
||||||
duration_secs: track.duration_secs,
|
duration_secs: track.duration_secs,
|
||||||
status: PlaybackStatus::Buffering,
|
status: PlaybackStatus::Buffering,
|
||||||
current_track_id: track.id.clone(),
|
current_track_id: track.id.clone(),
|
||||||
playlist_context,
|
playlist_context,
|
||||||
|
cover_art_id: track.cover_art.clone(),
|
||||||
});
|
});
|
||||||
self.notify_desktop_environment("Track Started");
|
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) {
|
fn toggle_play_pause(&mut self) {
|
||||||
if let Some(now_playing) = &mut self.now_playing {
|
if let Some(now_playing) = &mut self.now_playing {
|
||||||
match &now_playing.status {
|
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();
|
let area = f.area();
|
||||||
|
|
||||||
// --- 1. Layout Resizing Shield ---
|
// --- 1. Layout Resizing Shield ---
|
||||||
@@ -465,7 +566,7 @@ impl App {
|
|||||||
let emergency_msg = Paragraph::new(
|
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."
|
"\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);
|
f.render_widget(emergency_msg, area);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -475,93 +576,217 @@ impl App {
|
|||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Length(3),
|
Constraint::Length(7),
|
||||||
|
Constraint::Length(1),
|
||||||
])
|
])
|
||||||
.split(area);
|
.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()
|
let columns_layout = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Percentage(33),
|
Constraint::Percentage(artists_pct),
|
||||||
Constraint::Percentage(33),
|
Constraint::Percentage(albums_pct),
|
||||||
Constraint::Percentage(34),
|
Constraint::Percentage(tracks_pct),
|
||||||
])
|
])
|
||||||
.split(main_layout[0]);
|
.split(main_layout[0]);
|
||||||
|
|
||||||
let artists_list: Vec<ListItem> = self.artists.iter().map(|a| ListItem::new(format!(" {}", a.name))).collect();
|
let now_playing_id = self.now_playing.as_ref().map(|np| np.current_track_id.as_str());
|
||||||
let albums_list: Vec<ListItem> = self.albums.iter().map(|a| ListItem::new(format!(" {}", a.name))).collect();
|
|
||||||
let tracks_list: Vec<ListItem> = self.tracks.iter().map(|t| ListItem::new(format!(" {}", t.title))).collect();
|
// 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<ListItem> = 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<ListItem> = 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<ListItem> = 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| {
|
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();
|
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);
|
||||||
let mut mid_state = ListState::default();
|
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);
|
||||||
let mut right_state = ListState::default();
|
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 {
|
render_scrollbar(f, columns_layout[0], self.artists.len(), self.artists_state.offset(), self.theme.border_unfocused);
|
||||||
FocusPane::Artists => left_state.select(Some(self.selected_idx)),
|
render_scrollbar(f, columns_layout[1], self.albums.len(), self.albums_state.offset(), self.theme.border_unfocused);
|
||||||
FocusPane::Albums => mid_state.select(Some(self.selected_idx)),
|
render_scrollbar(f, columns_layout[2], self.tracks.len(), self.tracks_state.offset(), self.theme.border_unfocused);
|
||||||
FocusPane::Tracks => right_state.select(Some(self.selected_idx)),
|
|
||||||
|
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);
|
let title_line = if let Some(np) = &self.now_playing {
|
||||||
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);
|
Line::from(vec![
|
||||||
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);
|
Span::styled(format!("{} ", playback_label(&np.status)), Style::default().fg(self.theme.accent)),
|
||||||
|
Span::styled(np.title.clone(), Style::default().add_modifier(Modifier::BOLD)),
|
||||||
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)
|
|
||||||
} else {
|
} 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() };
|
let artist_line = Line::from(format!("Artist: {}", self.now_playing.as_ref().map(|np| np.artist.as_str()).unwrap_or("")));
|
||||||
f.render_widget(Paragraph::new(now_playing_text).block(Block::default().borders(Borders::ALL).title(dashboard_title)), dashboard_layout[0]);
|
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 shuffle_str = if self.shuffle { "ON" } else { "OFF" };
|
||||||
let repeat_str = match self.repeat {
|
let repeat_str = match self.repeat {
|
||||||
RepeatMode::Off => "OFF",
|
RepeatMode::Off => "OFF",
|
||||||
RepeatMode::One => "TRACK",
|
RepeatMode::One => "TRACK",
|
||||||
RepeatMode::All => "QUEUE",
|
RepeatMode::All => "QUEUE",
|
||||||
};
|
};
|
||||||
let modes_text = format!(" Shuffle: {} | Repeat: {}", shuffle_str, repeat_str);
|
let volume_line = Line::from(vec![
|
||||||
f.render_widget(Paragraph::new(modes_text).block(Block::default().borders(Borders::ALL).title(" Play Modes ([s]/[r]) ")), dashboard_layout[1]);
|
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 {
|
f.render_widget(
|
||||||
if let Some(total) = np.duration_secs {
|
Paragraph::new(vec![title_line, artist_line, album_line, progress_line, volume_line]),
|
||||||
if total > 0 { (np.position.as_secs() as f64 / total as f64).min(1.0) } else { 0.0 }
|
deck_split[1],
|
||||||
} else { 0.0 }
|
);
|
||||||
} else { 0.0 };
|
|
||||||
|
|
||||||
let progress_gauge = Gauge::default()
|
// --- Hotkey legend: one line at the very bottom instead of hints
|
||||||
.block(Block::default().borders(Borders::ALL).title(" Progression "))
|
// scattered across pane titles.
|
||||||
.gauge_style(Style::default().fg(Color::Cyan))
|
let legend = " [Space] Play/Pause [/] Search [h/l] Pane [j/k] Move [s] Shuffle [r] Repeat [+/-] Vol [?] Help [q] Quit";
|
||||||
.ratio(progress_ratio);
|
f.render_widget(Paragraph::new(legend).style(Style::default().fg(self.theme.text_muted)), main_layout[3]);
|
||||||
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]);
|
|
||||||
|
|
||||||
if self.show_help {
|
if self.show_help {
|
||||||
let help_text = r#"
|
let help_text = r#"
|
||||||
@@ -588,7 +813,7 @@ impl App {
|
|||||||
[q] : Exit riptune application safely
|
[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 paragraph = Paragraph::new(help_text).block(block);
|
||||||
|
|
||||||
let popup_area = Rect {
|
let popup_area = Rect {
|
||||||
@@ -604,6 +829,120 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<char>) -> 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<Span<'static>> {
|
||||||
|
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<char> = text.chars().collect();
|
||||||
|
let query_chars: Vec<char> = 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 {
|
fn playback_label(status: &PlaybackStatus) -> &str {
|
||||||
match status {
|
match status {
|
||||||
PlaybackStatus::Buffering => "[...] buffering...",
|
PlaybackStatus::Buffering => "[...] buffering...",
|
||||||
|
|||||||
@@ -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<String>) -> Option<Color> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ license.workspace = true
|
|||||||
description = "Shared command/event types crossing thread and crate boundaries (audio, MPRIS, niri, TUI)"
|
description = "Shared command/event types crossing thread and crate boundaries (audio, MPRIS, niri, TUI)"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ pub enum AudioEvent {
|
|||||||
Error(String),
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub now_playing: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub border_focused: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub border_unfocused: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub selection_bg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
/// User favorited/unfavorited a track (mirrors Subsonic star/unstar + can be
|
/// User favorited/unfavorited a track (mirrors Subsonic star/unstar + can be
|
||||||
|
|||||||
+27
-9
@@ -3,34 +3,52 @@ use crate::config::Config;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::mpsc::{Receiver, Sender};
|
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<AudioCommand>, audio_events: Receiver<AudioEvent>) -> Result<()> {
|
pub async fn run(cfg: Config, audio_tx: Sender<AudioCommand>, audio_events: Receiver<AudioEvent>) -> Result<()> {
|
||||||
let client = riptune_core::SubsonicClient::new(&cfg.server.url, &cfg.server.username, &cfg.server.password)?;
|
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?;
|
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_client = client.clone();
|
||||||
let sync_cache = cache.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 {
|
if let Err(e) = riptune_cache::sync_library(&sync_client, &sync_cache).await {
|
||||||
tracing::warn!(error = %e, "library sync failed");
|
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();
|
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 {
|
if let Err(e) = riptune_niri::listen(audio_tx).await {
|
||||||
tracing::warn!(error = %e, "niri IPC listener exited");
|
tracing::warn!(error = %e, "niri IPC listener exited");
|
||||||
}
|
}
|
||||||
});
|
}))
|
||||||
}
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let mpris_handle = riptune_mpris::spawn(audio_tx.clone()).await?;
|
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.clone(), audio_events, cfg.theme.clone()).run().await?;
|
||||||
riptune_tui::App::new(client, cache, audio_tx, audio_events).run().await?;
|
|
||||||
|
|
||||||
|
sync_task.abort();
|
||||||
|
if let Some(task) = niri_task {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
mpris_handle.shutdown().await;
|
mpris_handle.shutdown().await;
|
||||||
|
|
||||||
|
let _ = audio_tx.send(AudioCommand::Stop);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
+15
-3
@@ -54,10 +54,22 @@ fn run_audio_loop(cmd_rx: Receiver<AudioCommand>, event_tx: Sender<AudioEvent>)
|
|||||||
loop {
|
loop {
|
||||||
// Short timeout so we still get to check on playback progress
|
// Short timeout so we still get to check on playback progress
|
||||||
// (position/finished) even when no new command has arrived.
|
// (position/finished) even when no new command has arrived.
|
||||||
match cmd_rx.recv_timeout(Duration::from_millis(250)) {
|
match cmd_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
Ok(command) => handle_command(command, &stream_handle, &mut sink, &http, &event_tx, &mut current_volume),
|
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::Timeout) => {}
|
||||||
Err(RecvTimeoutError::Disconnected) => break, // sender dropped -> app is shutting down
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(active) = &sink {
|
if let Some(active) = &sink {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub struct Config {
|
|||||||
pub niri: NiriConfig,
|
pub niri: NiriConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache: CacheConfig,
|
pub cache: CacheConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: riptune_types::ThemeColors,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|||||||
+41
-19
@@ -1,12 +1,4 @@
|
|||||||
//! riptune — a niri-native TUI music client for Subsonic servers.
|
//! 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 app;
|
||||||
mod audio;
|
mod audio;
|
||||||
@@ -37,34 +29,64 @@ enum Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
tracing_subscriber::fmt::init();
|
init_logging()?;
|
||||||
let cli = Cli::parse();
|
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 {
|
if let Some(command) = cli.command {
|
||||||
let runtime = tokio::runtime::Runtime::new()?;
|
let runtime = tokio::runtime::Runtime::new()?;
|
||||||
return runtime.block_on(run_command(command, cfg));
|
return runtime.block_on(run_command(command, cfg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio runs on its own OS thread — decode/playback must not share a runtime
|
let (audio_tx, audio_events, _audio_handle) = audio::spawn(cfg.clone())?;
|
||||||
// with network I/O, or a slow server response can stutter playback.
|
|
||||||
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()
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.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(())
|
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)?;
|
let client = riptune_core::SubsonicClient::new(&cfg.server.url, &cfg.server.username, &cfg.server.password)?;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
|
|||||||
Reference in New Issue
Block a user