ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit 439bfcd6f486260b0eb247c030157ca2420556f1
parent 53fbcf0468bac8e2754a5e0613e3d34d5e44a8d2
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:31:13 +0900

Add application state, action handling, and session persistence

Central App struct holding library, queue, playlists, and playback state,
driven by an AppAction enum. Key and mouse handlers translate input into
actions. Volume, shuffle, repeat, pane layout, and playlists are saved to
disk on exit and restored on start.

Diffstat:
Asrc/app/handler.rs | 923+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/mod.rs | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/persist.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/state.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 1623 insertion(+), 0 deletion(-)

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,
+        }
+    }
+}
+