use ratatui::prelude::*; use ratatui::widgets::{ Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap, }; use crate::app::{App, InputMode, Panel}; use crate::library::{RightRow, SortMode}; use crate::state::RepeatMode; pub fn draw(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), Constraint::Length(3), ]) .split(f.area()); let top = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(chunks[0]); let left_col = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), Constraint::Length(8), ]) .split(top[0]); draw_left_panel(f, app, left_col[0]); draw_equalizer(f, app, left_col[1]); draw_right_panel(f, app, top[1]); draw_status(f, app, chunks[1]); if app.show_help { draw_help(f, f.area()); } if app.show_dirs { draw_dirs(f, app, f.area()); } } fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) { f.render_widget(Clear, area); let items: Vec = app .library .artists .iter() .map(|a| ListItem::new(a.name.clone())) .collect(); let highlight = if app.active_panel == Panel::Left { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default().add_modifier(Modifier::DIM) }; let sel = if app.library.artists.is_empty() { None } else { Some(app.library.artist_selected) }; app.library.left_state.select(sel); let title_text = match app.library.sort_mode { SortMode::ByArtist => " Исполнители ", SortMode::ByAlbum => " Альбомы ", SortMode::ByFilename => " Источник ", }; let album_color = Color::Rgb(100, 149, 237); let title = Span::styled(title_text, Style::default().fg(album_color)); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(highlight) .highlight_symbol("> "); f.render_stateful_widget(list, area, &mut app.library.left_state); } fn fmt_dur(d: std::time::Duration) -> String { if d.is_zero() { "--:--".into() } else { format!("{}:{:02}", d.as_secs() / 60, d.as_secs() % 60) } } fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) { f.render_widget(Clear, area); let rows = app.library.current_right_rows(); let items: Vec = rows .iter() .enumerate() .map(|(idx, row)| match row { RightRow::AlbumHeader { name } => { let mut album_total = std::time::Duration::ZERO; let mut i = idx + 1; while i < rows.len() { if let RightRow::Track { index_in_library } = rows[i] { if let Some(t) = app.library.track(index_in_library) { album_total += t.duration; } i += 1; } else { break; } } let total_str = fmt_dur(album_total); let inner_width = area.width.saturating_sub(6) as usize; let pad = inner_width .saturating_sub(name.len() + total_str.len() + 4) .max(1); let line = format!("{} ] {} {}", name, "─".repeat(pad), total_str); let album_color = Color::Rgb(100, 149, 237); ListItem::new(Line::from(vec![ Span::styled("[ ", Style::default().fg(album_color)), Span::styled( line, Style::default() .fg(album_color) .add_modifier(Modifier::BOLD), ), ])) } RightRow::Track { index_in_library } => { let is_playing = Some(*index_in_library) == app.playing_index; let marker = if is_playing { "♪ " } else { " " }; let (title, num, dur) = match app.library.track(*index_in_library) { Some(t) => ( t.title.clone(), t.track_number .map(|n| format!("{:02}. ", n)) .unwrap_or_default(), fmt_dur(t.duration), ), None => ("".into(), String::new(), "--:--".into()), }; let style = if is_playing { Style::default().fg(Color::Green) } else { Style::default() }; let left = format!(" {}{}{}", marker, num, title); let inner_width = area.width.saturating_sub(4) as usize; let left_len = left.chars().count(); let dur_len = dur.chars().count(); let pad = inner_width .saturating_sub(left_len + dur_len) .max(1); ListItem::new(Line::from(vec![ Span::styled(left, style), Span::raw(" ".repeat(pad)), Span::styled(dur, style), ])) } }) .collect(); let selected_name = app .library .artists .get(app.library.artist_selected) .map(|a| a.name.as_str()) .unwrap_or("—"); let title_text = match app.library.sort_mode { SortMode::ByArtist => { if app.library.filter.is_empty() { format!(" Альбомы и треки - {} ", selected_name) } else { format!( " Альбомы и треки - {} [{}] ", selected_name, app.library.filter ) } } SortMode::ByAlbum => { if app.library.filter.is_empty() { format!(" Исполнители и треки - {} ", selected_name) } else { format!( " Исполнители и треки - {} [{}] ", selected_name, app.library.filter ) } } SortMode::ByFilename => { if app.library.filter.is_empty() { " Альбомы и треки ".into() } else { format!(" Альбомы и треки [{}] ", app.library.filter) } } }; let album_color = Color::Rgb(100, 149, 237); let title = Span::styled(title_text, Style::default().fg(album_color)); let highlight = if app.active_panel == Panel::Right { Style::default().add_modifier(Modifier::REVERSED) } else { Style::default().add_modifier(Modifier::DIM) }; let sel = if rows.is_empty() { None } else { Some(app.library.right_selected) }; app.library.right_state.select(sel); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(highlight) .highlight_symbol("> "); f.render_stateful_widget(list, area, &mut app.library.right_state); } fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { let inner_width = area.width.saturating_sub(1) as usize; if inner_width == 0 { return; } let num_bars = (inner_width / 2).max(4); let bars_data = app.player.spectrum.bars(); let step = (bars_data.len() / num_bars).max(1); let mut display: Vec = Vec::with_capacity(num_bars); for i in 0..num_bars { let start = i * step; let end = (start + step).min(bars_data.len()); if start >= bars_data.len() { display.push(0.0); continue; } let sum: f32 = bars_data[start..end].iter().sum(); display.push(sum / (end - start).max(1) as f32); } let bars: Vec = display .iter() .map(|&v| { Bar::default() .value((v * 100.0) as u64) .style(Style::default().fg(Color::Rgb(100, 149, 237))) }) .collect(); let chart = BarChart::default() .data(BarGroup::default().bars(&bars)) .bar_width(1) .bar_gap(1) .max(100) .bar_set(ratatui::symbols::bar::NINE_LEVELS) .block( Block::default() .borders(Borders::ALL) .title(" Эквалайзер ") .title_style(Style::default().fg(Color::Rgb(100, 149, 237))), ); f.render_widget(chart, area); } fn draw_status(f: &mut Frame, app: &App, area: Rect) { let inner = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Min(1), Constraint::Length(18), Constraint::Length(16), Constraint::Length(12), ]) .split(area); let (status_text, status_title) = match app.input_mode { InputMode::Search => (format!(">{}", app.search_buffer), " Поиск "), InputMode::Command => (format!(":{}", app.command_buffer), " Команда "), InputMode::Normal => { let prefix = if app.player.is_paused() { "|| " } else { " " }; (format!("{}{}", prefix, app.status), " Статус ") } }; let status = Paragraph::new(status_text) .block( Block::default() .borders(Borders::ALL) .title(status_title) .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) ); f.render_widget(status, inner[0]); let (pos, total) = app.progress(); let time_text = format!( "{:02}:{:02} / {:02}:{:02}", pos.as_secs() / 60, pos.as_secs() % 60, total.as_secs() / 60, total.as_secs() % 60, ); let time = Paragraph::new(time_text) .alignment(Alignment::Center) .block( Block::default() .borders(Borders::ALL) .title(" Время ") .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) ); f.render_widget(time, inner[1]); let mode_str = match (app.repeat, app.shuffle) { (RepeatMode::Off, false) => "".to_string(), (RepeatMode::Off, true) => "SHUF".to_string(), (RepeatMode::All, false) => "RPT ALL".to_string(), (RepeatMode::One, false) => "RPT ONE".to_string(), (RepeatMode::All, true) => "RPT+SHUF".to_string(), (RepeatMode::One, true) => "ONE+SHUF".to_string(), }; let mode = Paragraph::new(mode_str) .alignment(Alignment::Center) .block( Block::default() .borders(Borders::ALL) .title(" Режим ") .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) ); f.render_widget(mode, inner[2]); let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32); let vol = Paragraph::new(vol_text) .alignment(Alignment::Center) .block( Block::default() .borders(Borders::ALL) .title(" Звук ") .title_style(Style::default().fg(Color::Rgb(100, 149, 237))) ); f.render_widget(vol, inner[3]); } fn draw_dirs(f: &mut Frame, app: &App, screen: Rect) { let count = app.sources().len(); // Строки: заголовок (1) + пустая (1) + "Список" (1) + N + пустая (1) + подсказка (1) let content_lines = 5 + count; let desired_height = (content_lines + 2) as u16; let width = 70u16.min(screen.width.saturating_sub(4)); let max_h = screen.height.saturating_sub(4); let height = desired_height.min(max_h).max(5); let x = (screen.width.saturating_sub(width)) / 2; let y = (screen.height.saturating_sub(height)) / 2; let area = Rect::new(x, y, width, height); f.render_widget(Clear, area); let title_color = Style::default().fg(Color::Rgb(100, 149, 237)); let header = Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD); let dim = Style::default().fg(Color::DarkGray); let mut lines = vec![ Line::from(Span::styled( " Источники библиотеки ", title_color.add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled(" Список", header)), ]; for (i, src) in app.sources().iter().enumerate() { lines.push(Line::from(vec![ Span::styled(format!(" [{}] ", i), dim), Span::raw(src.clone()), ])); } lines.push(Line::from("")); lines.push(Line::from(Span::styled( " :rm — удалить, Esc — закрыть", dim, ))); let para = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) .border_style(title_color) .title(" Библиотека "), ) .wrap(Wrap { trim: false }); f.render_widget(para, area); } fn draw_help(f: &mut Frame, screen: Rect) { let width = 62u16.min(screen.width.saturating_sub(4)); let height = 44u16.min(screen.height.saturating_sub(4)); let x = (screen.width.saturating_sub(width)) / 2; let y = (screen.height.saturating_sub(height)) / 2; let area = Rect::new(x, y, width, height); f.render_widget(Clear, area); let header = Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD); let title_color = Style::default().fg(Color::Rgb(100, 149, 237)); let help_text = vec![ Line::from(Span::styled( " Управление плеером ", title_color.add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled(" Навигация", header)), Line::from(" w / ↑ вверх по списку"), Line::from(" s / ↓ вниз по списку"), Line::from(" Tab переключить панель"), Line::from(" g / G в начало / в конец списка"), Line::from(" f к играющему треку"), Line::from(""), Line::from(Span::styled(" Воспроизведение", header)), Line::from(" Enter играть выделенный трек"), Line::from(" Space пауза / продолжить"), Line::from(" z стоп"), Line::from(" Ctrl+→ / Ctrl+d следующий трек"), Line::from(" Ctrl+← / Ctrl+a предыдущий трек"), Line::from(" r repeat: off → all → one"), Line::from(" x shuffle вкл/выкл"), Line::from(""), Line::from(Span::styled(" Перемотка", header)), Line::from(" a / ← −5 секунд"), Line::from(" d / → +5 секунд"), Line::from(" 1..9 перейти к 10%..90% трека"), Line::from(""), Line::from(Span::styled(" Команды", header)), Line::from(" :add <путь> добавить в сессию"), Line::from(" :load <путь> заменить сессию"), Line::from(" :reload пересканировать всё"), Line::from(" :save сохранить библиотеку"), Line::from(" :savepl <файл> экспорт в M3U/PLS"), Line::from(" :dirs список источников"), Line::from(" :rm удалить источник"), Line::from(" :clear очистить источники"), Line::from(" :help эта справка"), Line::from(" :q выход"), Line::from(""), Line::from(Span::styled(" Звук и прочее", header)), Line::from(" + / - громкость"), Line::from(" / поиск"), Line::from(" Del удалить из списка"), Line::from(" t сортировка: артист → альбом → файл"), Line::from(" F1 эта справка"), Line::from(" q / Esc выход"), Line::from(""), Line::from(Span::styled( " F1 или Esc - закрыть ", Style::default().fg(Color::DarkGray), )), ]; let help = Paragraph::new(help_text).block( Block::default() .borders(Borders::ALL) .border_style(title_color) .title(" Помощь "), ); f.render_widget(help, area); }