Compare commits

6 Commits

Author SHA1 Message Date
fb0786122e Extract built-in theme data from theme.rs into theme_builtins.rs
Moves ~265 lines of TOML theme constant data to a dedicated file,
reducing theme.rs from 553 to ~290 lines of logic.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 00:07:29 +00:00
763e9bc8db Split mouse.rs into page-specific handler files
Extract handle_artists_click and handle_playlists_click into
mouse_artists.rs and mouse_playlists.rs respectively, reducing
mouse.rs from ~530 to ~247 lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 00:03:41 +00:00
112f18582a Fix all 16 clippy warnings
- Collapse else { if } blocks into else if (6 instances)
- Replace map_or(false, ...) with is_some_and(...) (6 instances)
- Replace iter().cloned().collect() with .to_vec() (2 instances)
- Replace manual char comparison with array pattern (1 instance)
- Replace useless vec! with array literal (1 instance)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:58:18 +00:00
766614f5e9 Remove dead code and #![allow(dead_code)] blanket suppressions
- Delete src/audio/queue.rs (321 lines, PlayQueue never used)
- Remove #![allow(dead_code)] from audio, subsonic, and mpris module roots
- Remove unused MpvEvent2 enum, playlist_clear, get_volume, get_path,
  is_eof, observe_property from mpv.rs
- Remove unused DEFAULT_DEVICE_ID, is_available, get_effective_rate
  from pipewire.rs (and associated dead test)
- Remove unused search(), get_cover_art_url() from subsonic client
- Remove unused SearchResult3Data, SearchResult3 model structs
- Move parse_song_id_from_url into #[cfg(test)] block (only used by tests)
- Add targeted #[allow(dead_code)] on deserialization-only fields
  (MpvEvent, SubsonicResponseInner.version, ArtistIndex.name)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:56:52 +00:00
7582937439 Add missing tempfile dev-dependency for config tests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:44:01 +00:00
b94c12a301 Refactor app/mod.rs into focused submodules
Split the 2495-line mod.rs into 10 files by concern:
- playback.rs: playback controls and track management
- cava.rs: cava process management and VT100 parsing
- input.rs: event dispatch and global keybindings
- input_artists.rs: artists page keyboard handling
- input_queue.rs: queue page keyboard handling
- input_playlists.rs: playlists page keyboard handling
- input_server.rs: server page keyboard handling
- input_settings.rs: settings page keyboard handling
- mouse.rs: all mouse click and scroll handling
- mod.rs: App struct, new(), run(), event_loop(), load_initial_data()

Pure code reorganization — no behavioral changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 23:41:43 +00:00
32 changed files with 2617 additions and 3148 deletions

1
Cargo.lock generated
View File

@@ -647,6 +647,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
"toml", "toml",

View File

@@ -60,3 +60,6 @@ vt100 = "0.15"
lto = true lto = true
codegen-units = 1 codegen-units = 1
strip = true strip = true
[dev-dependencies]
tempfile = "3.24.0"

View File

@@ -1,12 +1,8 @@
//! Application actions and message passing //! Application actions and message passing
use crate::subsonic::models::{Album, Artist, Child, Playlist};
/// Actions that can be sent to the audio backend /// Actions that can be sent to the audio backend
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AudioAction { pub enum AudioAction {
/// Play a specific song by URL
Play { url: String, song: Child },
/// Pause playback /// Pause playback
Pause, Pause,
/// Resume playback /// Resume playback
@@ -26,86 +22,3 @@ pub enum AudioAction {
/// Set volume (0-100) /// Set volume (0-100)
SetVolume(i32), SetVolume(i32),
} }
/// Actions that can be sent to update the UI
#[derive(Debug, Clone)]
pub enum UiAction {
/// Update playback position
UpdatePosition { position: f64, duration: f64 },
/// Update playback state
UpdatePlaybackState(PlaybackStateUpdate),
/// Update audio properties
UpdateAudioProperties {
sample_rate: Option<u32>,
bit_depth: Option<u32>,
format: Option<String>,
},
/// Track ended (EOF from MPV)
TrackEnded,
/// Show notification
Notify { message: String, is_error: bool },
/// Artists loaded from server
ArtistsLoaded(Vec<Artist>),
/// Albums loaded for an artist
AlbumsLoaded {
artist_id: String,
albums: Vec<Album>,
},
/// Songs loaded for an album
SongsLoaded { album_id: String, songs: Vec<Child> },
/// Playlists loaded from server
PlaylistsLoaded(Vec<Playlist>),
/// Playlist songs loaded
PlaylistSongsLoaded {
playlist_id: String,
songs: Vec<Child>,
},
/// Server connection test result
ConnectionTestResult { success: bool, message: String },
/// Force redraw
Redraw,
}
/// Playback state update
#[derive(Debug, Clone, Copy)]
pub enum PlaybackStateUpdate {
Playing,
Paused,
Stopped,
}
/// Actions for the Subsonic client
#[derive(Debug, Clone)]
pub enum SubsonicAction {
/// Fetch all artists
FetchArtists,
/// Fetch albums for an artist
FetchAlbums { artist_id: String },
/// Fetch songs for an album
FetchAlbum { album_id: String },
/// Fetch all playlists
FetchPlaylists,
/// Fetch songs in a playlist
FetchPlaylist { playlist_id: String },
/// Test server connection
TestConnection,
}
/// Queue manipulation actions
#[derive(Debug, Clone)]
pub enum QueueAction {
/// Append songs to queue
Append(Vec<Child>),
/// Insert songs after current position
InsertNext(Vec<Child>),
/// Clear the queue
Clear,
/// Remove song at index
Remove(usize),
/// Move song from one index to another
Move { from: usize, to: usize },
/// Shuffle the queue (keeping current song in place)
Shuffle,
/// Play song at queue index
PlayIndex(usize),
}

243
src/app/cava.rs Normal file
View File

@@ -0,0 +1,243 @@
use std::io::Read as _;
use std::os::unix::io::FromRawFd;
use tracing::{error, info};
use super::*;
impl App {
/// Start cava process in noncurses mode via a pty
pub(super) fn start_cava(&mut self, cava_gradient: &[String; 8], cava_horizontal_gradient: &[String; 8], cava_size: u32) {
self.stop_cava();
// Compute pty dimensions to match the cava widget area
let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
let cava_h = (term_h as u32 * cava_size / 100).max(4) as u16;
let cava_w = term_w;
// Open a pty pair
let mut master: libc::c_int = 0;
let mut slave: libc::c_int = 0;
unsafe {
if libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
) != 0
{
error!("openpty failed");
return;
}
// Set pty size so cava knows its dimensions
let ws = libc::winsize {
ws_row: cava_h,
ws_col: cava_w,
ws_xpixel: 0,
ws_ypixel: 0,
};
libc::ioctl(slave, libc::TIOCSWINSZ, &ws);
}
// Generate themed cava config and write to temp file
// Dup slave fd before converting to File (from_raw_fd takes ownership)
let slave_stdin_fd = unsafe { libc::dup(slave) };
let slave_stderr_fd = unsafe { libc::dup(slave) };
let slave_stdout = unsafe { std::fs::File::from_raw_fd(slave) };
let slave_stdin = unsafe { std::fs::File::from_raw_fd(slave_stdin_fd) };
let slave_stderr = unsafe { std::fs::File::from_raw_fd(slave_stderr_fd) };
let config_path = std::env::temp_dir().join("ferrosonic-cava.conf");
if let Err(e) = std::fs::write(&config_path, generate_cava_config(cava_gradient, cava_horizontal_gradient)) {
error!("Failed to write cava config: {}", e);
return;
}
let mut cmd = std::process::Command::new("cava");
cmd.arg("-p").arg(&config_path);
cmd.stdout(std::process::Stdio::from(slave_stdout))
.stderr(std::process::Stdio::from(slave_stderr))
.stdin(std::process::Stdio::from(slave_stdin))
.env("TERM", "xterm-256color");
match cmd.spawn() {
Ok(child) => {
// Set master to non-blocking
unsafe {
let flags = libc::fcntl(master, libc::F_GETFL);
libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
let master_file = unsafe { std::fs::File::from_raw_fd(master) };
let parser = vt100::Parser::new(cava_h, cava_w, 0);
self.cava_process = Some(child);
self.cava_pty_master = Some(master_file);
self.cava_parser = Some(parser);
info!("Cava started in noncurses mode ({}x{})", cava_w, cava_h);
}
Err(e) => {
error!("Failed to start cava: {}", e);
unsafe {
libc::close(master);
}
}
}
}
/// Stop cava process and clean up
pub(super) fn stop_cava(&mut self) {
if let Some(ref mut child) = self.cava_process {
let _ = child.kill();
let _ = child.wait();
}
self.cava_process = None;
self.cava_pty_master = None;
self.cava_parser = None;
}
/// Read cava pty output and snapshot screen to state
pub(super) async fn read_cava_output(&mut self) {
let (Some(ref mut master), Some(ref mut parser)) =
(&mut self.cava_pty_master, &mut self.cava_parser)
else {
return;
};
// Read all available bytes from the pty master
let mut buf = [0u8; 16384];
let mut got_data = false;
loop {
match master.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
parser.process(&buf[..n]);
got_data = true;
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
Err(_) => return,
}
}
if !got_data {
return;
}
// Snapshot the vt100 screen into shared state
let screen = parser.screen();
let (rows, cols) = screen.size();
let mut cava_screen = Vec::with_capacity(rows as usize);
for row in 0..rows {
let mut spans: Vec<CavaSpan> = Vec::new();
let mut cur_text = String::new();
let mut cur_fg = CavaColor::Default;
let mut cur_bg = CavaColor::Default;
for col in 0..cols {
let cell = screen.cell(row, col).unwrap();
let fg = vt100_color_to_cava(cell.fgcolor());
let bg = vt100_color_to_cava(cell.bgcolor());
if fg != cur_fg || bg != cur_bg {
if !cur_text.is_empty() {
spans.push(CavaSpan {
text: std::mem::take(&mut cur_text),
fg: cur_fg,
bg: cur_bg,
});
}
cur_fg = fg;
cur_bg = bg;
}
let contents = cell.contents();
if contents.is_empty() {
cur_text.push(' ');
} else {
cur_text.push_str(&contents);
}
}
if !cur_text.is_empty() {
spans.push(CavaSpan {
text: cur_text,
fg: cur_fg,
bg: cur_bg,
});
}
cava_screen.push(CavaRow { spans });
}
let mut state = self.state.write().await;
state.cava_screen = cava_screen;
}
}
/// Convert vt100 color to our CavaColor type
fn vt100_color_to_cava(color: vt100::Color) -> CavaColor {
match color {
vt100::Color::Default => CavaColor::Default,
vt100::Color::Idx(i) => CavaColor::Indexed(i),
vt100::Color::Rgb(r, g, b) => CavaColor::Rgb(r, g, b),
}
}
/// Generate a cava configuration string with theme-appropriate gradient colors
pub(super) fn generate_cava_config(g: &[String; 8], h: &[String; 8]) -> String {
format!(
"\
[general]
framerate = 60
autosens = 1
overshoot = 0
bars = 0
bar_width = 1
bar_spacing = 0
lower_cutoff_freq = 10
higher_cutoff_freq = 18000
[input]
sample_rate = 96000
sample_bits = 32
remix = 1
[output]
method = noncurses
orientation = horizontal
channels = mono
mono_option = average
synchronized_sync = 1
disable_blanking = 1
[color]
gradient = 1
gradient_color_1 = '{g0}'
gradient_color_2 = '{g1}'
gradient_color_3 = '{g2}'
gradient_color_4 = '{g3}'
gradient_color_5 = '{g4}'
gradient_color_6 = '{g5}'
gradient_color_7 = '{g6}'
gradient_color_8 = '{g7}'
horizontal_gradient = 1
horizontal_gradient_color_1 = '{h0}'
horizontal_gradient_color_2 = '{h1}'
horizontal_gradient_color_3 = '{h2}'
horizontal_gradient_color_4 = '{h3}'
horizontal_gradient_color_5 = '{h4}'
horizontal_gradient_color_6 = '{h5}'
horizontal_gradient_color_7 = '{h6}'
horizontal_gradient_color_8 = '{h7}'
[smoothing]
monstercat = 0
waves = 0
noise_reduction = 11
",
g0 = g[0], g1 = g[1], g2 = g[2], g3 = g[3],
g4 = g[4], g5 = g[5], g6 = g[6], g7 = g[7],
h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3],
h4 = h[4], h5 = h[5], h6 = h[6], h7 = h[7],
)
}

146
src/app/input.rs Normal file
View File

@@ -0,0 +1,146 @@
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crate::error::Error;
use super::*;
impl App {
/// Handle terminal events
pub(super) async fn handle_event(&mut self, event: Event) -> Result<(), Error> {
match event {
Event::Key(key) => {
// Only handle key press events, ignore release and repeat
if key.kind == event::KeyEventKind::Press {
self.handle_key(key).await
} else {
Ok(())
}
}
Event::Mouse(mouse) => self.handle_mouse(mouse).await,
Event::Resize(_, _) => {
// Restart cava so it picks up the new terminal dimensions
if self.cava_parser.is_some() {
let state = self.state.read().await;
let td = state.settings_state.current_theme();
let g = td.cava_gradient.clone();
let h = td.cava_horizontal_gradient.clone();
let cs = state.settings_state.cava_size as u32;
drop(state);
self.start_cava(&g, &h, cs);
let mut state = self.state.write().await;
state.cava_screen.clear();
}
Ok(())
}
_ => Ok(()),
}
}
/// Handle keyboard input
pub(super) async fn handle_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
let mut state = self.state.write().await;
// Clear notification on any keypress
state.clear_notification();
// Bypass global keybindings when typing in server text fields or filtering artists
let is_server_text_field =
state.page == Page::Server && state.server_state.selected_field <= 2;
let is_filtering = state.page == Page::Artists && state.artists.filter_active;
if is_server_text_field || is_filtering {
let page = state.page;
drop(state);
return match page {
Page::Server => self.handle_server_key(key).await,
Page::Artists => self.handle_artists_key(key).await,
_ => Ok(()),
};
}
// Global keybindings
match (key.code, key.modifiers) {
// Quit
(KeyCode::Char('q'), KeyModifiers::NONE) => {
state.should_quit = true;
return Ok(());
}
// Page switching
(KeyCode::F(1), _) => {
state.page = Page::Artists;
return Ok(());
}
(KeyCode::F(2), _) => {
state.page = Page::Queue;
return Ok(());
}
(KeyCode::F(3), _) => {
state.page = Page::Playlists;
return Ok(());
}
(KeyCode::F(4), _) => {
state.page = Page::Server;
return Ok(());
}
(KeyCode::F(5), _) => {
state.page = Page::Settings;
return Ok(());
}
// Playback controls (global)
(KeyCode::Char('p'), KeyModifiers::NONE) | (KeyCode::Char(' '), KeyModifiers::NONE) => {
// Toggle pause
drop(state);
return self.toggle_pause().await;
}
(KeyCode::Char('l'), KeyModifiers::NONE) => {
// Next track
drop(state);
return self.next_track().await;
}
(KeyCode::Char('h'), KeyModifiers::NONE) => {
// Previous track
drop(state);
return self.prev_track().await;
}
// Cycle theme (global)
(KeyCode::Char('t'), KeyModifiers::NONE) => {
state.settings_state.next_theme();
state.config.theme = state.settings_state.theme_name().to_string();
let label = state.settings_state.theme_name().to_string();
state.notify(format!("Theme: {}", label));
let _ = state.config.save_default();
let cava_enabled = state.settings_state.cava_enabled;
let td = state.settings_state.current_theme();
let g = td.cava_gradient.clone();
let h = td.cava_horizontal_gradient.clone();
let cs = state.settings_state.cava_size as u32;
drop(state);
if cava_enabled {
self.start_cava(&g, &h, cs);
}
return Ok(());
}
// Ctrl+R to refresh data from server
(KeyCode::Char('r'), KeyModifiers::CONTROL) => {
state.notify("Refreshing...");
drop(state);
self.load_initial_data().await;
let mut state = self.state.write().await;
state.notify("Data refreshed");
return Ok(());
}
_ => {}
}
// Page-specific keybindings
let page = state.page;
drop(state);
match page {
Page::Artists => self.handle_artists_key(key).await,
Page::Queue => self.handle_queue_key(key).await,
Page::Playlists => self.handle_playlists_key(key).await,
Page::Server => self.handle_server_key(key).await,
Page::Settings => self.handle_settings_key(key).await,
}
}
}

328
src/app/input_artists.rs Normal file
View File

@@ -0,0 +1,328 @@
use crossterm::event::{self, KeyCode};
use tracing::{error, info};
use crate::error::Error;
use super::*;
impl App {
/// Handle artists page keys
pub(super) async fn handle_artists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
use crate::ui::pages::artists::{build_tree_items, TreeItem};
let mut state = self.state.write().await;
// Handle filter input mode
if state.artists.filter_active {
match key.code {
KeyCode::Esc => {
state.artists.filter_active = false;
state.artists.filter.clear();
}
KeyCode::Enter => {
state.artists.filter_active = false;
}
KeyCode::Backspace => {
state.artists.filter.pop();
}
KeyCode::Char(c) => {
state.artists.filter.push(c);
}
_ => {}
}
return Ok(());
}
match key.code {
KeyCode::Char('/') => {
state.artists.filter_active = true;
}
KeyCode::Esc => {
state.artists.filter.clear();
state.artists.expanded.clear();
state.artists.selected_index = Some(0);
}
KeyCode::Tab => {
state.artists.focus = (state.artists.focus + 1) % 2;
}
KeyCode::Left => {
state.artists.focus = 0;
}
KeyCode::Right => {
// Move focus to songs (right pane)
if !state.artists.songs.is_empty() {
state.artists.focus = 1;
if state.artists.selected_song.is_none() {
state.artists.selected_song = Some(0);
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
if state.artists.focus == 0 {
// Tree navigation
let tree_items = build_tree_items(&state);
if let Some(sel) = state.artists.selected_index {
if sel > 0 {
state.artists.selected_index = Some(sel - 1);
}
} else if !tree_items.is_empty() {
state.artists.selected_index = Some(0);
}
// Preview album songs in right pane
let album_id = state
.artists
.selected_index
.and_then(|i| tree_items.get(i))
.and_then(|item| match item {
TreeItem::Album { album } => Some(album.id.clone()),
_ => None,
});
if let Some(album_id) = album_id {
drop(state);
if let Some(ref client) = self.subsonic {
if let Ok((_album, songs)) = client.get_album(&album_id).await {
let mut state = self.state.write().await;
state.artists.songs = songs;
state.artists.selected_song = Some(0);
}
}
return Ok(());
}
} else {
// Song list
if let Some(sel) = state.artists.selected_song {
if sel > 0 {
state.artists.selected_song = Some(sel - 1);
}
} else if !state.artists.songs.is_empty() {
state.artists.selected_song = Some(0);
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
if state.artists.focus == 0 {
// Tree navigation
let tree_items = build_tree_items(&state);
let max = tree_items.len().saturating_sub(1);
if let Some(sel) = state.artists.selected_index {
if sel < max {
state.artists.selected_index = Some(sel + 1);
}
} else if !tree_items.is_empty() {
state.artists.selected_index = Some(0);
}
// Preview album songs in right pane
let album_id = state
.artists
.selected_index
.and_then(|i| tree_items.get(i))
.and_then(|item| match item {
TreeItem::Album { album } => Some(album.id.clone()),
_ => None,
});
if let Some(album_id) = album_id {
drop(state);
if let Some(ref client) = self.subsonic {
if let Ok((_album, songs)) = client.get_album(&album_id).await {
let mut state = self.state.write().await;
state.artists.songs = songs;
state.artists.selected_song = Some(0);
}
}
return Ok(());
}
} else {
// Song list
let max = state.artists.songs.len().saturating_sub(1);
if let Some(sel) = state.artists.selected_song {
if sel < max {
state.artists.selected_song = Some(sel + 1);
}
} else if !state.artists.songs.is_empty() {
state.artists.selected_song = Some(0);
}
}
}
KeyCode::Enter => {
if state.artists.focus == 0 {
// Get current tree item
let tree_items = build_tree_items(&state);
if let Some(idx) = state.artists.selected_index {
if let Some(item) = tree_items.get(idx) {
match item {
TreeItem::Artist { artist, expanded } => {
let artist_id = artist.id.clone();
let artist_name = artist.name.clone();
let was_expanded = *expanded;
if was_expanded {
state.artists.expanded.remove(&artist_id);
} else if !state.artists.albums_cache.contains_key(&artist_id) {
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_artist(&artist_id).await {
Ok((_artist, albums)) => {
let mut state = self.state.write().await;
let count = albums.len();
state.artists.albums_cache.insert(artist_id.clone(), albums);
state.artists.expanded.insert(artist_id);
info!("Loaded {} albums for {}", count, artist_name);
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!("Failed to load: {}", e));
}
}
}
return Ok(());
} else {
state.artists.expanded.insert(artist_id);
}
}
TreeItem::Album { album } => {
let album_id = album.id.clone();
let album_name = album.name.clone();
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_album(&album_id).await {
Ok((_album, songs)) => {
if songs.is_empty() {
let mut state = self.state.write().await;
state.notify_error("Album has no songs");
return Ok(());
}
let first_song = songs[0].clone();
let stream_url = client.get_stream_url(&first_song.id);
let mut state = self.state.write().await;
let count = songs.len();
state.queue.clear();
state.queue.extend(songs.clone());
state.queue_position = Some(0);
state.artists.songs = songs;
state.artists.selected_song = Some(0);
state.artists.focus = 1;
state.now_playing.song = Some(first_song.clone());
state.now_playing.state = PlaybackState::Playing;
state.now_playing.position = 0.0;
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
drop(state);
match stream_url {
Ok(url) => {
if self.mpv.is_paused().unwrap_or(false) {
let _ = self.mpv.resume();
}
if let Err(e) = self.mpv.loadfile(&url) {
error!("Failed to play: {}", e);
}
}
Err(e) => {
error!("Failed to get stream URL: {}", e);
}
}
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!("Failed to load album: {}", e));
}
}
}
return Ok(());
}
}
}
}
} else {
// Play selected song from current position
if let Some(idx) = state.artists.selected_song {
if idx < state.artists.songs.len() {
let song = state.artists.songs[idx].clone();
let songs = state.artists.songs.clone();
state.queue.clear();
state.queue.extend(songs);
state.queue_position = Some(idx);
state.now_playing.song = Some(song.clone());
state.now_playing.state = PlaybackState::Playing;
state.now_playing.position = 0.0;
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
state.notify(format!("Playing: {}", song.title));
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_stream_url(&song.id) {
Ok(url) => {
if self.mpv.is_paused().unwrap_or(false) {
let _ = self.mpv.resume();
}
if let Err(e) = self.mpv.loadfile(&url) {
error!("Failed to play: {}", e);
}
}
Err(e) => {
error!("Failed to get stream URL: {}", e);
}
}
}
return Ok(());
}
}
}
}
KeyCode::Backspace => {
if state.artists.focus == 1 {
state.artists.focus = 0;
}
}
KeyCode::Char('e') => {
if state.artists.focus == 1 {
if let Some(idx) = state.artists.selected_song {
if let Some(song) = state.artists.songs.get(idx).cloned() {
let title = song.title.clone();
state.queue.push(song);
state.notify(format!("Added to queue: {}", title));
}
}
} else if !state.artists.songs.is_empty() {
let count = state.artists.songs.len();
let songs = state.artists.songs.clone();
state.queue.extend(songs);
state.notify(format!("Added {} songs to queue", count));
}
}
KeyCode::Char('n') => {
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
if state.artists.focus == 1 {
if let Some(idx) = state.artists.selected_song {
if let Some(song) = state.artists.songs.get(idx).cloned() {
let title = song.title.clone();
state.queue.insert(insert_pos, song);
state.notify(format!("Playing next: {}", title));
}
}
} else if !state.artists.songs.is_empty() {
let count = state.artists.songs.len();
let songs: Vec<_> = state.artists.songs.to_vec();
for (i, song) in songs.into_iter().enumerate() {
state.queue.insert(insert_pos + i, song);
}
state.notify(format!("Playing {} songs next", count));
}
}
_ => {}
}
Ok(())
}
}

167
src/app/input_playlists.rs Normal file
View File

@@ -0,0 +1,167 @@
use crossterm::event::{self, KeyCode};
use crate::error::Error;
use super::*;
impl App {
/// Handle playlists page keys
pub(super) async fn handle_playlists_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
let mut state = self.state.write().await;
match key.code {
KeyCode::Tab => {
state.playlists.focus = (state.playlists.focus + 1) % 2;
}
KeyCode::Left => {
state.playlists.focus = 0;
}
KeyCode::Right => {
if !state.playlists.songs.is_empty() {
state.playlists.focus = 1;
if state.playlists.selected_song.is_none() {
state.playlists.selected_song = Some(0);
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
if state.playlists.focus == 0 {
// Playlist list
if let Some(sel) = state.playlists.selected_playlist {
if sel > 0 {
state.playlists.selected_playlist = Some(sel - 1);
}
} else if !state.playlists.playlists.is_empty() {
state.playlists.selected_playlist = Some(0);
}
} else {
// Song list
if let Some(sel) = state.playlists.selected_song {
if sel > 0 {
state.playlists.selected_song = Some(sel - 1);
}
} else if !state.playlists.songs.is_empty() {
state.playlists.selected_song = Some(0);
}
}
}
KeyCode::Down | KeyCode::Char('j') => {
if state.playlists.focus == 0 {
let max = state.playlists.playlists.len().saturating_sub(1);
if let Some(sel) = state.playlists.selected_playlist {
if sel < max {
state.playlists.selected_playlist = Some(sel + 1);
}
} else if !state.playlists.playlists.is_empty() {
state.playlists.selected_playlist = Some(0);
}
} else {
let max = state.playlists.songs.len().saturating_sub(1);
if let Some(sel) = state.playlists.selected_song {
if sel < max {
state.playlists.selected_song = Some(sel + 1);
}
} else if !state.playlists.songs.is_empty() {
state.playlists.selected_song = Some(0);
}
}
}
KeyCode::Enter => {
if state.playlists.focus == 0 {
// Load playlist songs
if let Some(idx) = state.playlists.selected_playlist {
if let Some(playlist) = state.playlists.playlists.get(idx) {
let playlist_id = playlist.id.clone();
let playlist_name = playlist.name.clone();
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_playlist(&playlist_id).await {
Ok((_playlist, songs)) => {
let mut state = self.state.write().await;
let count = songs.len();
state.playlists.songs = songs;
state.playlists.selected_song =
if count > 0 { Some(0) } else { None };
state.playlists.focus = 1;
state.notify(format!(
"Loaded playlist: {} ({} songs)",
playlist_name, count
));
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!(
"Failed to load playlist: {}",
e
));
}
}
}
return Ok(());
}
}
} else {
// Play selected song from playlist
if let Some(idx) = state.playlists.selected_song {
if idx < state.playlists.songs.len() {
let songs = state.playlists.songs.clone();
state.queue.clear();
state.queue.extend(songs);
drop(state);
return self.play_queue_position(idx).await;
}
}
}
}
KeyCode::Char('e') => {
// Add to queue
if state.playlists.focus == 1 {
if let Some(idx) = state.playlists.selected_song {
if let Some(song) = state.playlists.songs.get(idx).cloned() {
let title = song.title.clone();
state.queue.push(song);
state.notify(format!("Added to queue: {}", title));
}
}
} else {
// Add whole playlist
if !state.playlists.songs.is_empty() {
let count = state.playlists.songs.len();
let songs = state.playlists.songs.clone();
state.queue.extend(songs);
state.notify(format!("Added {} songs to queue", count));
}
}
}
KeyCode::Char('n') => {
// Add next
let insert_pos = state.queue_position.map(|p| p + 1).unwrap_or(0);
if state.playlists.focus == 1 {
if let Some(idx) = state.playlists.selected_song {
if let Some(song) = state.playlists.songs.get(idx).cloned() {
let title = song.title.clone();
state.queue.insert(insert_pos, song);
state.notify(format!("Playing next: {}", title));
}
}
}
}
KeyCode::Char('r') => {
// Shuffle play playlist
use rand::seq::SliceRandom;
if !state.playlists.songs.is_empty() {
let mut songs = state.playlists.songs.clone();
songs.shuffle(&mut rand::thread_rng());
state.queue.clear();
state.queue.extend(songs);
drop(state);
return self.play_queue_position(0).await;
}
}
_ => {}
}
Ok(())
}
}

144
src/app/input_queue.rs Normal file
View File

@@ -0,0 +1,144 @@
use crossterm::event::{self, KeyCode};
use crate::error::Error;
use super::*;
impl App {
/// Handle queue page keys
pub(super) async fn handle_queue_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
let mut state = self.state.write().await;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if let Some(sel) = state.queue_state.selected {
if sel > 0 {
state.queue_state.selected = Some(sel - 1);
}
} else if !state.queue.is_empty() {
state.queue_state.selected = Some(0);
}
}
KeyCode::Down | KeyCode::Char('j') => {
let max = state.queue.len().saturating_sub(1);
if let Some(sel) = state.queue_state.selected {
if sel < max {
state.queue_state.selected = Some(sel + 1);
}
} else if !state.queue.is_empty() {
state.queue_state.selected = Some(0);
}
}
KeyCode::Enter => {
// Play selected song
if let Some(idx) = state.queue_state.selected {
if idx < state.queue.len() {
drop(state);
return self.play_queue_position(idx).await;
}
}
}
KeyCode::Char('d') => {
// Remove selected song
if let Some(idx) = state.queue_state.selected {
if idx < state.queue.len() {
let song = state.queue.remove(idx);
state.notify(format!("Removed: {}", song.title));
// Adjust selection
if state.queue.is_empty() {
state.queue_state.selected = None;
} else if idx >= state.queue.len() {
state.queue_state.selected = Some(state.queue.len() - 1);
}
// Adjust queue position
if let Some(pos) = state.queue_position {
if idx < pos {
state.queue_position = Some(pos - 1);
} else if idx == pos {
state.queue_position = None;
}
}
}
}
}
KeyCode::Char('J') => {
// Move down
if let Some(idx) = state.queue_state.selected {
if idx < state.queue.len() - 1 {
state.queue.swap(idx, idx + 1);
state.queue_state.selected = Some(idx + 1);
// Adjust queue position if needed
if let Some(pos) = state.queue_position {
if pos == idx {
state.queue_position = Some(idx + 1);
} else if pos == idx + 1 {
state.queue_position = Some(idx);
}
}
}
}
}
KeyCode::Char('K') => {
// Move up
if let Some(idx) = state.queue_state.selected {
if idx > 0 {
state.queue.swap(idx, idx - 1);
state.queue_state.selected = Some(idx - 1);
// Adjust queue position if needed
if let Some(pos) = state.queue_position {
if pos == idx {
state.queue_position = Some(idx - 1);
} else if pos == idx - 1 {
state.queue_position = Some(idx);
}
}
}
}
}
KeyCode::Char('r') => {
// Shuffle queue
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
if let Some(pos) = state.queue_position {
// Keep current song in place, shuffle the rest
if pos < state.queue.len() {
let current = state.queue.remove(pos);
state.queue.shuffle(&mut rng);
state.queue.insert(0, current);
state.queue_position = Some(0);
}
} else {
state.queue.shuffle(&mut rng);
}
state.notify("Queue shuffled");
}
KeyCode::Char('c') => {
// Clear history (remove all songs before current position)
if let Some(pos) = state.queue_position {
if pos > 0 {
let removed = pos;
state.queue.drain(0..pos);
state.queue_position = Some(0);
// Adjust selection
if let Some(sel) = state.queue_state.selected {
if sel < pos {
state.queue_state.selected = Some(0);
} else {
state.queue_state.selected = Some(sel - pos);
}
}
state.notify(format!("Cleared {} played songs", removed));
} else {
state.notify("No history to clear");
}
} else {
state.notify("No history to clear");
}
}
_ => {}
}
Ok(())
}
}

136
src/app/input_server.rs Normal file
View File

@@ -0,0 +1,136 @@
use crossterm::event::{self, KeyCode};
use tracing::info;
use crate::error::Error;
use crate::subsonic::SubsonicClient;
use super::*;
impl App {
/// Handle server page keys
pub(super) async fn handle_server_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
let mut state = self.state.write().await;
let field = state.server_state.selected_field;
let is_text_field = field <= 2;
match key.code {
// Navigation - always works
KeyCode::Up => {
if field > 0 {
state.server_state.selected_field -= 1;
}
}
KeyCode::Down => {
if field < 4 {
state.server_state.selected_field += 1;
}
}
KeyCode::Tab => {
// Tab moves to next field, wrapping around
state.server_state.selected_field = (field + 1) % 5;
}
// Text input for text fields (0=URL, 1=Username, 2=Password)
KeyCode::Char(c) if is_text_field => match field {
0 => state.server_state.base_url.push(c),
1 => state.server_state.username.push(c),
2 => state.server_state.password.push(c),
_ => {}
},
KeyCode::Backspace if is_text_field => match field {
0 => {
state.server_state.base_url.pop();
}
1 => {
state.server_state.username.pop();
}
2 => {
state.server_state.password.pop();
}
_ => {}
},
// Enter activates buttons, ignored on text fields
KeyCode::Enter => {
match field {
3 => {
// Test connection
let url = state.server_state.base_url.clone();
let user = state.server_state.username.clone();
let pass = state.server_state.password.clone();
state.server_state.status = Some("Testing connection...".to_string());
drop(state);
match SubsonicClient::new(&url, &user, &pass) {
Ok(client) => match client.ping().await {
Ok(_) => {
let mut state = self.state.write().await;
state.server_state.status =
Some("Connection successful!".to_string());
}
Err(e) => {
let mut state = self.state.write().await;
state.server_state.status =
Some(format!("Connection failed: {}", e));
}
},
Err(e) => {
let mut state = self.state.write().await;
state.server_state.status = Some(format!("Invalid URL: {}", e));
}
}
return Ok(());
}
4 => {
// Save config and reconnect
info!(
"Saving config: url='{}', user='{}'",
state.server_state.base_url, state.server_state.username
);
state.config.base_url = state.server_state.base_url.clone();
state.config.username = state.server_state.username.clone();
state.config.password = state.server_state.password.clone();
let url = state.config.base_url.clone();
let user = state.config.username.clone();
let pass = state.config.password.clone();
match state.config.save_default() {
Ok(_) => {
info!("Config saved successfully");
state.server_state.status =
Some("Saved! Connecting...".to_string());
}
Err(e) => {
info!("Config save failed: {}", e);
state.server_state.status = Some(format!("Save failed: {}", e));
return Ok(());
}
}
drop(state);
// Create new client and load data
match SubsonicClient::new(&url, &user, &pass) {
Ok(client) => {
self.subsonic = Some(client);
self.load_initial_data().await;
let mut state = self.state.write().await;
state.server_state.status =
Some("Connected and loaded data!".to_string());
}
Err(e) => {
let mut state = self.state.write().await;
state.server_state.status =
Some(format!("Saved but connection failed: {}", e));
}
}
return Ok(());
}
_ => {} // Ignore Enter on text fields (handles paste with newlines)
}
}
_ => {}
}
Ok(())
}
}

127
src/app/input_settings.rs Normal file
View File

@@ -0,0 +1,127 @@
use crossterm::event::{self, KeyCode};
use crate::error::Error;
use super::*;
impl App {
/// Handle settings page keys
pub(super) async fn handle_settings_key(&mut self, key: event::KeyEvent) -> Result<(), Error> {
let mut config_changed = false;
{
let mut state = self.state.write().await;
let field = state.settings_state.selected_field;
match key.code {
// Navigate between fields
KeyCode::Up | KeyCode::Char('k') => {
if field > 0 {
state.settings_state.selected_field = field - 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if field < 2 {
state.settings_state.selected_field = field + 1;
}
}
// Left
KeyCode::Left | KeyCode::Char('h') => match field {
0 => {
state.settings_state.prev_theme();
state.config.theme = state.settings_state.theme_name().to_string();
let label = state.settings_state.theme_name().to_string();
state.notify(format!("Theme: {}", label));
config_changed = true;
}
1 if state.cava_available => {
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
state.config.cava = state.settings_state.cava_enabled;
let status = if state.settings_state.cava_enabled {
"On"
} else {
"Off"
};
state.notify(format!("Cava: {}", status));
config_changed = true;
}
2 if state.cava_available => {
let cur = state.settings_state.cava_size;
if cur > 10 {
let new_size = cur - 5;
state.settings_state.cava_size = new_size;
state.config.cava_size = new_size;
state.notify(format!("Cava Size: {}%", new_size));
config_changed = true;
}
}
_ => {}
},
// Right / Enter / Space
KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter | KeyCode::Char(' ') => {
match field {
0 => {
state.settings_state.next_theme();
state.config.theme = state.settings_state.theme_name().to_string();
let label = state.settings_state.theme_name().to_string();
state.notify(format!("Theme: {}", label));
config_changed = true;
}
1 if state.cava_available => {
state.settings_state.cava_enabled = !state.settings_state.cava_enabled;
state.config.cava = state.settings_state.cava_enabled;
let status = if state.settings_state.cava_enabled {
"On"
} else {
"Off"
};
state.notify(format!("Cava: {}", status));
config_changed = true;
}
2 if state.cava_available => {
let cur = state.settings_state.cava_size;
if cur < 80 {
let new_size = cur + 5;
state.settings_state.cava_size = new_size;
state.config.cava_size = new_size;
state.notify(format!("Cava Size: {}%", new_size));
config_changed = true;
}
}
_ => {}
}
}
_ => {}
}
}
if config_changed {
// Save config
let state = self.state.read().await;
if let Err(e) = state.config.save_default() {
drop(state);
let mut state = self.state.write().await;
state.notify_error(format!("Failed to save: {}", e));
} else {
// Start/stop cava based on new setting, or restart on theme change
let cava_enabled = state.settings_state.cava_enabled;
let td = state.settings_state.current_theme();
let g = td.cava_gradient.clone();
let h = td.cava_horizontal_gradient.clone();
let cs = state.settings_state.cava_size as u32;
let cava_running = self.cava_parser.is_some();
drop(state);
if cava_enabled {
// (Re)start cava — picks up new theme colors or toggle-on
self.start_cava(&g, &h, cs);
} else if cava_running {
self.stop_cava();
let mut state = self.state.write().await;
state.cava_screen.clear();
}
}
}
Ok(())
}
}

File diff suppressed because it is too large Load Diff

246
src/app/mouse.rs Normal file
View File

@@ -0,0 +1,246 @@
use crossterm::event::{self, MouseButton, MouseEventKind};
use crate::error::Error;
use super::*;
impl App {
/// Handle mouse input
pub(super) async fn handle_mouse(&mut self, mouse: event::MouseEvent) -> Result<(), Error> {
let x = mouse.column;
let y = mouse.row;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.handle_mouse_click(x, y).await
}
MouseEventKind::ScrollUp => {
self.handle_mouse_scroll_up().await
}
MouseEventKind::ScrollDown => {
self.handle_mouse_scroll_down().await
}
_ => Ok(()),
}
}
/// Handle left mouse click
async fn handle_mouse_click(&mut self, x: u16, y: u16) -> Result<(), Error> {
use crate::ui::header::{Header, HeaderRegion};
let state = self.state.read().await;
let layout = state.layout.clone();
let page = state.page;
let duration = state.now_playing.duration;
drop(state);
// Check header area
if y >= layout.header.y && y < layout.header.y + layout.header.height {
if let Some(region) = Header::region_at(layout.header, x, y) {
match region {
HeaderRegion::Tab(tab_page) => {
let mut state = self.state.write().await;
state.page = tab_page;
}
HeaderRegion::PrevButton => {
return self.prev_track().await;
}
HeaderRegion::PlayButton => {
return self.toggle_pause().await;
}
HeaderRegion::PauseButton => {
return self.toggle_pause().await;
}
HeaderRegion::StopButton => {
return self.stop_playback().await;
}
HeaderRegion::NextButton => {
return self.next_track().await;
}
}
}
return Ok(());
}
// Check now playing area (progress bar seeking)
if y >= layout.now_playing.y && y < layout.now_playing.y + layout.now_playing.height {
let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2;
if y == inner_bottom && duration > 0.0 {
let inner_x_start = layout.now_playing.x + 1;
let inner_width = layout.now_playing.width.saturating_sub(2);
if inner_width > 15 && x >= inner_x_start {
let rel_x = x - inner_x_start;
let time_width = 15u16;
let bar_width = inner_width.saturating_sub(time_width + 2);
let bar_start = (inner_width.saturating_sub(time_width + 2 + bar_width)) / 2 + time_width + 2;
if bar_width > 0 && rel_x >= bar_start && rel_x < bar_start + bar_width {
let fraction = (rel_x - bar_start) as f64 / bar_width as f64;
let seek_pos = fraction * duration;
let _ = self.mpv.seek(seek_pos);
let mut state = self.state.write().await;
state.now_playing.position = seek_pos;
}
}
}
return Ok(());
}
// Check content area
if y >= layout.content.y && y < layout.content.y + layout.content.height {
return self.handle_content_click(x, y, page, &layout).await;
}
Ok(())
}
/// Handle click within the content area
async fn handle_content_click(
&mut self,
x: u16,
y: u16,
page: Page,
layout: &LayoutAreas,
) -> Result<(), Error> {
match page {
Page::Artists => self.handle_artists_click(x, y, layout).await,
Page::Queue => self.handle_queue_click(y, layout).await,
Page::Playlists => self.handle_playlists_click(x, y, layout).await,
_ => Ok(()),
}
}
/// Handle click on queue page
async fn handle_queue_click(&mut self, y: u16, layout: &LayoutAreas) -> Result<(), Error> {
let mut state = self.state.write().await;
let content = layout.content;
// Account for border (1 row top)
let row_in_viewport = y.saturating_sub(content.y + 1) as usize;
let item_index = state.queue_state.scroll_offset + row_in_viewport;
if item_index < state.queue.len() {
let was_selected = state.queue_state.selected == Some(item_index);
state.queue_state.selected = Some(item_index);
let is_second_click = was_selected
&& self.last_click.is_some_and(|(_, ly, t)| {
ly == y && t.elapsed().as_millis() < 500
});
if is_second_click {
drop(state);
self.last_click = Some((0, y, std::time::Instant::now()));
return self.play_queue_position(item_index).await;
}
}
self.last_click = Some((0, y, std::time::Instant::now()));
Ok(())
}
/// Handle mouse scroll up (move selection up in current list)
async fn handle_mouse_scroll_up(&mut self) -> Result<(), Error> {
let mut state = self.state.write().await;
match state.page {
Page::Artists => {
if state.artists.focus == 0 {
if let Some(sel) = state.artists.selected_index {
if sel > 0 {
state.artists.selected_index = Some(sel - 1);
}
}
} else if let Some(sel) = state.artists.selected_song {
if sel > 0 {
state.artists.selected_song = Some(sel - 1);
}
}
}
Page::Queue => {
if let Some(sel) = state.queue_state.selected {
if sel > 0 {
state.queue_state.selected = Some(sel - 1);
}
} else if !state.queue.is_empty() {
state.queue_state.selected = Some(0);
}
}
Page::Playlists => {
if state.playlists.focus == 0 {
if let Some(sel) = state.playlists.selected_playlist {
if sel > 0 {
state.playlists.selected_playlist = Some(sel - 1);
}
}
} else if let Some(sel) = state.playlists.selected_song {
if sel > 0 {
state.playlists.selected_song = Some(sel - 1);
}
}
}
_ => {}
}
Ok(())
}
/// Handle mouse scroll down (move selection down in current list)
async fn handle_mouse_scroll_down(&mut self) -> Result<(), Error> {
let mut state = self.state.write().await;
match state.page {
Page::Artists => {
if state.artists.focus == 0 {
let tree_items = crate::ui::pages::artists::build_tree_items(&state);
let max = tree_items.len().saturating_sub(1);
if let Some(sel) = state.artists.selected_index {
if sel < max {
state.artists.selected_index = Some(sel + 1);
}
} else if !tree_items.is_empty() {
state.artists.selected_index = Some(0);
}
} else {
let max = state.artists.songs.len().saturating_sub(1);
if let Some(sel) = state.artists.selected_song {
if sel < max {
state.artists.selected_song = Some(sel + 1);
}
} else if !state.artists.songs.is_empty() {
state.artists.selected_song = Some(0);
}
}
}
Page::Queue => {
let max = state.queue.len().saturating_sub(1);
if let Some(sel) = state.queue_state.selected {
if sel < max {
state.queue_state.selected = Some(sel + 1);
}
} else if !state.queue.is_empty() {
state.queue_state.selected = Some(0);
}
}
Page::Playlists => {
if state.playlists.focus == 0 {
let max = state.playlists.playlists.len().saturating_sub(1);
if let Some(sel) = state.playlists.selected_playlist {
if sel < max {
state.playlists.selected_playlist = Some(sel + 1);
}
} else if !state.playlists.playlists.is_empty() {
state.playlists.selected_playlist = Some(0);
}
} else {
let max = state.playlists.songs.len().saturating_sub(1);
if let Some(sel) = state.playlists.selected_song {
if sel < max {
state.playlists.selected_song = Some(sel + 1);
}
} else if !state.playlists.songs.is_empty() {
state.playlists.selected_song = Some(0);
}
}
}
_ => {}
}
Ok(())
}
}

196
src/app/mouse_artists.rs Normal file
View File

@@ -0,0 +1,196 @@
use tracing::error;
use crate::error::Error;
use super::*;
impl App {
/// Handle click on artists page
pub(super) async fn handle_artists_click(
&mut self,
x: u16,
y: u16,
layout: &LayoutAreas,
) -> Result<(), Error> {
use crate::ui::pages::artists::{build_tree_items, TreeItem};
let mut state = self.state.write().await;
let left = layout.content_left.unwrap_or(layout.content);
let right = layout.content_right.unwrap_or(layout.content);
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
// Tree pane click — account for border (1 row top)
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
let item_index = state.artists.tree_scroll_offset + row_in_viewport;
let tree_items = build_tree_items(&state);
if item_index < tree_items.len() {
let was_selected = state.artists.selected_index == Some(item_index);
state.artists.focus = 0;
state.artists.selected_index = Some(item_index);
// Second click = activate (same as Enter)
let is_second_click = was_selected
&& self.last_click.is_some_and(|(lx, ly, t)| {
lx == x && ly == y && t.elapsed().as_millis() < 500
});
if is_second_click {
// Activate: expand/collapse artist, or play album
match &tree_items[item_index] {
TreeItem::Artist { artist, expanded } => {
let artist_id = artist.id.clone();
let artist_name = artist.name.clone();
let was_expanded = *expanded;
if was_expanded {
state.artists.expanded.remove(&artist_id);
} else if !state.artists.albums_cache.contains_key(&artist_id) {
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_artist(&artist_id).await {
Ok((_artist, albums)) => {
let mut state = self.state.write().await;
let count = albums.len();
state.artists.albums_cache.insert(artist_id.clone(), albums);
state.artists.expanded.insert(artist_id);
tracing::info!("Loaded {} albums for {}", count, artist_name);
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!("Failed to load: {}", e));
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
} else {
state.artists.expanded.insert(artist_id);
}
}
TreeItem::Album { album } => {
let album_id = album.id.clone();
let album_name = album.name.clone();
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_album(&album_id).await {
Ok((_album, songs)) => {
if songs.is_empty() {
let mut state = self.state.write().await;
state.notify_error("Album has no songs");
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
}
let first_song = songs[0].clone();
let stream_url = client.get_stream_url(&first_song.id);
let mut state = self.state.write().await;
let count = songs.len();
state.queue.clear();
state.queue.extend(songs.clone());
state.queue_position = Some(0);
state.artists.songs = songs;
state.artists.selected_song = Some(0);
state.artists.focus = 1;
state.now_playing.song = Some(first_song.clone());
state.now_playing.state = PlaybackState::Playing;
state.now_playing.position = 0.0;
state.now_playing.duration = first_song.duration.unwrap_or(0) as f64;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
state.notify(format!("Playing album: {} ({} songs)", album_name, count));
drop(state);
if let Ok(url) = stream_url {
if self.mpv.is_paused().unwrap_or(false) {
let _ = self.mpv.resume();
}
if let Err(e) = self.mpv.loadfile(&url) {
error!("Failed to play: {}", e);
}
}
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!("Failed to load album: {}", e));
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
}
}
} else {
// First click on album: preview songs in right pane
if let TreeItem::Album { album } = &tree_items[item_index] {
let album_id = album.id.clone();
drop(state);
if let Some(ref client) = self.subsonic {
if let Ok((_album, songs)) = client.get_album(&album_id).await {
let mut state = self.state.write().await;
state.artists.songs = songs;
state.artists.selected_song = Some(0);
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
}
}
}
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
// Songs pane click
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
let item_index = state.artists.song_scroll_offset + row_in_viewport;
if item_index < state.artists.songs.len() {
let was_selected = state.artists.selected_song == Some(item_index);
state.artists.focus = 1;
state.artists.selected_song = Some(item_index);
let is_second_click = was_selected
&& self.last_click.is_some_and(|(lx, ly, t)| {
lx == x && ly == y && t.elapsed().as_millis() < 500
});
if is_second_click {
// Play selected song
let song = state.artists.songs[item_index].clone();
let songs = state.artists.songs.clone();
state.queue.clear();
state.queue.extend(songs);
state.queue_position = Some(item_index);
state.now_playing.song = Some(song.clone());
state.now_playing.state = PlaybackState::Playing;
state.now_playing.position = 0.0;
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
state.notify(format!("Playing: {}", song.title));
drop(state);
if let Some(ref client) = self.subsonic {
if let Ok(url) = client.get_stream_url(&song.id) {
if self.mpv.is_paused().unwrap_or(false) {
let _ = self.mpv.resume();
}
if let Err(e) = self.mpv.loadfile(&url) {
error!("Failed to play: {}", e);
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
Ok(())
}
}

View File

@@ -0,0 +1,89 @@
use crate::error::Error;
use super::*;
impl App {
/// Handle click on playlists page
pub(super) async fn handle_playlists_click(
&mut self,
x: u16,
y: u16,
layout: &LayoutAreas,
) -> Result<(), Error> {
let mut state = self.state.write().await;
let left = layout.content_left.unwrap_or(layout.content);
let right = layout.content_right.unwrap_or(layout.content);
if x >= left.x && x < left.x + left.width && y >= left.y && y < left.y + left.height {
// Playlists pane
let row_in_viewport = y.saturating_sub(left.y + 1) as usize;
let item_index = state.playlists.playlist_scroll_offset + row_in_viewport;
if item_index < state.playlists.playlists.len() {
let was_selected = state.playlists.selected_playlist == Some(item_index);
state.playlists.focus = 0;
state.playlists.selected_playlist = Some(item_index);
let is_second_click = was_selected
&& self.last_click.is_some_and(|(lx, ly, t)| {
lx == x && ly == y && t.elapsed().as_millis() < 500
});
if is_second_click {
// Load playlist songs (same as Enter)
let playlist = state.playlists.playlists[item_index].clone();
let playlist_id = playlist.id.clone();
let playlist_name = playlist.name.clone();
drop(state);
if let Some(ref client) = self.subsonic {
match client.get_playlist(&playlist_id).await {
Ok((_playlist, songs)) => {
let mut state = self.state.write().await;
let count = songs.len();
state.playlists.songs = songs;
state.playlists.selected_song = if count > 0 { Some(0) } else { None };
state.playlists.focus = 1;
state.notify(format!("Loaded playlist: {} ({} songs)", playlist_name, count));
}
Err(e) => {
let mut state = self.state.write().await;
state.notify_error(format!("Failed to load playlist: {}", e));
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
return Ok(());
}
}
} else if x >= right.x && x < right.x + right.width && y >= right.y && y < right.y + right.height {
// Songs pane
let row_in_viewport = y.saturating_sub(right.y + 1) as usize;
let item_index = state.playlists.song_scroll_offset + row_in_viewport;
if item_index < state.playlists.songs.len() {
let was_selected = state.playlists.selected_song == Some(item_index);
state.playlists.focus = 1;
state.playlists.selected_song = Some(item_index);
let is_second_click = was_selected
&& self.last_click.is_some_and(|(lx, ly, t)| {
lx == x && ly == y && t.elapsed().as_millis() < 500
});
if is_second_click {
// Play selected song from playlist
let songs = state.playlists.songs.clone();
state.queue.clear();
state.queue.extend(songs);
drop(state);
self.last_click = Some((x, y, std::time::Instant::now()));
return self.play_queue_position(item_index).await;
}
}
}
self.last_click = Some((x, y, std::time::Instant::now()));
Ok(())
}
}

411
src/app/playback.rs Normal file
View File

@@ -0,0 +1,411 @@
use tracing::{debug, error, info, warn};
use super::*;
impl App {
/// Update playback position and audio info from MPV
pub(super) async fn update_playback_info(&mut self) {
// Only update if something should be playing
let state = self.state.read().await;
let is_playing = state.now_playing.state == PlaybackState::Playing;
let is_active = is_playing || state.now_playing.state == PlaybackState::Paused;
drop(state);
if !is_active || !self.mpv.is_running() {
return;
}
// Check for track advancement
if is_playing {
// Early transition: if near end of track and no preloaded next track,
// advance immediately instead of waiting for idle detection
{
let state = self.state.read().await;
let time_remaining = state.now_playing.duration - state.now_playing.position;
let has_next = state
.queue_position
.map(|p| p + 1 < state.queue.len())
.unwrap_or(false);
drop(state);
if has_next && time_remaining > 0.0 && time_remaining < 2.0 {
if let Ok(count) = self.mpv.get_playlist_count() {
if count < 2 {
info!("Near end of track with no preloaded next — advancing early");
let _ = self.next_track().await;
return;
}
}
}
}
// Re-preload if the appended track was lost
if let Ok(count) = self.mpv.get_playlist_count() {
if count == 1 {
let state = self.state.read().await;
if let Some(pos) = state.queue_position {
if pos + 1 < state.queue.len() {
drop(state);
debug!("Playlist count is 1, re-preloading next track");
self.preload_next_track(pos).await;
}
}
}
}
// Check if MPV advanced to next track in playlist (gapless transition)
if let Ok(Some(mpv_pos)) = self.mpv.get_playlist_pos() {
if mpv_pos == 1 {
// Gapless advance happened - update our state to match
let state = self.state.read().await;
if let Some(current_pos) = state.queue_position {
let next_pos = current_pos + 1;
if next_pos < state.queue.len() {
drop(state);
info!("Gapless advancement to track {}", next_pos);
// Update state - keep audio properties since they'll be similar
// for gapless transitions (same album, same format)
let mut state = self.state.write().await;
state.queue_position = Some(next_pos);
if let Some(song) = state.queue.get(next_pos).cloned() {
state.now_playing.song = Some(song.clone());
state.now_playing.position = 0.0;
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
// Don't reset audio properties - let them update naturally
// This avoids triggering PipeWire rate changes unnecessarily
}
drop(state);
// Remove the finished track (index 0) from MPV's playlist
// This is less disruptive than playlist_clear during playback
let _ = self.mpv.playlist_remove(0);
// Preload the next track for continued gapless playback
self.preload_next_track(next_pos).await;
return;
}
}
drop(state);
}
}
// Check if MPV went idle (track ended, no preloaded track)
if let Ok(idle) = self.mpv.is_idle() {
if idle {
info!("Track ended, advancing to next");
let _ = self.next_track().await;
return;
}
}
}
// Get position from MPV
if let Ok(position) = self.mpv.get_time_pos() {
let mut state = self.state.write().await;
state.now_playing.position = position;
}
// Get duration if not set
{
let state = self.state.read().await;
if state.now_playing.duration <= 0.0 {
drop(state);
if let Ok(duration) = self.mpv.get_duration() {
if duration > 0.0 {
let mut state = self.state.write().await;
state.now_playing.duration = duration;
}
}
}
}
// Get audio properties - keep polling until we get valid values
// MPV may not have them ready immediately when playback starts
{
let state = self.state.read().await;
let need_sample_rate = state.now_playing.sample_rate.is_none();
drop(state);
if need_sample_rate {
// Try to get audio properties from MPV
let sample_rate = self.mpv.get_sample_rate().ok().flatten();
let bit_depth = self.mpv.get_bit_depth().ok().flatten();
let format = self.mpv.get_audio_format().ok().flatten();
let channels = self.mpv.get_channels().ok().flatten();
// Only update if we got a valid sample rate (indicates audio is ready)
if let Some(rate) = sample_rate {
// Only switch PipeWire sample rate if it's actually different
// This avoids unnecessary rate switches during gapless playback
// of albums with the same sample rate
let current_pw_rate = self.pipewire.get_current_rate();
if current_pw_rate != Some(rate) {
info!("Sample rate change: {:?} -> {} Hz", current_pw_rate, rate);
if let Err(e) = self.pipewire.set_rate(rate) {
warn!("Failed to set PipeWire sample rate: {}", e);
}
} else {
debug!(
"Sample rate unchanged at {} Hz, skipping PipeWire switch",
rate
);
}
let mut state = self.state.write().await;
state.now_playing.sample_rate = Some(rate);
state.now_playing.bit_depth = bit_depth;
state.now_playing.format = format;
state.now_playing.channels = channels;
}
}
}
// Update MPRIS properties to keep external clients in sync
if let Some(ref server) = self.mpris_server {
if let Err(e) = update_mpris_properties(server, &self.state).await {
debug!("Failed to update MPRIS properties: {}", e);
}
}
}
/// Toggle play/pause
pub(super) async fn toggle_pause(&mut self) -> Result<(), Error> {
let state = self.state.read().await;
let is_playing = state.now_playing.state == PlaybackState::Playing;
let is_paused = state.now_playing.state == PlaybackState::Paused;
drop(state);
if !is_playing && !is_paused {
return Ok(());
}
match self.mpv.toggle_pause() {
Ok(now_paused) => {
let mut state = self.state.write().await;
if now_paused {
state.now_playing.state = PlaybackState::Paused;
debug!("Paused playback");
} else {
state.now_playing.state = PlaybackState::Playing;
debug!("Resumed playback");
}
}
Err(e) => {
error!("Failed to toggle pause: {}", e);
}
}
Ok(())
}
/// Pause playback (only if currently playing)
pub(super) async fn pause_playback(&mut self) -> Result<(), Error> {
let state = self.state.read().await;
if state.now_playing.state != PlaybackState::Playing {
return Ok(());
}
drop(state);
match self.mpv.pause() {
Ok(()) => {
let mut state = self.state.write().await;
state.now_playing.state = PlaybackState::Paused;
debug!("Paused playback");
}
Err(e) => {
error!("Failed to pause: {}", e);
}
}
Ok(())
}
/// Resume playback (only if currently paused)
pub(super) async fn resume_playback(&mut self) -> Result<(), Error> {
let state = self.state.read().await;
if state.now_playing.state != PlaybackState::Paused {
return Ok(());
}
drop(state);
match self.mpv.resume() {
Ok(()) => {
let mut state = self.state.write().await;
state.now_playing.state = PlaybackState::Playing;
debug!("Resumed playback");
}
Err(e) => {
error!("Failed to resume: {}", e);
}
}
Ok(())
}
/// Play next track in queue
pub(super) async fn next_track(&mut self) -> Result<(), Error> {
let state = self.state.read().await;
let queue_len = state.queue.len();
let current_pos = state.queue_position;
drop(state);
if queue_len == 0 {
return Ok(());
}
let next_pos = match current_pos {
Some(pos) if pos + 1 < queue_len => pos + 1,
_ => {
info!("Reached end of queue");
let _ = self.mpv.stop();
let mut state = self.state.write().await;
state.now_playing.state = PlaybackState::Stopped;
state.now_playing.position = 0.0;
return Ok(());
}
};
self.play_queue_position(next_pos).await
}
/// Play previous track in queue (or restart current if < 3 seconds in)
pub(super) async fn prev_track(&mut self) -> Result<(), Error> {
let state = self.state.read().await;
let queue_len = state.queue.len();
let current_pos = state.queue_position;
let position = state.now_playing.position;
drop(state);
if queue_len == 0 {
return Ok(());
}
if position < 3.0 {
if let Some(pos) = current_pos {
if pos > 0 {
return self.play_queue_position(pos - 1).await;
}
}
if let Err(e) = self.mpv.seek(0.0) {
error!("Failed to restart track: {}", e);
} else {
let mut state = self.state.write().await;
state.now_playing.position = 0.0;
}
return Ok(());
}
debug!("Restarting current track (position: {:.1}s)", position);
if let Err(e) = self.mpv.seek(0.0) {
error!("Failed to restart track: {}", e);
} else {
let mut state = self.state.write().await;
state.now_playing.position = 0.0;
}
Ok(())
}
/// Play a specific position in the queue
pub(super) async fn play_queue_position(&mut self, pos: usize) -> Result<(), Error> {
let state = self.state.read().await;
let song = match state.queue.get(pos) {
Some(s) => s.clone(),
None => return Ok(()),
};
drop(state);
let stream_url = if let Some(ref client) = self.subsonic {
match client.get_stream_url(&song.id) {
Ok(url) => url,
Err(e) => {
error!("Failed to get stream URL: {}", e);
let mut state = self.state.write().await;
state.notify_error(format!("Failed to get stream URL: {}", e));
return Ok(());
}
}
} else {
return Ok(());
};
{
let mut state = self.state.write().await;
state.queue_position = Some(pos);
state.now_playing.song = Some(song.clone());
state.now_playing.state = PlaybackState::Playing;
state.now_playing.position = 0.0;
state.now_playing.duration = song.duration.unwrap_or(0) as f64;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
}
info!("Playing: {} (queue pos {})", song.title, pos);
if self.mpv.is_paused().unwrap_or(false) {
let _ = self.mpv.resume();
}
if let Err(e) = self.mpv.loadfile(&stream_url) {
error!("Failed to play: {}", e);
let mut state = self.state.write().await;
state.notify_error(format!("MPV error: {}", e));
return Ok(());
}
self.preload_next_track(pos).await;
Ok(())
}
/// Pre-load the next track into MPV's playlist for gapless playback
pub(super) async fn preload_next_track(&mut self, current_pos: usize) {
let state = self.state.read().await;
let next_pos = current_pos + 1;
if next_pos >= state.queue.len() {
return;
}
let next_song = match state.queue.get(next_pos) {
Some(s) => s.clone(),
None => return,
};
drop(state);
if let Some(ref client) = self.subsonic {
if let Ok(url) = client.get_stream_url(&next_song.id) {
debug!("Pre-loading next track for gapless: {}", next_song.title);
if let Err(e) = self.mpv.loadfile_append(&url) {
debug!("Failed to pre-load next track: {}", e);
} else if let Ok(count) = self.mpv.get_playlist_count() {
if count < 2 {
warn!(
"Preload may have failed: playlist count is {} (expected 2)",
count
);
} else {
debug!("Preload confirmed: playlist count is {}", count);
}
}
}
}
}
/// Stop playback and clear the queue
pub(super) async fn stop_playback(&mut self) -> Result<(), Error> {
if let Err(e) = self.mpv.stop() {
error!("Failed to stop: {}", e);
}
let mut state = self.state.write().await;
state.now_playing.state = PlaybackState::Stopped;
state.now_playing.song = None;
state.now_playing.position = 0.0;
state.now_playing.duration = 0.0;
state.now_playing.sample_rate = None;
state.now_playing.bit_depth = None;
state.now_playing.format = None;
state.now_playing.channels = None;
state.queue.clear();
state.queue_position = None;
Ok(())
}
}

View File

@@ -32,16 +32,6 @@ impl Page {
} }
} }
pub fn from_index(index: usize) -> Self {
match index {
0 => Page::Artists,
1 => Page::Queue,
2 => Page::Playlists,
3 => Page::Server,
4 => Page::Settings,
_ => Page::Artists,
}
}
pub fn label(&self) -> &'static str { pub fn label(&self) -> &'static str {
match self { match self {
@@ -205,6 +195,8 @@ pub struct SettingsState {
pub theme_index: usize, pub theme_index: usize,
/// Cava visualizer enabled /// Cava visualizer enabled
pub cava_enabled: bool, pub cava_enabled: bool,
/// Cava visualizer height percentage (10-80, step 5)
pub cava_size: u8,
} }
impl Default for SettingsState { impl Default for SettingsState {
@@ -214,6 +206,7 @@ impl Default for SettingsState {
themes: vec![ThemeData::default_theme()], themes: vec![ThemeData::default_theme()],
theme_index: 0, theme_index: 0,
cava_enabled: false, cava_enabled: false,
cava_size: 40,
} }
} }
} }
@@ -269,10 +262,8 @@ pub struct Notification {
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct LayoutAreas { pub struct LayoutAreas {
pub header: Rect, pub header: Rect,
pub cava: Option<Rect>,
pub content: Rect, pub content: Rect,
pub now_playing: Rect, pub now_playing: Rect,
pub footer: Rect,
/// Left pane for dual-pane pages (Artists tree, Playlists list) /// Left pane for dual-pane pages (Artists tree, Playlists list)
pub content_left: Option<Rect>, pub content_left: Option<Rect>,
/// Right pane for dual-pane pages (Songs list) /// Right pane for dual-pane pages (Songs list)
@@ -349,6 +340,7 @@ impl AppState {
state.server_state.password = config.password.clone(); state.server_state.password = config.password.clone();
// Initialize cava from config // Initialize cava from config
state.settings_state.cava_enabled = config.cava; state.settings_state.cava_enabled = config.cava;
state.settings_state.cava_size = config.cava_size.clamp(10, 80);
state state
} }

View File

@@ -1,7 +1,4 @@
//! Audio playback module //! Audio playback module
#![allow(dead_code)]
pub mod mpv; pub mod mpv;
pub mod pipewire; pub mod pipewire;
pub mod queue;

View File

@@ -32,8 +32,9 @@ struct MpvResponse {
error: String, error: String,
} }
/// MPV event /// MPV event (used for deserialization and debug tracing)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] // Fields populated by deserialization, read via Debug
struct MpvEvent { struct MpvEvent {
event: String, event: String,
#[serde(default)] #[serde(default)]
@@ -42,27 +43,6 @@ struct MpvEvent {
data: Option<Value>, data: Option<Value>,
} }
/// Events emitted by MPV
#[derive(Debug, Clone)]
pub enum MpvEvent2 {
/// Track reached end of file
EndFile,
/// Playback paused
Pause,
/// Playback resumed
Unpause,
/// Position changed (time in seconds)
TimePos(f64),
/// Audio properties changed
AudioProperties {
sample_rate: Option<u32>,
bit_depth: Option<u32>,
format: Option<String>,
},
/// MPV shut down
Shutdown,
}
/// MPV controller /// MPV controller
pub struct MpvController { pub struct MpvController {
/// Path to the IPC socket /// Path to the IPC socket
@@ -217,13 +197,6 @@ impl MpvController {
Ok(()) Ok(())
} }
/// Clear the playlist except current track
pub fn playlist_clear(&mut self) -> Result<(), AudioError> {
debug!("Clearing playlist");
self.send_command(vec![json!("playlist-clear")])?;
Ok(())
}
/// Remove a specific entry from the playlist by index /// Remove a specific entry from the playlist by index
pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> { pub fn playlist_remove(&mut self, index: usize) -> Result<(), AudioError> {
debug!("Removing playlist entry {}", index); debug!("Removing playlist entry {}", index);
@@ -307,15 +280,6 @@ impl MpvController {
Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0)) Ok(data.and_then(|v| v.as_f64()).unwrap_or(0.0))
} }
/// Get volume (0-100)
pub fn get_volume(&mut self) -> Result<i32, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("volume")])?;
Ok(data
.and_then(|v| v.as_f64())
.map(|v| v as i32)
.unwrap_or(100))
}
/// Set volume (0-100) /// Set volume (0-100)
pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> { pub fn set_volume(&mut self, volume: i32) -> Result<(), AudioError> {
debug!("Setting volume to {}", volume); debug!("Setting volume to {}", volume);
@@ -378,24 +342,12 @@ impl MpvController {
})) }))
} }
/// Get current filename/URL
pub fn get_path(&mut self) -> Result<Option<String>, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("path")])?;
Ok(data.and_then(|v| v.as_str().map(String::from)))
}
/// Check if anything is loaded /// Check if anything is loaded
pub fn is_idle(&mut self) -> Result<bool, AudioError> { pub fn is_idle(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?; let data = self.send_command(vec![json!("get_property"), json!("idle-active")])?;
Ok(data.and_then(|v| v.as_bool()).unwrap_or(true)) Ok(data.and_then(|v| v.as_bool()).unwrap_or(true))
} }
/// Check if current file has reached EOF
pub fn is_eof(&mut self) -> Result<bool, AudioError> {
let data = self.send_command(vec![json!("get_property"), json!("eof-reached")])?;
Ok(data.and_then(|v| v.as_bool()).unwrap_or(false))
}
/// Quit MPV /// Quit MPV
pub fn quit(&mut self) -> Result<(), AudioError> { pub fn quit(&mut self) -> Result<(), AudioError> {
if self.socket.is_some() { if self.socket.is_some() {
@@ -414,11 +366,6 @@ impl MpvController {
Ok(()) Ok(())
} }
/// Observe a property for changes
pub fn observe_property(&mut self, id: u64, name: &str) -> Result<(), AudioError> {
self.send_command(vec![json!("observe_property"), json!(id), json!(name)])?;
Ok(())
}
} }
impl Drop for MpvController { impl Drop for MpvController {

View File

@@ -5,9 +5,6 @@ use tracing::{debug, error, info};
use crate::error::AudioError; use crate::error::AudioError;
/// Default audio device ID for PipeWire
const DEFAULT_DEVICE_ID: u32 = 0;
/// PipeWire sample rate controller /// PipeWire sample rate controller
pub struct PipeWireController { pub struct PipeWireController {
/// Original sample rate before ferrosonic started /// Original sample rate before ferrosonic started
@@ -133,47 +130,6 @@ impl PipeWireController {
Ok(()) Ok(())
} }
/// Check if PipeWire is available
pub fn is_available() -> bool {
Command::new("pw-metadata")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Get the effective sample rate (from pw-metadata or system default)
pub fn get_effective_rate() -> Option<u32> {
// Try to get from PipeWire
let output = Command::new("pw-metadata")
.arg("-n")
.arg("settings")
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Look for clock.rate or clock.force-rate
for line in stdout.lines() {
if (line.contains("clock.rate") || line.contains("clock.force-rate"))
&& line.contains("value:")
{
if let Some(start) = line.find("value:'") {
let rest = &line[start + 7..];
if let Some(end) = rest.find('\'') {
let rate_str = &rest[..end];
if let Ok(rate) = rate_str.parse::<u32>() {
if rate > 0 {
return Some(rate);
}
}
}
}
}
}
None
}
} }
impl Default for PipeWireController { impl Default for PipeWireController {
@@ -190,13 +146,3 @@ impl Drop for PipeWireController {
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available() {
// This test just checks the function doesn't panic
let _ = PipeWireController::is_available();
}
}

View File

@@ -1,321 +0,0 @@
//! Play queue management
use rand::seq::SliceRandom;
use tracing::debug;
use crate::subsonic::models::Child;
use crate::subsonic::SubsonicClient;
/// Play queue
#[derive(Debug, Clone, Default)]
pub struct PlayQueue {
/// Songs in the queue
songs: Vec<Child>,
/// Current position in the queue (None = stopped)
position: Option<usize>,
}
impl PlayQueue {
/// Create a new empty queue
pub fn new() -> Self {
Self::default()
}
/// Get the songs in the queue
pub fn songs(&self) -> &[Child] {
&self.songs
}
/// Get the current position
pub fn position(&self) -> Option<usize> {
self.position
}
/// Get the current song
pub fn current(&self) -> Option<&Child> {
self.position.and_then(|pos| self.songs.get(pos))
}
/// Get number of songs in queue
pub fn len(&self) -> usize {
self.songs.len()
}
/// Check if queue is empty
pub fn is_empty(&self) -> bool {
self.songs.is_empty()
}
/// Add songs to the end of the queue
pub fn append(&mut self, songs: impl IntoIterator<Item = Child>) {
self.songs.extend(songs);
debug!("Queue now has {} songs", self.songs.len());
}
/// Insert songs after the current position
pub fn insert_next(&mut self, songs: impl IntoIterator<Item = Child>) {
let insert_pos = self.position.map(|p| p + 1).unwrap_or(0);
let new_songs: Vec<_> = songs.into_iter().collect();
let count = new_songs.len();
for (i, song) in new_songs.into_iter().enumerate() {
self.songs.insert(insert_pos + i, song);
}
debug!("Inserted {} songs at position {}", count, insert_pos);
}
/// Clear the queue
pub fn clear(&mut self) {
self.songs.clear();
self.position = None;
debug!("Queue cleared");
}
/// Remove song at index
pub fn remove(&mut self, index: usize) -> Option<Child> {
if index >= self.songs.len() {
return None;
}
let song = self.songs.remove(index);
// Adjust position if needed
if let Some(pos) = self.position {
if index < pos {
self.position = Some(pos - 1);
} else if index == pos {
// Removed current song
if self.songs.is_empty() {
self.position = None;
} else if pos >= self.songs.len() {
self.position = Some(self.songs.len() - 1);
}
}
}
debug!("Removed song at index {}", index);
Some(song)
}
/// Move song from one position to another
pub fn move_song(&mut self, from: usize, to: usize) {
if from >= self.songs.len() || to >= self.songs.len() {
return;
}
let song = self.songs.remove(from);
self.songs.insert(to, song);
// Adjust position if needed
if let Some(pos) = self.position {
if from == pos {
self.position = Some(to);
} else if from < pos && to >= pos {
self.position = Some(pos - 1);
} else if from > pos && to <= pos {
self.position = Some(pos + 1);
}
}
debug!("Moved song from {} to {}", from, to);
}
/// Shuffle the queue, keeping current song in place
pub fn shuffle(&mut self) {
if self.songs.len() <= 1 {
return;
}
let mut rng = rand::thread_rng();
if let Some(pos) = self.position {
// Keep current song, shuffle the rest
let current = self.songs.remove(pos);
// Shuffle remaining songs
self.songs.shuffle(&mut rng);
// Put current song at the front
self.songs.insert(0, current);
self.position = Some(0);
} else {
// No current song, shuffle everything
self.songs.shuffle(&mut rng);
}
debug!("Queue shuffled");
}
/// Set current position
pub fn set_position(&mut self, position: Option<usize>) {
if let Some(pos) = position {
if pos < self.songs.len() {
self.position = Some(pos);
debug!("Position set to {}", pos);
}
} else {
self.position = None;
debug!("Position cleared");
}
}
/// Advance to next song
/// Returns true if there was a next song
pub fn next(&mut self) -> bool {
match self.position {
Some(pos) if pos + 1 < self.songs.len() => {
self.position = Some(pos + 1);
debug!("Advanced to position {}", pos + 1);
true
}
_ => {
self.position = None;
debug!("Reached end of queue");
false
}
}
}
/// Go to previous song
/// Returns true if there was a previous song
pub fn previous(&mut self) -> bool {
match self.position {
Some(pos) if pos > 0 => {
self.position = Some(pos - 1);
debug!("Went back to position {}", pos - 1);
true
}
_ => {
if !self.songs.is_empty() {
self.position = Some(0);
}
debug!("At start of queue");
false
}
}
}
/// Get stream URL for current song
pub fn current_stream_url(&self, client: &SubsonicClient) -> Option<String> {
self.current()
.and_then(|song| client.get_stream_url(&song.id).ok())
}
/// Get stream URL for song at index
pub fn stream_url_at(&self, index: usize, client: &SubsonicClient) -> Option<String> {
self.songs
.get(index)
.and_then(|song| client.get_stream_url(&song.id).ok())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_song(id: &str, title: &str) -> Child {
Child {
id: id.to_string(),
title: title.to_string(),
parent: None,
is_dir: false,
album: None,
artist: None,
track: None,
year: None,
genre: None,
cover_art: None,
size: None,
content_type: None,
suffix: None,
duration: None,
bit_rate: None,
path: None,
disc_number: None,
}
}
#[test]
fn test_append_and_len() {
let mut queue = PlayQueue::new();
assert!(queue.is_empty());
queue.append(vec![make_song("1", "Song 1"), make_song("2", "Song 2")]);
assert_eq!(queue.len(), 2);
}
#[test]
fn test_position_and_navigation() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
assert!(queue.current().is_none());
queue.set_position(Some(0));
assert_eq!(queue.current().unwrap().id, "1");
assert!(queue.next());
assert_eq!(queue.current().unwrap().id, "2");
assert!(queue.next());
assert_eq!(queue.current().unwrap().id, "3");
assert!(!queue.next());
assert!(queue.current().is_none());
}
#[test]
fn test_remove() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
queue.set_position(Some(1));
// Remove song before current
queue.remove(0);
assert_eq!(queue.position(), Some(0));
assert_eq!(queue.current().unwrap().id, "2");
// Remove current song
queue.remove(0);
assert_eq!(queue.current().unwrap().id, "3");
}
#[test]
fn test_insert_next() {
let mut queue = PlayQueue::new();
queue.append(vec![make_song("1", "Song 1"), make_song("3", "Song 3")]);
queue.set_position(Some(0));
queue.insert_next(vec![make_song("2", "Song 2")]);
assert_eq!(queue.songs[0].id, "1");
assert_eq!(queue.songs[1].id, "2");
assert_eq!(queue.songs[2].id, "3");
}
#[test]
fn test_move_song() {
let mut queue = PlayQueue::new();
queue.append(vec![
make_song("1", "Song 1"),
make_song("2", "Song 2"),
make_song("3", "Song 3"),
]);
queue.set_position(Some(0));
queue.move_song(0, 2);
assert_eq!(queue.songs[0].id, "2");
assert_eq!(queue.songs[1].id, "3");
assert_eq!(queue.songs[2].id, "1");
assert_eq!(queue.position(), Some(2));
}
}

View File

@@ -30,9 +30,17 @@ pub struct Config {
/// Enable cava audio visualizer /// Enable cava audio visualizer
#[serde(rename = "Cava", default)] #[serde(rename = "Cava", default)]
pub cava: bool, pub cava: bool,
/// Cava visualizer height percentage (10-80, step 5)
#[serde(rename = "CavaSize", default = "Config::default_cava_size")]
pub cava_size: u8,
} }
impl Config { impl Config {
fn default_cava_size() -> u8 {
40
}
/// Create a new empty config /// Create a new empty config
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()

View File

@@ -1,5 +1,3 @@
//! MPRIS2 D-Bus integration module //! MPRIS2 D-Bus integration module
#![allow(dead_code)]
pub mod server; pub mod server;

View File

@@ -297,77 +297,23 @@ impl SubsonicClient {
Ok(url.to_string()) Ok(url.to_string())
} }
/// Parse song ID from a stream URL
///
/// Useful for session restoration
pub fn parse_song_id_from_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
parsed
.query_pairs()
.find(|(k, _)| k == "id")
.map(|(_, v)| v.to_string())
}
/// Get cover art URL for a given cover art ID
pub fn get_cover_art_url(&self, cover_art_id: &str) -> Result<String, SubsonicError> {
let (salt, token) = generate_auth_params(&self.password);
let mut url = Url::parse(&format!("{}/rest/getCoverArt", self.base_url))?;
url.query_pairs_mut()
.append_pair("id", cover_art_id)
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Ok(url.to_string())
}
/// Search for artists, albums, and songs
pub async fn search(
&self,
query: &str,
) -> Result<(Vec<Artist>, Vec<Album>, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("search3?query={}", urlencoding::encode(query)))?;
debug!("Searching: {}", query);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<SearchResult3Data> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse search response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let result = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty search data".to_string()))?
.search_result3;
debug!(
"Search found {} artists, {} albums, {} songs",
result.artist.len(),
result.album.len(),
result.song.len()
);
Ok((result.artist, result.album, result.song))
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
impl SubsonicClient {
/// Parse song ID from a stream URL
fn parse_song_id_from_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
parsed
.query_pairs()
.find(|(k, _)| k == "id")
.map(|(_, v)| v.to_string())
}
}
#[test] #[test]
fn test_parse_song_id() { fn test_parse_song_id() {
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test"; let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";

View File

@@ -1,7 +1,5 @@
//! Subsonic API client module //! Subsonic API client module
#![allow(dead_code)]
pub mod auth; pub mod auth;
pub mod client; pub mod client;
pub mod models; pub mod models;

View File

@@ -12,6 +12,7 @@ pub struct SubsonicResponse<T> {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct SubsonicResponseInner<T> { pub struct SubsonicResponseInner<T> {
pub status: String, pub status: String,
#[allow(dead_code)] // Present in API response, needed for deserialization
pub version: String, pub version: String,
#[serde(default)] #[serde(default)]
pub error: Option<ApiError>, pub error: Option<ApiError>,
@@ -40,6 +41,7 @@ pub struct ArtistsIndex {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ArtistIndex { pub struct ArtistIndex {
#[allow(dead_code)] // Present in API response, needed for deserialization
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub artist: Vec<Artist>, pub artist: Vec<Artist>,
@@ -217,19 +219,3 @@ pub struct PlaylistDetail {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct PingData {} pub struct PingData {}
/// Search result
#[derive(Debug, Deserialize)]
pub struct SearchResult3Data {
#[serde(rename = "searchResult3")]
pub search_result3: SearchResult3,
}
#[derive(Debug, Deserialize)]
pub struct SearchResult3 {
#[serde(default)]
pub artist: Vec<Artist>,
#[serde(default)]
pub album: Vec<Album>,
#[serde(default)]
pub song: Vec<Child>,
}

View File

@@ -38,13 +38,11 @@ impl Widget for Header {
let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area); let chunks = Layout::horizontal([Constraint::Min(40), Constraint::Length(30)]).split(area);
// Page tabs // Page tabs
let titles: Vec<Line> = vec![ let titles: Vec<Line> = [Page::Artists,
Page::Artists,
Page::Queue, Page::Queue,
Page::Playlists, Page::Playlists,
Page::Server, Page::Server,
Page::Settings, Page::Settings]
]
.iter() .iter()
.map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label()))) .map(|p: &Page| Line::from(format!("{} {}", p.shortcut(), p.label())))
.collect(); .collect();

View File

@@ -28,7 +28,7 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active { let (header_area, cava_area, content_area, now_playing_area, footer_area) = if cava_active {
let chunks = Layout::vertical([ let chunks = Layout::vertical([
Constraint::Length(1), // Header Constraint::Length(1), // Header
Constraint::Percentage(40), // Cava visualizer — top half-ish Constraint::Percentage(state.settings_state.cava_size as u16), // Cava visualizer
Constraint::Min(10), // Page content Constraint::Min(10), // Page content
Constraint::Length(7), // Now playing Constraint::Length(7), // Now playing
Constraint::Length(1), // Footer Constraint::Length(1), // Footer
@@ -62,10 +62,8 @@ pub fn draw(frame: &mut Frame, state: &mut AppState) {
// Store layout areas for mouse hit-testing // Store layout areas for mouse hit-testing
state.layout = LayoutAreas { state.layout = LayoutAreas {
header: header_area, header: header_area,
cava: cava_area,
content: content_area, content: content_area,
now_playing: now_playing_area, now_playing: now_playing_area,
footer: footer_area,
content_left, content_left,
content_right, content_right,
}; };

View File

@@ -5,6 +5,7 @@ pub mod header;
pub mod layout; pub mod layout;
pub mod pages; pub mod pages;
pub mod theme; pub mod theme;
mod theme_builtins;
pub mod widgets; pub mod widgets;
pub use layout::draw; pub use layout::draw;

View File

@@ -47,7 +47,7 @@ pub fn build_tree_items(state: &AppState) -> Vec<TreeItem> {
// If expanded, add albums sorted by year (oldest first) // If expanded, add albums sorted by year (oldest first)
if is_expanded { if is_expanded {
if let Some(albums) = artists.albums_cache.get(&artist.id) { if let Some(albums) = artists.albums_cache.get(&artist.id) {
let mut sorted_albums: Vec<Album> = albums.iter().cloned().collect(); let mut sorted_albums: Vec<Album> = albums.to_vec();
sorted_albums.sort_by(|a, b| { sorted_albums.sort_by(|a, b| {
// Albums with no year go last // Albums with no year go last
match (a.year, b.year) { match (a.year, b.year) {

View File

@@ -34,6 +34,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
Constraint::Length(2), // Theme selector Constraint::Length(2), // Theme selector
Constraint::Length(1), // Spacing Constraint::Length(1), // Spacing
Constraint::Length(2), // Cava toggle Constraint::Length(2), // Cava toggle
Constraint::Length(1), // Spacing
Constraint::Length(2), // Cava size
Constraint::Min(1), // Remaining space Constraint::Min(1), // Remaining space
]) ])
.split(inner); .split(inner);
@@ -66,11 +68,29 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) {
&colors, &colors,
); );
// Cava size (field 2)
let cava_size_value = if !state.cava_available {
"N/A (cava not found)".to_string()
} else {
format!("{}%", settings.cava_size)
};
render_option(
frame,
chunks[5],
"Cava Size",
&cava_size_value,
settings.selected_field == 2,
&colors,
);
// Help text at bottom // Help text at bottom
let help_text = match settings.selected_field { let help_text = match settings.selected_field {
0 => "← → or Enter to change theme (auto-saves)", 0 => "← → or Enter to change theme (auto-saves)",
1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)", 1 if state.cava_available => "← → or Enter to toggle cava visualizer (auto-saves)",
1 => "cava is not installed on this system", 1 => "cava is not installed on this system",
2 if state.cava_available => "← → to adjust cava size (10%-80%, auto-saves)",
2 => "cava is not installed on this system",
_ => "", _ => "",
}; };
let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted)); let help = Paragraph::new(help_text).style(Style::default().fg(colors.muted));

View File

@@ -215,7 +215,7 @@ pub fn load_themes() -> Vec<ThemeData> {
.filter(|e| { .filter(|e| {
e.path() e.path()
.extension() .extension()
.map_or(false, |ext| ext == "toml") .is_some_and(|ext| ext == "toml")
}) })
.collect(); .collect();
entries.sort_by_key(|e| e.file_name()); entries.sort_by_key(|e| e.file_name());
@@ -248,7 +248,7 @@ pub fn load_themes() -> Vec<ThemeData> {
/// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine" /// Convert a filename stem like "tokyo-night" or "rose_pine" to "Tokyo Night" or "Rose Pine"
fn titlecase_filename(s: &str) -> String { fn titlecase_filename(s: &str) -> String {
s.split(|c: char| c == '-' || c == '_') s.split(['-', '_'])
.filter(|w| !w.is_empty()) .filter(|w| !w.is_empty())
.map(|word| { .map(|word| {
let mut chars = word.chars(); let mut chars = word.chars();
@@ -286,268 +286,4 @@ pub fn seed_default_themes(dir: &Path) {
} }
} }
const BUILTIN_THEMES: &[(&str, &str)] = &[ use super::theme_builtins::BUILTIN_THEMES;
("monokai.toml", r##"[colors]
primary = "#a6e22e"
secondary = "#75715e"
accent = "#fd971f"
artist = "#a6e22e"
album = "#f92672"
song = "#e6db74"
muted = "#75715e"
highlight_bg = "#49483e"
highlight_fg = "#f8f8f2"
success = "#a6e22e"
error = "#f92672"
playing = "#fd971f"
played = "#75715e"
border_focused = "#a6e22e"
border_unfocused = "#49483e"
[cava]
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
"##),
("dracula.toml", r##"[colors]
primary = "#bd93f9"
secondary = "#6272a4"
accent = "#ffb86c"
artist = "#50fa7b"
album = "#ff79c6"
song = "#8be9fd"
muted = "#6272a4"
highlight_bg = "#44475a"
highlight_fg = "#f8f8f2"
success = "#50fa7b"
error = "#ff5555"
playing = "#ffb86c"
played = "#6272a4"
border_focused = "#bd93f9"
border_unfocused = "#44475a"
[cava]
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
"##),
("nord.toml", r##"[colors]
primary = "#88c0d0"
secondary = "#4c566a"
accent = "#ebcb8b"
artist = "#a3be8c"
album = "#b48ead"
song = "#88c0d0"
muted = "#4c566a"
highlight_bg = "#434c5e"
highlight_fg = "#eceff4"
success = "#a3be8c"
error = "#bf616a"
playing = "#ebcb8b"
played = "#4c566a"
border_focused = "#88c0d0"
border_unfocused = "#3b4252"
[cava]
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
"##),
("gruvbox.toml", r##"[colors]
primary = "#d79921"
secondary = "#928374"
accent = "#fe8019"
artist = "#b8bb26"
album = "#d3869b"
song = "#83a598"
muted = "#928374"
highlight_bg = "#504945"
highlight_fg = "#ebdbb2"
success = "#b8bb26"
error = "#fb4934"
playing = "#fe8019"
played = "#928374"
border_focused = "#d79921"
border_unfocused = "#3c3836"
[cava]
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
"##),
("catppuccin.toml", r##"[colors]
primary = "#89b4fa"
secondary = "#585b70"
accent = "#f9e2af"
artist = "#a6e3a1"
album = "#f5c2e7"
song = "#94e2d5"
muted = "#6c7086"
highlight_bg = "#45475a"
highlight_fg = "#cdd6f4"
success = "#a6e3a1"
error = "#f38ba8"
playing = "#f9e2af"
played = "#6c7086"
border_focused = "#89b4fa"
border_unfocused = "#45475a"
[cava]
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
"##),
("solarized.toml", r##"[colors]
primary = "#268bd2"
secondary = "#586e75"
accent = "#b58900"
artist = "#859900"
album = "#d33682"
song = "#2aa198"
muted = "#586e75"
highlight_bg = "#073642"
highlight_fg = "#eee8d5"
success = "#859900"
error = "#dc322f"
playing = "#b58900"
played = "#586e75"
border_focused = "#268bd2"
border_unfocused = "#073642"
[cava]
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
"##),
("tokyo-night.toml", r##"[colors]
primary = "#7aa2f7"
secondary = "#3d59a1"
accent = "#e0af68"
artist = "#9ece6a"
album = "#bb9af7"
song = "#7dcfff"
muted = "#565f89"
highlight_bg = "#292e42"
highlight_fg = "#c0caf5"
success = "#9ece6a"
error = "#f7768e"
playing = "#e0af68"
played = "#565f89"
border_focused = "#7aa2f7"
border_unfocused = "#292e42"
[cava]
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
"##),
("rose-pine.toml", r##"[colors]
primary = "#c4a7e7"
secondary = "#6e6a86"
accent = "#f6c177"
artist = "#9ccfd8"
album = "#ebbcba"
song = "#31748f"
muted = "#6e6a86"
highlight_bg = "#393552"
highlight_fg = "#e0def4"
success = "#9ccfd8"
error = "#eb6f92"
playing = "#f6c177"
played = "#6e6a86"
border_focused = "#c4a7e7"
border_unfocused = "#393552"
[cava]
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
"##),
("everforest.toml", r##"[colors]
primary = "#a7c080"
secondary = "#859289"
accent = "#dbbc7f"
artist = "#83c092"
album = "#d699b6"
song = "#7fbbb3"
muted = "#859289"
highlight_bg = "#505851"
highlight_fg = "#d3c6aa"
success = "#a7c080"
error = "#e67e80"
playing = "#dbbc7f"
played = "#859289"
border_focused = "#a7c080"
border_unfocused = "#505851"
[cava]
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
"##),
("kanagawa.toml", r##"[colors]
primary = "#7e9cd8"
secondary = "#54546d"
accent = "#e6c384"
artist = "#98bb6c"
album = "#957fb8"
song = "#7fb4ca"
muted = "#727169"
highlight_bg = "#363646"
highlight_fg = "#dcd7ba"
success = "#98bb6c"
error = "#ff5d62"
playing = "#e6c384"
played = "#727169"
border_focused = "#7e9cd8"
border_unfocused = "#363646"
[cava]
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
"##),
("one-dark.toml", r##"[colors]
primary = "#61afef"
secondary = "#5c6370"
accent = "#e5c07b"
artist = "#98c379"
album = "#c678dd"
song = "#56b6c2"
muted = "#5c6370"
highlight_bg = "#3e4451"
highlight_fg = "#abb2bf"
success = "#98c379"
error = "#e06c75"
playing = "#e5c07b"
played = "#5c6370"
border_focused = "#61afef"
border_unfocused = "#3e4451"
[cava]
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
"##),
("ayu-dark.toml", r##"[colors]
primary = "#59c2ff"
secondary = "#6b788a"
accent = "#e6b450"
artist = "#aad94c"
album = "#d2a6ff"
song = "#95e6cb"
muted = "#6b788a"
highlight_bg = "#2f3846"
highlight_fg = "#bfc7d5"
success = "#aad94c"
error = "#f07178"
playing = "#e6b450"
played = "#6b788a"
border_focused = "#59c2ff"
border_unfocused = "#2f3846"
[cava]
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
"##),
];

267
src/ui/theme_builtins.rs Normal file
View File

@@ -0,0 +1,267 @@
//! Built-in theme TOML definitions seeded into ~/.config/ferrosonic/themes/
pub(super) const BUILTIN_THEMES: &[(&str, &str)] = &[
("monokai.toml", r##"[colors]
primary = "#a6e22e"
secondary = "#75715e"
accent = "#fd971f"
artist = "#a6e22e"
album = "#f92672"
song = "#e6db74"
muted = "#75715e"
highlight_bg = "#49483e"
highlight_fg = "#f8f8f2"
success = "#a6e22e"
error = "#f92672"
playing = "#fd971f"
played = "#75715e"
border_focused = "#a6e22e"
border_unfocused = "#49483e"
[cava]
gradient = ["#a6e22e", "#e6db74", "#fd971f", "#fd971f", "#f92672", "#f92672", "#ae81ff", "#ae81ff"]
horizontal_gradient = ["#f92672", "#f92672", "#fd971f", "#e6db74", "#e6db74", "#a6e22e", "#a6e22e", "#66d9ef"]
"##),
("dracula.toml", r##"[colors]
primary = "#bd93f9"
secondary = "#6272a4"
accent = "#ffb86c"
artist = "#50fa7b"
album = "#ff79c6"
song = "#8be9fd"
muted = "#6272a4"
highlight_bg = "#44475a"
highlight_fg = "#f8f8f2"
success = "#50fa7b"
error = "#ff5555"
playing = "#ffb86c"
played = "#6272a4"
border_focused = "#bd93f9"
border_unfocused = "#44475a"
[cava]
gradient = ["#50fa7b", "#8be9fd", "#8be9fd", "#bd93f9", "#bd93f9", "#ff79c6", "#ff5555", "#ff5555"]
horizontal_gradient = ["#ff79c6", "#ff79c6", "#bd93f9", "#bd93f9", "#8be9fd", "#8be9fd", "#50fa7b", "#50fa7b"]
"##),
("nord.toml", r##"[colors]
primary = "#88c0d0"
secondary = "#4c566a"
accent = "#ebcb8b"
artist = "#a3be8c"
album = "#b48ead"
song = "#88c0d0"
muted = "#4c566a"
highlight_bg = "#434c5e"
highlight_fg = "#eceff4"
success = "#a3be8c"
error = "#bf616a"
playing = "#ebcb8b"
played = "#4c566a"
border_focused = "#88c0d0"
border_unfocused = "#3b4252"
[cava]
gradient = ["#a3be8c", "#88c0d0", "#88c0d0", "#81a1c1", "#81a1c1", "#5e81ac", "#b48ead", "#b48ead"]
horizontal_gradient = ["#bf616a", "#d08770", "#ebcb8b", "#a3be8c", "#88c0d0", "#81a1c1", "#5e81ac", "#b48ead"]
"##),
("gruvbox.toml", r##"[colors]
primary = "#d79921"
secondary = "#928374"
accent = "#fe8019"
artist = "#b8bb26"
album = "#d3869b"
song = "#83a598"
muted = "#928374"
highlight_bg = "#504945"
highlight_fg = "#ebdbb2"
success = "#b8bb26"
error = "#fb4934"
playing = "#fe8019"
played = "#928374"
border_focused = "#d79921"
border_unfocused = "#3c3836"
[cava]
gradient = ["#b8bb26", "#d79921", "#d79921", "#fe8019", "#fe8019", "#fb4934", "#cc241d", "#cc241d"]
horizontal_gradient = ["#cc241d", "#fb4934", "#fe8019", "#d79921", "#b8bb26", "#689d6a", "#458588", "#83a598"]
"##),
("catppuccin.toml", r##"[colors]
primary = "#89b4fa"
secondary = "#585b70"
accent = "#f9e2af"
artist = "#a6e3a1"
album = "#f5c2e7"
song = "#94e2d5"
muted = "#6c7086"
highlight_bg = "#45475a"
highlight_fg = "#cdd6f4"
success = "#a6e3a1"
error = "#f38ba8"
playing = "#f9e2af"
played = "#6c7086"
border_focused = "#89b4fa"
border_unfocused = "#45475a"
[cava]
gradient = ["#a6e3a1", "#94e2d5", "#89dceb", "#74c7ec", "#cba6f7", "#f5c2e7", "#f38ba8", "#f38ba8"]
horizontal_gradient = ["#f38ba8", "#eba0ac", "#fab387", "#f9e2af", "#a6e3a1", "#94e2d5", "#89b4fa", "#cba6f7"]
"##),
("solarized.toml", r##"[colors]
primary = "#268bd2"
secondary = "#586e75"
accent = "#b58900"
artist = "#859900"
album = "#d33682"
song = "#2aa198"
muted = "#586e75"
highlight_bg = "#073642"
highlight_fg = "#eee8d5"
success = "#859900"
error = "#dc322f"
playing = "#b58900"
played = "#586e75"
border_focused = "#268bd2"
border_unfocused = "#073642"
[cava]
gradient = ["#859900", "#b58900", "#b58900", "#cb4b16", "#cb4b16", "#dc322f", "#d33682", "#6c71c4"]
horizontal_gradient = ["#dc322f", "#cb4b16", "#b58900", "#859900", "#2aa198", "#268bd2", "#6c71c4", "#d33682"]
"##),
("tokyo-night.toml", r##"[colors]
primary = "#7aa2f7"
secondary = "#3d59a1"
accent = "#e0af68"
artist = "#9ece6a"
album = "#bb9af7"
song = "#7dcfff"
muted = "#565f89"
highlight_bg = "#292e42"
highlight_fg = "#c0caf5"
success = "#9ece6a"
error = "#f7768e"
playing = "#e0af68"
played = "#565f89"
border_focused = "#7aa2f7"
border_unfocused = "#292e42"
[cava]
gradient = ["#9ece6a", "#e0af68", "#e0af68", "#ff9e64", "#ff9e64", "#f7768e", "#bb9af7", "#bb9af7"]
horizontal_gradient = ["#f7768e", "#ff9e64", "#e0af68", "#9ece6a", "#73daca", "#7dcfff", "#7aa2f7", "#bb9af7"]
"##),
("rose-pine.toml", r##"[colors]
primary = "#c4a7e7"
secondary = "#6e6a86"
accent = "#f6c177"
artist = "#9ccfd8"
album = "#ebbcba"
song = "#31748f"
muted = "#6e6a86"
highlight_bg = "#393552"
highlight_fg = "#e0def4"
success = "#9ccfd8"
error = "#eb6f92"
playing = "#f6c177"
played = "#6e6a86"
border_focused = "#c4a7e7"
border_unfocused = "#393552"
[cava]
gradient = ["#31748f", "#9ccfd8", "#c4a7e7", "#c4a7e7", "#ebbcba", "#ebbcba", "#eb6f92", "#eb6f92"]
horizontal_gradient = ["#eb6f92", "#ebbcba", "#f6c177", "#f6c177", "#9ccfd8", "#c4a7e7", "#31748f", "#31748f"]
"##),
("everforest.toml", r##"[colors]
primary = "#a7c080"
secondary = "#859289"
accent = "#dbbc7f"
artist = "#83c092"
album = "#d699b6"
song = "#7fbbb3"
muted = "#859289"
highlight_bg = "#505851"
highlight_fg = "#d3c6aa"
success = "#a7c080"
error = "#e67e80"
playing = "#dbbc7f"
played = "#859289"
border_focused = "#a7c080"
border_unfocused = "#505851"
[cava]
gradient = ["#a7c080", "#dbbc7f", "#dbbc7f", "#e69875", "#e69875", "#e67e80", "#d699b6", "#d699b6"]
horizontal_gradient = ["#e67e80", "#e69875", "#dbbc7f", "#a7c080", "#83c092", "#7fbbb3", "#d699b6", "#d699b6"]
"##),
("kanagawa.toml", r##"[colors]
primary = "#7e9cd8"
secondary = "#54546d"
accent = "#e6c384"
artist = "#98bb6c"
album = "#957fb8"
song = "#7fb4ca"
muted = "#727169"
highlight_bg = "#363646"
highlight_fg = "#dcd7ba"
success = "#98bb6c"
error = "#ff5d62"
playing = "#e6c384"
played = "#727169"
border_focused = "#7e9cd8"
border_unfocused = "#363646"
[cava]
gradient = ["#98bb6c", "#e6c384", "#e6c384", "#ffa066", "#ffa066", "#ff5d62", "#957fb8", "#957fb8"]
horizontal_gradient = ["#ff5d62", "#ffa066", "#e6c384", "#98bb6c", "#7fb4ca", "#7e9cd8", "#957fb8", "#938aa9"]
"##),
("one-dark.toml", r##"[colors]
primary = "#61afef"
secondary = "#5c6370"
accent = "#e5c07b"
artist = "#98c379"
album = "#c678dd"
song = "#56b6c2"
muted = "#5c6370"
highlight_bg = "#3e4451"
highlight_fg = "#abb2bf"
success = "#98c379"
error = "#e06c75"
playing = "#e5c07b"
played = "#5c6370"
border_focused = "#61afef"
border_unfocused = "#3e4451"
[cava]
gradient = ["#98c379", "#e5c07b", "#e5c07b", "#d19a66", "#d19a66", "#e06c75", "#c678dd", "#c678dd"]
horizontal_gradient = ["#e06c75", "#d19a66", "#e5c07b", "#98c379", "#56b6c2", "#61afef", "#c678dd", "#c678dd"]
"##),
("ayu-dark.toml", r##"[colors]
primary = "#59c2ff"
secondary = "#6b788a"
accent = "#e6b450"
artist = "#aad94c"
album = "#d2a6ff"
song = "#95e6cb"
muted = "#6b788a"
highlight_bg = "#2f3846"
highlight_fg = "#bfc7d5"
success = "#aad94c"
error = "#f07178"
playing = "#e6b450"
played = "#6b788a"
border_focused = "#59c2ff"
border_unfocused = "#2f3846"
[cava]
gradient = ["#aad94c", "#e6b450", "#e6b450", "#ff8f40", "#ff8f40", "#f07178", "#d2a6ff", "#d2a6ff"]
horizontal_gradient = ["#f07178", "#ff8f40", "#e6b450", "#aad94c", "#95e6cb", "#59c2ff", "#d2a6ff", "#d2a6ff"]
"##),
];