use std::borrow::Cow;
use crate::{
client::get_lyrics_client,
config::get_config,
error::LyricsError,
song::{
LyricParser, LyricsLine, PlayTime, PlayerAction, SongInfo, get_current_song,
get_current_time_song, player_action,
},
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect, Size},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Padding, Paragraph, Widget, Wrap},
};
use rust_i18n::t;
use tracing::{info, warn};
use super::{LYRICS_GAUGE_STYLE, LYRICS_HEADER_STYLE, render_error};
#[derive(Clone, Default)]
pub(super) struct LyricsScreen {
state: LyricState,
}
impl LyricsScreen {
pub fn render(&mut self, area: Rect, buf: &mut Buffer) {
let config = &get_config().read().unwrap().ui;
let header_height = match config.title {
true => Constraint::Length(4),
false => Constraint::Length(0),
};
let progress_height = match config.progress_bar {
true => Constraint::Length(1),
false => Constraint::Length(0),
};
let [header_chunk, lyric_chunk, progress_chunk] = Layout::new(
Direction::Vertical,
[
header_height, Constraint::Min(1), progress_height, ],
)
.areas(area);
let size = lyric_chunk.as_size();
self.update_size(size);
self.render_title(header_chunk, buf);
self.render_lyric(lyric_chunk, buf);
self.render_progress(progress_chunk, buf);
}
fn get_window_title(&self) -> String {
match !self.state.song.title.is_empty() {
true => self.state.song.title.clone(),
false => " No song playing ".into(),
}
}
pub fn render_title(&self, area: Rect, buf: &mut Buffer) {
if self.state.song.title.is_empty() {
return;
}
let header_block = Block::default()
.borders(Borders::ALL)
.style(LYRICS_HEADER_STYLE);
let song = &self.state.song.clone();
let line_title = song.title.clone();
let line_artist = song.artist.clone();
let lines = vec![Line::raw(line_title), Line::raw(line_artist)];
Paragraph::new(lines)
.block(header_block)
.centered()
.wrap(Wrap { trim: true })
.render(area, buf);
}
pub fn render_progress(&self, area: Rect, buf: &mut Buffer) {
if self.state.song.title.is_empty() {
return;
}
let song = &self.state.song.clone();
let label = Span::styled(
format!(
"{:0>2}:{:0>2} / {:0>2}:{:0>2}",
(&self.state.play_time.current_time / 60.0).floor() as u64,
(&self.state.play_time.current_time % 60.0).floor() as u64,
(song.duration / 60.0).floor() as u64,
(song.duration % 60.0).floor() as u64,
),
LYRICS_GAUGE_STYLE,
);
Gauge::default()
.gauge_style(Style::new().blue().on_dark_gray())
.percent((self.state.progress * 100.0) as u16)
.label(label)
.render(area, buf);
}
pub fn render_lyric(&self, area: Rect, buf: &mut Buffer) {
let state = &self.state;
let config = &get_config().read().unwrap().ui;
if let Some(err_msg) = &state.error_message {
render_error(area, buf, err_msg);
return;
}
let metrics = &state.view_metrics;
let start = state.target_scroll.min(metrics.scroll_range);
let end = (start + metrics.visible_lines).min(metrics.content_height);
let mut lines = Vec::new();
for (i, line) in state.lyrics[start..end].iter().enumerate() {
let is_current = start + i == state.find_current_line().unwrap_or(0);
let line_text = match config.time {
true => format!(
"[{:0>2}:{:0>2}] {}",
(line.timestamp_start / 60.0).floor() as u64,
(line.timestamp_start % 60.0).floor() as u64,
line.text
),
false => line.text.clone(),
};
let style = if is_current {
Style::new()
.fg(Color::Indexed(196))
.bg(Color::Reset)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(Color::Indexed(94 + i as u8))
.bg(Color::Reset)
};
let line = Line::styled(line_text, style);
match config.text_center {
true => lines.push(line.centered()),
false => lines.push(line),
}
}
let block = Block::default()
.title(self.get_window_title())
.borders(Borders::ALL)
.padding(Padding::horizontal(1));
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: true })
.render(area, buf);
}
pub async fn handle_key_event(&mut self, key_event: &KeyEvent) {
match key_event.code {
KeyCode::Char('d') | KeyCode::Delete => self.delete().await,
KeyCode::Left => self.state.action(PlayerAction::Left).await,
KeyCode::Right => self.state.action(PlayerAction::Right).await,
KeyCode::Char(' ') => self.state.action(PlayerAction::Toggle).await,
KeyCode::Char('n') | KeyCode::Char('j') => self.state.action(PlayerAction::Next).await,
KeyCode::Char('p') | KeyCode::Char('k') => {
self.state.action(PlayerAction::Previous).await
}
KeyCode::Char('c') => {
let config = &mut get_config().write().unwrap();
config.ui.text_center = !config.ui.text_center;
}
KeyCode::Char('t') => {
let config = &mut get_config().write().unwrap();
config.ui.title = !config.ui.title;
}
_ => {}
}
}
pub fn help<'a>() -> Vec<(&'a str, Cow<'a, str>)> {
vec![
("h | ? ", t!("help.lyrics.help")),
("q | ESC ", t!("help.lyrics.quit")),
("d | Delete ", t!("help.lyrics.delete")),
("Left ", t!("help.lyrics.back")),
("Right ", t!("help.lyrics.next")),
("Space", t!("help.lyrics.pause")),
("n | j ", t!("help.lyrics.prev_song")),
("p | k ", t!("help.lyrics.next_song")),
("t", t!("help.lyrics.toggle_title")),
("s", t!("help.lyrics.search")),
]
}
pub async fn update(&mut self) {
self.state.update().await;
}
pub fn update_size(&mut self, size: Size) {
self.state.calculate_metrics(size);
}
async fn delete(&mut self) {
self.state.delete().await;
}
pub fn reset(&mut self) {
self.state.reset();
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ViewMetrics {
pub visible_lines: usize,
pub content_height: usize,
pub scroll_range: usize,
}
#[derive(Clone, Default)]
pub struct LyricState {
pub song: SongInfo,
pub play_time: PlayTime,
pub lyrics: Vec<LyricsLine>,
pub target_scroll: usize,
pub view_metrics: ViewMetrics,
pub error_message: Option<String>,
pub retry_counter: u32,
pub progress: f64,
}
impl LyricState {
pub fn calculate_metrics(&mut self, area: Size) {
let content_height = self.lyrics.len();
let viewport_height = area.height as usize;
let visible_lines = viewport_height.saturating_sub(2); let scroll_range = content_height.saturating_sub(visible_lines);
self.view_metrics = ViewMetrics {
visible_lines,
content_height,
scroll_range,
};
}
pub fn reset(&mut self) {
*self = LyricState::default();
}
pub async fn update(&mut self) {
match self.try_update().await {
Ok(_) => {
self.error_message = None; self.retry_counter = 0;
}
Err(e) => {
if self.retry_counter < 5 {
self.handle_error(e).await;
}
}
}
}
async fn try_update(&mut self) -> Result<(), LyricsError> {
let song = match get_current_song().await {
Ok(s) => s,
Err(e) => {
self.reset();
return Err(e);
}
};
if song != self.song {
self.error_message = None;
self.retry_counter = 0;
self.song = song.clone();
match get_lyrics_client().get_lyrics(&song).await {
Ok(doc) => {
self.lyrics = LyricParser::parse(doc, song.duration).await?;
}
Err(LyricsError::NoLyricsFound) => {
info!(
"No lyrics found, attempting auto-download for: {} - {}",
song.artist, song.title
);
self.auto_download_lyrics(&song).await?;
}
Err(e) => return Err(e),
}
}
self.play_time = get_current_time_song(self.play_time.clone()).await?;
self.progress = self.play_time.current_time / song.duration;
if let Some(pos) = self.find_current_line() {
let target_offset = pos.saturating_sub(self.view_metrics.visible_lines / 2);
self.target_scroll = target_offset.min(self.view_metrics.scroll_range);
}
Ok(())
}
async fn auto_download_lyrics(&mut self, song: &SongInfo) -> Result<(), LyricsError> {
let search_results = get_lyrics_client().get_search(song).await?;
if search_results.is_empty() {
warn!("Auto-download failed: No search results found");
return Err(LyricsError::NoLyricsFound);
}
let best_match = super::super::client::get_first(search_results, song)?;
info!(
"Auto-downloading lyrics from {}: {} - {}",
best_match.source, best_match.artist, best_match.title
);
get_lyrics_client().download(song, &best_match).await?;
let doc = get_lyrics_client().get_lyrics(song).await?;
self.lyrics = LyricParser::parse(doc, song.duration).await?;
info!("Auto-download successful");
Ok(())
}
pub fn find_current_line(&self) -> Option<usize> {
self.lyrics
.iter()
.enumerate()
.find(|(_, line)| {
self.play_time.current_time >= line.timestamp_start
&& self.play_time.current_time < line.timestamp_end
})
.map(|(i, _)| i)
}
async fn handle_error(&mut self, error: LyricsError) {
if self.retry_counter < 5 {
self.retry_counter += 1;
let error_msg = format!("Error: {} (Retry {}/5)", error, self.retry_counter);
info!("{}", error_msg);
self.error_message = Some(error_msg);
} else {
info!("Maximum retries reached");
}
}
pub async fn delete(&mut self) {
if !self.song.title.is_empty() {
get_lyrics_client().cache.delete(&self.song).await;
self.reset();
}
}
pub async fn action(&self, action: PlayerAction) {
if let Err(e) = player_action(action, &self.song).await {
tracing::error!("Action: {e}");
}
}
}