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 }