491 lines
17 KiB
Rust
491 lines
17 KiB
Rust
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<ListItem> = 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<ListItem> = 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 => ("<unknown>".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<f32> = 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<Bar> = 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 <N> — удалить, 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 <N> удалить источник"),
|
||
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);
|
||
} |