| 1 | //! Screen 7: lyrics of the current song. Also drawn as a pane on the queue. |
| 2 | |
| 3 | use crossterm::event::{MouseEvent, MouseEventKind}; |
| 4 | use ratatui::{ |
| 5 | Frame, |
| 6 | layout::{Alignment, Constraint, Layout, Rect}, |
| 7 | text::{Line, Span}, |
| 8 | widgets::Paragraph, |
| 9 | }; |
| 10 | |
| 11 | use super::{Ctx, Effect, Screen}; |
| 12 | use crate::{ |
| 13 | action::Action, |
| 14 | lyrics::{Lyrics, LyricsStatus}, |
| 15 | ui, |
| 16 | }; |
| 17 | |
| 18 | #[derive(Debug, Default)] |
| 19 | pub struct LyricsScreen { |
| 20 | /// Scroll offset for plain (unsynced) lyrics. |
| 21 | scroll: usize, |
| 22 | height: usize, |
| 23 | file: Option<String>, |
| 24 | } |
| 25 | |
| 26 | /// Draws the lyrics pane next to the queue (no manual scrolling). |
| 27 | pub fn draw_lyrics(frame: &mut Frame, area: Rect, ctx: &Ctx) { |
| 28 | let theme = ctx.theme(); |
| 29 | let [header, line, body] = Layout::vertical([ |
| 30 | Constraint::Length(1), |
| 31 | Constraint::Length(1), |
| 32 | Constraint::Fill(1), |
| 33 | ]) |
| 34 | .areas(area); |
| 35 | frame.render_widget(Line::styled("Lyrics", theme.header()), header); |
| 36 | ui::draw_hline(frame, line, theme); |
| 37 | render(frame, body, ctx, 0); |
| 38 | } |
| 39 | |
| 40 | fn render(frame: &mut Frame, inner: Rect, ctx: &Ctx, scroll: usize) -> usize { |
| 41 | let theme = ctx.theme(); |
| 42 | let height = usize::from(inner.height); |
| 43 | |
| 44 | let lyrics = match (&ctx.mpd.current, &ctx.lyrics.status) { |
| 45 | (None, _) => { |
| 46 | ui::draw_placeholder(frame, inner, &["Nothing is playing"], theme); |
| 47 | return height; |
| 48 | } |
| 49 | (Some(_), LyricsStatus::Found(lyrics)) => lyrics, |
| 50 | (Some(_), LyricsStatus::Idle | LyricsStatus::Loading) => { |
| 51 | ui::draw_placeholder(frame, inner, &["Looking for lyrics…"], theme); |
| 52 | return height; |
| 53 | } |
| 54 | (Some(_), LyricsStatus::NotFound) => { |
| 55 | ui::draw_placeholder(frame, inner, &["No lyrics found"], theme); |
| 56 | return height; |
| 57 | } |
| 58 | (Some(_), LyricsStatus::Failed(error)) => { |
| 59 | ui::draw_placeholder(frame, inner, &["Could not fetch lyrics", error], theme); |
| 60 | return height; |
| 61 | } |
| 62 | }; |
| 63 | |
| 64 | let current = lyrics.current_line(ctx.mpd.elapsed()); |
| 65 | let top = match (lyrics, current) { |
| 66 | // Keep the current line in the middle. |
| 67 | (Lyrics::Synced(_), Some(line)) => line.saturating_sub(height / 2), |
| 68 | (Lyrics::Synced(_), None) => 0, |
| 69 | (Lyrics::Plain(_), _) => scroll.min(lyrics.len().saturating_sub(1)), |
| 70 | }; |
| 71 | let lines: Vec<Line> = (top..lyrics.len().min(top + height)) |
| 72 | .map(|i| { |
| 73 | let text = lyrics.line(i); |
| 74 | let style = match (lyrics, current) { |
| 75 | (Lyrics::Synced(_), Some(c)) if i == c => theme.header(), |
| 76 | (Lyrics::Synced(_), Some(c)) if i < c => theme.dim(), |
| 77 | (Lyrics::Synced(_), None) => theme.dim(), |
| 78 | _ => theme.text(), |
| 79 | }; |
| 80 | Line::from(Span::styled(text.to_owned(), style)) |
| 81 | }) |
| 82 | .collect(); |
| 83 | frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); |
| 84 | height |
| 85 | } |
| 86 | |
| 87 | impl Screen for LyricsScreen { |
| 88 | fn title(&self, ctx: &Ctx) -> Line<'static> { |
| 89 | match &ctx.mpd.current { |
| 90 | Some(song) => Line::from(format!("Lyrics: {}", ui::song_label(song))), |
| 91 | None => Line::from("Lyrics"), |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { |
| 96 | // Plain lyrics start from the top for every song. |
| 97 | let file = ctx.mpd.current.as_ref().map(|s| s.file.clone()); |
| 98 | if file != self.file { |
| 99 | self.file = file; |
| 100 | self.scroll = 0; |
| 101 | } |
| 102 | self.height = render(frame, area, ctx, self.scroll); |
| 103 | } |
| 104 | |
| 105 | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { |
| 106 | let len = ctx.lyrics.lyrics().map_or(0, Lyrics::len); |
| 107 | let page = self.height.max(1); |
| 108 | let max = len.saturating_sub(1); |
| 109 | self.scroll = match action { |
| 110 | Action::Up => self.scroll.saturating_sub(1), |
| 111 | Action::Down => (self.scroll + 1).min(max), |
| 112 | Action::PageUp => self.scroll.saturating_sub(page), |
| 113 | Action::PageDown => (self.scroll + page).min(max), |
| 114 | Action::HalfPageUp => self.scroll.saturating_sub(page / 2), |
| 115 | Action::HalfPageDown => (self.scroll + page / 2).min(max), |
| 116 | Action::Top => 0, |
| 117 | Action::Bottom => max, |
| 118 | Action::ReloadLyrics => { |
| 119 | ctx.effects.push(Effect::ReloadLyrics); |
| 120 | ctx.notify("Fetching lyrics again…"); |
| 121 | return true; |
| 122 | } |
| 123 | _ => return false, |
| 124 | }; |
| 125 | true |
| 126 | } |
| 127 | |
| 128 | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { |
| 129 | let action = match event.kind { |
| 130 | MouseEventKind::ScrollDown => Action::Down, |
| 131 | MouseEventKind::ScrollUp => Action::Up, |
| 132 | _ => return, |
| 133 | }; |
| 134 | for _ in 0..3 { |
| 135 | self.handle_action(action, ctx); |
| 136 | } |
| 137 | } |
| 138 | } |