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

src/ui/panes/library_pane.rs (16435B)

      1 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
      2 use ratatui::layout::Rect;
      3 use ratatui::style::{Color, Modifier, Style};
      4 use ratatui::text::{Line, Span};
      5 use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
      6 use ratatui::Frame;
      7 
      8 use crate::app::{App, AppAction};
      9 use crate::ui::pane::Pane;
     10 use crate::ui::theme::Theme;
     11 use crate::ui::text::max_scroll;
     12 
     13 /// An entry in the flattened library list
     14 #[derive(Debug, Clone)]
     15 enum LibraryEntry {
     16     SectionHeader(String),
     17     Separator,
     18     AllTracks(usize),
     19     PlaylistEntry { idx: usize, name: String, count: usize },
     20     FavoriteDir(String),
     21     Album { name: String, artist: String },
     22 }
     23 
     24 const HOVER_BG: Color = Color::Indexed(238);
     25 
     26 /// Library browser for the Queue tab.
     27 /// Shows 4 sections: Playlist, Directories, Albums.
     28 pub struct LibraryPane {
     29     pub selected: usize,
     30     pub scroll_offset: usize,
     31     pub hover_row: Option<usize>,
     32 }
     33 
     34 impl LibraryPane {
     35     pub fn new() -> Self {
     36         Self {
     37             selected: 0,
     38             scroll_offset: 0,
     39             hover_row: None,
     40         }
     41     }
     42 
     43     /// Rows currently on offer, so callers can tell a click on a row from a
     44     /// click on the blank space below the list.
     45     pub fn row_count(&self, app: &App) -> usize {
     46         Self::build_entries(app).len()
     47     }
     48 
     49     fn build_entries(app: &App) -> Vec<LibraryEntry> {
     50         let mut entries = Vec::new();
     51 
     52         // --- Playlist ---
     53         entries.push(LibraryEntry::SectionHeader("\u{F054} Playlist".into()));
     54         entries.push(LibraryEntry::AllTracks(app.library.tracks.len()));
     55         for (idx, pl) in app.playlists.iter().enumerate() {
     56             entries.push(LibraryEntry::PlaylistEntry {
     57                 idx,
     58                 name: pl.name.clone(),
     59                 count: pl.tracks.len(),
     60             });
     61         }
     62 
     63         entries.push(LibraryEntry::Separator);
     64 
     65         // --- Directories ---
     66         let dirs = app.library.directories();
     67         entries.push(LibraryEntry::SectionHeader(format!(
     68             "\u{F054} Directories ({})",
     69             dirs.len()
     70         )));
     71         for d in dirs {
     72             entries.push(LibraryEntry::FavoriteDir(d.clone()));
     73         }
     74 
     75         entries.push(LibraryEntry::Separator);
     76 
     77         // --- Albums ---
     78         let albums = app.library.albums();
     79         entries.push(LibraryEntry::SectionHeader(format!(
     80             "\u{F054} Albums ({})",
     81             albums.len()
     82         )));
     83         for (album, artist) in albums {
     84             entries.push(LibraryEntry::Album { name: album.clone(), artist: artist.clone() });
     85         }
     86 
     87         entries
     88     }
     89 }
     90 
     91 impl Pane for LibraryPane {
     92     fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
     93         let entries = Self::build_entries(app);
     94         let count = entries.len();
     95         let border_color = if focused {
     96             theme.border_focused
     97         } else {
     98             theme.border_unfocused
     99         };
    100 
    101         let block = Block::default()
    102             .borders(Borders::ALL)
    103             .border_style(Style::default().fg(border_color))
    104             .title_style(Style::default().fg(if focused {
    105                 theme.border_focused
    106             } else {
    107                 theme.fg
    108             }));
    109 
    110         let inner = block.inner(area);
    111         let inner_height = inner.height as usize;
    112 
    113         // Auto-scroll
    114         if count > 0 {
    115             if self.selected < self.scroll_offset {
    116                 self.scroll_offset = self.selected;
    117             }
    118             if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
    119                 self.scroll_offset = self.selected - inner_height + 1;
    120             }
    121             // Keep the viewport full: the scroll handlers clamp to count - 1,
    122             // which lets the list scroll until one row sits above a blank screen.
    123             self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
    124         }
    125 
    126         let has_scrollbar = count > inner_height;
    127 
    128         let items: Vec<ListItem> = entries
    129             .iter()
    130             .enumerate()
    131             .skip(self.scroll_offset)
    132             .take(inner_height)
    133             .map(|(i, entry)| {
    134                 let is_selected = i == self.selected;
    135                 let is_hovered = self.hover_row == Some(i);
    136                 let highlight = Style::default()
    137                     .bg(theme.highlight_bg)
    138                     .fg(theme.highlight_fg)
    139                     .add_modifier(Modifier::BOLD);
    140                 let hover_bg = if is_hovered && !(is_selected && focused) {
    141                     HOVER_BG
    142                 } else {
    143                     Color::Reset
    144                 };
    145 
    146                 match entry {
    147                     LibraryEntry::SectionHeader(text) => {
    148                         let style = if is_selected && focused {
    149                             highlight
    150                         } else {
    151                             Style::default()
    152                                 .fg(Color::Cyan)
    153                                 .add_modifier(Modifier::BOLD)
    154                                 .bg(hover_bg)
    155                         };
    156                         ListItem::new(Line::from(Span::styled(text.as_str(), style)))
    157                     }
    158                     LibraryEntry::Separator => {
    159                         ListItem::new(Line::from(""))
    160                     }
    161                     LibraryEntry::AllTracks(track_count) => {
    162                         if is_selected && focused {
    163                             ListItem::new(Line::from(vec![
    164                                 Span::styled("  \u{F001} ", highlight),
    165                                 Span::styled("All Tracks", highlight),
    166                                 Span::styled(format!(" ({})", track_count), highlight),
    167                             ]))
    168                         } else {
    169                             ListItem::new(Line::from(vec![
    170                                 Span::styled("  \u{F001} ", Style::default().fg(Color::Cyan).bg(hover_bg)),
    171                                 Span::styled("All Tracks", Style::default().fg(theme.fg).bg(hover_bg)),
    172                                 Span::styled(
    173                                     format!(" ({})", track_count),
    174                                     Style::default().fg(Color::DarkGray).bg(hover_bg),
    175                                 ),
    176                             ]))
    177                         }
    178                     }
    179                     LibraryEntry::PlaylistEntry { name, count, .. } => {
    180                         let icon = "\u{F005} "; // ★
    181                         if is_selected && focused {
    182                             ListItem::new(Line::from(vec![
    183                                 Span::styled(format!("  {}", icon), highlight),
    184                                 Span::styled(name.as_str(), highlight),
    185                                 Span::styled(format!(" ({})", count), highlight),
    186                             ]))
    187                         } else {
    188                             ListItem::new(Line::from(vec![
    189                                 Span::styled(format!("  {}", icon), Style::default().fg(Color::Yellow).bg(hover_bg)),
    190                                 Span::styled(name.as_str(), Style::default().fg(theme.fg).bg(hover_bg)),
    191                                 Span::styled(
    192                                     format!(" ({})", count),
    193                                     Style::default().fg(Color::DarkGray).bg(hover_bg),
    194                                 ),
    195                             ]))
    196                         }
    197                     }
    198                     LibraryEntry::FavoriteDir(name) => {
    199                         if is_selected && focused {
    200                             ListItem::new(Line::from(vec![
    201                                 Span::styled("  \u{F07B} ", highlight),
    202                                 Span::styled(format!("{}/", name), highlight),
    203                             ]))
    204                         } else {
    205                             ListItem::new(Line::from(vec![
    206                                 Span::styled("  \u{F07B} ", Style::default().fg(Color::Green).bg(hover_bg)),
    207                                 Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(hover_bg)),
    208                             ]))
    209                         }
    210                     }
    211                     LibraryEntry::Album { name, artist } => {
    212                         let album_display = if name.is_empty() {
    213                             "Unknown Album"
    214                         } else {
    215                             name.as_str()
    216                         };
    217                         let artist_display = if artist.is_empty() {
    218                             ""
    219                         } else {
    220                             artist.as_str()
    221                         };
    222 
    223                         if is_selected && focused {
    224                             let mut spans = vec![
    225                                 Span::styled("  \u{F0025} ", highlight),
    226                                 Span::styled(album_display, highlight),
    227                             ];
    228                             if !artist_display.is_empty() {
    229                                 spans.push(Span::styled(
    230                                     format!("  {}", artist_display),
    231                                     highlight,
    232                                 ));
    233                             }
    234                             ListItem::new(Line::from(spans))
    235                         } else {
    236                             let mut spans = vec![
    237                                 Span::styled(
    238                                     "  \u{F0025} ",
    239                                     Style::default().fg(Color::Magenta).bg(hover_bg),
    240                                 ),
    241                                 Span::styled(album_display, Style::default().fg(theme.fg).bg(hover_bg)),
    242                             ];
    243                             if !artist_display.is_empty() {
    244                                 spans.push(Span::styled(
    245                                     format!("  {}", artist_display),
    246                                     Style::default().fg(Color::Gray).bg(hover_bg),
    247                                 ));
    248                             }
    249                             ListItem::new(Line::from(spans))
    250                         }
    251                     }
    252                 }
    253             })
    254             .collect();
    255 
    256         let list = List::new(items).block(block);
    257         frame.render_widget(list, area);
    258 
    259         // Scrollbar
    260         if has_scrollbar {
    261             let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
    262                 .begin_symbol(None)
    263                 .end_symbol(None);
    264             let mut scrollbar_state = ScrollbarState::new(count)
    265                 .position(self.scroll_offset);
    266             frame.render_stateful_widget(
    267                 scrollbar,
    268                 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
    269                 &mut scrollbar_state,
    270             );
    271         }
    272     }
    273 
    274     fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
    275         let entries = Self::build_entries(app);
    276         let count = entries.len();
    277         if count == 0 {
    278             return None;
    279         }
    280 
    281         match key.code {
    282             KeyCode::Char('j') | KeyCode::Down => {
    283                 if self.selected < count - 1 {
    284                     self.selected += 1;
    285                     // Skip separators when navigating
    286                     if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
    287                         && self.selected < count - 1
    288                     {
    289                         self.selected += 1;
    290                     }
    291                 }
    292                 None
    293             }
    294             KeyCode::Char('k') | KeyCode::Up => {
    295                 if self.selected > 0 {
    296                     self.selected -= 1;
    297                     // Skip separators when navigating
    298                     if matches!(entries.get(self.selected), Some(LibraryEntry::Separator))
    299                         && self.selected > 0
    300                     {
    301                         self.selected -= 1;
    302                     }
    303                 }
    304                 None
    305             }
    306             KeyCode::Enter => {
    307                 if self.selected >= count {
    308                     return None;
    309                 }
    310                 match &entries[self.selected] {
    311                     LibraryEntry::SectionHeader(_) | LibraryEntry::Separator => None,
    312                     LibraryEntry::PlaylistEntry { idx, .. } => {
    313                         if let Some(pl) = app.playlists.get(*idx) {
    314                             if !pl.tracks.is_empty() {
    315                                 return Some(AppAction::AddToQueue(pl.tracks.clone()));
    316                             }
    317                         }
    318                         None
    319                     }
    320                     LibraryEntry::AllTracks(_) => {
    321                         let indices: Vec<usize> = (0..app.library.tracks.len()).collect();
    322                         if !indices.is_empty() {
    323                             Some(AppAction::AddToQueue(indices))
    324                         } else {
    325                             None
    326                         }
    327                     }
    328                     LibraryEntry::FavoriteDir(dir_name) => {
    329                         let indices: Vec<usize> = app
    330                             .library
    331                             .tracks
    332                             .iter()
    333                             .enumerate()
    334                             .filter(|(_, t)| {
    335                                 t.path
    336                                     .parent()
    337                                     .and_then(|p| p.file_name())
    338                                     .and_then(|n| n.to_str())
    339                                     == Some(dir_name.as_str())
    340                             })
    341                             .map(|(i, _)| i)
    342                             .collect();
    343                         if !indices.is_empty() {
    344                             Some(AppAction::AddToQueue(indices))
    345                         } else {
    346                             None
    347                         }
    348                     }
    349                     LibraryEntry::Album { name, artist } => {
    350                         let tracks = app.library.tracks_by_album(name, artist).to_vec();
    351                         if !tracks.is_empty() {
    352                             Some(AppAction::AddToQueue(tracks))
    353                         } else {
    354                             None
    355                         }
    356                     }
    357                 }
    358             }
    359             KeyCode::Home | KeyCode::Char('g') => {
    360                 self.selected = 0;
    361                 self.scroll_offset = 0;
    362                 None
    363             }
    364             KeyCode::End | KeyCode::Char('G') => {
    365                 self.selected = count.saturating_sub(1);
    366                 None
    367             }
    368             _ => None,
    369         }
    370     }
    371 
    372     fn handle_mouse(&mut self, event: MouseEvent, area: Rect, app: &App) -> Option<AppAction> {
    373         let block = Block::default().borders(Borders::ALL);
    374         let inner = block.inner(area);
    375         let count = Self::build_entries(app).len();
    376 
    377         match event.kind {
    378             MouseEventKind::Down(MouseButton::Left) => {
    379                 if event.column >= inner.x
    380                     && event.column < inner.x + inner.width
    381                     && event.row >= inner.y
    382                     && event.row < inner.y + inner.height
    383                 {
    384                     let clicked = self.scroll_offset + (event.row - inner.y) as usize;
    385                     if clicked < count {
    386                         self.selected = clicked;
    387                     }
    388                 }
    389                 None
    390             }
    391             MouseEventKind::ScrollDown => self.handle_scroll(false, app),
    392             MouseEventKind::ScrollUp => self.handle_scroll(true, app),
    393             _ => None,
    394         }
    395     }
    396 
    397     fn handle_scroll(&mut self, up: bool, app: &App) -> Option<AppAction> {
    398         let count = Self::build_entries(app).len();
    399         if count == 0 {
    400             return None;
    401         }
    402         if up {
    403             self.scroll_offset = self.scroll_offset.saturating_sub(3);
    404             self.selected = self.selected.saturating_sub(3);
    405         } else {
    406             self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
    407             self.selected = (self.selected + 3).min(count.saturating_sub(1));
    408         }
    409         None
    410     }
    411 }