diff --git a/src/app/handler.rs b/src/app/handler.rs
@@ -0,0 +1,923 @@
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
+use std::time::{Duration, Instant};
+
+use crate::app::state::{FocusedPane, Tab};
+use crate::app::{App, AppAction};
+use crate::ui::layout::LayoutAreas;
+use crate::ui::pane::Pane;
+use crate::ui::widgets::{progress_bar, tab_bar};
+use crate::ui::widgets::playlist_modal::PlaylistModalMode;
+use crate::ui::Ui;
+
+pub fn handle_key_event(key: KeyEvent, app: &App, ui: &mut Ui) -> Vec<AppAction> {
+ let mut actions = Vec::new();
+
+ // About modal: Esc to close, g/s to open URLs
+ if ui.show_about_modal {
+ match key.code {
+ KeyCode::Esc | KeyCode::Char('q') => {
+ ui.show_about_modal = false;
+ }
+ KeyCode::Char('g') => {
+ let _ = std::process::Command::new("xdg-open")
+ .arg("https://github.com/sqzer-x/ommp")
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn();
+ }
+ KeyCode::Char('s') => {
+ let _ = std::process::Command::new("xdg-open")
+ .arg("https://github.com/sponsors/sqzer-x")
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn();
+ }
+ _ => {}
+ }
+ return actions;
+ }
+
+ // Help modal: Esc to close
+ if ui.show_help_modal {
+ if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
+ ui.show_help_modal = false;
+ }
+ return actions;
+ }
+
+ // Playlist modal ("b" key) — list, create, rename modes
+ if ui.show_playlist_modal {
+ match ui.playlist_modal_mode {
+ PlaylistModalMode::Create | PlaylistModalMode::Rename => {
+ match key.code {
+ KeyCode::Esc => {
+ ui.playlist_modal_mode = PlaylistModalMode::List;
+ ui.playlist_modal_input.clear();
+ }
+ KeyCode::Enter => {
+ let name = ui.playlist_modal_input.trim().to_string();
+ if !name.is_empty() {
+ if ui.playlist_modal_mode == PlaylistModalMode::Create {
+ actions.push(AppAction::CreatePlaylist(name));
+ } else {
+ actions.push(AppAction::RenamePlaylist {
+ idx: ui.playlist_modal_selected,
+ name,
+ });
+ }
+ }
+ ui.playlist_modal_mode = PlaylistModalMode::List;
+ ui.playlist_modal_input.clear();
+ }
+ KeyCode::Backspace => {
+ ui.playlist_modal_input.pop();
+ }
+ KeyCode::Char(c) => {
+ ui.playlist_modal_input.push(c);
+ }
+ _ => {}
+ }
+ }
+ PlaylistModalMode::List => {
+ match key.code {
+ KeyCode::Esc => {
+ ui.show_playlist_modal = false;
+ ui.playlist_modal_selected = 0;
+ }
+ KeyCode::Up | KeyCode::Char('k') => {
+ if ui.playlist_modal_selected > 0 {
+ ui.playlist_modal_selected -= 1;
+ }
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ if !app.playlists.is_empty()
+ && ui.playlist_modal_selected < app.playlists.len() - 1
+ {
+ ui.playlist_modal_selected += 1;
+ }
+ }
+ KeyCode::Enter => {
+ // Toggle track in selected playlist
+ if let Some(track_idx) = app.queue.current_index
+ .and_then(|qi| app.queue.tracks.get(qi).copied())
+ {
+ let pl_idx = ui.playlist_modal_selected;
+ if pl_idx < app.playlists.len() {
+ if app.playlists[pl_idx].tracks.contains(&track_idx) {
+ actions.push(AppAction::RemoveFromPlaylist {
+ playlist_idx: pl_idx,
+ track_idx,
+ });
+ } else {
+ actions.push(AppAction::AddToPlaylist {
+ playlist_idx: pl_idx,
+ track_idx,
+ });
+ }
+ }
+ }
+ }
+ KeyCode::Char('a') => {
+ ui.playlist_modal_mode = PlaylistModalMode::Create;
+ ui.playlist_modal_input.clear();
+ }
+ KeyCode::Char('d') => {
+ if !app.playlists.is_empty() {
+ actions.push(AppAction::DeletePlaylist(ui.playlist_modal_selected));
+ if ui.playlist_modal_selected > 0
+ && ui.playlist_modal_selected >= app.playlists.len() - 1
+ {
+ ui.playlist_modal_selected -= 1;
+ }
+ }
+ }
+ KeyCode::Char('r') => {
+ if !app.playlists.is_empty() {
+ ui.playlist_modal_mode = PlaylistModalMode::Rename;
+ ui.playlist_modal_input =
+ app.playlists[ui.playlist_modal_selected].name.clone();
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ return actions;
+ }
+
+ // Search modal: input handling
+ if ui.show_search_modal {
+ match key.code {
+ KeyCode::Esc => {
+ ui.show_search_modal = false;
+ ui.search_modal_input.clear();
+ ui.search_modal_results.clear();
+ ui.search_modal_selected = 0;
+ ui.search_modal_scroll = 0;
+ }
+ KeyCode::Enter => {
+ if !ui.search_modal_results.is_empty() {
+ let track_idx = ui.search_modal_results[ui.search_modal_selected];
+ actions.push(AppAction::AddToQueue(vec![track_idx]));
+ ui.show_search_modal = false;
+ ui.search_modal_input.clear();
+ ui.search_modal_results.clear();
+ ui.search_modal_selected = 0;
+ ui.search_modal_scroll = 0;
+ }
+ }
+ KeyCode::Up | KeyCode::BackTab => {
+ if ui.search_modal_selected > 0 {
+ ui.search_modal_selected -= 1;
+ if ui.search_modal_selected < ui.search_modal_scroll {
+ ui.search_modal_scroll = ui.search_modal_selected;
+ }
+ }
+ }
+ KeyCode::Down | KeyCode::Tab => {
+ if !ui.search_modal_results.is_empty()
+ && ui.search_modal_selected < ui.search_modal_results.len() - 1
+ {
+ ui.search_modal_selected += 1;
+ let h = ui.search_modal_result_height;
+ if h > 0 && ui.search_modal_selected >= ui.search_modal_scroll + h {
+ ui.search_modal_scroll = ui.search_modal_selected - h + 1;
+ }
+ }
+ }
+ KeyCode::Backspace => {
+ ui.search_modal_input.pop();
+ ui.search_modal_results = app.library.search(&ui.search_modal_input);
+ ui.search_modal_selected = 0;
+ ui.search_modal_scroll = 0;
+ }
+ KeyCode::Char(c) => {
+ ui.search_modal_input.push(c);
+ ui.search_modal_results = app.library.search(&ui.search_modal_input);
+ ui.search_modal_selected = 0;
+ ui.search_modal_scroll = 0;
+ }
+ _ => {}
+ }
+ return actions;
+ }
+
+ // In search input mode, ignore (search handled by modal now)
+ if app.search_mode {
+ return actions;
+ }
+
+ // Chord: Ctrl+E pressed, waiting for next key
+ if ui.chord_pending {
+ ui.chord_pending = false;
+ match key.code {
+ KeyCode::Char('s') => {
+ ui.show_search_modal = true;
+ }
+ KeyCode::Char('h') => {
+ ui.show_help_modal = true;
+ }
+ KeyCode::Char('r') => {
+ ui.resize_mode = !ui.resize_mode;
+ }
+ KeyCode::Char('i') => {
+ ui.show_about_modal = true;
+ }
+ KeyCode::Char('l') => {
+ actions.push(AppAction::LibrarySync);
+ }
+ _ => {} // unknown chord, ignore
+ }
+ return actions;
+ }
+
+ // Ctrl+E → chord pending
+ if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('e') {
+ ui.chord_pending = true;
+ return actions;
+ }
+
+ // Resize mode key handling
+ if ui.resize_mode {
+ match key.code {
+ KeyCode::Char('h') | KeyCode::Left => {
+ resize_pane(ui, app.focus, -2);
+ }
+ KeyCode::Char('l') | KeyCode::Right => {
+ resize_pane(ui, app.focus, 2);
+ }
+ KeyCode::Char('k') | KeyCode::Up => {
+ // Grow info pane (shrink lyrics)
+ let new_split = (ui.right_split as i16 - 3).clamp(10, 90) as u16;
+ ui.right_split = new_split;
+ }
+ KeyCode::Char('j') | KeyCode::Down => {
+ // Grow lyrics (shrink info pane)
+ let new_split = (ui.right_split as i16 + 3).clamp(10, 90) as u16;
+ ui.right_split = new_split;
+ }
+ KeyCode::Esc | KeyCode::Enter => {
+ ui.resize_mode = false;
+ }
+ KeyCode::Char('q') => {
+ ui.resize_mode = false;
+ actions.push(AppAction::Quit);
+ }
+ _ => {}
+ }
+ return actions;
+ }
+
+ // Global keybindings first
+ match (key.modifiers, key.code) {
+ (_, KeyCode::Char('q')) => {
+ actions.push(AppAction::Quit);
+ return actions;
+ }
+ (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
+ actions.push(AppAction::Quit);
+ return actions;
+ }
+ (_, KeyCode::Char(' ')) => {
+ actions.push(AppAction::PauseResume);
+ return actions;
+ }
+ (_, KeyCode::Char('n')) => {
+ actions.push(AppAction::NextTrack);
+ return actions;
+ }
+ (KeyModifiers::SHIFT, KeyCode::Char('N')) => {
+ actions.push(AppAction::PrevTrack);
+ return actions;
+ }
+ (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
+ actions.push(AppAction::VolumeUp);
+ return actions;
+ }
+ (_, KeyCode::Char('-')) => {
+ actions.push(AppAction::VolumeDown);
+ return actions;
+ }
+ (_, KeyCode::Right) => {
+ actions.push(AppAction::SeekForward);
+ return actions;
+ }
+ (_, KeyCode::Left) => {
+ actions.push(AppAction::SeekBackward);
+ return actions;
+ }
+ (_, KeyCode::Char('s')) => {
+ actions.push(AppAction::ToggleShuffle);
+ return actions;
+ }
+ (_, KeyCode::Char('r')) => {
+ actions.push(AppAction::CycleRepeat);
+ return actions;
+ }
+ (_, KeyCode::Char('b')) => {
+ // Only open if a track is playing
+ if app.queue.current_index.is_some() {
+ ui.show_playlist_modal = true;
+ ui.playlist_modal_selected = 0;
+ }
+ return actions;
+ }
+ (_, KeyCode::Char('p')) => {
+ ui.info_view = ui.info_view.next();
+ return actions;
+ }
+ (_, KeyCode::Tab) => {
+ actions.push(AppAction::FocusNext);
+ return actions;
+ }
+ (KeyModifiers::SHIFT, KeyCode::BackTab) => {
+ actions.push(AppAction::FocusPrev);
+ return actions;
+ }
+ // Tab switching with number keys
+ (_, KeyCode::Char('1')) => {
+ actions.push(AppAction::SwitchTab(Tab::Queue));
+ return actions;
+ }
+ (_, KeyCode::Char('2')) => {
+ actions.push(AppAction::SwitchTab(Tab::Directories));
+ return actions;
+ }
+ (_, KeyCode::Char('3')) => {
+ actions.push(AppAction::SwitchTab(Tab::Artists));
+ return actions;
+ }
+ (_, KeyCode::Char('4')) => {
+ actions.push(AppAction::SwitchTab(Tab::Albums));
+ return actions;
+ }
+ (_, KeyCode::Char('5')) => {
+ actions.push(AppAction::SwitchTab(Tab::Genre));
+ return actions;
+ }
+ (_, KeyCode::Char('6')) => {
+ actions.push(AppAction::SwitchTab(Tab::Format));
+ return actions;
+ }
+ (_, KeyCode::Char('7')) => {
+ actions.push(AppAction::SwitchTab(Tab::Playlists));
+ return actions;
+ }
+ // h/l for pane focus
+ (_, KeyCode::Char('h')) => {
+ actions.push(AppAction::FocusPrev);
+ return actions;
+ }
+ (_, KeyCode::Char('l')) => {
+ actions.push(AppAction::FocusNext);
+ return actions;
+ }
+ _ => {}
+ }
+
+ // Route to focused pane
+ let action = match app.focus {
+ FocusedPane::Library => match app.tab {
+ Tab::Queue => ui.library_pane.handle_key(key, app),
+ Tab::Directories => ui.dir_browser_pane.handle_key(key, app),
+ Tab::Artists => ui.artists_pane.handle_key(key, app),
+ Tab::Albums => ui.albums_pane.handle_key(key, app),
+ Tab::Genre => ui.genre_pane.handle_key(key, app),
+ Tab::Format => ui.format_pane.handle_key(key, app),
+ Tab::Playlists => ui.playlists_pane.handle_key(key, app),
+ },
+ FocusedPane::Playlist => {
+ match key.code {
+ KeyCode::Char('j') | KeyCode::Down => None,
+ KeyCode::Char('k') | KeyCode::Up => None,
+ _ => ui.queue_pane.handle_key(key, app),
+ }
+ }
+ FocusedPane::Lyrics => ui.lyrics_pane.handle_key(key, app),
+ };
+
+ // Auto-focus to Queue pane when adding tracks from Library
+ if let Some(ref a) = action {
+ if matches!(a, AppAction::AddToQueue(_)) && app.focus == FocusedPane::Library {
+ actions.push(AppAction::FocusPane(FocusedPane::Playlist));
+ }
+ }
+
+ if let Some(a) = action {
+ actions.push(a);
+ }
+
+ actions
+}
+
+pub fn handle_mouse_event(
+ mouse: MouseEvent,
+ app: &App,
+ ui: &mut Ui,
+ terminal_area: ratatui::layout::Rect,
+) -> Vec<AppAction> {
+ let mut actions = Vec::new();
+ let areas = LayoutAreas::compute(terminal_area, ui.pane_widths, ui.right_split);
+
+ let x = mouse.column;
+ let y = mouse.row;
+
+ // Store mouse position for hover tracking across all event types
+ ui.mouse_pos = Some((x, y));
+
+ // Search modal mouse handling
+ if ui.show_search_modal {
+ let ra = ui.search_modal_result_area;
+ let in_results = x >= ra.x && x < ra.x + ra.width
+ && y >= ra.y && y < ra.y + ra.height;
+
+ // Hover tracking
+ if in_results && !ui.search_modal_results.is_empty() {
+ let row = ui.search_modal_scroll + (y - ra.y) as usize;
+ if row < ui.search_modal_results.len() {
+ ui.search_modal_hover_row = Some(row);
+ } else {
+ ui.search_modal_hover_row = None;
+ }
+ } else {
+ ui.search_modal_hover_row = None;
+ }
+
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ if in_results && !ui.search_modal_results.is_empty() {
+ let clicked = ui.search_modal_scroll + (y - ra.y) as usize;
+ if clicked < ui.search_modal_results.len() {
+ // Double-click detection
+ let is_double = if let Some((last_time, _lx, ly)) = ui.last_click {
+ last_time.elapsed() < Duration::from_millis(400) && ly == y
+ } else {
+ false
+ };
+ ui.last_click = Some((Instant::now(), x, y));
+
+ if is_double {
+ // Double-click: select and confirm (add to queue)
+ let track_idx = ui.search_modal_results[clicked];
+ actions.push(AppAction::AddToQueue(vec![track_idx]));
+ ui.show_search_modal = false;
+ ui.search_modal_input.clear();
+ ui.search_modal_results.clear();
+ ui.search_modal_selected = 0;
+ ui.search_modal_scroll = 0;
+ } else {
+ // Single click: select
+ ui.search_modal_selected = clicked;
+ }
+ }
+ }
+ }
+ MouseEventKind::ScrollDown => {
+ if in_results && !ui.search_modal_results.is_empty() {
+ let max_scroll = ui.search_modal_results.len()
+ .saturating_sub(ui.search_modal_result_height);
+ if ui.search_modal_scroll < max_scroll {
+ ui.search_modal_scroll += 1;
+ }
+ }
+ }
+ MouseEventKind::ScrollUp => {
+ if in_results && ui.search_modal_scroll > 0 {
+ ui.search_modal_scroll -= 1;
+ }
+ }
+ _ => {}
+ }
+ return actions;
+ }
+
+ // Block all mouse events when any other modal is open
+ if ui.show_about_modal || ui.show_help_modal || ui.show_playlist_modal {
+ return actions;
+ }
+
+ // Determine which pane the mouse is in
+ let in_library = x >= areas.library.x
+ && x < areas.library.x + areas.library.width
+ && y >= areas.library.y
+ && y < areas.library.y + areas.library.height;
+
+ let in_playlist = x >= areas.playlist.x
+ && x < areas.playlist.x + areas.playlist.width
+ && y >= areas.playlist.y
+ && y < areas.playlist.y + areas.playlist.height;
+
+ let in_lyrics = x >= areas.lyrics.x
+ && x < areas.lyrics.x + areas.lyrics.width
+ && y >= areas.lyrics.y
+ && y < areas.lyrics.y + areas.lyrics.height;
+
+ // --- Hover tracking (runs on every mouse event including Moved) ---
+ update_hover(ui, &areas, app, x, y, in_library, in_playlist);
+
+ // --- Tab hover highlight ---
+ if y >= areas.tab_bar.y && y < areas.tab_bar.y + areas.tab_bar.height {
+ ui.hovered_tab = tab_bar::tab_hit_test(areas.tab_bar, x);
+ } else {
+ ui.hovered_tab = None;
+ }
+
+ // --- Focus switching on hover (any mouse event in a pane) ---
+ if in_library && app.focus != FocusedPane::Library {
+ actions.push(AppAction::FocusPane(FocusedPane::Library));
+ } else if in_playlist && app.focus != FocusedPane::Playlist {
+ actions.push(AppAction::FocusPane(FocusedPane::Playlist));
+ } else if in_lyrics && app.focus != FocusedPane::Lyrics {
+ actions.push(AppAction::FocusPane(FocusedPane::Lyrics));
+ }
+
+ // --- Border drag resize ---
+ let border0_x = areas.library.x + areas.library.width; // lib|playlist boundary
+ let border1_x = areas.playlist.x + areas.playlist.width; // playlist|lyrics boundary
+ let in_dashboard_y = y >= areas.library.y && y < areas.library.y + areas.library.height;
+
+ // Handle active drag (before normal mouse processing)
+ if ui.dragging_border.is_some() {
+ match mouse.kind {
+ MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
+ if let Some(border_idx) = ui.dragging_border {
+ let dashboard_x = areas.library.x;
+ let dashboard_w = areas.library.width + areas.playlist.width + areas.lyrics.width;
+ if dashboard_w > 0 {
+ let rel_x = x.saturating_sub(dashboard_x);
+ let pct = ((rel_x as u32 * 100) / dashboard_w as u32) as u16;
+ let min_w: u16 = 10;
+ if border_idx == 0 {
+ // Dragging lib|playlist border
+ let new_lib = pct.clamp(min_w, 100 - min_w - ui.pane_widths[2]);
+ let new_play = (100 - new_lib - ui.pane_widths[2]).max(min_w);
+ let new_lib = 100 - new_play - ui.pane_widths[2];
+ if new_lib >= min_w {
+ ui.pane_widths[0] = new_lib;
+ ui.pane_widths[1] = new_play;
+ }
+ } else if border_idx == 1 {
+ // Dragging playlist|lyrics border
+ let new_right = (100u16.saturating_sub(pct)).max(min_w);
+ let new_play = (100 - ui.pane_widths[0] - new_right).max(min_w);
+ let new_right = 100 - ui.pane_widths[0] - new_play;
+ if new_right >= min_w {
+ ui.pane_widths[1] = new_play;
+ ui.pane_widths[2] = new_right;
+ }
+ } else if border_idx == 2 {
+ // Dragging info|lyrics horizontal border
+ let right_top = areas.info_pane.y;
+ let right_h = areas.info_pane.height + areas.lyrics.height;
+ if right_h > 0 {
+ let rel_y = y.saturating_sub(right_top);
+ let pct_v = ((rel_y as u32 * 100) / right_h as u32).clamp(10, 90) as u16;
+ ui.right_split = pct_v;
+ }
+ }
+ }
+ }
+ return actions;
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ ui.dragging_border = None;
+ return actions;
+ }
+ _ => {
+ ui.dragging_border = None;
+ }
+ }
+ }
+
+ // --- Handle specific event kinds ---
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ // Border drag start detection
+ if in_dashboard_y {
+ if x.abs_diff(border0_x) <= 1 {
+ ui.dragging_border = Some(0);
+ return actions;
+ }
+ if x.abs_diff(border1_x) <= 1 {
+ ui.dragging_border = Some(1);
+ return actions;
+ }
+ // Horizontal border between info_pane and lyrics (within right column)
+ let border2_y = areas.info_pane.y + areas.info_pane.height;
+ if x >= areas.info_pane.x
+ && x < areas.info_pane.x + areas.info_pane.width
+ && y.abs_diff(border2_y) <= 1
+ {
+ ui.dragging_border = Some(2);
+ return actions;
+ }
+ }
+
+ // Double-click detection
+ let is_double_click = if let Some((last_time, _last_col, last_row)) = ui.last_click {
+ last_time.elapsed() < Duration::from_millis(400) && last_row == y
+ } else {
+ false
+ };
+ ui.last_click = Some((Instant::now(), x, y));
+
+ // Tab bar click
+ if y >= areas.tab_bar.y && y < areas.tab_bar.y + areas.tab_bar.height {
+ if let Some(tab_idx) = tab_bar::tab_hit_test(areas.tab_bar, x) {
+ actions.push(AppAction::SwitchTab(Tab::from_index(tab_idx)));
+ }
+ return actions;
+ }
+
+ // Progress bar click
+ if y >= areas.progress_bar.y && y < areas.progress_bar.y + areas.progress_bar.height {
+ let gauge_area = progress_bar::progress_gauge_area(areas.progress_bar);
+ if x >= gauge_area.x && x < gauge_area.x + gauge_area.width {
+ let ratio = (x - gauge_area.x) as f64 / gauge_area.width as f64;
+ let seek_pos = ratio * app.playback.duration_secs;
+ actions.push(AppAction::Seek(seek_pos));
+ }
+ return actions;
+ }
+
+ // Double-click in playlist → play that track
+ if is_double_click && in_playlist {
+ let block = ratatui::widgets::Block::default()
+ .borders(ratatui::widgets::Borders::ALL);
+ let inner = block.inner(areas.playlist);
+ if y >= inner.y && y < inner.y + inner.height {
+ let clicked = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
+ if clicked < app.queue.tracks.len() {
+ actions.push(AppAction::PlayQueueIndex(clicked));
+ return actions;
+ }
+ }
+ }
+
+ // Single click in library → select + activate (Enter)
+ if in_library {
+ // First, route mouse to pane for selection update
+ let _sel_action = match app.tab {
+ Tab::Queue => ui.library_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Artists => ui.artists_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Albums => ui.albums_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Genre => ui.genre_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Format => ui.format_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Playlists => ui.playlists_pane.handle_mouse(mouse, areas.library, app),
+ };
+ // Then, trigger Enter action to activate the clicked item
+ let enter_key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
+ let activate_action = match app.tab {
+ Tab::Queue => ui.library_pane.handle_key(enter_key, app),
+ Tab::Directories => ui.dir_browser_pane.handle_key(enter_key, app),
+ Tab::Artists => ui.artists_pane.handle_key(enter_key, app),
+ Tab::Albums => ui.albums_pane.handle_key(enter_key, app),
+ Tab::Genre => ui.genre_pane.handle_key(enter_key, app),
+ Tab::Format => ui.format_pane.handle_key(enter_key, app),
+ Tab::Playlists => ui.playlists_pane.handle_key(enter_key, app),
+ };
+ if let Some(action) = activate_action {
+ if matches!(action, AppAction::AddToQueue(_)) {
+ actions.push(AppAction::FocusPane(FocusedPane::Playlist));
+ }
+ actions.push(action);
+ }
+ } else if in_playlist {
+ if let Some(a) = ui.queue_pane.handle_mouse(mouse, areas.playlist, app) {
+ actions.push(a);
+ }
+ // Update queue selection on click
+ let block = ratatui::widgets::Block::default()
+ .borders(ratatui::widgets::Borders::ALL);
+ let inner = block.inner(areas.playlist);
+ if y >= inner.y && y < inner.y + inner.height {
+ let clicked = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
+ if clicked < app.queue.tracks.len() {
+ actions.push(AppAction::SetQueueSelection(clicked));
+ }
+ }
+ } else if in_lyrics {
+ if let Some(a) = ui.lyrics_pane.handle_mouse(mouse, areas.lyrics, app) {
+ actions.push(a);
+ }
+ }
+ }
+ MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
+ if in_library {
+ let action = match app.tab {
+ Tab::Queue => ui.library_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Artists => ui.artists_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Albums => ui.albums_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Genre => ui.genre_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Format => ui.format_pane.handle_mouse(mouse, areas.library, app),
+ Tab::Playlists => ui.playlists_pane.handle_mouse(mouse, areas.library, app),
+ };
+ if let Some(a) = action {
+ actions.push(a);
+ }
+ } else if in_playlist {
+ if let Some(a) = ui.queue_pane.handle_mouse(mouse, areas.playlist, app) {
+ actions.push(a);
+ }
+ } else if in_lyrics {
+ if let Some(a) = ui.lyrics_pane.handle_mouse(mouse, areas.lyrics, app) {
+ actions.push(a);
+ }
+ }
+ }
+ _ => {
+ // Moved and other events — hover already handled above
+ }
+ }
+
+ actions
+}
+
+/// Clear all hover_row state across all panes
+fn clear_all_hovers(ui: &mut Ui) {
+ ui.queue_pane.hover_row = None;
+ ui.library_pane.hover_row = None;
+ ui.dir_browser_pane.hover_row = None;
+ ui.artists_pane.hover_row = None;
+ ui.albums_pane.hover_row = None;
+ ui.genre_pane.hover_row = None;
+ ui.format_pane.hover_row = None;
+ ui.playlists_pane.hover_row = None;
+}
+
+/// Update hover_row state for panes based on mouse position
+fn update_hover(
+ ui: &mut Ui,
+ areas: &LayoutAreas,
+ app: &App,
+ x: u16,
+ y: u16,
+ in_library: bool,
+ in_playlist: bool,
+) {
+ clear_all_hovers(ui);
+
+ if in_playlist {
+ let block = ratatui::widgets::Block::default()
+ .borders(ratatui::widgets::Borders::ALL);
+ let inner = block.inner(areas.playlist);
+ if x >= inner.x && x < inner.x + inner.width
+ && y >= inner.y && y < inner.y + inner.height
+ {
+ let row = ui.queue_pane.scroll_offset + (y - inner.y) as usize;
+ if row < app.queue.tracks.len() {
+ ui.queue_pane.hover_row = Some(row);
+ }
+ }
+ } else if in_library {
+ let block = ratatui::widgets::Block::default()
+ .borders(ratatui::widgets::Borders::ALL);
+ let inner = block.inner(areas.library);
+ if x >= inner.x && x < inner.x + inner.width
+ && y >= inner.y && y < inner.y + inner.height
+ {
+ let visual_row = (y - inner.y) as usize;
+ match app.tab {
+ Tab::Queue => {
+ let row = ui.library_pane.scroll_offset + visual_row;
+ ui.library_pane.hover_row = Some(row);
+ }
+ Tab::Directories => {
+ let row = ui.dir_browser_pane.scroll_offset + visual_row;
+ ui.dir_browser_pane.hover_row = Some(row);
+ }
+ Tab::Artists => {
+ let row = ui.artists_pane.scroll_offset + visual_row;
+ ui.artists_pane.hover_row = Some(row);
+ }
+ Tab::Albums => {
+ let row = ui.albums_pane.scroll_offset + visual_row;
+ ui.albums_pane.hover_row = Some(row);
+ }
+ Tab::Genre => {
+ let row = ui.genre_pane.scroll_offset + visual_row;
+ ui.genre_pane.hover_row = Some(row);
+ }
+ Tab::Format => {
+ let row = ui.format_pane.scroll_offset + visual_row;
+ ui.format_pane.hover_row = Some(row);
+ }
+ Tab::Playlists => {
+ let row = ui.playlists_pane.scroll_offset + visual_row;
+ ui.playlists_pane.hover_row = Some(row);
+ }
+ }
+ }
+ }
+}
+
+/// Refresh hover state from the stored mouse position.
+/// Call this on Tick events so hover stays updated even without Moved events.
+/// Returns a focus action if the mouse is over a different pane.
+pub fn refresh_hover(app: &App, ui: &mut Ui, terminal_area: ratatui::layout::Rect) -> Vec<AppAction> {
+ let mut actions = Vec::new();
+ // Skip hover updates when any modal is open
+ if ui.show_about_modal || ui.show_help_modal || ui.show_search_modal || ui.show_playlist_modal {
+ return actions;
+ }
+ if let Some((x, y)) = ui.mouse_pos {
+ let areas = LayoutAreas::compute(terminal_area, ui.pane_widths, ui.right_split);
+ let in_library = x >= areas.library.x
+ && x < areas.library.x + areas.library.width
+ && y >= areas.library.y
+ && y < areas.library.y + areas.library.height;
+ let in_playlist = x >= areas.playlist.x
+ && x < areas.playlist.x + areas.playlist.width
+ && y >= areas.playlist.y
+ && y < areas.playlist.y + areas.playlist.height;
+ let in_lyrics = x >= areas.lyrics.x
+ && x < areas.lyrics.x + areas.lyrics.width
+ && y >= areas.lyrics.y
+ && y < areas.lyrics.y + areas.lyrics.height;
+ update_hover(ui, &areas, app, x, y, in_library, in_playlist);
+
+ // Tab hover highlight
+ if y >= areas.tab_bar.y && y < areas.tab_bar.y + areas.tab_bar.height {
+ ui.hovered_tab = tab_bar::tab_hit_test(areas.tab_bar, x);
+ } else {
+ ui.hovered_tab = None;
+ }
+
+ // Focus switching on hover
+ if in_library && app.focus != FocusedPane::Library {
+ actions.push(AppAction::FocusPane(FocusedPane::Library));
+ } else if in_playlist && app.focus != FocusedPane::Playlist {
+ actions.push(AppAction::FocusPane(FocusedPane::Playlist));
+ } else if in_lyrics && app.focus != FocusedPane::Lyrics {
+ actions.push(AppAction::FocusPane(FocusedPane::Lyrics));
+ }
+ }
+ actions
+}
+
+/// Resize the focused pane by delta percentage points.
+/// Positive delta = grow the focused pane rightward, negative = shrink rightward.
+fn resize_pane(ui: &mut Ui, focus: FocusedPane, delta: i16) {
+ let min_width: u16 = 10;
+ let w = &mut ui.pane_widths;
+
+ match focus {
+ FocusedPane::Library => {
+ let new_lib = (w[0] as i16 + delta).clamp(min_width as i16, 80) as u16;
+ let diff = new_lib as i16 - w[0] as i16;
+ let new_play = (w[1] as i16 - diff).max(min_width as i16) as u16;
+ let actual_diff = w[1] as i16 - new_play as i16;
+ w[0] = (w[0] as i16 + actual_diff) as u16;
+ w[1] = new_play;
+ }
+ FocusedPane::Playlist => {
+ if delta < 0 {
+ let shrink = (-delta) as u16;
+ if w[1] > min_width + shrink - 1 {
+ w[1] -= shrink;
+ w[0] += shrink;
+ }
+ } else {
+ let grow = delta as u16;
+ if w[2] > min_width + grow - 1 {
+ w[2] -= grow;
+ w[1] += grow;
+ }
+ }
+ }
+ FocusedPane::Lyrics => {
+ let new_lyr = (w[2] as i16 - delta).clamp(min_width as i16, 80) as u16;
+ let diff = w[2] as i16 - new_lyr as i16;
+ let new_play = (w[1] as i16 + diff).max(min_width as i16) as u16;
+ let actual_diff = new_play as i16 - w[1] as i16;
+ w[2] = (w[2] as i16 - actual_diff) as u16;
+ w[1] = new_play;
+ }
+ }
+}
+
+/// Update queue selection based on keyboard in playlist focus
+pub fn update_queue_selection(app: &mut App, key: KeyEvent) {
+ let count = app.queue.tracks.len();
+ if count == 0 {
+ return;
+ }
+
+ match key.code {
+ KeyCode::Char('j') | KeyCode::Down => {
+ if app.queue.selected_index < count - 1 {
+ app.queue.selected_index += 1;
+ }
+ }
+ KeyCode::Char('k') | KeyCode::Up => {
+ if app.queue.selected_index > 0 {
+ app.queue.selected_index -= 1;
+ }
+ }
+ _ => {}
+ }
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -0,0 +1,452 @@
+pub mod handler;
+pub mod persist;
+pub mod state;
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use crossbeam_channel::Sender;
+
+use crate::audio::{AudioEngine, PlayerCommand};
+use crate::event::Event;
+use crate::library::Library;
+use state::*;
+
+#[derive(Debug, Clone)]
+pub enum AppAction {
+ Quit,
+ PlayTrack(usize),
+ PauseResume,
+ NextTrack,
+ PrevTrack,
+ SetVolume(f32),
+ VolumeUp,
+ VolumeDown,
+ Seek(f64),
+ SeekForward,
+ SeekBackward,
+ ToggleShuffle,
+ CycleRepeat,
+ SwitchTab(Tab),
+ FocusNext,
+ FocusPrev,
+ FocusPane(FocusedPane),
+ AddToQueue(Vec<usize>),
+ ClearQueue,
+ RemoveFromQueue(usize),
+ PlayQueueIndex(usize),
+ UpdatePosition { position_secs: f64, duration_secs: f64 },
+ TrackFinished,
+ SetQueueSelection(usize),
+ AddToPlaylist { playlist_idx: usize, track_idx: usize },
+ RemoveFromPlaylist { playlist_idx: usize, track_idx: usize },
+ CreatePlaylist(String),
+ DeletePlaylist(usize),
+ RenamePlaylist { idx: usize, name: String },
+ LibrarySync,
+}
+
+pub struct App {
+ pub should_quit: bool,
+ pub tab: Tab,
+ pub focus: FocusedPane,
+ pub playback: PlaybackState,
+ pub queue: QueueState,
+ pub library: Library,
+ pub music_dir: PathBuf,
+ pub search_query: String,
+ pub search_mode: bool,
+ pub search_results: Vec<usize>,
+ pub playlists: Vec<state::Playlist>,
+ pub track_just_changed: bool,
+ pub sync_state: SyncState,
+ pub initial_scan_complete: bool,
+ audio_engine: Option<AudioEngine>,
+ event_tx: Option<Sender<Event>>,
+}
+
+impl App {
+ pub fn new(music_dir: PathBuf) -> Self {
+ Self {
+ should_quit: false,
+ tab: Tab::Queue,
+ focus: FocusedPane::Library,
+ playback: PlaybackState::default(),
+ queue: QueueState::default(),
+ library: Library::new(),
+ music_dir,
+ search_query: String::new(),
+ search_mode: false,
+ search_results: Vec::new(),
+ playlists: vec![state::Playlist::new("Bookmarks")],
+ track_just_changed: false,
+ sync_state: SyncState::Idle,
+ initial_scan_complete: false,
+ audio_engine: None,
+ event_tx: None,
+ }
+ }
+
+ pub fn set_event_tx(&mut self, tx: Sender<Event>) {
+ self.event_tx = Some(tx);
+ }
+
+ pub fn set_audio_engine(&mut self, engine: AudioEngine) {
+ self.audio_engine = Some(engine);
+ }
+
+ pub fn handle_action(&mut self, action: AppAction) {
+ match action {
+ AppAction::Quit => {
+ self.should_quit = true;
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Stop);
+ }
+ }
+ AppAction::PlayTrack(track_idx) => {
+ if track_idx < self.library.tracks.len() {
+ let path = self.library.tracks[track_idx].path.clone();
+ let dur = self.library.tracks[track_idx].duration.as_secs_f64();
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Play(path));
+ }
+ self.playback.state = PlayState::Playing;
+ self.playback.position_secs = 0.0;
+ self.playback.duration_secs = dur;
+ self.track_just_changed = true;
+ }
+ }
+ AppAction::PauseResume => match self.playback.state {
+ PlayState::Playing => {
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Pause);
+ }
+ self.playback.state = PlayState::Paused;
+ }
+ PlayState::Paused => {
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Resume);
+ }
+ self.playback.state = PlayState::Playing;
+ }
+ PlayState::Stopped => {
+ // Try to play current queue item
+ if let Some(idx) = self.queue.current_index {
+ if let Some(&track_idx) = self.queue.tracks.get(idx) {
+ self.handle_action(AppAction::PlayTrack(track_idx));
+ }
+ }
+ }
+ },
+ AppAction::NextTrack => {
+ self.play_next();
+ }
+ AppAction::PrevTrack => {
+ self.play_prev();
+ }
+ AppAction::SetVolume(vol) => {
+ self.playback.volume = vol.clamp(0.0, 1.0);
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::SetVolume(self.playback.volume));
+ }
+ }
+ AppAction::VolumeUp => {
+ let vol = (self.playback.volume + 0.05).min(1.0);
+ self.handle_action(AppAction::SetVolume(vol));
+ }
+ AppAction::VolumeDown => {
+ let vol = (self.playback.volume - 0.05).max(0.0);
+ self.handle_action(AppAction::SetVolume(vol));
+ }
+ AppAction::Seek(secs) => {
+ let clamped = secs.clamp(0.0, self.playback.duration_secs);
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Seek(clamped));
+ }
+ self.playback.position_secs = clamped;
+ }
+ AppAction::SeekForward => {
+ let pos = self.playback.position_secs + 5.0;
+ self.handle_action(AppAction::Seek(pos));
+ }
+ AppAction::SeekBackward => {
+ let pos = self.playback.position_secs - 5.0;
+ self.handle_action(AppAction::Seek(pos));
+ }
+ AppAction::ToggleShuffle => {
+ self.playback.shuffle = !self.playback.shuffle;
+ }
+ AppAction::CycleRepeat => {
+ self.playback.repeat = self.playback.repeat.next();
+ }
+ AppAction::SwitchTab(tab) => {
+ self.tab = tab;
+ }
+ AppAction::FocusNext => {
+ self.focus = self.focus.next();
+ }
+ AppAction::FocusPrev => {
+ self.focus = self.focus.prev();
+ }
+ AppAction::FocusPane(pane) => {
+ self.focus = pane;
+ }
+ AppAction::AddToQueue(track_indices) => {
+ self.queue.tracks = track_indices;
+ self.queue.current_index = if self.queue.tracks.is_empty() { None } else { Some(0) };
+ self.queue.selected_index = 0;
+ self.queue.scroll_offset = 0;
+ }
+ AppAction::ClearQueue => {
+ self.queue.tracks.clear();
+ self.queue.current_index = None;
+ self.queue.selected_index = 0;
+ self.queue.scroll_offset = 0;
+ }
+ AppAction::RemoveFromQueue(idx) => {
+ if idx < self.queue.tracks.len() {
+ self.queue.tracks.remove(idx);
+ if self.queue.tracks.is_empty() {
+ self.queue.current_index = None;
+ } else if let Some(ref mut ci) = self.queue.current_index {
+ if idx < *ci {
+ *ci -= 1;
+ } else if idx == *ci && *ci >= self.queue.tracks.len() {
+ *ci = self.queue.tracks.len() - 1;
+ }
+ }
+ }
+ }
+ AppAction::PlayQueueIndex(idx) => {
+ if idx < self.queue.tracks.len() {
+ self.queue.current_index = Some(idx);
+ let track_idx = self.queue.tracks[idx];
+ self.handle_action(AppAction::PlayTrack(track_idx));
+ }
+ }
+ AppAction::UpdatePosition { position_secs, duration_secs } => {
+ self.playback.position_secs = position_secs;
+ if duration_secs > 0.0 {
+ self.playback.duration_secs = duration_secs;
+ }
+ }
+ AppAction::TrackFinished => {
+ self.play_next();
+ }
+ AppAction::SetQueueSelection(idx) => {
+ if idx < self.queue.tracks.len() {
+ self.queue.selected_index = idx;
+ }
+ }
+ AppAction::AddToPlaylist { playlist_idx, track_idx } => {
+ if let Some(pl) = self.playlists.get_mut(playlist_idx) {
+ if !pl.tracks.contains(&track_idx) {
+ pl.tracks.push(track_idx);
+ }
+ }
+ }
+ AppAction::RemoveFromPlaylist { playlist_idx, track_idx } => {
+ if let Some(pl) = self.playlists.get_mut(playlist_idx) {
+ pl.tracks.retain(|&t| t != track_idx);
+ }
+ }
+ AppAction::CreatePlaylist(name) => {
+ self.playlists.push(state::Playlist::new(name));
+ }
+ AppAction::DeletePlaylist(idx) => {
+ if idx < self.playlists.len() {
+ self.playlists.remove(idx);
+ }
+ }
+ AppAction::RenamePlaylist { idx, name } => {
+ if let Some(pl) = self.playlists.get_mut(idx) {
+ pl.name = name;
+ }
+ }
+ AppAction::LibrarySync => {
+ if self.sync_state == SyncState::Scanning || !self.initial_scan_complete {
+ return;
+ }
+ self.sync_state = SyncState::Scanning;
+ if let Some(ref tx) = self.event_tx {
+ let dir = self.music_dir.clone();
+ let tx = tx.clone();
+ std::thread::spawn(move || {
+ let lib = Library::scan(&dir);
+ let _ = tx.send(Event::LibraryReady(lib));
+ });
+ }
+ }
+ }
+ }
+
+ pub fn replace_library(&mut self, new_lib: Library) {
+ // Build path→new_index map
+ let path_map: HashMap<PathBuf, usize> = new_lib.tracks.iter().enumerate()
+ .map(|(i, t)| (t.path.clone(), i))
+ .collect();
+
+ // Capture current playing track path
+ let playing_path = self.queue.current_index
+ .and_then(|qi| self.queue.tracks.get(qi))
+ .and_then(|&ti| self.library.tracks.get(ti))
+ .map(|t| t.path.clone());
+
+ // Remap queue tracks
+ let new_queue_tracks: Vec<usize> = self.queue.tracks.iter()
+ .filter_map(|&old_idx| {
+ self.library.tracks.get(old_idx)
+ .and_then(|t| path_map.get(&t.path))
+ .copied()
+ })
+ .collect();
+
+ // Remap current_index: find playing track in new queue
+ let new_current = playing_path.and_then(|pp| {
+ path_map.get(&pp).and_then(|&new_ti| {
+ new_queue_tracks.iter().position(|&idx| idx == new_ti)
+ })
+ });
+
+ self.queue.tracks = new_queue_tracks;
+ self.queue.current_index = new_current;
+ self.queue.selected_index = self.queue.selected_index.min(
+ self.queue.tracks.len().saturating_sub(1)
+ );
+ self.queue.scroll_offset = self.queue.scroll_offset.min(
+ self.queue.tracks.len().saturating_sub(1)
+ );
+
+ // Remap playlists
+ for pl in &mut self.playlists {
+ pl.tracks = pl.tracks.iter()
+ .filter_map(|&old_idx| {
+ self.library.tracks.get(old_idx)
+ .and_then(|t| path_map.get(&t.path))
+ .copied()
+ })
+ .collect();
+ }
+
+ // Remap search results
+ if !self.search_query.is_empty() {
+ self.search_results = new_lib.search(&self.search_query);
+ }
+
+ self.library = new_lib;
+ self.sync_state = SyncState::Idle;
+ }
+
+ fn play_next(&mut self) {
+ if self.queue.tracks.is_empty() {
+ return;
+ }
+
+ match self.playback.repeat {
+ RepeatMode::One => {
+ if let Some(idx) = self.queue.current_index {
+ let track_idx = self.queue.tracks[idx];
+ let path = self.library.tracks[track_idx].path.clone();
+ let dur = self.library.tracks[track_idx].duration.as_secs_f64();
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Play(path));
+ }
+ self.playback.state = PlayState::Playing;
+ self.playback.position_secs = 0.0;
+ self.playback.duration_secs = dur;
+ self.track_just_changed = true;
+ }
+ }
+ _ => {
+ let next = if self.playback.shuffle {
+ use rand::Rng;
+ let mut rng = rand::thread_rng();
+ Some(rng.gen_range(0..self.queue.tracks.len()))
+ } else if let Some(idx) = self.queue.current_index {
+ let next_idx = idx + 1;
+ if next_idx < self.queue.tracks.len() {
+ Some(next_idx)
+ } else if self.playback.repeat == RepeatMode::All {
+ Some(0)
+ } else {
+ None
+ }
+ } else {
+ Some(0)
+ };
+
+ if let Some(next_idx) = next {
+ self.queue.current_index = Some(next_idx);
+ let track_idx = self.queue.tracks[next_idx];
+ let path = self.library.tracks[track_idx].path.clone();
+ let dur = self.library.tracks[track_idx].duration.as_secs_f64();
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Play(path));
+ }
+ self.playback.state = PlayState::Playing;
+ self.playback.position_secs = 0.0;
+ self.playback.duration_secs = dur;
+ self.track_just_changed = true;
+ } else {
+ self.playback.state = PlayState::Stopped;
+ self.playback.position_secs = 0.0;
+ }
+ }
+ }
+ }
+
+ fn play_prev(&mut self) {
+ if self.queue.tracks.is_empty() {
+ return;
+ }
+
+ // If more than 3 seconds in, restart current track
+ if self.playback.position_secs > 3.0 {
+ if let Some(idx) = self.queue.current_index {
+ let track_idx = self.queue.tracks[idx];
+ let path = self.library.tracks[track_idx].path.clone();
+ let dur = self.library.tracks[track_idx].duration.as_secs_f64();
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Play(path));
+ }
+ self.playback.position_secs = 0.0;
+ self.playback.duration_secs = dur;
+ self.track_just_changed = true;
+ return;
+ }
+ }
+
+ let prev = if let Some(idx) = self.queue.current_index {
+ if idx > 0 {
+ Some(idx - 1)
+ } else if self.playback.repeat == RepeatMode::All {
+ Some(self.queue.tracks.len() - 1)
+ } else {
+ Some(0)
+ }
+ } else {
+ Some(0)
+ };
+
+ if let Some(prev_idx) = prev {
+ self.queue.current_index = Some(prev_idx);
+ let track_idx = self.queue.tracks[prev_idx];
+ let path = self.library.tracks[track_idx].path.clone();
+ let dur = self.library.tracks[track_idx].duration.as_secs_f64();
+ if let Some(ref engine) = self.audio_engine {
+ engine.send(PlayerCommand::Play(path));
+ }
+ self.playback.state = PlayState::Playing;
+ self.playback.position_secs = 0.0;
+ self.playback.duration_secs = dur;
+ self.track_just_changed = true;
+ }
+ }
+
+ pub fn current_track(&self) -> Option<&crate::library::track::Track> {
+ self.queue
+ .current_index
+ .and_then(|qi| self.queue.tracks.get(qi))
+ .and_then(|&ti| self.library.tracks.get(ti))
+ }
+}
diff --git a/src/app/persist.rs b/src/app/persist.rs
@@ -0,0 +1,51 @@
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+
+#[derive(Serialize, Deserialize)]
+pub struct SavedState {
+ pub volume: f32,
+ pub shuffle: bool,
+ pub repeat: String,
+ pub pane_widths: [u16; 3],
+ pub playlists: Vec<SavedPlaylist>,
+ #[serde(default = "default_info_view")]
+ pub info_view: String,
+ #[serde(default = "default_right_split")]
+ pub right_split: u16,
+}
+
+fn default_info_view() -> String {
+ "Clock".to_string()
+}
+
+fn default_right_split() -> u16 {
+ 50
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct SavedPlaylist {
+ pub name: String,
+ pub tracks: Vec<PathBuf>,
+}
+
+fn state_path() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+ PathBuf::from(home).join(".config/ommp/state.json")
+}
+
+pub fn save(state: &SavedState) -> anyhow::Result<()> {
+ let path = state_path();
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let json = serde_json::to_string_pretty(state)?;
+ fs::write(&path, json)?;
+ Ok(())
+}
+
+pub fn load() -> Option<SavedState> {
+ let path = state_path();
+ let data = fs::read_to_string(path).ok()?;
+ serde_json::from_str(&data).ok()
+}
diff --git a/src/app/state.rs b/src/app/state.rs
@@ -0,0 +1,197 @@
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SyncState {
+ Idle,
+ Scanning,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PlayState {
+ Playing,
+ Paused,
+ Stopped,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepeatMode {
+ Off,
+ All,
+ One,
+}
+
+impl RepeatMode {
+ pub fn next(self) -> Self {
+ match self {
+ RepeatMode::Off => RepeatMode::All,
+ RepeatMode::All => RepeatMode::One,
+ RepeatMode::One => RepeatMode::Off,
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ RepeatMode::Off => "Off",
+ RepeatMode::All => "All",
+ RepeatMode::One => "One",
+ }
+ }
+
+ pub fn from_label(s: &str) -> Self {
+ match s {
+ "All" => RepeatMode::All,
+ "One" => RepeatMode::One,
+ _ => RepeatMode::Off,
+ }
+ }
+
+ pub fn symbol(self) -> &'static str {
+ match self {
+ RepeatMode::Off => "\u{F0456}", // nf-md-repeat
+ RepeatMode::All => "\u{F0456}", // nf-md-repeat
+ RepeatMode::One => "\u{F0458}", // nf-md-repeat_once
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Tab {
+ Queue,
+ Directories,
+ Artists,
+ Albums,
+ Genre,
+ Format,
+ Playlists,
+}
+
+impl Tab {
+ pub const ALL: [Tab; 7] = [
+ Tab::Queue,
+ Tab::Directories,
+ Tab::Artists,
+ Tab::Albums,
+ Tab::Genre,
+ Tab::Format,
+ Tab::Playlists,
+ ];
+
+ pub fn title(self) -> &'static str {
+ match self {
+ Tab::Queue => "Queue",
+ Tab::Directories => "Directories",
+ Tab::Artists => "Artists",
+ Tab::Albums => "Albums",
+ Tab::Genre => "Genre",
+ Tab::Format => "Format",
+ Tab::Playlists => "Playlists",
+ }
+ }
+
+ pub fn index(self) -> usize {
+ Tab::ALL.iter().position(|&t| t == self).unwrap_or(0)
+ }
+
+ pub fn from_index(i: usize) -> Self {
+ Tab::ALL.get(i).copied().unwrap_or(Tab::Queue)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FocusedPane {
+ Library,
+ Playlist,
+ Lyrics,
+}
+
+impl FocusedPane {
+ pub fn next(self) -> Self {
+ match self {
+ FocusedPane::Library => FocusedPane::Playlist,
+ FocusedPane::Playlist => FocusedPane::Lyrics,
+ FocusedPane::Lyrics => FocusedPane::Library,
+ }
+ }
+
+ pub fn prev(self) -> Self {
+ match self {
+ FocusedPane::Library => FocusedPane::Lyrics,
+ FocusedPane::Playlist => FocusedPane::Library,
+ FocusedPane::Lyrics => FocusedPane::Playlist,
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct PlaybackState {
+ pub state: PlayState,
+ pub position_secs: f64,
+ pub duration_secs: f64,
+ pub volume: f32,
+ pub shuffle: bool,
+ pub repeat: RepeatMode,
+}
+
+impl Default for PlaybackState {
+ fn default() -> Self {
+ Self {
+ state: PlayState::Stopped,
+ position_secs: 0.0,
+ duration_secs: 0.0,
+ volume: 0.8,
+ shuffle: false,
+ repeat: RepeatMode::Off,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct QueueState {
+ pub tracks: Vec<usize>,
+ pub current_index: Option<usize>,
+ pub selected_index: usize,
+ pub scroll_offset: usize,
+}
+
+#[derive(Debug, Clone)]
+pub struct Playlist {
+ pub name: String,
+ pub tracks: Vec<usize>,
+}
+
+impl Playlist {
+ pub fn new(name: impl Into<String>) -> Self {
+ Self {
+ name: name.into(),
+ tracks: Vec::new(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InfoView {
+ Clock,
+ AlbumArt,
+}
+
+impl InfoView {
+ pub fn next(self) -> Self {
+ match self {
+ InfoView::Clock => InfoView::AlbumArt,
+ InfoView::AlbumArt => InfoView::Clock,
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ InfoView::Clock => "Clock",
+ InfoView::AlbumArt => "AlbumArt",
+ }
+ }
+
+ pub fn from_label(s: &str) -> Self {
+ match s {
+ "AlbumArt" => InfoView::AlbumArt,
+ _ => InfoView::Clock,
+ }
+ }
+}
+
|