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/widgets/info_pane.rs (13850B)

      1 use std::path::{Path, PathBuf};
      2 use std::time::SystemTime;
      3 
      4 use ratatui::layout::{Alignment, Rect};
      5 use ratatui::style::{Color, Modifier, Style};
      6 use ratatui::text::{Line, Span};
      7 use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
      8 use ratatui::Frame;
      9 
     10 use lofty::file::TaggedFileExt;
     11 use lofty::picture::{Picture, PictureType};
     12 use ratatui_image::picker::Picker;
     13 use ratatui_image::protocol::StatefulProtocol;
     14 use ratatui_image::StatefulImage;
     15 
     16 use crate::app::state::InfoView;
     17 use crate::app::App;
     18 use crate::library::track::Track;
     19 use crate::ui::theme::Theme;
     20 
     21 // ── AlbumArtCache ────────────────────────────────────────────────────────
     22 
     23 pub struct AlbumArtCache {
     24     track_path: Option<PathBuf>,
     25     picker: Picker,
     26     protocol: Option<StatefulProtocol>,
     27 }
     28 
     29 impl AlbumArtCache {
     30     pub fn new(picker: Picker) -> Self {
     31         Self {
     32             track_path: None,
     33             picker,
     34             protocol: None,
     35         }
     36     }
     37 
     38     fn needs_reload(&self, path: Option<&Path>) -> bool {
     39         self.track_path.as_deref() != path
     40     }
     41 
     42     fn load(&mut self, path: Option<&Path>) {
     43         self.track_path = path.map(|p| p.to_path_buf());
     44         self.protocol = None;
     45 
     46         let img = match path.and_then(embedded_cover) {
     47             Some(i) => i,
     48             None => return,
     49         };
     50 
     51         // StatefulProtocol handles resizing automatically per-frame
     52         self.protocol = Some(self.picker.new_resize_protocol(img));
     53     }
     54 }
     55 
     56 /// Decode the cover art embedded in the audio file itself.
     57 fn embedded_cover(path: &Path) -> Option<image::DynamicImage> {
     58     let tagged_file = lofty::read_from_path(path).ok()?;
     59     let tag = tagged_file
     60         .primary_tag()
     61         .or_else(|| tagged_file.first_tag())?;
     62     let picture = pick_cover(tag.pictures())?;
     63     image::load_from_memory(picture.data()).ok()
     64 }
     65 
     66 /// Prefer the front cover; fall back to whatever picture the file carries.
     67 fn pick_cover(pictures: &[Picture]) -> Option<&Picture> {
     68     pictures
     69         .iter()
     70         .find(|p| p.pic_type() == PictureType::CoverFront)
     71         .or_else(|| pictures.first())
     72 }
     73 
     74 // ── Public render function ───────────────────────────────────────────────
     75 
     76 pub fn render_info_pane(
     77     frame: &mut Frame,
     78     area: Rect,
     79     app: &App,
     80     theme: &Theme,
     81     view: InfoView,
     82     art_cache: &mut AlbumArtCache,
     83 ) {
     84     let block = Block::default()
     85         .borders(Borders::ALL)
     86         .border_style(Style::default().fg(theme.border_unfocused));
     87 
     88     let inner = block.inner(area);
     89     frame.render_widget(block, area);
     90 
     91     match view {
     92         InfoView::Clock => render_clock(frame, inner, theme),
     93         InfoView::AlbumArt => render_album_art(frame, inner, app, art_cache),
     94     }
     95 }
     96 
     97 // ── Clock View ───────────────────────────────────────────────────────────
     98 
     99 /// 5-line tall ASCII art digits using block characters
    100 const DIGIT_PATTERNS: [&[&str]; 11] = [
    101     // 0
    102     &["████", "█  █", "█  █", "█  █", "████"],
    103     // 1
    104     &["  █ ", " ██ ", "  █ ", "  █ ", " ███"],
    105     // 2
    106     &["████", "   █", "████", "█   ", "████"],
    107     // 3
    108     &["████", "   █", "████", "   █", "████"],
    109     // 4
    110     &["█  █", "█  █", "████", "   █", "   █"],
    111     // 5
    112     &["████", "█   ", "████", "   █", "████"],
    113     // 6
    114     &["████", "█   ", "████", "█  █", "████"],
    115     // 7
    116     &["████", "   █", "  █ ", " █  ", " █  "],
    117     // 8
    118     &["████", "█  █", "████", "█  █", "████"],
    119     // 9
    120     &["████", "█  █", "████", "   █", "████"],
    121     // : (colon, index 10)
    122     &["    ", " ██ ", "    ", " ██ ", "    "],
    123 ];
    124 
    125 fn render_clock(frame: &mut Frame, area: Rect, _theme: &Theme) {
    126     if area.width < 4 || area.height < 5 {
    127         return;
    128     }
    129 
    130     let now = SystemTime::now()
    131         .duration_since(SystemTime::UNIX_EPOCH)
    132         .unwrap_or_default()
    133         .as_secs();
    134 
    135     let (hours, minutes, _) = {
    136         let total_secs = now as i64;
    137         let mut tm = unsafe { std::mem::zeroed::<libc::tm>() };
    138         unsafe {
    139             libc::localtime_r(&total_secs as *const i64, &mut tm);
    140         }
    141         (tm.tm_hour as u32, tm.tm_min as u32, tm.tm_sec as u32)
    142     };
    143 
    144     // Build digit indices: HH:MM
    145     let digits = [
    146         (hours / 10) as usize,
    147         (hours % 10) as usize,
    148         10, // colon
    149         (minutes / 10) as usize,
    150         (minutes % 10) as usize,
    151     ];
    152 
    153     // Each digit is 4 chars wide + 1 space gap
    154     // Total: 5 * 4 + 4 gaps = 24 chars
    155     let total_width: u16 = 5 * 4 + 4;
    156     let x_offset = if area.width > total_width {
    157         (area.width - total_width) / 2
    158     } else {
    159         0
    160     };
    161     let y_offset = if area.height > 5 {
    162         (area.height - 5) / 2
    163     } else {
    164         0
    165     };
    166 
    167     // Gradient colors per digit: sky blue → cyan → mint → purple → pink
    168     let digit_colors: [Color; 5] = [
    169         Color::Rgb(100, 180, 255), // sky blue
    170         Color::Rgb(80, 220, 255),  // cyan
    171         Color::Rgb(200, 200, 200), // colon: white-gray
    172         Color::Rgb(200, 130, 255), // purple
    173         Color::Rgb(255, 130, 200), // pink
    174     ];
    175 
    176     for row in 0..5u16 {
    177         let y = area.y + y_offset + row;
    178         if y >= area.y + area.height {
    179             break;
    180         }
    181         let mut x = area.x + x_offset;
    182         for (i, &digit_idx) in digits.iter().enumerate() {
    183             let style = Style::default().fg(digit_colors[i % digit_colors.len()]);
    184             let pattern = DIGIT_PATTERNS[digit_idx][row as usize];
    185             for ch in pattern.chars() {
    186                 if x >= area.x + area.width {
    187                     break;
    188                 }
    189                 let display_ch = if ch == '█' { '█' } else { ' ' };
    190                 if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
    191                     cell.set_char(display_ch).set_style(style);
    192                 }
    193                 x += 1;
    194             }
    195             // Gap between digits
    196             if i < digits.len() - 1 {
    197                 x += 1;
    198             }
    199         }
    200     }
    201 }
    202 
    203 // ── Album Art View ───────────────────────────────────────────────────────
    204 
    205 fn render_album_art(frame: &mut Frame, area: Rect, app: &App, cache: &mut AlbumArtCache) {
    206     if area.width == 0 || area.height == 0 {
    207         return;
    208     }
    209 
    210     let track_path = app.current_track().map(|t| t.path.clone());
    211 
    212     if cache.needs_reload(track_path.as_deref()) {
    213         cache.load(track_path.as_deref());
    214     }
    215 
    216     match cache.protocol {
    217         Some(ref mut protocol) => {
    218             // Center the image area: most album art is square, so
    219             // compute a centered sub-rect matching the aspect ratio.
    220             let font = cache.picker.font_size();
    221             // Terminal cell aspect ratio: font_w / font_h
    222             // For a square image, we need w_cells * font_w == h_cells * font_h
    223             let (fw, fh) = (font.0.max(1) as u32, font.1.max(1) as u32);
    224             // Desired square in pixels: min(area.width * fw, area.height * fh)
    225             let px_w = area.width as u32 * fw;
    226             let px_h = area.height as u32 * fh;
    227             let side = px_w.min(px_h);
    228             let fit_w = (side / fw) as u16;
    229             let fit_h = (side / fh) as u16;
    230             let x_off = (area.width.saturating_sub(fit_w)) / 2;
    231             let y_off = (area.height.saturating_sub(fit_h)) / 2;
    232             let centered = Rect {
    233                 x: area.x + x_off,
    234                 y: area.y + y_off,
    235                 width: fit_w.min(area.width),
    236                 height: fit_h.min(area.height),
    237             };
    238             let widget = StatefulImage::default();
    239             frame.render_stateful_widget(widget, centered, protocol);
    240         }
    241         None => {
    242             // No album art placeholder
    243             let placeholder = vec![
    244                 Line::from(""),
    245                 Line::from(Span::styled(
    246                     "\u{266A}",
    247                     Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD),
    248                 )),
    249                 Line::from(""),
    250                 Line::from(Span::styled("No Album Art", Style::default().fg(Color::DarkGray))),
    251             ];
    252             let para = Paragraph::new(placeholder).alignment(Alignment::Center);
    253             let v_offset = if area.height > 4 { (area.height - 4) / 2 } else { 0 };
    254             let centered = Rect {
    255                 x: area.x,
    256                 y: area.y + v_offset,
    257                 width: area.width,
    258                 height: area.height.saturating_sub(v_offset),
    259             };
    260             frame.render_widget(para, centered);
    261         }
    262     }
    263 }
    264 
    265 // ── Track Info View ──────────────────────────────────────────────────────
    266 
    267 pub fn render_track_info(frame: &mut Frame, area: Rect, app: &App, theme: &Theme, scroll: u16) {
    268     let track = match app.current_track() {
    269         Some(t) => t,
    270         None => {
    271             let para = Paragraph::new("No track playing")
    272                 .style(Style::default().fg(Color::DarkGray))
    273                 .alignment(Alignment::Center);
    274             frame.render_widget(para, area);
    275             return;
    276         }
    277     };
    278 
    279     let format_ext = track
    280         .path
    281         .extension()
    282         .and_then(|e| e.to_str())
    283         .unwrap_or("unknown")
    284         .to_ascii_uppercase();
    285 
    286     let bitrate_str = track
    287         .bitrate
    288         .map(|b| format!("{} kbps", b))
    289         .unwrap_or_else(|| "N/A".to_string());
    290 
    291     let track_num_str = Track::format_index(track.track_number, track.track_total);
    292     let disc_str = Track::format_index(track.disc_number, track.disc_total);
    293 
    294     let duration_str = track.format_duration();
    295 
    296     let na = |s: &str| {
    297         if s.is_empty() {
    298             "N/A".to_string()
    299         } else {
    300             s.to_string()
    301         }
    302     };
    303 
    304     // Each field has a unique label color and value style
    305     let mut fields: Vec<(&str, String, Color, Style)> = vec![
    306         ("Title", track.title.clone(),
    307             Color::Rgb(100, 180, 255),
    308             Style::default().fg(Color::Rgb(100, 220, 255)).add_modifier(Modifier::BOLD)),
    309         ("Artist", track.display_artist().to_string(),
    310             Color::Rgb(255, 180, 100),
    311             Style::default().fg(Color::Rgb(255, 210, 140))),
    312         ("Album", track.display_album().to_string(),
    313             Color::Rgb(200, 130, 255),
    314             Style::default().fg(Color::Rgb(220, 170, 255))),
    315         ("Album Artist", na(&track.album_artist),
    316             Color::Rgb(200, 130, 255),
    317             Style::default().fg(theme.fg)),
    318         ("Genre", na(&track.genre),
    319             Color::Rgb(255, 120, 150),
    320             Style::default().fg(Color::Rgb(255, 170, 190))),
    321         ("Year",
    322             track.year.map(|y| y.to_string()).unwrap_or_else(|| "N/A".to_string()),
    323             Color::Rgb(255, 120, 150),
    324             Style::default().fg(theme.fg)),
    325         ("Track #", track_num_str,
    326             Color::Rgb(120, 220, 180),
    327             Style::default().fg(theme.fg)),
    328         ("Disc #", disc_str,
    329             Color::Rgb(120, 220, 180),
    330             Style::default().fg(theme.fg)),
    331         ("Duration", duration_str,
    332             Color::Rgb(120, 220, 180),
    333             Style::default().fg(theme.fg)),
    334         ("Quality", track.format_quality(),
    335             Color::Rgb(255, 220, 100),
    336             Style::default().fg(theme.fg)),
    337         ("Bitrate", bitrate_str,
    338             Color::Rgb(255, 220, 100),
    339             Style::default().fg(theme.fg)),
    340         ("Format", format_ext,
    341             Color::Rgb(255, 220, 100),
    342             Style::default().fg(theme.fg)),
    343     ];
    344 
    345     if let Some(comment) = &track.comment {
    346         fields.push(("Comment", comment.clone(),
    347             Color::Rgb(160, 160, 160),
    348             Style::default().fg(Color::Rgb(150, 150, 150))));
    349     }
    350 
    351     let lines: Vec<Line> = fields
    352         .iter()
    353         .map(|(label, value, label_color, val_style)| {
    354             Line::from(vec![
    355                 Span::styled(format!("{:>13}: ", label), Style::default().fg(*label_color)),
    356                 Span::styled(value.as_str(), *val_style),
    357             ])
    358         })
    359         .collect();
    360 
    361     // Honour the pane's scroll offset. It was maintained by seven handlers and
    362     // never read, so j/k did nothing while the field list — now 13 rows — did
    363     // not fit the pane's default height.
    364     let max_scroll = (lines.len() as u16).saturating_sub(area.height);
    365     let para = Paragraph::new(lines)
    366         .wrap(Wrap { trim: false })
    367         .scroll((scroll.min(max_scroll), 0));
    368     frame.render_widget(para, area);
    369 }
    370 
    371 #[cfg(test)]
    372 mod tests {
    373     use super::*;
    374     use lofty::picture::MimeType;
    375 
    376     fn pic(pic_type: PictureType, data: u8) -> Picture {
    377         Picture::new_unchecked(pic_type, Some(MimeType::Jpeg), None, vec![data])
    378     }
    379 
    380     #[test]
    381     fn cover_front_wins_over_earlier_pictures() {
    382         let pictures = [pic(PictureType::CoverBack, 1), pic(PictureType::CoverFront, 2)];
    383         assert_eq!(pick_cover(&pictures).unwrap().data(), &[2]);
    384     }
    385 
    386     #[test]
    387     fn falls_back_to_first_picture_and_handles_none() {
    388         let pictures = [pic(PictureType::Artist, 1), pic(PictureType::Band, 2)];
    389         assert_eq!(pick_cover(&pictures).unwrap().data(), &[1]);
    390         assert!(pick_cover(&[]).is_none());
    391     }
    392 }