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 5aeea01f7c1878fa70c2636db895c0307e73f07a
parent 439bfcd6f486260b0eb247c030157ca2420556f1
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:31:13 +0900

Add TUI panes, widgets, layout, and theming

Resizable three-column dashboard with tabbed library browsing by artist,
album, genre, format, directory, and playlist. Widgets cover the status
bar, progress bar, info pane with album art and clock, and the search,
playlist, help, and about modals.

Diffstat:
Asrc/ui/layout.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/mod.rs | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/pane.rs | 15+++++++++++++++
Asrc/ui/panes/album_artists_pane.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/albums_pane.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/artists_pane.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/dir_browser_pane.rs | 272+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/format_pane.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/genre_pane.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/library_pane.rs | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/lyrics_pane.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/mod.rs | 9+++++++++
Asrc/ui/panes/playlists_pane.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/queue_pane.rs | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/panes/search_pane.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/theme.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/about_modal.rs | 379+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/help_modal.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/info_pane.rs | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/mod.rs | 8++++++++
Asrc/ui/widgets/playlist_modal.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/progress_bar.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/search_modal.rs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/status_bar.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/widgets/tab_bar.rs | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
25 files changed, 4428 insertion(+), 0 deletion(-)

diff --git a/src/ui/layout.rs b/src/ui/layout.rs
@@ -0,0 +1,58 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+
+#[derive(Debug, Clone)]
+pub struct LayoutAreas {
+    pub status_bar: Rect,
+    pub tab_bar: Rect,
+    pub library: Rect,
+    pub playlist: Rect,
+    pub info_pane: Rect,
+    pub lyrics: Rect,
+    pub progress_bar: Rect,
+}
+
+impl LayoutAreas {
+    pub fn compute(area: Rect, pane_widths: [u16; 3], right_split: u16) -> Self {
+        let vertical = Layout::default()
+            .direction(Direction::Vertical)
+            .constraints([
+                Constraint::Length(4),  // Status bar (bordered = 2 content + 2 border)
+                Constraint::Length(3),  // Tab bar (bordered)
+                Constraint::Min(10),   // Dashboard
+                Constraint::Length(3), // Progress bar (bordered)
+            ])
+            .split(area);
+
+        let status_bar = vertical[0];
+        let tab_bar = vertical[1];
+        let dashboard = vertical[2];
+        let progress_bar = vertical[3];
+
+        let columns = Layout::default()
+            .direction(Direction::Horizontal)
+            .constraints([
+                Constraint::Percentage(pane_widths[0]),
+                Constraint::Percentage(pane_widths[1]),
+                Constraint::Percentage(pane_widths[2]),
+            ])
+            .split(dashboard);
+
+        let right_col = Layout::default()
+            .direction(Direction::Vertical)
+            .constraints([
+                Constraint::Percentage(right_split),
+                Constraint::Percentage(100 - right_split),
+            ])
+            .split(columns[2]);
+
+        Self {
+            status_bar,
+            tab_bar,
+            library: columns[0],
+            playlist: columns[1],
+            info_pane: right_col[0],
+            lyrics: right_col[1],
+            progress_bar,
+        }
+    }
+}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -0,0 +1,304 @@
+pub mod layout;
+pub mod pane;
+pub mod panes;
+pub mod theme;
+pub mod widgets;
+
+use ratatui::Frame;
+use ratatui::style::{Color, Style};
+use ratatui::widgets::{Block, Borders};
+use widgets::{about_modal, help_modal, playlist_modal, search_modal};
+use widgets::playlist_modal::PlaylistModalMode;
+
+use crate::app::App;
+use crate::app::state::{FocusedPane, InfoView, Tab};
+use layout::LayoutAreas;
+use pane::Pane;
+use panes::albums_pane::AlbumsPane;
+use panes::artists_pane::ArtistsPane;
+use panes::dir_browser_pane::DirBrowserPane;
+use panes::format_pane::FormatPane;
+use panes::genre_pane::GenrePane;
+use panes::library_pane::LibraryPane;
+use panes::lyrics_pane::LyricsPane;
+use panes::playlists_pane::PlaylistsPane;
+use panes::queue_pane::QueuePane;
+use theme::Theme;
+use widgets::info_pane;
+use widgets::progress_bar;
+use widgets::status_bar;
+use widgets::tab_bar;
+
+pub struct Ui {
+    pub theme: Theme,
+    pub library_pane: LibraryPane,
+    pub dir_browser_pane: DirBrowserPane,
+    pub queue_pane: QueuePane,
+    pub artists_pane: ArtistsPane,
+    pub albums_pane: AlbumsPane,
+    pub genre_pane: GenrePane,
+    pub format_pane: FormatPane,
+    pub playlists_pane: PlaylistsPane,
+    pub lyrics_pane: LyricsPane,
+    pub last_click: Option<(std::time::Instant, u16, u16)>,
+    /// Last known mouse position (column, row) for hover tracking
+    pub mouse_pos: Option<(u16, u16)>,
+    /// Tab index currently hovered by mouse
+    pub hovered_tab: Option<usize>,
+    /// Pane width percentages [Library, Playlist, Lyrics], sum = 100
+    pub pane_widths: [u16; 3],
+    /// Resize mode active (Ctrl+E)
+    pub resize_mode: bool,
+    /// Border being dragged: 0 = lib|playlist, 1 = playlist|lyrics, 2 = info|lyrics (horizontal), None = not dragging
+    pub dragging_border: Option<u8>,
+    /// Right column split: info pane height percentage (top), lyrics gets the rest
+    pub right_split: u16,
+    /// Ctrl+E pressed, waiting for next key
+    pub chord_pending: bool,
+    /// Help modal visible
+    pub show_help_modal: bool,
+    /// Search modal visible
+    pub show_search_modal: bool,
+    /// Search modal input text
+    pub search_modal_input: String,
+    /// Search modal filtered results (track indices)
+    pub search_modal_results: Vec<usize>,
+    /// Search modal selected result index
+    pub search_modal_selected: usize,
+    /// Search modal scroll offset
+    pub search_modal_scroll: usize,
+    /// Search modal visible result row count (set during render)
+    pub search_modal_result_height: usize,
+    /// Search modal result area rect (set during render, for mouse hit-testing)
+    pub search_modal_result_area: ratatui::layout::Rect,
+    /// Search modal hovered row index
+    pub search_modal_hover_row: Option<usize>,
+    /// Playlist modal visible ("b" key)
+    pub show_playlist_modal: bool,
+    /// Playlist modal selected index
+    pub playlist_modal_selected: usize,
+    /// Playlist modal mode (List / Create / Rename)
+    pub playlist_modal_mode: PlaylistModalMode,
+    /// Playlist modal text input (for create/rename)
+    pub playlist_modal_input: String,
+    /// About modal visible
+    pub show_about_modal: bool,
+    /// Splash screen visible at startup
+    pub show_splash: bool,
+    /// Splash screen start time
+    pub splash_start: Option<std::time::Instant>,
+    /// Current info pane view (Clock / AlbumArt / TrackInfo)
+    pub info_view: InfoView,
+    /// Album art pixel cache
+    pub album_art_cache: info_pane::AlbumArtCache,
+}
+
+impl Ui {
+    pub fn new(music_dir: std::path::PathBuf, picker: ratatui_image::picker::Picker) -> Self {
+        Self {
+            theme: Theme::default(),
+            library_pane: LibraryPane::new(),
+            dir_browser_pane: DirBrowserPane::new(music_dir),
+            queue_pane: QueuePane::new(),
+            artists_pane: ArtistsPane::new(),
+            albums_pane: AlbumsPane::new(),
+            genre_pane: GenrePane::new(),
+            format_pane: FormatPane::new(),
+            playlists_pane: PlaylistsPane::new(),
+            lyrics_pane: LyricsPane::new(),
+            last_click: None,
+            mouse_pos: None,
+            hovered_tab: None,
+            pane_widths: [20, 60, 20],
+            resize_mode: false,
+            dragging_border: None,
+            right_split: 50,
+            chord_pending: false,
+            show_help_modal: false,
+            show_search_modal: false,
+            search_modal_input: String::new(),
+            search_modal_results: Vec::new(),
+            search_modal_selected: 0,
+            search_modal_scroll: 0,
+            search_modal_result_height: 10,
+            search_modal_result_area: ratatui::layout::Rect::default(),
+            search_modal_hover_row: None,
+            show_playlist_modal: false,
+            playlist_modal_selected: 0,
+            playlist_modal_mode: PlaylistModalMode::List,
+            playlist_modal_input: String::new(),
+            show_about_modal: false,
+            show_splash: true,
+            splash_start: Some(std::time::Instant::now()),
+            info_view: InfoView::Clock,
+            album_art_cache: info_pane::AlbumArtCache::new(picker),
+        }
+    }
+
+    pub fn render(&mut self, frame: &mut Frame, app: &App) {
+        if self.show_splash {
+            // Timeline: 0–0.5s fade-in, 0.5–1.5s hold, 1.5–2.0s fade-out
+            let elapsed = self.splash_start
+                .map(|s| s.elapsed().as_secs_f32())
+                .unwrap_or(2.0);
+            let opacity = if elapsed < 0.5 {
+                elapsed / 0.5
+            } else if elapsed < 1.5 {
+                1.0
+            } else {
+                (1.0 - (elapsed - 1.5) / 0.5).max(0.0)
+            };
+            about_modal::render_splash_screen(frame, frame.area(), &self.theme, opacity);
+            return;
+        }
+
+        let areas = LayoutAreas::compute(frame.area(), self.pane_widths, self.right_split);
+
+        // Status bar
+        status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme, self.resize_mode);
+
+        // Tab bar
+        tab_bar::render_tab_bar(frame, areas.tab_bar, app.tab, self.hovered_tab, &self.theme);
+
+        // Left pane (varies by tab)
+        let lib_focused = app.focus == FocusedPane::Library;
+        match app.tab {
+            Tab::Queue => self.library_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Directories => self.dir_browser_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Artists => self.artists_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Albums => self.albums_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Genre => self.genre_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Format => self.format_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+            Tab::Playlists => self.playlists_pane.render(frame, areas.library, lib_focused, app, &self.theme),
+        }
+
+        // Center pane (Queue)
+        let playlist_focused = app.focus == FocusedPane::Playlist;
+        self.queue_pane.render(frame, areas.playlist, playlist_focused, app, &self.theme);
+
+        // Right pane top (Info)
+        info_pane::render_info_pane(frame, areas.info_pane, app, &self.theme, self.info_view, &mut self.album_art_cache);
+
+        // Right pane bottom (Lyrics)
+        let lyrics_focused = app.focus == FocusedPane::Lyrics;
+        self.lyrics_pane.render(frame, areas.lyrics, lyrics_focused, app, &self.theme);
+
+        // Progress bar
+        progress_bar::render_progress_bar(frame, areas.progress_bar, app, &self.theme);
+
+        // Resize mode: overlay yellow border on focused pane
+        if self.resize_mode {
+            let focused_area = match app.focus {
+                FocusedPane::Library => areas.library,
+                FocusedPane::Playlist => areas.playlist,
+                FocusedPane::Lyrics => areas.lyrics,
+            };
+            let overlay = Block::default()
+                .borders(Borders::ALL)
+                .border_style(Style::default().fg(Color::Yellow));
+            frame.render_widget(overlay, focused_area);
+        }
+
+        // Modal overlays (rendered last, on top of everything)
+        if self.show_search_modal {
+            let (rh, ra) = search_modal::render_search_modal(
+                frame,
+                frame.area(),
+                &self.search_modal_input,
+                &self.search_modal_results,
+                self.search_modal_selected,
+                self.search_modal_scroll,
+                self.search_modal_hover_row,
+                app,
+                &self.theme,
+            );
+            self.search_modal_result_height = rh;
+            self.search_modal_result_area = ra;
+        }
+
+        if self.show_help_modal {
+            help_modal::render_help_modal(frame, frame.area(), &self.theme);
+        }
+
+        if self.show_about_modal {
+            about_modal::render_about_modal(frame, frame.area(), &self.theme);
+        }
+
+        if self.show_playlist_modal {
+            playlist_modal::render_playlist_modal(
+                frame,
+                frame.area(),
+                self.playlist_modal_selected,
+                &self.playlist_modal_mode,
+                &self.playlist_modal_input,
+                app,
+                &self.theme,
+            );
+        }
+    }
+
+    pub fn refresh_dir_browser(&mut self, app: &App) {
+        self.dir_browser_pane.refresh(app);
+    }
+
+    pub fn clamp_selections(&mut self, app: &App) {
+        let artists_len = app.library.get_artists().len();
+        if artists_len == 0 {
+            self.artists_pane.selected = 0;
+            self.artists_pane.scroll_offset = 0;
+        } else {
+            self.artists_pane.selected = self.artists_pane.selected.min(artists_len - 1);
+            self.artists_pane.scroll_offset = self.artists_pane.scroll_offset.min(artists_len - 1);
+        }
+
+        let albums_len = app.library.get_albums().len();
+        if albums_len == 0 {
+            self.albums_pane.selected = 0;
+            self.albums_pane.scroll_offset = 0;
+        } else {
+            self.albums_pane.selected = self.albums_pane.selected.min(albums_len - 1);
+            self.albums_pane.scroll_offset = self.albums_pane.scroll_offset.min(albums_len - 1);
+        }
+
+        let genres_len = app.library.get_genres().len();
+        if genres_len == 0 {
+            self.genre_pane.selected = 0;
+            self.genre_pane.scroll_offset = 0;
+        } else {
+            self.genre_pane.selected = self.genre_pane.selected.min(genres_len - 1);
+            self.genre_pane.scroll_offset = self.genre_pane.scroll_offset.min(genres_len - 1);
+        }
+
+        let formats_len = app.library.get_formats().len();
+        if formats_len == 0 {
+            self.format_pane.selected = 0;
+            self.format_pane.scroll_offset = 0;
+        } else {
+            self.format_pane.selected = self.format_pane.selected.min(formats_len - 1);
+            self.format_pane.scroll_offset = self.format_pane.scroll_offset.min(formats_len - 1);
+        }
+
+        let playlists_len = app.playlists.len();
+        if playlists_len == 0 {
+            self.playlists_pane.selected = 0;
+            self.playlists_pane.scroll_offset = 0;
+        } else {
+            self.playlists_pane.selected = self.playlists_pane.selected.min(playlists_len - 1);
+            self.playlists_pane.scroll_offset = self.playlists_pane.scroll_offset.min(playlists_len - 1);
+        }
+
+        // Reset library/dir browser to top since track indices changed
+        self.library_pane.selected = 0;
+        self.library_pane.scroll_offset = 0;
+        self.dir_browser_pane.selected = 0;
+        self.dir_browser_pane.scroll_offset = 0;
+
+        // Clamp queue pane scroll
+        let queue_len = app.queue.tracks.len();
+        if queue_len == 0 {
+            self.queue_pane.scroll_offset = 0;
+        } else {
+            self.queue_pane.scroll_offset = self.queue_pane.scroll_offset.min(queue_len - 1);
+        }
+    }
+}
diff --git a/src/ui/pane.rs b/src/ui/pane.rs
@@ -0,0 +1,15 @@
+use crossterm::event::{KeyEvent, MouseEvent};
+use ratatui::Frame;
+use ratatui::layout::Rect;
+
+use crate::app::{App, AppAction};
+use crate::ui::theme::Theme;
+
+pub trait Pane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme);
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction>;
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction>;
+    fn handle_scroll(&mut self, _up: bool, _app: &App) -> Option<AppAction> {
+        None
+    }
+}
diff --git a/src/ui/panes/album_artists_pane.rs b/src/ui/panes/album_artists_pane.rs
@@ -0,0 +1,186 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct AlbumArtistsPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl AlbumArtistsPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for AlbumArtistsPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let album_artists = app.library.get_album_artists();
+        let count = album_artists.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = album_artists
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, artist)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+                let style = if is_selected && focused {
+                    highlight
+                } else if is_hovered {
+                    Style::default().fg(theme.fg).bg(HOVER_BG)
+                } else {
+                    Style::default().fg(theme.fg)
+                };
+                ListItem::new(Line::from(Span::styled(format!("  {}", artist), style)))
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let album_artists = app.library.get_album_artists();
+        let count = album_artists.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < count {
+                    let tracks =
+                        app.library.get_tracks_by_album_artist(&album_artists[self.selected]);
+                    if !tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(tracks));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.library.get_album_artists().len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.library.get_album_artists().len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/albums_pane.rs b/src/ui/panes/albums_pane.rs
@@ -0,0 +1,190 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct AlbumsPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl AlbumsPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for AlbumsPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let albums = app.library.get_albums();
+        let count = albums.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = albums
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, (album, artist))| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+
+                if is_selected && focused {
+                    ListItem::new(Line::from(vec![
+                        Span::styled(format!("  {}", album), highlight),
+                        Span::styled(format!("  {}", artist), highlight),
+                    ]))
+                } else {
+                    let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                    ListItem::new(Line::from(vec![
+                        Span::styled(format!("  {}", album), Style::default().fg(theme.fg).bg(bg)),
+                        Span::styled(format!("  {}", artist), Style::default().fg(Color::Gray).bg(bg)),
+                    ]))
+                }
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let albums = app.library.get_albums();
+        let count = albums.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < count {
+                    let tracks = app.library.get_tracks_by_album(&albums[self.selected].0);
+                    if !tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(tracks));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.library.get_albums().len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.library.get_albums().len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/artists_pane.rs b/src/ui/panes/artists_pane.rs
@@ -0,0 +1,185 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct ArtistsPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl ArtistsPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for ArtistsPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let artists = app.library.get_artists();
+        let count = artists.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = artists
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, artist)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+                let style = if is_selected && focused {
+                    highlight
+                } else if is_hovered {
+                    Style::default().fg(theme.fg).bg(HOVER_BG)
+                } else {
+                    Style::default().fg(theme.fg)
+                };
+                ListItem::new(Line::from(Span::styled(format!("  {}", artist), style)))
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let artists = app.library.get_artists();
+        let count = artists.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < count {
+                    let tracks = app.library.get_tracks_by_artist(&artists[self.selected]);
+                    if !tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(tracks));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.library.get_artists().len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.library.get_artists().len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/dir_browser_pane.rs b/src/ui/panes/dir_browser_pane.rs
@@ -0,0 +1,272 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+use std::path::PathBuf;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct DirBrowserPane {
+    pub current_dir: PathBuf,
+    pub entries: Vec<DirEntry>,
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub enum DirEntry {
+    ParentDir,
+    Directory(String),
+    Track(usize),
+}
+
+impl DirBrowserPane {
+    pub fn new(music_dir: PathBuf) -> Self {
+        Self {
+            current_dir: music_dir,
+            entries: Vec::new(),
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+
+    pub fn refresh(&mut self, app: &App) {
+        self.entries.clear();
+
+        if self.current_dir != app.music_dir {
+            self.entries.push(DirEntry::ParentDir);
+        }
+
+        let (subdirs, tracks) = app.library.get_directory_entries(&self.current_dir);
+        for dir in subdirs {
+            self.entries.push(DirEntry::Directory(dir));
+        }
+        for track_idx in tracks {
+            self.entries.push(DirEntry::Track(track_idx));
+        }
+    }
+}
+
+impl Pane for DirBrowserPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let count = self.entries.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = self
+            .entries
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, entry)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+
+                match entry {
+                    DirEntry::ParentDir => {
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", highlight),
+                                Span::styled("..", highlight),
+                            ]))
+                        } else {
+                            let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", Style::default().fg(Color::Yellow).bg(bg)),
+                                Span::styled("..", Style::default().fg(theme.fg).bg(bg)),
+                            ]))
+                        }
+                    }
+                    DirEntry::Directory(name) => {
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", highlight),
+                                Span::styled(format!("{}/", name), highlight),
+                            ]))
+                        } else {
+                            let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", Style::default().fg(Color::Green).bg(bg)),
+                                Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(bg)),
+                            ]))
+                        }
+                    }
+                    DirEntry::Track(idx) => {
+                        let t = &app.library.tracks[*idx];
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F001} ", highlight),
+                                Span::styled(&t.title, highlight),
+                            ]))
+                        } else {
+                            let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F001} ", Style::default().fg(Color::Cyan).bg(bg)),
+                                Span::styled(&t.title, Style::default().fg(theme.fg).bg(bg)),
+                            ]))
+                        }
+                    }
+                }
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let count = self.entries.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                match &self.entries[self.selected] {
+                    DirEntry::ParentDir => {
+                        if let Some(parent) = self.current_dir.parent() {
+                            self.current_dir = parent.to_path_buf();
+                            self.selected = 0;
+                            self.scroll_offset = 0;
+                            self.refresh(app);
+                        }
+                    }
+                    DirEntry::Directory(name) => {
+                        self.current_dir = self.current_dir.join(name);
+                        self.selected = 0;
+                        self.scroll_offset = 0;
+                        self.refresh(app);
+                    }
+                    DirEntry::Track(idx) => {
+                        return Some(AppAction::AddToQueue(vec![*idx]));
+                    }
+                }
+                None
+            }
+            KeyCode::Backspace => {
+                if let Some(parent) = self.current_dir.parent() {
+                    self.current_dir = parent.to_path_buf();
+                    self.selected = 0;
+                    self.scroll_offset = 0;
+                    self.refresh(app);
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, _app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = self.entries.len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, _app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, _app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, _app: &App) -> Option<AppAction> {
+        let count = self.entries.len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/format_pane.rs b/src/ui/panes/format_pane.rs
@@ -0,0 +1,196 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct FormatPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl FormatPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for FormatPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let formats = app.library.get_formats();
+        let count = formats.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = formats
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, fmt)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+                let track_count = app.library.get_tracks_by_format(fmt).len();
+                let style = if is_selected && focused {
+                    highlight
+                } else if is_hovered {
+                    Style::default().fg(theme.fg).bg(HOVER_BG)
+                } else {
+                    Style::default().fg(theme.fg)
+                };
+                let count_style = if is_selected && focused {
+                    highlight
+                } else if is_hovered {
+                    Style::default().fg(Color::DarkGray).bg(HOVER_BG)
+                } else {
+                    Style::default().fg(Color::DarkGray)
+                };
+                ListItem::new(Line::from(vec![
+                    Span::styled(format!("  {}", fmt), style),
+                    Span::styled(format!(" ({})", track_count), count_style),
+                ]))
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let formats = app.library.get_formats();
+        let count = formats.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < count {
+                    let tracks = app.library.get_tracks_by_format(&formats[self.selected]);
+                    if !tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(tracks));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.library.get_formats().len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.library.get_formats().len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/genre_pane.rs b/src/ui/panes/genre_pane.rs
@@ -0,0 +1,185 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct GenrePane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl GenrePane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for GenrePane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let genres = app.library.get_genres();
+        let count = genres.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = genres
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, genre)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+                let style = if is_selected && focused {
+                    highlight
+                } else if is_hovered {
+                    Style::default().fg(theme.fg).bg(HOVER_BG)
+                } else {
+                    Style::default().fg(theme.fg)
+                };
+                ListItem::new(Line::from(Span::styled(format!("  {}", genre), style)))
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let genres = app.library.get_genres();
+        let count = genres.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < count {
+                    let tracks = app.library.get_tracks_by_genre(&genres[self.selected]);
+                    if !tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(tracks));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.library.get_genres().len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.library.get_genres().len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/library_pane.rs b/src/ui/panes/library_pane.rs
@@ -0,0 +1,408 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+/// An entry in the flattened library list
+#[derive(Debug, Clone)]
+enum LibraryEntry {
+    SectionHeader(String),
+    Separator,
+    AllTracks(usize),
+    PlaylistEntry { idx: usize, name: String, count: usize },
+    FavoriteDir(String),
+    Album { name: String, artist: String },
+}
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+/// Library browser for the Queue tab.
+/// Shows 4 sections: Playlist, Directories, Albums.
+pub struct LibraryPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl LibraryPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+
+    fn build_entries(app: &App) -> Vec<LibraryEntry> {
+        let mut entries = Vec::new();
+
+        // --- Playlist ---
+        entries.push(LibraryEntry::SectionHeader("\u{F054} Playlist".into()));
+        entries.push(LibraryEntry::AllTracks(app.library.tracks.len()));
+        for (idx, pl) in app.playlists.iter().enumerate() {
+            entries.push(LibraryEntry::PlaylistEntry {
+                idx,
+                name: pl.name.clone(),
+                count: pl.tracks.len(),
+            });
+        }
+
+        entries.push(LibraryEntry::Separator);
+
+        // --- Directories ---
+        let mut dirs = std::collections::BTreeSet::new();
+        for t in &app.library.tracks {
+            if let Some(parent) = t.path.parent() {
+                if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
+                    dirs.insert(name.to_string());
+                }
+            }
+        }
+        entries.push(LibraryEntry::SectionHeader(format!(
+            "\u{F054} Directories ({})",
+            dirs.len()
+        )));
+        for d in &dirs {
+            entries.push(LibraryEntry::FavoriteDir(d.clone()));
+        }
+
+        entries.push(LibraryEntry::Separator);
+
+        // --- Albums ---
+        let albums = app.library.get_albums();
+        entries.push(LibraryEntry::SectionHeader(format!(
+            "\u{F054} Albums ({})",
+            albums.len()
+        )));
+        for (album, artist) in albums {
+            entries.push(LibraryEntry::Album { name: album, artist });
+        }
+
+        entries
+    }
+}
+
+impl Pane for LibraryPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let entries = Self::build_entries(app);
+        let count = entries.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+
+        let items: Vec<ListItem> = entries
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, entry)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+                let highlight = Style::default()
+                    .bg(theme.highlight_bg)
+                    .fg(theme.highlight_fg)
+                    .add_modifier(Modifier::BOLD);
+                let hover_bg = if is_hovered && !(is_selected && focused) {
+                    HOVER_BG
+                } else {
+                    Color::Reset
+                };
+
+                match entry {
+                    LibraryEntry::SectionHeader(text) => {
+                        let style = if is_selected && focused {
+                            highlight
+                        } else {
+                            Style::default()
+                                .fg(Color::Cyan)
+                                .add_modifier(Modifier::BOLD)
+                                .bg(hover_bg)
+                        };
+                        ListItem::new(Line::from(Span::styled(text.as_str(), style)))
+                    }
+                    LibraryEntry::Separator => {
+                        ListItem::new(Line::from(""))
+                    }
+                    LibraryEntry::AllTracks(track_count) => {
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F001} ", highlight),
+                                Span::styled("All Tracks", highlight),
+                                Span::styled(format!(" ({})", track_count), highlight),
+                            ]))
+                        } else {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F001} ", Style::default().fg(Color::Cyan).bg(hover_bg)),
+                                Span::styled("All Tracks", Style::default().fg(theme.fg).bg(hover_bg)),
+                                Span::styled(
+                                    format!(" ({})", track_count),
+                                    Style::default().fg(Color::DarkGray).bg(hover_bg),
+                                ),
+                            ]))
+                        }
+                    }
+                    LibraryEntry::PlaylistEntry { name, count, .. } => {
+                        let icon = "\u{F005} "; // ★
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled(format!("  {}", icon), highlight),
+                                Span::styled(name.as_str(), highlight),
+                                Span::styled(format!(" ({})", count), highlight),
+                            ]))
+                        } else {
+                            ListItem::new(Line::from(vec![
+                                Span::styled(format!("  {}", icon), Style::default().fg(Color::Yellow).bg(hover_bg)),
+                                Span::styled(name.as_str(), Style::default().fg(theme.fg).bg(hover_bg)),
+                                Span::styled(
+                                    format!(" ({})", count),
+                                    Style::default().fg(Color::DarkGray).bg(hover_bg),
+                                ),
+                            ]))
+                        }
+                    }
+                    LibraryEntry::FavoriteDir(name) => {
+                        if is_selected && focused {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", highlight),
+                                Span::styled(format!("{}/", name), highlight),
+                            ]))
+                        } else {
+                            ListItem::new(Line::from(vec![
+                                Span::styled("  \u{F07B} ", Style::default().fg(Color::Green).bg(hover_bg)),
+                                Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(hover_bg)),
+                            ]))
+                        }
+                    }
+                    LibraryEntry::Album { name, artist } => {
+                        let album_display = if name.is_empty() {
+                            "Unknown Album"
+                        } else {
+                            name.as_str()
+                        };
+                        let artist_display = if artist.is_empty() {
+                            ""
+                        } else {
+                            artist.as_str()
+                        };
+
+                        if is_selected && focused {
+                            let mut spans = vec![
+                                Span::styled("  \u{F0025} ", highlight),
+                                Span::styled(album_display, highlight),
+                            ];
+                            if !artist_display.is_empty() {
+                                spans.push(Span::styled(
+                                    format!("  {}", artist_display),
+                                    highlight,
+                                ));
+                            }
+                            ListItem::new(Line::from(spans))
+                        } else {
+                            let mut spans = vec![
+                                Span::styled(
+                                    "  \u{F0025} ",
+                                    Style::default().fg(Color::Magenta).bg(hover_bg),
+                                ),
+                                Span::styled(album_display, Style::default().fg(theme.fg).bg(hover_bg)),
+                            ];
+                            if !artist_display.is_empty() {
+                                spans.push(Span::styled(
+                                    format!("  {}", artist_display),
+                                    Style::default().fg(Color::Gray).bg(hover_bg),
+                                ));
+                            }
+                            ListItem::new(Line::from(spans))
+                        }
+                    }
+                }
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        // Scrollbar
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let entries = Self::build_entries(app);
+        let count = entries.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                    // Skip separators when navigating
+                    if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
+                        && self.selected < count - 1
+                    {
+                        self.selected += 1;
+                    }
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                    // Skip separators when navigating
+                    if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
+                        && self.selected > 0
+                    {
+                        self.selected -= 1;
+                    }
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected >= count {
+                    return None;
+                }
+                match &entries[self.selected] {
+                    LibraryEntry::SectionHeader(_) | LibraryEntry::Separator => None,
+                    LibraryEntry::PlaylistEntry { idx, .. } => {
+                        if let Some(pl) = app.playlists.get(*idx) {
+                            if !pl.tracks.is_empty() {
+                                return Some(AppAction::AddToQueue(pl.tracks.clone()));
+                            }
+                        }
+                        None
+                    }
+                    LibraryEntry::AllTracks(_) => {
+                        let indices: Vec<usize> = (0..app.library.tracks.len()).collect();
+                        if !indices.is_empty() {
+                            Some(AppAction::AddToQueue(indices))
+                        } else {
+                            None
+                        }
+                    }
+                    LibraryEntry::FavoriteDir(dir_name) => {
+                        let indices: Vec<usize> = app
+                            .library
+                            .tracks
+                            .iter()
+                            .enumerate()
+                            .filter(|(_, t)| {
+                                t.path
+                                    .parent()
+                                    .and_then(|p| p.file_name())
+                                    .and_then(|n| n.to_str())
+                                    == Some(dir_name.as_str())
+                            })
+                            .map(|(i, _)| i)
+                            .collect();
+                        if !indices.is_empty() {
+                            Some(AppAction::AddToQueue(indices))
+                        } else {
+                            None
+                        }
+                    }
+                    LibraryEntry::Album { name, .. } => {
+                        let tracks = app.library.get_tracks_by_album(name);
+                        if !tracks.is_empty() {
+                            Some(AppAction::AddToQueue(tracks))
+                        } else {
+                            None
+                        }
+                    }
+                }
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = Self::build_entries(app).len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = Self::build_entries(app).len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/lyrics_pane.rs b/src/ui/panes/lyrics_pane.rs
@@ -0,0 +1,80 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::Style;
+use ratatui::widgets::{Block, Borders};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+use crate::ui::widgets::info_pane;
+
+pub struct LyricsPane {
+    pub scroll_offset: u16,
+}
+
+impl LyricsPane {
+    pub fn new() -> Self {
+        Self { scroll_offset: 0 }
+    }
+}
+
+impl Pane for LyricsPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color));
+
+        let inner = block.inner(area);
+        frame.render_widget(block, area);
+
+        info_pane::render_track_info(frame, inner, app, theme);
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, _app: &App) -> Option<AppAction> {
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                self.scroll_offset = self.scroll_offset.saturating_add(1);
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                self.scroll_offset = self.scroll_offset.saturating_sub(1);
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.scroll_offset = 0;
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, _area: Rect, _app: &App) -> Option<AppAction> {
+        match event.kind {
+            MouseEventKind::ScrollDown => {
+                self.scroll_offset = self.scroll_offset.saturating_add(3);
+                None
+            }
+            MouseEventKind::ScrollUp => {
+                self.scroll_offset = self.scroll_offset.saturating_sub(3);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, _app: &App) -> Option<AppAction> {
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+        } else {
+            self.scroll_offset = self.scroll_offset.saturating_add(3);
+        }
+        None
+    }
+}
diff --git a/src/ui/panes/mod.rs b/src/ui/panes/mod.rs
@@ -0,0 +1,9 @@
+pub mod library_pane;
+pub mod dir_browser_pane;
+pub mod queue_pane;
+pub mod artists_pane;
+pub mod albums_pane;
+pub mod genre_pane;
+pub mod format_pane;
+pub mod playlists_pane;
+pub mod lyrics_pane;
diff --git a/src/ui/panes/playlists_pane.rs b/src/ui/panes/playlists_pane.rs
@@ -0,0 +1,196 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+pub struct PlaylistsPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl PlaylistsPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for PlaylistsPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let count = app.playlists.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+
+        // Auto-scroll
+        if count > 0 {
+            if self.selected >= count {
+                self.selected = count.saturating_sub(1);
+            }
+            if self.selected < self.scroll_offset {
+                self.scroll_offset = self.selected;
+            }
+            if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
+                self.scroll_offset = self.selected - inner_height + 1;
+            }
+        }
+
+        let has_scrollbar = count > inner_height;
+        let highlight = Style::default()
+            .bg(theme.highlight_bg)
+            .fg(theme.highlight_fg)
+            .add_modifier(Modifier::BOLD);
+
+        let items: Vec<ListItem> = app
+            .playlists
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, pl)| {
+                let is_selected = i == self.selected;
+                let is_hovered = self.hover_row == Some(i);
+
+                if is_selected && focused {
+                    ListItem::new(Line::from(vec![
+                        Span::styled(format!("  \u{F005} {}", pl.name), highlight),
+                        Span::styled(format!(" ({})", pl.tracks.len()), highlight),
+                    ]))
+                } else {
+                    let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                    ListItem::new(Line::from(vec![
+                        Span::styled(
+                            "  \u{F005} ",
+                            Style::default().fg(Color::Yellow).bg(bg),
+                        ),
+                        Span::styled(
+                            pl.name.as_str(),
+                            Style::default().fg(theme.fg).bg(bg),
+                        ),
+                        Span::styled(
+                            format!(" ({})", pl.tracks.len()),
+                            Style::default().fg(Color::DarkGray).bg(bg),
+                        ),
+                    ]))
+                }
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let count = app.playlists.len();
+        if count == 0 {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < count - 1 {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if let Some(pl) = app.playlists.get(self.selected) {
+                    if !pl.tracks.is_empty() {
+                        return Some(AppAction::AddToQueue(pl.tracks.clone()));
+                    }
+                }
+                None
+            }
+            KeyCode::Home | KeyCode::Char('g') => {
+                self.selected = 0;
+                self.scroll_offset = 0;
+                None
+            }
+            KeyCode::End | KeyCode::Char('G') => {
+                self.selected = count.saturating_sub(1);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.playlists.len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => {
+                if count > 0 {
+                    self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+                    self.selected = (self.selected + 3).min(count.saturating_sub(1));
+                }
+                None
+            }
+            MouseEventKind::ScrollUp => {
+                self.scroll_offset = self.scroll_offset.saturating_sub(3);
+                self.selected = self.selected.saturating_sub(3);
+                None
+            }
+            _ => None,
+        }
+    }
+}
diff --git a/src/ui/panes/queue_pane.rs b/src/ui/panes/queue_pane.rs
@@ -0,0 +1,296 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+use unicode_width::UnicodeWidthStr;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238); // very dark gray
+
+pub struct QueuePane {
+    pub scroll_offset: usize,
+    pub hover_row: Option<usize>,
+}
+
+impl QueuePane {
+    pub fn new() -> Self {
+        Self {
+            scroll_offset: 0,
+            hover_row: None,
+        }
+    }
+}
+
+/// Color for each audio format extension
+fn format_color(ext: &str) -> Color {
+    match ext {
+        "FLAC" => Color::Green,
+        "M4A" | "AAC" | "MP4" | "ALAC" => Color::Cyan,
+        "MP3" => Color::Yellow,
+        "OGG" => Color::Magenta,
+        "WAV" | "WAVE" => Color::Blue,
+        _ => Color::White,
+    }
+}
+
+/// Truncate a string to fit within `max_width` columns, adding "…" if needed.
+/// Pads with spaces to exactly fill `max_width`.
+fn fit_to_width(s: &str, max_width: usize) -> String {
+    let str_width = UnicodeWidthStr::width(s);
+    if str_width <= max_width {
+        let padding = max_width - str_width;
+        format!("{}{}", s, " ".repeat(padding))
+    } else {
+        let mut w = 0;
+        let mut result = String::new();
+        for ch in s.chars() {
+            let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
+            if w + ch_w + 1 > max_width {
+                result.push('\u{2026}'); // …
+                w += 1;
+                break;
+            }
+            w += ch_w;
+            result.push(ch);
+        }
+        let pad = max_width.saturating_sub(w);
+        result.push_str(&" ".repeat(pad));
+        result
+    }
+}
+
+impl Pane for QueuePane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let count = app.queue.tracks.len();
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        let inner_height = inner.height as usize;
+        let inner_width = inner.width as usize;
+
+        // Auto-scroll to keep selected_index visible
+        if count > 0 {
+            if app.queue.selected_index < self.scroll_offset {
+                self.scroll_offset = app.queue.selected_index;
+            }
+            if inner_height > 0 && app.queue.selected_index >= self.scroll_offset + inner_height {
+                self.scroll_offset = app.queue.selected_index - inner_height + 1;
+            }
+        }
+
+        // Column layout: prefix(2) + title(55%) + artist(45%) + ext(4) + gap(1) + dur(5) + trail(1)
+        let ext_col_width = 4;
+        let dur_col_width = 5;
+        let prefix_width = 2;
+        let fixed_width = prefix_width + 1 + ext_col_width + 1 + dur_col_width + 1;
+        let flex_total = inner_width.saturating_sub(fixed_width);
+        let title_max = (flex_total * 55 / 100).max(4);
+        let artist_max = flex_total.saturating_sub(title_max).max(4);
+        let has_scrollbar = count > inner_height;
+
+        let items: Vec<ListItem> = app
+            .queue
+            .tracks
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, &track_idx)| {
+                let track = &app.library.tracks[track_idx];
+                let is_current = app.queue.current_index == Some(i);
+                let is_selected = i == app.queue.selected_index;
+
+                let artist = track.display_artist();
+                let ext = track
+                    .path
+                    .extension()
+                    .and_then(|e| e.to_str())
+                    .unwrap_or("?")
+                    .to_uppercase();
+                let dur = track.format_duration();
+
+                // Base styles
+                let sel_style = Style::default()
+                    .bg(theme.highlight_bg)
+                    .fg(theme.highlight_fg)
+                    .add_modifier(Modifier::BOLD);
+                let cur_style = theme.current_track_style;
+                let normal_style = Style::default().fg(theme.fg);
+                let dim_style = Style::default().fg(Color::Gray);
+
+                let is_hovered = self.hover_row == Some(i);
+
+                let (title_style, artist_style, ext_style, dur_style, prefix_style) =
+                    if is_selected && focused {
+                        (sel_style, sel_style, sel_style, sel_style, sel_style)
+                    } else if is_current {
+                        let bg = if is_hovered { HOVER_BG } else { Color::Reset };
+                        (
+                            cur_style.bg(bg),
+                            dim_style.bg(bg),
+                            Style::default().fg(format_color(&ext)).add_modifier(Modifier::BOLD).bg(bg),
+                            Style::default().fg(Color::DarkGray).bg(bg),
+                            cur_style.bg(bg),
+                        )
+                    } else if is_hovered {
+                        (
+                            normal_style.bg(HOVER_BG),
+                            dim_style.bg(HOVER_BG),
+                            Style::default().fg(format_color(&ext)).bg(HOVER_BG),
+                            Style::default().fg(Color::DarkGray).bg(HOVER_BG),
+                            normal_style.bg(HOVER_BG),
+                        )
+                    } else {
+                        (
+                            normal_style,
+                            dim_style,
+                            Style::default().fg(format_color(&ext)),
+                            Style::default().fg(Color::DarkGray),
+                            normal_style,
+                        )
+                    };
+
+                let in_playlist = app.playlists.iter().any(|pl| pl.tracks.contains(&track_idx));
+                let prefix = if is_current { "\u{F04B} " } else { "  " }; // nf-fa-play
+
+                // Star integrated into title text so it stays next to the title
+                let title_text = if in_playlist {
+                    format!("{} \u{F005}", track.title) // "Title nf-fa-star"
+                } else {
+                    track.title.clone()
+                };
+                let title_fitted = fit_to_width(&title_text, title_max);
+                let artist_fitted = fit_to_width(artist, artist_max);
+
+                // Right-align ext to ext_col_width
+                let ext_padded = format!("{:>width$}", ext, width = ext_col_width);
+                // Right-align dur to dur_col_width
+                let dur_padded = format!("{:>width$}", dur, width = dur_col_width);
+
+                // Row background for gap spans (keeps selection/hover highlight continuous)
+                let row_bg = if is_selected && focused {
+                    sel_style
+                } else if is_hovered {
+                    Style::default().bg(HOVER_BG)
+                } else {
+                    Style::default()
+                };
+
+                ListItem::new(Line::from(vec![
+                    Span::styled(prefix, prefix_style),
+                    Span::styled(title_fitted, title_style),
+                    Span::styled(artist_fitted, artist_style),
+                    Span::styled(" ", row_bg),
+                    Span::styled(ext_padded, ext_style),
+                    Span::styled(" ", row_bg),
+                    Span::styled(dur_padded, dur_style),
+                    Span::styled(" ", row_bg),
+                ]))
+            })
+            .collect();
+
+        let list = List::new(items).block(block);
+        frame.render_widget(list, area);
+
+        // Scrollbar (rendered over the right border)
+        if has_scrollbar {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(count)
+                .position(self.scroll_offset);
+            frame.render_stateful_widget(
+                scrollbar,
+                area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        let count = app.queue.tracks.len();
+        if count == 0 && key.code != KeyCode::Char('c') {
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('j') | KeyCode::Down => None,
+            KeyCode::Char('k') | KeyCode::Up => None,
+            KeyCode::Enter => {
+                if count > 0 {
+                    Some(AppAction::PlayQueueIndex(app.queue.selected_index))
+                } else {
+                    None
+                }
+            }
+            KeyCode::Char('d') | KeyCode::Delete => {
+                if count > 0 {
+                    Some(AppAction::RemoveFromQueue(app.queue.selected_index))
+                } else {
+                    None
+                }
+            }
+            KeyCode::Char('c') => Some(AppAction::ClearQueue),
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+        let count = app.queue.tracks.len();
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.column >= inner.x
+                    && event.column < inner.x + inner.width
+                    && event.row >= inner.y
+                    && event.row < inner.y + inner.height
+                {
+                    let clicked = self.scroll_offset + (event.row - inner.y) as usize;
+                    if clicked < count {
+                        // Selection handled by handler.rs
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.queue.tracks.len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            let new_sel = app.queue.selected_index.saturating_sub(3);
+            Some(AppAction::SetQueueSelection(new_sel))
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            let new_sel = (app.queue.selected_index + 3).min(count.saturating_sub(1));
+            Some(AppAction::SetQueueSelection(new_sel))
+        }
+    }
+}
diff --git a/src/ui/panes/search_pane.rs b/src/ui/panes/search_pane.rs
@@ -0,0 +1,207 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
+use ratatui::Frame;
+
+use crate::app::{App, AppAction};
+use crate::ui::pane::Pane;
+use crate::ui::theme::Theme;
+
+pub struct SearchPane {
+    pub selected: usize,
+    pub scroll_offset: usize,
+    pub input_mode: bool,
+    pub hover_row: Option<usize>,
+}
+
+impl SearchPane {
+    pub fn new() -> Self {
+        Self {
+            selected: 0,
+            scroll_offset: 0,
+            input_mode: false,
+            hover_row: None,
+        }
+    }
+}
+
+impl Pane for SearchPane {
+    fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
+        let border_color = if focused {
+            theme.border_focused
+        } else {
+            theme.border_unfocused
+        };
+
+        let block = Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(border_color))
+            .title_style(Style::default().fg(if focused {
+                theme.border_focused
+            } else {
+                theme.fg
+            }));
+
+        let inner = block.inner(area);
+        frame.render_widget(block, area);
+
+        let chunks = Layout::default()
+            .direction(Direction::Vertical)
+            .constraints([Constraint::Length(1), Constraint::Min(1)])
+            .split(inner);
+
+        // Search input line
+        let input_style = if self.input_mode {
+            Style::default().fg(Color::White).bg(Color::DarkGray)
+        } else {
+            Style::default().fg(Color::Gray)
+        };
+
+        let cursor = if self.input_mode { "▎" } else { "" };
+        let input = Paragraph::new(Line::from(vec![
+            Span::styled(" / ", Style::default().fg(Color::Cyan)),
+            Span::styled(&app.search_query, input_style),
+            Span::styled(cursor, Style::default().fg(Color::White)),
+        ]));
+        frame.render_widget(input, chunks[0]);
+
+        // Results
+        let inner_height = chunks[1].height as usize;
+        let items: Vec<ListItem> = app
+            .search_results
+            .iter()
+            .enumerate()
+            .skip(self.scroll_offset)
+            .take(inner_height)
+            .map(|(i, &track_idx)| {
+                let track = &app.library.tracks[track_idx];
+                let is_selected = i == self.selected;
+                let style = if is_selected && focused {
+                    Style::default()
+                        .bg(theme.highlight_bg)
+                        .fg(theme.highlight_fg)
+                        .add_modifier(Modifier::BOLD)
+                } else {
+                    Style::default().fg(theme.fg)
+                };
+
+                let artist_style = if is_selected && focused {
+                    style
+                } else {
+                    Style::default().fg(Color::Gray)
+                };
+
+                ListItem::new(Line::from(vec![
+                    Span::styled(&track.title, style),
+                    Span::styled(format!(" - {}", track.display_artist()), artist_style),
+                ]))
+            })
+            .collect();
+
+        let list = List::new(items);
+        frame.render_widget(list, chunks[1]);
+    }
+
+    fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
+        if self.input_mode || app.search_mode {
+            match key.code {
+                KeyCode::Esc => {
+                    self.input_mode = false;
+                    return Some(AppAction::ExitSearchMode);
+                }
+                KeyCode::Enter => {
+                    self.input_mode = false;
+                    // Add selected search result to queue
+                    if self.selected < app.search_results.len() {
+                        let track_idx = app.search_results[self.selected];
+                        return Some(AppAction::AddToQueue(vec![track_idx]));
+                    }
+                    return Some(AppAction::ExitSearchMode);
+                }
+                KeyCode::Backspace => {
+                    let mut q = app.search_query.clone();
+                    q.pop();
+                    return Some(AppAction::SearchQuery(q));
+                }
+                KeyCode::Char(c) => {
+                    let mut q = app.search_query.clone();
+                    q.push(c);
+                    return Some(AppAction::SearchQuery(q));
+                }
+                _ => {}
+            }
+            return None;
+        }
+
+        match key.code {
+            KeyCode::Char('/') | KeyCode::Char('i') => {
+                self.input_mode = true;
+                Some(AppAction::EnterSearchMode)
+            }
+            KeyCode::Char('j') | KeyCode::Down => {
+                if self.selected < app.search_results.len().saturating_sub(1) {
+                    self.selected += 1;
+                }
+                None
+            }
+            KeyCode::Char('k') | KeyCode::Up => {
+                if self.selected > 0 {
+                    self.selected -= 1;
+                }
+                None
+            }
+            KeyCode::Enter => {
+                if self.selected < app.search_results.len() {
+                    let track_idx = app.search_results[self.selected];
+                    Some(AppAction::AddToQueue(vec![track_idx]))
+                } else {
+                    None
+                }
+            }
+            _ => None,
+        }
+    }
+
+    fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
+        let block = Block::default().borders(Borders::ALL);
+        let inner = block.inner(area);
+
+        match event.kind {
+            MouseEventKind::Down(MouseButton::Left) => {
+                if event.row == inner.y {
+                    // Click on search input
+                    self.input_mode = true;
+                    return Some(AppAction::EnterSearchMode);
+                }
+                let results_y = inner.y + 1;
+                if event.row >= results_y && event.row < inner.y + inner.height {
+                    let clicked = self.scroll_offset + (event.row - results_y) as usize;
+                    if clicked < app.search_results.len() {
+                        self.selected = clicked;
+                    }
+                }
+                None
+            }
+            MouseEventKind::ScrollDown => self.handle_scroll(false, app),
+            MouseEventKind::ScrollUp => self.handle_scroll(true, app),
+            _ => None,
+        }
+    }
+
+    fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
+        let count = app.search_results.len();
+        if count == 0 {
+            return None;
+        }
+        if up {
+            self.scroll_offset = self.scroll_offset.saturating_sub(3);
+            self.selected = self.selected.saturating_sub(3);
+        } else {
+            self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
+            self.selected = (self.selected + 3).min(count.saturating_sub(1));
+        }
+        None
+    }
+}
diff --git a/src/ui/theme.rs b/src/ui/theme.rs
@@ -0,0 +1,50 @@
+use ratatui::style::{Color, Modifier, Style};
+
+#[allow(dead_code)]
+pub struct Theme {
+    pub bg: Color,
+    pub fg: Color,
+    pub border_focused: Color,
+    pub border_unfocused: Color,
+    pub highlight_bg: Color,
+    pub highlight_fg: Color,
+    pub tab_active: Style,
+    pub tab_inactive: Style,
+    pub status_bar_bg: Color,
+    pub progress_filled: Color,
+    pub progress_empty: Color,
+    pub playing_indicator: Color,
+    pub title_style: Style,
+    pub artist_style: Style,
+    pub dim_style: Style,
+    pub current_track_style: Style,
+}
+
+impl Default for Theme {
+    fn default() -> Self {
+        Self {
+            bg: Color::Reset,
+            fg: Color::White,
+            border_focused: Color::Cyan,
+            border_unfocused: Color::Cyan,
+            highlight_bg: Color::Cyan,
+            highlight_fg: Color::Black,
+            tab_active: Style::default()
+                .fg(Color::Cyan)
+                .add_modifier(Modifier::BOLD),
+            tab_inactive: Style::default().fg(Color::DarkGray),
+            status_bar_bg: Color::DarkGray,
+            progress_filled: Color::Rgb(200, 80, 255),
+            progress_empty: Color::Indexed(236),
+            playing_indicator: Color::Rgb(80, 255, 120),
+            title_style: Style::default()
+                .fg(Color::White)
+                .add_modifier(Modifier::BOLD),
+            artist_style: Style::default().fg(Color::Gray),
+            dim_style: Style::default().fg(Color::DarkGray),
+            current_track_style: Style::default()
+                .fg(Color::Rgb(100, 220, 255))
+                .add_modifier(Modifier::BOLD),
+        }
+    }
+}
diff --git a/src/ui/widgets/about_modal.rs b/src/ui/widgets/about_modal.rs
@@ -0,0 +1,379 @@
+use ratatui::buffer::Buffer;
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::widgets::{Block, Borders, Clear};
+use ratatui::Frame;
+
+use crate::ui::theme::Theme;
+
+// Neon colors
+const NEON_PINK: Color = Color::Rgb(255, 50, 150);
+const NEON_CYAN: Color = Color::Rgb(0, 255, 255);
+const BG_DARK: Color = Color::Rgb(10, 5, 30);
+
+// Logo gradient endpoints: hot pink -> cyan (smooth per-character interpolation)
+const LOGO_START: (f32, f32, f32) = (255.0, 50.0, 150.0); // hot pink
+const LOGO_END: (f32, f32, f32) = (0.0, 255.0, 255.0);    // cyan
+
+// Dim background scatter colors
+const DIM_PURPLE: Color = Color::Rgb(60, 20, 80);
+const DIM_CYAN: Color = Color::Rgb(20, 60, 80);
+const DIM_PINK: Color = Color::Rgb(80, 20, 50);
+
+const NOTES: [char; 4] = ['♪', '♫', '♩', '◆'];
+const DIM_COLORS: [Color; 3] = [DIM_PURPLE, DIM_CYAN, DIM_PINK];
+
+// Waveform pattern for below-logo decoration
+const WAVEFORM: &str = "▁▂▃▅▇█▇▅▃▂▁▂▃▅▇█▇▅▃▂▁";
+
+// Unicode block-art logo — each row is 35 display columns
+const LOGO: [&str; 5] = [
+    " ██████  ██    ██ ██    ██ ██████  ",
+    "██    ██ ███  ███ ███  ███ ██   ██ ",
+    "██    ██ ██ ██ ██ ██ ██ ██ ██████  ",
+    "██    ██ ██    ██ ██    ██ ██      ",
+    " ██████  ██    ██ ██    ██ ██      ",
+];
+const LOGO_DISPLAY_W: u16 = 35;
+
+/// Fill area with dark background + scattered music notes
+fn render_background(buf: &mut Buffer, area: Rect) {
+    let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
+    for y in area.y..area.y + area.height {
+        for x in area.x..area.x + area.width {
+            buf.set_string(x, y, " ", bg);
+        }
+    }
+
+    for y in area.y..area.y + area.height {
+        for x in area.x..area.x + area.width {
+            let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
+            if hash.is_multiple_of(37) {
+                let note = NOTES[(hash as usize / 37) % NOTES.len()];
+                let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
+                buf.set_string(x, y, note.to_string(), Style::default().fg(color).bg(BG_DARK));
+            }
+        }
+    }
+}
+
+/// Render waveform with cyan->purple gradient, centered at given y
+fn render_waveform(buf: &mut Buffer, area_x: u16, area_w: u16, y: u16, max_y: u16) {
+    if y >= max_y {
+        return;
+    }
+    let chars: Vec<char> = WAVEFORM.chars().collect();
+    let total_w = chars.len() as u16;
+    let start_x = area_x + area_w.saturating_sub(total_w) / 2;
+
+    for (i, ch) in chars.iter().enumerate() {
+        let x = start_x + i as u16;
+        if x >= area_x && x < area_x + area_w {
+            let frac = i as f32 / chars.len().max(1) as f32;
+            let r = (frac * 160.0) as u8;
+            let g = (255.0 - frac * 195.0) as u8;
+            let b = (255.0 - frac * 15.0) as u8;
+            buf.set_string(
+                x, y, ch.to_string(),
+                Style::default().fg(Color::Rgb(r, g, b)).bg(BG_DARK),
+            );
+        }
+    }
+}
+
+/// Lerp a foreground color toward BG_DARK by opacity (0.0 = invisible, 1.0 = full color)
+fn fade_color(target: Color, opacity: f32) -> Color {
+    match target {
+        Color::Rgb(r, g, b) => {
+            let (br, bg, bb) = (10.0_f32, 5.0_f32, 30.0_f32);
+            Color::Rgb(
+                (br + (r as f32 - br) * opacity) as u8,
+                (bg + (g as f32 - bg) * opacity) as u8,
+                (bb + (b as f32 - bb) * opacity) as u8,
+            )
+        }
+        other => other,
+    }
+}
+
+/// Helper: center a string of `display_w` columns within `area_w`, returning x offset from area.x
+fn center_x(area_x: u16, area_w: u16, display_w: u16) -> u16 {
+    area_x + area_w.saturating_sub(display_w) / 2
+}
+
+pub fn render_about_modal(frame: &mut Frame, area: Rect, _theme: &Theme) {
+    let modal = centered_rect(50, 60, area);
+    frame.render_widget(Clear, modal);
+
+    // Background with scattered music notes on entire modal
+    render_background(frame.buffer_mut(), modal);
+
+    // Modal border
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(NEON_PINK).bg(BG_DARK))
+        .title(" ♪ OMMP ♪ ")
+        .title_style(
+            Style::default()
+                .fg(NEON_PINK)
+                .bg(BG_DARK)
+                .add_modifier(Modifier::BOLD),
+        );
+
+    let inner = block.inner(modal);
+    frame.render_widget(block, modal);
+
+    // Re-render background inside inner area (border rendering overwrites)
+    render_background(frame.buffer_mut(), inner);
+
+    // === Calculate total content height for vertical centering ===
+    // Logo(5) + waveform(1) + 1 + subtitle(1) + tagline(1) + 1 + divider(1) + 1 + info(2) + 1 + links(2) + 1 + divider(1) + 1 + footer(1) = 21
+    let content_h: u16 = 21;
+    let top_pad = inner.height.saturating_sub(content_h) / 2;
+    let mut cur_y = inner.y + top_pad;
+
+    let buf = frame.buffer_mut();
+
+    // --- Logo with smooth horizontal gradient (hot pink -> cyan, per-character RGB lerp) ---
+    for (row_idx, row) in LOGO.iter().enumerate() {
+        if cur_y < inner.y + inner.height {
+            let logo_x = center_x(inner.x, inner.width, LOGO_DISPLAY_W);
+            for (col, ch) in row.chars().enumerate() {
+                let t = (col as f32 + row_idx as f32 * 3.0)
+                    / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
+                let t = t.clamp(0.0, 1.0);
+                let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
+                let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
+                let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
+                let style = Style::default()
+                    .fg(Color::Rgb(r, g, b))
+                    .bg(BG_DARK)
+                    .add_modifier(Modifier::BOLD);
+                buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
+            }
+        }
+        cur_y += 1;
+    }
+
+    // --- Waveform below logo ---
+    render_waveform(buf, inner.x, inner.width, cur_y, inner.y + inner.height);
+    cur_y += 2;
+
+    // --- Subtitle ---
+    let subtitle = "Oh My Music Player";
+    let sub_w = subtitle.len() as u16;
+    if cur_y < inner.y + inner.height {
+        let x = center_x(inner.x, inner.width, sub_w);
+        buf.set_string(
+            x, cur_y, subtitle,
+            Style::default()
+                .fg(NEON_CYAN)
+                .bg(BG_DARK)
+                .add_modifier(Modifier::BOLD | Modifier::ITALIC),
+        );
+    }
+    cur_y += 1;
+
+    // --- Tagline ---
+    let tagline = "Terminal music, your way";
+    let tag_w = tagline.len() as u16;
+    if cur_y < inner.y + inner.height {
+        let x = center_x(inner.x, inner.width, tag_w);
+        buf.set_string(x, cur_y, tagline, Style::default().fg(NEON_CYAN).bg(BG_DARK));
+    }
+    cur_y += 2;
+
+    // --- Divider ---
+    let div_w = inner.width.saturating_sub(6) as usize;
+    let div_style = Style::default().fg(DIM_PURPLE).bg(BG_DARK);
+    if cur_y < inner.y + inner.height {
+        buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
+    }
+    cur_y += 2;
+
+    // --- Info rows ---
+    let label_style = Style::default()
+        .fg(NEON_PINK)
+        .bg(BG_DARK)
+        .add_modifier(Modifier::BOLD);
+    let value_style = Style::default().fg(NEON_CYAN).bg(BG_DARK);
+
+    for (label, value) in &[("Version", "0.1.0"), ("License", "MIT")] {
+        if cur_y < inner.y + inner.height {
+            let lbl = format!("  {:12}", label);
+            let val = *value;
+            let total_w = (lbl.len() + val.len()) as u16;
+            let x = center_x(inner.x, inner.width, total_w);
+            buf.set_string(x, cur_y, &lbl, label_style);
+            buf.set_string(x + lbl.len() as u16, cur_y, val, value_style);
+        }
+        cur_y += 1;
+    }
+    cur_y += 1;
+
+    // --- Links ---
+    let link_style = Style::default()
+        .fg(Color::Rgb(100, 180, 255))
+        .bg(BG_DARK)
+        .add_modifier(Modifier::UNDERLINED);
+    let icon_style = Style::default()
+        .fg(NEON_PINK)
+        .bg(BG_DARK)
+        .add_modifier(Modifier::BOLD);
+
+    for (icon, url) in &[
+        ("\u{F09B} ", "https://github.com/sqzer-x/ommp"),
+        ("\u{F004} ", "https://github.com/sponsors/sqzer-x"),
+    ] {
+        if cur_y < inner.y + inner.height {
+            let icon_w = icon.chars().count() as u16;
+            let url_w = url.len() as u16;
+            let total_w = icon_w + url_w;
+            let x = center_x(inner.x, inner.width, total_w);
+            buf.set_string(x, cur_y, *icon, icon_style);
+            buf.set_string(x + icon_w, cur_y, *url, link_style);
+        }
+        cur_y += 1;
+    }
+    cur_y += 1;
+
+    // --- Divider ---
+    if cur_y < inner.y + inner.height {
+        buf.set_string(inner.x + 3, cur_y, "─".repeat(div_w), div_style);
+    }
+    cur_y += 2;
+
+    // --- Footer ---
+    let footer = "g: Open GitHub  s: Open Sponsor  Esc: Close";
+    let fw = footer.len() as u16;
+    if cur_y < inner.y + inner.height {
+        let x = center_x(inner.x, inner.width, fw);
+        buf.set_string(
+            x, cur_y, footer,
+            Style::default().fg(Color::Rgb(120, 120, 140)).bg(BG_DARK),
+        );
+    }
+}
+
+/// Render a full-screen splash screen with fade-in/fade-out.
+/// `opacity`: 0.0 = fully transparent (BG_DARK only), 1.0 = full brightness.
+pub fn render_splash_screen(frame: &mut Frame, area: Rect, _theme: &Theme, opacity: f32) {
+    let opacity = opacity.clamp(0.0, 1.0);
+    let buf = frame.buffer_mut();
+
+    // Fill entire screen with dark background
+    let bg = Style::default().bg(BG_DARK).fg(BG_DARK);
+    for y in area.y..area.y + area.height {
+        for x in area.x..area.x + area.width {
+            buf.set_string(x, y, " ", bg);
+        }
+    }
+
+    // Scattered music notes (faded)
+    for y in area.y..area.y + area.height {
+        for x in area.x..area.x + area.width {
+            let hash = (x as u32).wrapping_mul(7).wrapping_add((y as u32).wrapping_mul(13));
+            if hash.is_multiple_of(37) {
+                let note = NOTES[(hash as usize / 37) % NOTES.len()];
+                let color = DIM_COLORS[(hash as usize / 37 + 1) % DIM_COLORS.len()];
+                buf.set_string(
+                    x, y, note.to_string(),
+                    Style::default().fg(fade_color(color, opacity)).bg(BG_DARK),
+                );
+            }
+        }
+    }
+
+    // Content height: logo(5) + waveform(1) + gap(1) + subtitle(1) + tagline(1) = 9
+    let content_h: u16 = 9;
+    let top_pad = area.height.saturating_sub(content_h) / 2;
+    let mut cur_y = area.y + top_pad;
+
+    // --- Logo with smooth horizontal gradient (faded) ---
+    for (row_idx, row) in LOGO.iter().enumerate() {
+        if cur_y < area.y + area.height {
+            let logo_x = center_x(area.x, area.width, LOGO_DISPLAY_W);
+            for (col, ch) in row.chars().enumerate() {
+                let t = (col as f32 + row_idx as f32 * 3.0)
+                    / (LOGO_DISPLAY_W as f32 + 4.0 * 3.0);
+                let t = t.clamp(0.0, 1.0);
+                let r = (LOGO_START.0 + (LOGO_END.0 - LOGO_START.0) * t) as u8;
+                let g = (LOGO_START.1 + (LOGO_END.1 - LOGO_START.1) * t) as u8;
+                let b = (LOGO_START.2 + (LOGO_END.2 - LOGO_START.2) * t) as u8;
+                let style = Style::default()
+                    .fg(fade_color(Color::Rgb(r, g, b), opacity))
+                    .bg(BG_DARK)
+                    .add_modifier(Modifier::BOLD);
+                buf.set_string(logo_x + col as u16, cur_y, ch.to_string(), style);
+            }
+        }
+        cur_y += 1;
+    }
+
+    // --- Waveform below logo (faded) ---
+    if cur_y < area.y + area.height {
+        let chars: Vec<char> = WAVEFORM.chars().collect();
+        let total_w = chars.len() as u16;
+        let start_x = area.x + area.width.saturating_sub(total_w) / 2;
+        for (i, ch) in chars.iter().enumerate() {
+            let x = start_x + i as u16;
+            if x >= area.x && x < area.x + area.width {
+                let frac = i as f32 / chars.len().max(1) as f32;
+                let r = (frac * 160.0) as u8;
+                let g = (255.0 - frac * 195.0) as u8;
+                let b = (255.0 - frac * 15.0) as u8;
+                buf.set_string(
+                    x, cur_y, ch.to_string(),
+                    Style::default().fg(fade_color(Color::Rgb(r, g, b), opacity)).bg(BG_DARK),
+                );
+            }
+        }
+    }
+    cur_y += 2;
+
+    // --- Subtitle (faded) ---
+    let subtitle = "Oh My Music Player";
+    let sub_w = subtitle.len() as u16;
+    if cur_y < area.y + area.height {
+        let x = center_x(area.x, area.width, sub_w);
+        buf.set_string(
+            x, cur_y, subtitle,
+            Style::default()
+                .fg(fade_color(NEON_CYAN, opacity))
+                .bg(BG_DARK)
+                .add_modifier(Modifier::BOLD | Modifier::ITALIC),
+        );
+    }
+    cur_y += 1;
+
+    // --- Tagline (faded) ---
+    let tagline = "Terminal music, your way";
+    let tag_w = tagline.len() as u16;
+    if cur_y < area.y + area.height {
+        let x = center_x(area.x, area.width, tag_w);
+        buf.set_string(
+            x, cur_y, tagline,
+            Style::default().fg(fade_color(NEON_CYAN, opacity)).bg(BG_DARK),
+        );
+    }
+}
+
+fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
+    let vertical = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Percentage((100 - percent_y) / 2),
+            Constraint::Percentage(percent_y),
+            Constraint::Percentage((100 - percent_y) / 2),
+        ])
+        .split(area);
+
+    Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage((100 - percent_x) / 2),
+            Constraint::Percentage(percent_x),
+            Constraint::Percentage((100 - percent_x) / 2),
+        ])
+        .split(vertical[1])[1]
+}
diff --git a/src/ui/widgets/help_modal.rs b/src/ui/widgets/help_modal.rs
@@ -0,0 +1,90 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, Paragraph};
+use ratatui::Frame;
+
+use crate::ui::theme::Theme;
+
+const KEYBINDINGS: &[(&str, &str)] = &[
+    ("Ctrl+E, s", "Search"),
+    ("Ctrl+E, h", "Help (this modal)"),
+    ("Ctrl+E, r", "Resize mode"),
+    ("Ctrl+E, i", "About OMMP"),
+    ("Ctrl+E, l", "Sync library"),
+    ("", ""),
+    ("Space", "Play / Pause"),
+    ("n / N", "Next / Previous track"),
+    ("+ / -", "Volume up / down"),
+    ("\u{2192} / \u{2190}", "Seek forward / backward"),
+    ("s", "Toggle shuffle"),
+    ("r", "Cycle repeat mode"),
+    ("b", "Add to playlist"),
+    ("", ""),
+    ("1-7", "Switch tab"),
+    ("Tab / Shift+Tab", "Cycle pane focus"),
+    ("j / k", "Navigate list"),
+    ("g / G", "Jump to first / last"),
+    ("Enter", "Select / Activate"),
+    ("d", "Remove from queue"),
+    ("c", "Clear queue"),
+    ("q", "Quit"),
+];
+
+pub fn render_help_modal(frame: &mut Frame, area: Rect, theme: &Theme) {
+    let modal = centered_rect(50, 70, area);
+
+    frame.render_widget(Clear, modal);
+
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(Color::Cyan))
+        .title(" Keybindings ")
+        .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
+
+    let inner = block.inner(modal);
+    frame.render_widget(block, modal);
+
+    let lines: Vec<Line> = KEYBINDINGS
+        .iter()
+        .map(|(key, desc)| {
+            if key.is_empty() {
+                Line::from("")
+            } else {
+                Line::from(vec![
+                    Span::styled(
+                        format!("  {:20}", key),
+                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+                    ),
+                    Span::styled(
+                        desc.to_string(),
+                        Style::default().fg(theme.fg),
+                    ),
+                ])
+            }
+        })
+        .collect();
+
+    let help_text = Paragraph::new(lines);
+    frame.render_widget(help_text, inner);
+}
+
+fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
+    let vertical = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Percentage((100 - percent_y) / 2),
+            Constraint::Percentage(percent_y),
+            Constraint::Percentage((100 - percent_y) / 2),
+        ])
+        .split(area);
+
+    Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage((100 - percent_x) / 2),
+            Constraint::Percentage(percent_x),
+            Constraint::Percentage((100 - percent_x) / 2),
+        ])
+        .split(vertical[1])[1]
+}
diff --git a/src/ui/widgets/info_pane.rs b/src/ui/widgets/info_pane.rs
@@ -0,0 +1,382 @@
+use std::path::{Path, PathBuf};
+use std::time::SystemTime;
+
+use ratatui::layout::{Alignment, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
+use ratatui::Frame;
+
+use ratatui_image::picker::Picker;
+use ratatui_image::protocol::StatefulProtocol;
+use ratatui_image::StatefulImage;
+
+use crate::app::state::InfoView;
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+// ── AlbumArtCache ────────────────────────────────────────────────────────
+
+pub struct AlbumArtCache {
+    track_dir: Option<PathBuf>,
+    picker: Picker,
+    protocol: Option<StatefulProtocol>,
+}
+
+impl AlbumArtCache {
+    pub fn new(picker: Picker) -> Self {
+        Self {
+            track_dir: None,
+            picker,
+            protocol: None,
+        }
+    }
+
+    fn needs_reload(&self, dir: Option<&Path>) -> bool {
+        match (&self.track_dir, dir) {
+            (Some(a), Some(b)) => a != b,
+            (None, None) => false,
+            _ => true,
+        }
+    }
+
+    fn load(&mut self, dir: Option<&Path>) {
+        self.track_dir = dir.map(|d| d.to_path_buf());
+        self.protocol = None;
+
+        let dir = match dir {
+            Some(d) => d,
+            None => return,
+        };
+
+        let cover_path = match find_cover_image(dir) {
+            Some(p) => p,
+            None => return,
+        };
+
+        let img = match image::open(&cover_path) {
+            Ok(i) => i,
+            Err(_) => return,
+        };
+
+        // StatefulProtocol handles resizing automatically per-frame
+        self.protocol = Some(self.picker.new_resize_protocol(img));
+    }
+}
+
+fn find_cover_image(dir: &Path) -> Option<PathBuf> {
+    let entries = std::fs::read_dir(dir).ok()?;
+    for entry in entries.flatten() {
+        let path = entry.path();
+        if !path.is_file() {
+            continue;
+        }
+        // Check extension first (fast path)
+        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
+            let l = ext.to_ascii_lowercase();
+            if l == "jpg" || l == "jpeg" || l == "png" {
+                return Some(path);
+            }
+        }
+        // No extension or unknown ext — check magic bytes
+        if is_image_by_magic(&path) {
+            return Some(path);
+        }
+    }
+    None
+}
+
+/// Check file header bytes to detect JPEG/PNG regardless of extension.
+fn is_image_by_magic(path: &Path) -> bool {
+    use std::fs::File;
+    use std::io::Read;
+    let mut f = match File::open(path) {
+        Ok(f) => f,
+        Err(_) => return false,
+    };
+    let mut buf = [0u8; 8];
+    if f.read_exact(&mut buf).is_err() {
+        return false;
+    }
+    // JPEG: FF D8 FF
+    if buf[0] == 0xFF && buf[1] == 0xD8 && buf[2] == 0xFF {
+        return true;
+    }
+    // PNG: 89 50 4E 47 0D 0A 1A 0A
+    if buf == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] {
+        return true;
+    }
+    false
+}
+
+// ── Public render function ───────────────────────────────────────────────
+
+pub fn render_info_pane(
+    frame: &mut Frame,
+    area: Rect,
+    app: &App,
+    theme: &Theme,
+    view: InfoView,
+    art_cache: &mut AlbumArtCache,
+) {
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(theme.border_unfocused));
+
+    let inner = block.inner(area);
+    frame.render_widget(block, area);
+
+    match view {
+        InfoView::Clock => render_clock(frame, inner, theme),
+        InfoView::AlbumArt => render_album_art(frame, inner, app, art_cache),
+    }
+}
+
+// ── Clock View ───────────────────────────────────────────────────────────
+
+/// 5-line tall ASCII art digits using block characters
+const DIGIT_PATTERNS: [&[&str]; 11] = [
+    // 0
+    &["████", "█  █", "█  █", "█  █", "████"],
+    // 1
+    &["  █ ", " ██ ", "  █ ", "  █ ", " ███"],
+    // 2
+    &["████", "   █", "████", "█   ", "████"],
+    // 3
+    &["████", "   █", "████", "   █", "████"],
+    // 4
+    &["█  █", "█  █", "████", "   █", "   █"],
+    // 5
+    &["████", "█   ", "████", "   █", "████"],
+    // 6
+    &["████", "█   ", "████", "█  █", "████"],
+    // 7
+    &["████", "   █", "  █ ", " █  ", " █  "],
+    // 8
+    &["████", "█  █", "████", "█  █", "████"],
+    // 9
+    &["████", "█  █", "████", "   █", "████"],
+    // : (colon, index 10)
+    &["    ", " ██ ", "    ", " ██ ", "    "],
+];
+
+fn render_clock(frame: &mut Frame, area: Rect, _theme: &Theme) {
+    if area.width < 4 || area.height < 5 {
+        return;
+    }
+
+    let now = SystemTime::now()
+        .duration_since(SystemTime::UNIX_EPOCH)
+        .unwrap_or_default()
+        .as_secs();
+
+    let (hours, minutes, _) = {
+        let total_secs = now as i64;
+        let mut tm = unsafe { std::mem::zeroed::<libc::tm>() };
+        unsafe {
+            libc::localtime_r(&total_secs as *const i64, &mut tm);
+        }
+        (tm.tm_hour as u32, tm.tm_min as u32, tm.tm_sec as u32)
+    };
+
+    // Build digit indices: HH:MM
+    let digits = [
+        (hours / 10) as usize,
+        (hours % 10) as usize,
+        10, // colon
+        (minutes / 10) as usize,
+        (minutes % 10) as usize,
+    ];
+
+    // Each digit is 4 chars wide + 1 space gap
+    // Total: 5 * 4 + 4 gaps = 24 chars
+    let total_width: u16 = 5 * 4 + 4;
+    let x_offset = if area.width > total_width {
+        (area.width - total_width) / 2
+    } else {
+        0
+    };
+    let y_offset = if area.height > 5 {
+        (area.height - 5) / 2
+    } else {
+        0
+    };
+
+    // Gradient colors per digit: sky blue → cyan → mint → purple → pink
+    let digit_colors: [Color; 5] = [
+        Color::Rgb(100, 180, 255), // sky blue
+        Color::Rgb(80, 220, 255),  // cyan
+        Color::Rgb(200, 200, 200), // colon: white-gray
+        Color::Rgb(200, 130, 255), // purple
+        Color::Rgb(255, 130, 200), // pink
+    ];
+
+    for row in 0..5u16 {
+        let y = area.y + y_offset + row;
+        if y >= area.y + area.height {
+            break;
+        }
+        let mut x = area.x + x_offset;
+        for (i, &digit_idx) in digits.iter().enumerate() {
+            let style = Style::default().fg(digit_colors[i % digit_colors.len()]);
+            let pattern = DIGIT_PATTERNS[digit_idx][row as usize];
+            for ch in pattern.chars() {
+                if x >= area.x + area.width {
+                    break;
+                }
+                let display_ch = if ch == '█' { '█' } else { ' ' };
+                if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
+                    cell.set_char(display_ch).set_style(style);
+                }
+                x += 1;
+            }
+            // Gap between digits
+            if i < digits.len() - 1 {
+                x += 1;
+            }
+        }
+    }
+}
+
+// ── Album Art View ───────────────────────────────────────────────────────
+
+fn render_album_art(frame: &mut Frame, area: Rect, app: &App, cache: &mut AlbumArtCache) {
+    if area.width == 0 || area.height == 0 {
+        return;
+    }
+
+    let track_dir = app.current_track().and_then(|t| t.path.parent().map(|p| p.to_path_buf()));
+
+    if cache.needs_reload(track_dir.as_deref()) {
+        cache.load(track_dir.as_deref());
+    }
+
+    match cache.protocol {
+        Some(ref mut protocol) => {
+            // Center the image area: most album art is square, so
+            // compute a centered sub-rect matching the aspect ratio.
+            let font = cache.picker.font_size();
+            // Terminal cell aspect ratio: font_w / font_h
+            // For a square image, we need w_cells * font_w == h_cells * font_h
+            let (fw, fh) = (font.0.max(1) as u32, font.1.max(1) as u32);
+            // Desired square in pixels: min(area.width * fw, area.height * fh)
+            let px_w = area.width as u32 * fw;
+            let px_h = area.height as u32 * fh;
+            let side = px_w.min(px_h);
+            let fit_w = (side / fw) as u16;
+            let fit_h = (side / fh) as u16;
+            let x_off = (area.width.saturating_sub(fit_w)) / 2;
+            let y_off = (area.height.saturating_sub(fit_h)) / 2;
+            let centered = Rect {
+                x: area.x + x_off,
+                y: area.y + y_off,
+                width: fit_w.min(area.width),
+                height: fit_h.min(area.height),
+            };
+            let widget = StatefulImage::default();
+            frame.render_stateful_widget(widget, centered, protocol);
+        }
+        None => {
+            // No album art placeholder
+            let placeholder = vec![
+                Line::from(""),
+                Line::from(Span::styled(
+                    "\u{266A}",
+                    Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD),
+                )),
+                Line::from(""),
+                Line::from(Span::styled("No Album Art", Style::default().fg(Color::DarkGray))),
+            ];
+            let para = Paragraph::new(placeholder).alignment(Alignment::Center);
+            let v_offset = if area.height > 4 { (area.height - 4) / 2 } else { 0 };
+            let centered = Rect {
+                x: area.x,
+                y: area.y + v_offset,
+                width: area.width,
+                height: area.height.saturating_sub(v_offset),
+            };
+            frame.render_widget(para, centered);
+        }
+    }
+}
+
+// ── Track Info View ──────────────────────────────────────────────────────
+
+pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+    let track = match app.current_track() {
+        Some(t) => t,
+        None => {
+            let para = Paragraph::new("No track playing")
+                .style(Style::default().fg(Color::DarkGray))
+                .alignment(Alignment::Center);
+            frame.render_widget(para, area);
+            return;
+        }
+    };
+
+    let format_ext = track
+        .path
+        .extension()
+        .and_then(|e| e.to_str())
+        .unwrap_or("unknown")
+        .to_ascii_uppercase();
+
+    let bitrate_str = track
+        .bitrate
+        .map(|b| format!("{} kbps", b))
+        .unwrap_or_else(|| "N/A".to_string());
+
+    let track_num_str = track
+        .track_number
+        .map(|n| n.to_string())
+        .unwrap_or_else(|| "N/A".to_string());
+
+    let duration_str = track.format_duration();
+
+
+    // Each field has a unique label color and value style
+    let fields: Vec<(&str, String, Color, Style)> = vec![
+        ("Title", track.title.clone(),
+            Color::Rgb(100, 180, 255),
+            Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)),
+        ("Artist", track.display_artist().to_string(),
+            Color::Rgb(255, 180, 100),
+            Style::default().fg(Color::Rgb(255, 210, 140))),
+        ("Album", track.display_album().to_string(),
+            Color::Rgb(200, 130, 255),
+            Style::default().fg(Color::Rgb(220, 170, 255))),
+        ("Album Artist",
+            if track.album_artist.is_empty() { "N/A".to_string() } else { track.album_artist.clone() },
+            Color::Rgb(200, 130, 255),
+            Style::default().fg(theme.fg)),
+        ("Genre",
+            if track.genre.is_empty() { "N/A".to_string() } else { track.genre.clone() },
+            Color::Rgb(255, 120, 150),
+            Style::default().fg(Color::Rgb(255, 170, 190))),
+        ("Track #", track_num_str,
+            Color::Rgb(120, 220, 180),
+            Style::default().fg(theme.fg)),
+        ("Duration", duration_str,
+            Color::Rgb(120, 220, 180),
+            Style::default().fg(theme.fg)),
+        ("Bitrate", bitrate_str,
+            Color::Rgb(255, 220, 100),
+            Style::default().fg(theme.fg)),
+        ("Format", format_ext,
+            Color::Rgb(255, 220, 100),
+            Style::default().fg(theme.fg)),
+    ];
+
+    let lines: Vec<Line> = fields
+        .iter()
+        .map(|(label, value, label_color, val_style)| {
+            Line::from(vec![
+                Span::styled(format!("{:>13}: ", label), Style::default().fg(*label_color)),
+                Span::styled(value.as_str(), *val_style),
+            ])
+        })
+        .collect();
+
+    let para = Paragraph::new(lines).wrap(Wrap { trim: false });
+    frame.render_widget(para, area);
+}
diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs
@@ -0,0 +1,8 @@
+pub mod status_bar;
+pub mod progress_bar;
+pub mod tab_bar;
+pub mod help_modal;
+pub mod search_modal;
+pub mod playlist_modal;
+pub mod about_modal;
+pub mod info_pane;
diff --git a/src/ui/widgets/playlist_modal.rs b/src/ui/widgets/playlist_modal.rs
@@ -0,0 +1,173 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum PlaylistModalMode {
+    List,
+    Create,
+    Rename,
+}
+
+pub fn render_playlist_modal(
+    frame: &mut Frame,
+    area: Rect,
+    selected: usize,
+    mode: &PlaylistModalMode,
+    input: &str,
+    app: &App,
+    theme: &Theme,
+) {
+    let modal = centered_rect(45, 45, area);
+
+    frame.render_widget(Clear, modal);
+
+    let title = match mode {
+        PlaylistModalMode::List => " Playlist ",
+        PlaylistModalMode::Create => " New Playlist ",
+        PlaylistModalMode::Rename => " Rename Playlist ",
+    };
+
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(Color::Yellow))
+        .title(title)
+        .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
+
+    let inner = block.inner(modal);
+    frame.render_widget(block, modal);
+
+    match mode {
+        PlaylistModalMode::Create | PlaylistModalMode::Rename => {
+            let chunks = Layout::default()
+                .direction(Direction::Vertical)
+                .constraints([
+                    Constraint::Length(2),
+                    Constraint::Length(1),
+                    Constraint::Min(1),
+                ])
+                .split(inner);
+
+            let prompt = if *mode == PlaylistModalMode::Create { "Name:" } else { "New name:" };
+            let input_line = Line::from(vec![
+                Span::styled(
+                    format!(" {} ", prompt),
+                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
+                ),
+                Span::styled(input, Style::default().fg(Color::White)),
+                Span::styled("_", Style::default().fg(Color::Yellow).add_modifier(Modifier::SLOW_BLINK)),
+            ]);
+            frame.render_widget(Paragraph::new(input_line), chunks[0]);
+
+            let hint = Line::from(Span::styled(
+                " Enter: confirm  Esc: cancel",
+                Style::default().fg(Color::DarkGray),
+            ));
+            frame.render_widget(Paragraph::new(hint), chunks[2]);
+        }
+        PlaylistModalMode::List => {
+            if app.playlists.is_empty() {
+                let chunks = Layout::default()
+                    .direction(Direction::Vertical)
+                    .constraints([Constraint::Min(1), Constraint::Length(1)])
+                    .split(inner);
+
+                frame.render_widget(
+                    Paragraph::new(Line::from(Span::styled(
+                        "  No playlists. Press 'a' to create.",
+                        Style::default().fg(Color::DarkGray),
+                    ))),
+                    chunks[0],
+                );
+                let hint = Line::from(Span::styled(
+                    " a: add  Esc: close ",
+                    Style::default().fg(Color::DarkGray),
+                ));
+                frame.render_widget(Paragraph::new(hint), chunks[1]);
+                return;
+            }
+
+            let current_track_idx = app.queue.current_index
+                .and_then(|qi| app.queue.tracks.get(qi).copied());
+
+            let items: Vec<ListItem> = app
+                .playlists
+                .iter()
+                .enumerate()
+                .map(|(i, pl)| {
+                    let is_selected = i == selected;
+                    let already_in = current_track_idx
+                        .is_some_and(|ti| pl.tracks.contains(&ti));
+
+                    let check = if already_in { "\u{F00C} " } else { "  " };
+                    let icon = "\u{F005} ";
+
+                    let style = if is_selected {
+                        Style::default()
+                            .bg(theme.highlight_bg)
+                            .fg(theme.highlight_fg)
+                            .add_modifier(Modifier::BOLD)
+                    } else {
+                        Style::default().fg(theme.fg)
+                    };
+
+                    let check_style = if is_selected {
+                        style
+                    } else if already_in {
+                        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
+                    } else {
+                        Style::default().fg(Color::DarkGray)
+                    };
+
+                    let count_style = if is_selected {
+                        style
+                    } else {
+                        Style::default().fg(Color::DarkGray)
+                    };
+
+                    ListItem::new(Line::from(vec![
+                        Span::styled(check, check_style),
+                        Span::styled(icon, if is_selected { style } else { Style::default().fg(Color::Yellow) }),
+                        Span::styled(&pl.name, style),
+                        Span::styled(format!(" ({})", pl.tracks.len()), count_style),
+                    ]))
+                })
+                .collect();
+
+            let hint = Line::from(Span::styled(
+                " Enter: toggle  a: add  d: delete  r: rename  Esc: close ",
+                Style::default().fg(Color::DarkGray),
+            ));
+
+            let list = List::new(items).block(
+                Block::default().title_bottom(hint)
+            );
+            frame.render_widget(list, inner);
+        }
+    }
+}
+
+fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
+    let vertical = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Percentage((100 - percent_y) / 2),
+            Constraint::Percentage(percent_y),
+            Constraint::Percentage((100 - percent_y) / 2),
+        ])
+        .split(area);
+
+    Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage((100 - percent_x) / 2),
+            Constraint::Percentage(percent_x),
+            Constraint::Percentage((100 - percent_x) / 2),
+        ])
+        .split(vertical[1])[1]
+}
diff --git a/src/ui/widgets/progress_bar.rs b/src/ui/widgets/progress_bar.rs
@@ -0,0 +1,90 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph, Gauge};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::app::state::PlayState;
+use crate::ui::theme::Theme;
+
+pub fn render_progress_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(theme.border_unfocused));
+
+    let inner = block.inner(area);
+    frame.render_widget(block, area);
+
+    let cols = Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Length(3),  // Play icon
+            Constraint::Min(10),   // Gauge bar
+            Constraint::Length(13), // Time display
+        ])
+        .split(inner);
+
+    // Play icon
+    let icon = match app.playback.state {
+        PlayState::Playing => "\u{F04B}",  // nf-fa-play
+        PlayState::Paused => "\u{F04C}",   // nf-fa-pause
+        PlayState::Stopped => "\u{F04D}",  // nf-fa-stop
+    };
+    let icon_color = match app.playback.state {
+        PlayState::Playing => Color::Rgb(80, 255, 120),
+        PlayState::Paused => Color::Rgb(255, 200, 80),
+        PlayState::Stopped => Color::Rgb(255, 100, 100),
+    };
+    let icon_widget = Paragraph::new(Line::from(Span::styled(
+        format!(" {}", icon),
+        Style::default().fg(icon_color),
+    )));
+    frame.render_widget(icon_widget, cols[0]);
+
+    // Gauge
+    let ratio = if app.playback.duration_secs > 0.0 {
+        (app.playback.position_secs / app.playback.duration_secs).clamp(0.0, 1.0)
+    } else {
+        0.0
+    };
+
+    let gauge = Gauge::default()
+        .ratio(ratio)
+        .label("")
+        .gauge_style(Style::default().fg(theme.progress_filled).bg(theme.progress_empty));
+    frame.render_widget(gauge, cols[1]);
+
+    // Time
+    let pos = format_time(app.playback.position_secs);
+    let dur = format_time(app.playback.duration_secs);
+    let time_widget = Paragraph::new(Line::from(Span::styled(
+        format!(" {} / {}", pos, dur),
+        Style::default().fg(Color::White),
+    )));
+    frame.render_widget(time_widget, cols[2]);
+}
+
+/// Returns the gauge area for mouse click seeking
+pub fn progress_gauge_area(area: Rect) -> Rect {
+    let block = Block::default().borders(Borders::ALL);
+    let inner = block.inner(area);
+
+    let cols = Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Length(3),
+            Constraint::Min(10),
+            Constraint::Length(13),
+        ])
+        .split(inner);
+
+    cols[1]
+}
+
+fn format_time(secs: f64) -> String {
+    let total = secs as u64;
+    let m = total / 60;
+    let s = total % 60;
+    format!("{}:{:02}", m, s)
+}
diff --git a/src/ui/widgets/search_modal.rs b/src/ui/widgets/search_modal.rs
@@ -0,0 +1,195 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::ui::theme::Theme;
+
+const HOVER_BG: Color = Color::Indexed(238);
+
+#[allow(clippy::too_many_arguments)]
+pub fn render_search_modal(
+    frame: &mut Frame,
+    area: Rect,
+    input: &str,
+    results: &[usize],
+    selected: usize,
+    scroll: usize,
+    hover_row: Option<usize>,
+    app: &App,
+    theme: &Theme,
+) -> (usize, Rect) {
+    let modal = centered_rect(50, 60, area);
+
+    frame.render_widget(Clear, modal);
+
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(Color::Cyan))
+        .title(" Search ")
+        .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
+
+    let inner = block.inner(modal);
+    frame.render_widget(block, modal);
+
+    // Split inner: input(1) + separator(1) + results(rest)
+    let chunks = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Length(1), // input line
+            Constraint::Length(1), // separator
+            Constraint::Min(1),   // results
+        ])
+        .split(inner);
+
+    // Input line with cursor
+    let input_line = Line::from(vec![
+        Span::styled(" > ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
+        Span::styled(input, Style::default().fg(Color::White)),
+        Span::styled("_", Style::default().fg(Color::Cyan).add_modifier(Modifier::SLOW_BLINK)),
+    ]);
+    frame.render_widget(Paragraph::new(input_line), chunks[0]);
+
+    // Separator
+    let sep = "─".repeat(chunks[1].width as usize);
+    frame.render_widget(
+        Paragraph::new(Line::from(Span::styled(sep, Style::default().fg(Color::DarkGray)))),
+        chunks[1],
+    );
+
+    // Results list
+    let result_height = chunks[2].height as usize;
+    let result_width = chunks[2].width as usize;
+
+    if results.is_empty() {
+        let msg = if input.is_empty() {
+            "Type to search..."
+        } else {
+            "No results found"
+        };
+        frame.render_widget(
+            Paragraph::new(Line::from(Span::styled(
+                format!("  {}", msg),
+                Style::default().fg(Color::DarkGray),
+            ))),
+            chunks[2],
+        );
+    } else {
+        let items: Vec<ListItem> = results
+            .iter()
+            .enumerate()
+            .skip(scroll)
+            .take(result_height)
+            .map(|(i, &track_idx)| {
+                let track = &app.library.tracks[track_idx];
+                let is_selected = i == selected;
+                let is_hovered = hover_row == Some(i);
+
+                let artist = track.display_artist();
+                let title_w = (result_width * 55 / 100).max(4);
+                let artist_w = result_width.saturating_sub(title_w + 3); // 3 = prefix + gap
+
+                let title_fitted = fit_to_width(&track.title, title_w);
+                let artist_fitted = fit_to_width(artist, artist_w);
+
+                let (style, artist_style) = if is_selected {
+                    let s = Style::default()
+                        .bg(theme.highlight_bg)
+                        .fg(theme.highlight_fg)
+                        .add_modifier(Modifier::BOLD);
+                    (s, s)
+                } else if is_hovered {
+                    (
+                        Style::default().fg(theme.fg).bg(HOVER_BG),
+                        Style::default().fg(Color::Gray).bg(HOVER_BG),
+                    )
+                } else {
+                    (
+                        Style::default().fg(theme.fg),
+                        Style::default().fg(Color::Gray),
+                    )
+                };
+
+                let prefix = if is_selected { " > " } else { "   " };
+
+                ListItem::new(Line::from(vec![
+                    Span::styled(prefix, style),
+                    Span::styled(title_fitted, style),
+                    Span::styled(artist_fitted, artist_style),
+                ]))
+            })
+            .collect();
+
+        let count_info = format!(" {}/{} ", results.len(), app.library.tracks.len());
+        let list = List::new(items).block(
+            Block::default()
+                .title_bottom(Line::from(Span::styled(
+                    count_info,
+                    Style::default().fg(Color::DarkGray),
+                )))
+        );
+        frame.render_widget(list, chunks[2]);
+
+        // Scrollbar
+        if results.len() > result_height {
+            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
+                .begin_symbol(None)
+                .end_symbol(None);
+            let mut scrollbar_state = ScrollbarState::new(results.len())
+                .position(scroll);
+            frame.render_stateful_widget(
+                scrollbar,
+                chunks[2],
+                &mut scrollbar_state,
+            );
+        }
+    }
+
+    (result_height, chunks[2])
+}
+
+fn fit_to_width(s: &str, max_width: usize) -> String {
+    use unicode_width::UnicodeWidthStr;
+    let str_width = UnicodeWidthStr::width(s);
+    if str_width <= max_width {
+        format!("{}{}", s, " ".repeat(max_width - str_width))
+    } else {
+        let mut w = 0;
+        let mut result = String::new();
+        for ch in s.chars() {
+            let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
+            if w + ch_w + 1 > max_width {
+                result.push('\u{2026}');
+                w += 1;
+                break;
+            }
+            w += ch_w;
+            result.push(ch);
+        }
+        let pad = max_width.saturating_sub(w);
+        result.push_str(&" ".repeat(pad));
+        result
+    }
+}
+
+fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
+    let vertical = Layout::default()
+        .direction(Direction::Vertical)
+        .constraints([
+            Constraint::Percentage((100 - percent_y) / 2),
+            Constraint::Percentage(percent_y),
+            Constraint::Percentage((100 - percent_y) / 2),
+        ])
+        .split(area);
+
+    Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage((100 - percent_x) / 2),
+            Constraint::Percentage(percent_x),
+            Constraint::Percentage((100 - percent_x) / 2),
+        ])
+        .split(vertical[1])[1]
+}
diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs
@@ -0,0 +1,175 @@
+use ratatui::layout::{Constraint, Direction, Layout, Rect, Alignment};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::Frame;
+
+use crate::app::App;
+use crate::app::state::{PlayState, SyncState};
+use crate::ui::theme::Theme;
+
+pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, resize_mode: bool) {
+    let block = if resize_mode {
+        Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(Color::Yellow))
+            .title(" [RESIZE] ")
+            .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
+    } else if app.sync_state == SyncState::Scanning {
+        Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(Color::Rgb(255, 200, 80)))
+            .title(" [SYNCING] ")
+            .title_style(Style::default().fg(Color::Rgb(255, 200, 80)).add_modifier(Modifier::BOLD))
+    } else {
+        Block::default()
+            .borders(Borders::ALL)
+            .border_style(Style::default().fg(theme.border_unfocused))
+    };
+
+    let inner = block.inner(area);
+    frame.render_widget(block, area);
+
+    let cols = Layout::default()
+        .direction(Direction::Horizontal)
+        .constraints([
+            Constraint::Percentage(25),
+            Constraint::Percentage(50),
+            Constraint::Percentage(25),
+        ])
+        .split(inner);
+
+    // Left: Play state + time
+    let state_icon = match app.playback.state {
+        PlayState::Playing => "\u{F04B}",  // nf-fa-play
+        PlayState::Paused => "\u{F04C}",   // nf-fa-pause
+        PlayState::Stopped => "\u{F04D}",  // nf-fa-stop
+    };
+
+    let pos = format_time(app.playback.position_secs);
+    let dur = format_time(app.playback.duration_secs);
+
+    let bitrate = app
+        .current_track()
+        .and_then(|t| t.bitrate)
+        .map(|b| format!(" ({}kbps)", b))
+        .unwrap_or_default();
+
+    let state_color = match app.playback.state {
+        PlayState::Playing => Color::Rgb(80, 255, 120),   // bright green
+        PlayState::Paused => Color::Rgb(255, 200, 80),    // amber
+        PlayState::Stopped => Color::Rgb(255, 100, 100),  // soft red
+    };
+
+    let left_line1 = Line::from(vec![
+        Span::styled(
+            format!(" {} {}", state_icon, match app.playback.state {
+                PlayState::Playing => "Playing",
+                PlayState::Paused => "Paused",
+                PlayState::Stopped => "Stopped",
+            }),
+            Style::default().fg(state_color).add_modifier(Modifier::BOLD),
+        ),
+    ]);
+
+    let left_line2 = Line::from(vec![
+        Span::styled(
+            format!(" {}/{}{}", pos, dur, bitrate),
+            Style::default().fg(Color::Gray),
+        ),
+    ]);
+
+    let left = Paragraph::new(vec![left_line1, left_line2]);
+    frame.render_widget(left, cols[0]);
+
+    // Center: Track info
+    let (title, artist_album) = if let Some(track) = app.current_track() {
+        (
+            track.title.clone(),
+            format!("{} - {}", track.display_artist(), track.display_album()),
+        )
+    } else {
+        ("No track playing".to_string(), String::new())
+    };
+
+    let center_line1 = Line::from(Span::styled(
+        title,
+        Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD),
+    )).alignment(Alignment::Center);
+
+    let center_line2 = Line::from(Span::styled(
+        artist_album,
+        Style::default().fg(Color::Rgb(200, 170, 255)),
+    )).alignment(Alignment::Center);
+
+    let center = Paragraph::new(vec![center_line1, center_line2]);
+    frame.render_widget(center, cols[1]);
+
+    // Right: Volume + shuffle/repeat
+    let vol_pct = (app.playback.volume * 100.0) as u8;
+
+    let shuffle_style = if app.playback.shuffle {
+        Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)
+    } else {
+        Style::default().fg(Color::DarkGray)
+    };
+
+    let repeat_style = match app.playback.repeat {
+        crate::app::state::RepeatMode::Off => Style::default().fg(Color::DarkGray),
+        crate::app::state::RepeatMode::All => Style::default().fg(Color::Rgb(120, 255, 180)).add_modifier(Modifier::BOLD),
+        crate::app::state::RepeatMode::One => Style::default().fg(Color::Rgb(255, 220, 100)).add_modifier(Modifier::BOLD),
+    };
+
+    // Volume staircase with gradient: green → yellow → orange → red
+    const STEPS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
+    let vol_colors: [Color; 8] = [
+        Color::Rgb(80, 200, 120),  // green
+        Color::Rgb(120, 220, 100), // green-yellow
+        Color::Rgb(180, 230, 80),  // yellow-green
+        Color::Rgb(230, 220, 60),  // yellow
+        Color::Rgb(255, 190, 50),  // amber
+        Color::Rgb(255, 150, 40),  // orange
+        Color::Rgb(255, 110, 50),  // red-orange
+        Color::Rgb(255, 70, 70),   // red
+    ];
+    let filled = (vol_pct as u16 * 8 / 100).min(8) as usize;
+    let mut vol_spans = Vec::with_capacity(10);
+    for (i, &ch) in STEPS.iter().enumerate() {
+        let style = if i < filled {
+            Style::default().fg(vol_colors[i]).add_modifier(Modifier::BOLD)
+        } else {
+            Style::default().fg(Color::Indexed(238))
+        };
+        vol_spans.push(Span::styled(String::from(ch), style));
+    }
+    vol_spans.push(Span::styled(format!(" {}% ", vol_pct), Style::default().fg(Color::White)));
+
+    let right_line1 = Line::from(vol_spans).alignment(Alignment::Right);
+
+    let is_bookmarked = app.queue.current_index
+        .and_then(|qi| app.queue.tracks.get(qi))
+        .is_some_and(|&ti| {
+            app.playlists.iter().any(|pl| pl.tracks.contains(&ti))
+        });
+    let bookmark_style = if is_bookmarked {
+        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
+    } else {
+        Style::default().fg(Color::DarkGray)
+    };
+
+    let right_line2 = Line::from(vec![
+        Span::styled("\u{F005} ", bookmark_style),  // nf-fa-star
+        Span::styled("\u{F074} ", shuffle_style),   // nf-fa-random
+        Span::styled(format!("{} ", app.playback.repeat.symbol()), repeat_style),
+    ]).alignment(Alignment::Right);
+
+    let right = Paragraph::new(vec![right_line1, right_line2]);
+    frame.render_widget(right, cols[2]);
+}
+
+fn format_time(secs: f64) -> String {
+    let total = secs as u64;
+    let m = total / 60;
+    let s = total % 60;
+    format!("{}:{:02}", m, s)
+}
diff --git a/src/ui/widgets/tab_bar.rs b/src/ui/widgets/tab_bar.rs
@@ -0,0 +1,99 @@
+use ratatui::layout::{Alignment, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+use ratatui::widgets::{Block, Borders, Paragraph};
+use ratatui::Frame;
+
+use crate::app::state::Tab;
+use crate::ui::theme::Theme;
+
+pub fn render_tab_bar(
+    frame: &mut Frame,
+    area: Rect,
+    current: Tab,
+    hovered: Option<usize>,
+    theme: &Theme,
+) {
+    let block = Block::default()
+        .borders(Borders::ALL)
+        .border_style(Style::default().fg(theme.border_unfocused));
+
+    let inner = block.inner(area);
+    frame.render_widget(block, area);
+
+    // Each tab has its own accent color
+    let tab_colors: [Color; 6] = [
+        Color::Rgb(100, 220, 255), // Queue - sky blue
+        Color::Rgb(120, 255, 180), // Directories - mint green
+        Color::Rgb(255, 180, 100), // Artists - orange
+        Color::Rgb(200, 130, 255), // Albums - purple
+        Color::Rgb(255, 120, 150), // Genre - pink
+        Color::Rgb(255, 220, 100), // Playlists - gold
+    ];
+
+    let mut spans = Vec::new();
+    for (i, tab) in Tab::ALL.iter().enumerate() {
+        if i > 0 {
+            spans.push(Span::styled(" \u{2502} ", theme.dim_style));
+        }
+        let color = tab_colors[i % tab_colors.len()];
+        let style = if i == current.index() {
+            Style::default().fg(color).add_modifier(Modifier::BOLD)
+        } else if hovered == Some(i) {
+            Style::default().fg(color)
+        } else {
+            theme.tab_inactive
+        };
+        spans.push(Span::styled(tab.title(), style));
+    }
+
+    let line = Line::from(spans);
+    let paragraph = Paragraph::new(line).alignment(Alignment::Center);
+    frame.render_widget(paragraph, inner);
+}
+
+/// Returns which tab index was clicked given mouse x position
+pub fn tab_hit_test(area: Rect, mouse_x: u16) -> Option<usize> {
+    let block = Block::default().borders(Borders::ALL);
+    let inner = block.inner(area);
+
+    if mouse_x < inner.x || mouse_x >= inner.x + inner.width {
+        return None;
+    }
+
+    // Calculate total content width to find center offset
+    let divider_len = 3; // " │ "
+    let mut total_width: usize = 0;
+    for (i, tab) in Tab::ALL.iter().enumerate() {
+        if i > 0 {
+            total_width += divider_len;
+        }
+        total_width += tab.title().len();
+    }
+
+    let inner_w = inner.width as usize;
+    let pad_left = if inner_w > total_width {
+        (inner_w - total_width) / 2
+    } else {
+        0
+    };
+
+    let rel_x = (mouse_x - inner.x) as usize;
+    if rel_x < pad_left {
+        return None;
+    }
+
+    let content_x = rel_x - pad_left;
+    let mut pos = 0;
+    for (i, tab) in Tab::ALL.iter().enumerate() {
+        if i > 0 {
+            pos += divider_len;
+        }
+        let title_len = tab.title().len();
+        if content_x < pos + title_len {
+            return Some(i);
+        }
+        pos += title_len;
+    }
+    None
+}