diff --git a/src/app/mod.rs b/src/app/mod.rs index e719276..90ecd02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,8 @@ mod input_queue; mod input_server; mod input_settings; mod mouse; +mod mouse_artists; +mod mouse_playlists; mod playback; pub mod state; diff --git a/src/app/mouse.rs b/src/app/mouse.rs index aa359dc..ccb14d4 100644 --- a/src/app/mouse.rs +++ b/src/app/mouse.rs @@ -1,5 +1,4 @@ use crossterm::event::{self, MouseButton, MouseEventKind}; -use tracing::error; use crate::error::Error; @@ -65,19 +64,12 @@ impl App { // Check now playing area (progress bar seeking) if y >= layout.now_playing.y && y < layout.now_playing.y + layout.now_playing.height { - // The progress bar is on the last content line of the now_playing block. - // The block has a 1-cell border, so inner area starts at y+1. - // Progress bar row depends on layout height, but it's always the last inner row. - let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2; // -1 for border, -1 for 0-index + let inner_bottom = layout.now_playing.y + layout.now_playing.height - 2; if y == inner_bottom && duration > 0.0 { - // Calculate seek position from x coordinate within the now_playing area - // The progress bar renders centered: "MM:SS / MM:SS [━━━━━────]" - // We approximate: the bar occupies roughly the right portion of the inner area - let inner_x_start = layout.now_playing.x + 1; // border + 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; - // Time text is roughly "MM:SS / MM:SS " = ~15 chars, bar fills the rest 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; @@ -117,195 +109,6 @@ impl App { } } - /// Handle click on artists page - 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(()) - } - /// 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; @@ -335,90 +138,6 @@ impl App { Ok(()) } - /// Handle click on playlists page - 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(()) - } - /// 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; diff --git a/src/app/mouse_artists.rs b/src/app/mouse_artists.rs new file mode 100644 index 0000000..3b95151 --- /dev/null +++ b/src/app/mouse_artists.rs @@ -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(()) + } +} diff --git a/src/app/mouse_playlists.rs b/src/app/mouse_playlists.rs new file mode 100644 index 0000000..1e0f77e --- /dev/null +++ b/src/app/mouse_playlists.rs @@ -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(()) + } +}