lyrics-next 0.2.1

在终端下为 mpd/mpris 提供歌词.
Documentation
use std::time::Duration;

use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind};
use help::HelpScreen;
use lyrics::LyricsScreen;
use ratatui::{
    Frame,
    buffer::Buffer,
    layout::{Alignment, Rect},
    style::{
        Color, Modifier, Style,
        palette::material::{BLUE, BROWN, GRAY, LIGHT_BLUE, WHITE, YELLOW},
    },
    widgets::{Block, Borders, Paragraph, Widget},
};
use search::SearchScreen;
use tokio_stream::StreamExt;

mod help;
mod lyrics;
mod search;

#[derive(Default, Clone, Debug)]
enum Screen {
    #[default]
    Lyrics,
    Search,
    Help,
}

#[derive(Clone, Default)]
pub struct App {
    exit: bool,
    screen: Screen,

    lyrics: LyricsScreen,
    search: SearchScreen,
    help: HelpScreen,
}

impl App {
    const FRAMES_PER_SECOND: f32 = 30.0;

    // 保持UI和主循环不变
    pub async fn run(&mut self) -> Result<()> {
        let mut terminal = ratatui::init();

        rust_i18n::set_locale("zh");
        let period = Duration::from_secs_f32(1.0 / Self::FRAMES_PER_SECOND);
        let mut interval = tokio::time::interval(period);
        let mut events = EventStream::new();

        while !self.exit {
            tokio::select! {
                _ = interval.tick() => {
                    terminal.draw(|frame| self.draw(frame))?;
                    self.update().await;
                },
                Some(Ok(event)) = events.next() => self.handle_event(&event).await,
            }
        }
        Ok(())
    }

    pub fn restore_term(&self) -> Result<()> {
        ratatui::restore();
        Ok(())
    }

    // 状态刷新
    async fn update(&mut self) {
        match self.screen {
            Screen::Lyrics => {
                self.lyrics.update().await;
            }
            Screen::Search => {
                if self.search.lyrics_reset() {
                    self.lyrics.reset();
                    self.screen = Screen::Lyrics;
                }
                self.search.update().await;
            }
            _ => {}
        }
    }

    fn draw<'a>(&mut self, frame: &mut Frame<'a>) {
        let area = frame.area();
        let buf = frame.buffer_mut();
        match self.screen {
            Screen::Lyrics => self.lyrics.render(area, buf),
            Screen::Search => self.search.render(area, buf),
            Screen::Help => self.help.render(area, buf),
        }
    }

    async fn handle_event(&mut self, event: &Event) {
        if let Event::Key(key) = event
            && key.kind == KeyEventKind::Press
        {
            match self.screen {
                Screen::Lyrics => match key.code {
                    KeyCode::Char('h') | KeyCode::Char('?') => self.screen = Screen::Help,
                    KeyCode::Char('s') => self.screen = Screen::Search,
                    KeyCode::Char('q') | KeyCode::Esc => self.exit(),
                    _ => self.lyrics.handle_key_event(key).await,
                },
                Screen::Search => match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => self.screen = Screen::Lyrics,
                    KeyCode::Char('h') | KeyCode::Char('?') => self.screen = Screen::Help,
                    _ => self.search.handle_key_event(key).await,
                },
                Screen::Help => match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => self.screen = Screen::Lyrics,
                    KeyCode::Char('t') => self.toggle_locale(),
                    _ => {}
                },
            }
        }
    }

    /// 关闭
    fn exit(&mut self) {
        self.exit = true;
    }

    fn toggle_locale(&self) {
        let locale = rust_i18n::locale();
        if &*locale == "en" {
            rust_i18n::set_locale("zh");
        } else {
            rust_i18n::set_locale("en");
        }
    }
}

const LYRICS_HEADER_STYLE: Style = Style::new().fg(BLUE.c400);
const LYRICS_GAUGE_STYLE: Style = Style::new()
    .add_modifier(Modifier::ITALIC)
    .add_modifier(Modifier::BOLD)
    .fg(WHITE);

const HELP_KEY_STYLE: Style = Style::new()
    .fg(LIGHT_BLUE.c400)
    .add_modifier(Modifier::BOLD);

const NORMAL_ROW_BG: Color = GRAY.c900;
const ALT_ROW_BG_COLOR: Color = GRAY.c800;
const SELECTED_STYLE: Style = Style::new().bg(BROWN.c900).add_modifier(Modifier::BOLD);

const fn alternate_colors(i: usize) -> Color {
    if i.is_multiple_of(2) {
        NORMAL_ROW_BG
    } else {
        ALT_ROW_BG_COLOR
    }
}

fn render_error(area: Rect, buf: &mut Buffer, err_msg: &str) {
    Paragraph::new(err_msg)
        .style(Style::default().fg(Color::Red))
        .block(
            Block::default()
                .title("ERROR")
                .title_alignment(Alignment::Center)
                .borders(Borders::ALL),
        )
        .render(area, buf);
}