nm/crates/tui/src/ui.rs
2026-07-25 21:00:32 +03:00

852 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Gauge, List, ListItem, Paragraph, Tabs};
use ratatui::Frame;
use ratatui_image::StatefulImage;
use crate::app::{protocol_to_str, App, Focus, LibraryMode, LibraryView, RightPanelView, Tab};
pub fn draw(frame: &mut Frame, app: &mut App) {
if app.fullscreen {
draw_fullscreen(frame, app);
return;
}
let area = frame.area();
const MIN_WIDTH: u16 = 70;
const MIN_HEIGHT: u16 = 18;
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
draw_too_small(frame, area, MIN_WIDTH, MIN_HEIGHT);
return;
}
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(frame.area());
draw_tabs(frame, app, root[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(root[1]);
match app.tab {
Tab::Library => draw_library(frame, app, body[0]),
Tab::Playlists => draw_playlists(frame, app, body[0]),
Tab::Queue => draw_queue(frame, app, body[0]),
Tab::Radio => draw_radio(frame, app, body[0]),
Tab::Stats => draw_stats(frame, app, body[0]),
Tab::NewReleases => draw_new_releases(frame, app, body[0]),
}
draw_now_playing(frame, app, body[1]);
draw_status(frame, app, root[2]);
if app.focus == Focus::FirstRunPrompt {
draw_first_run_prompt(frame);
}
}
/// Небольшой прямоугольник по центру экрана заданного размера (в колонках/строках).
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height)
}
fn draw_first_run_prompt(frame: &mut Frame) {
let area = centered_rect(60, 9, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(" Welcome to nsm ")
.style(Style::default().bg(Color::Black));
let inner = block.inner(area);
frame.render_widget(block, area);
let lines = vec![
Line::from(""),
Line::from("Remember what you listen to?"),
Line::from("This lets you see listening stats later (day/week/month)."),
Line::from(""),
Line::from(Span::styled(
" Y — yes, track it N — no, don't track",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from("(You can change this anytime with 'h')"),
];
frame.render_widget(
Paragraph::new(lines).alignment(Alignment::Center),
inner,
);
}
fn draw_fullscreen(frame: &mut Frame, app: &mut App) {
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let title = match app.right_panel_view {
RightPanelView::Cover => " Cover — fullscreen ",
RightPanelView::Spectrum => " Spectrum — fullscreen ",
};
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(root[0]);
frame.render_widget(block, root[0]);
match app.right_panel_view {
RightPanelView::Cover => {
if let Some(protocol) = app.cover_protocol.as_mut() {
let image = StatefulImage::new(None);
frame.render_stateful_widget(image, inner, protocol);
} else {
frame.render_widget(
Paragraph::new("(no cover)").alignment(Alignment::Center),
inner,
);
}
}
RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, inner),
}
let now_playing_line = app
.now_playing
.as_ref()
.map(|np| format!("{}{}", np.artist, np.title))
.unwrap_or_else(|| "Nothing playing".to_string());
let hint = format!(
"{now_playing_line} (v: switch, z/Esc: exit fullscreen, Space: play/pause, N/P: next/prev, ←/→: seek)"
);
frame.render_widget(Paragraph::new(hint), root[1]);
}
fn draw_too_small(frame: &mut Frame, area: Rect, min_width: u16, min_height: u16) {
let msg = format!(
"Terminal too small ({}x{}).\nResize to at least {}x{}.",
area.width, area.height, min_width, min_height
);
frame.render_widget(
Paragraph::new(msg).alignment(Alignment::Center),
area,
);
}
fn draw_tabs(frame: &mut Frame, app: &App, area: Rect) {
let titles = [
Tab::Library,
Tab::Playlists,
Tab::Queue,
Tab::Radio,
Tab::Stats,
Tab::NewReleases,
]
.iter()
.map(|t| t.title())
.collect::<Vec<_>>();
let selected = match app.tab {
Tab::Library => 0,
Tab::Playlists => 1,
Tab::Queue => 2,
Tab::Radio => 3,
Tab::Stats => 4,
Tab::NewReleases => 5,
};
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" nsm "))
.select(selected)
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
frame.render_widget(tabs, area);
}
fn draw_library(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let search_style = if app.focus == Focus::Search {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let search = Paragraph::new(format!("/{}", app.search_query))
.style(search_style)
.block(Block::default().borders(Borders::ALL).title(" Search "));
frame.render_widget(search, chunks[0]);
let items: Vec<ListItem> = match &app.library_view {
LibraryView::Albums(rows) => {
if let Some(album_idx) = app.expanded_album {
if let Some(row) = rows.get(album_idx) {
row.tracks
.iter()
.enumerate()
.map(|(i, item)| {
let name = item.title_override.clone().unwrap_or_else(|| {
item.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
});
ListItem::new(format!(" {:>2}. {}", i + 1, name))
})
.collect()
} else {
Vec::new()
}
} else {
rows.iter()
.map(|r| {
ListItem::new(format!(
"{}{} ({} tracks)",
r.artist,
r.album,
r.tracks.len()
))
})
.collect()
}
}
LibraryView::Tracks(rows) => rows
.iter()
.map(|r| ListItem::new(format!("{}{} [{}]", r.artist, r.title, r.album)))
.collect(),
LibraryView::Search(rows) => rows
.iter()
.map(|r| ListItem::new(format!("{}{} [{}]", r.artist, r.title, r.album)))
.collect(),
};
let title = if app.expanded_album.is_some() {
" Album (Enter: play from here, Esc: back) "
} else if !app.search_query.is_empty() {
" Search results (Enter: play, Esc: clear search & show all) "
} else {
match app.library_mode {
LibraryMode::Albums => {
" Albums (Enter: expand/play, /: search, a: add to playlist, t: all tracks, c: connect SMB, L: add folder, C: clear library) "
}
LibraryMode::Tracks => {
" All Tracks (Enter: play all from here, /: search, a: add to playlist, t: by album) "
}
}
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.library_selected));
frame.render_stateful_widget(list, chunks[1], &mut state);
}
fn draw_playlists(frame: &mut Frame, app: &App, area: Rect) {
if let Some(pl) = &app.open_playlist {
let items: Vec<ListItem> = pl
.tracks
.iter()
.map(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("?");
ListItem::new(name.to_string())
})
.collect();
let list = List::new(items)
.block(
Block::default().borders(Borders::ALL).title(format!(
" {} (Enter: play, J/K: move, d: delete, Esc: back) ",
pl.name
)),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.playlist_track_selected));
frame.render_stateful_widget(list, area, &mut state);
} else {
let items: Vec<ListItem> = app
.playlist_names
.iter()
.map(|n| ListItem::new(n.clone()))
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Playlists (Enter: open, n: new) "),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.playlists_selected));
frame.render_stateful_widget(list, area, &mut state);
}
}
fn draw_queue(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.current_queue
.iter()
.enumerate()
.map(|(i, item)| {
let name = item.title_override.clone().unwrap_or_else(|| {
item.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
});
let marker = if i == app.queue_selected { "" } else { " " };
ListItem::new(format!("{marker}{name}"))
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title(" Queue "));
frame.render_widget(list, area);
}
fn draw_new_releases(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let checked_line = match &app.new_releases_checked_at {
Some(t) => format!("Last checked: {}", t.format("%Y-%m-%d %H:%M")),
None => "Never checked yet".to_string(),
};
let progress_line = if let Some((idx, total, artist)) = &app.new_releases_progress {
format!("Checking… {idx}/{total} ({artist})")
} else {
format!("{checked_line} Period: {} (p: cycle) f: refresh all a: check one artist", app.new_releases_period.label())
};
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"New releases — for artists already in your library",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan),
)),
Line::from(progress_line),
]),
chunks[0],
);
let days = match app.new_releases_period {
library::history::StatsPeriod::Day => 1,
library::history::StatsPeriod::Week => 7,
library::history::StatsPeriod::Month => 30,
};
let filtered = library::new_releases::filter_within_days(&app.new_releases, days);
let items: Vec<ListItem> = if filtered.is_empty() {
vec![ListItem::new(
"(nothing found for this period — press 'f' to check, or widen the period with 'p')",
)]
} else {
filtered
.iter()
.map(|r| {
ListItem::new(format!(
"{}{} [{}] ({})",
r.artist,
r.title,
r.primary_type,
r.release_date.format("%Y-%m-%d")
))
})
.collect()
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} new releases ", filtered.len())),
);
frame.render_widget(list, chunks[1]);
}
fn draw_stats(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
let history_status = if app.settings.history_enabled {
"ON"
} else {
"OFF"
};
let reset_hint = if app.reset_armed {
" Press 'x' AGAIN to confirm erase!"
} else {
" x: reset history"
};
let header = format!(
"History tracking: {history_status} (toggle: h) Period: {} (p: cycle){reset_hint}",
app.stats_period.label()
);
frame.render_widget(
Paragraph::new(header).style(if app.reset_armed {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default()
}),
chunks[0],
);
let entries = app.history_store.load_all().unwrap_or_default();
let total = library::history::total_count(&entries);
let buckets = library::history::aggregate(&entries, app.stats_period);
let recent = library::history::entries_within_period(&entries, app.stats_period);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(chunks[1]);
let bars: Vec<Bar> = buckets
.iter()
.map(|b| {
Bar::default()
.value(b.count)
.label(Line::from(b.label.clone()))
.text_value(b.count.to_string())
})
.collect();
let chart = BarChart::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Plays by {} — total {} ", app.stats_period.label().to_lowercase(), total)),
)
.data(BarGroup::default().bars(&bars))
.bar_width(if app.stats_period == library::history::StatsPeriod::Day { 3 } else { 4 })
.bar_gap(1)
.bar_style(Style::default().fg(Color::Green))
.value_style(Style::default().fg(Color::Black).bg(Color::Green));
frame.render_widget(chart, body[0]);
let track_items: Vec<ListItem> = if recent.is_empty() {
vec![ListItem::new("(nothing played in this period yet)")]
} else {
recent
.iter()
.take(200)
.map(|e| {
ListItem::new(format!(
"{} {}{}",
e.played_at.format("%d.%m %H:%M"),
e.artist,
e.title
))
})
.collect()
};
let track_list = List::new(track_items).block(
Block::default()
.borders(Borders::ALL)
.title(" What played (newest first) "),
);
frame.render_widget(track_list, body[1]);
}
fn draw_radio(frame: &mut Frame, app: &App, area: Rect) {
if app.station_search_in_progress {
let msg = Paragraph::new("Searching online (doesn't block the UI — feel free to keep browsing other tabs)…")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Radio — searching… "));
frame.render_widget(msg, area);
return;
}
if !app.online_results.is_empty() {
let items: Vec<ListItem> = app
.online_results
.iter()
.map(|s| ListItem::new(s.name.clone()))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(
" Search results (Enter: play, a: add to my stations, Esc: back) ",
))
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.online_selected));
frame.render_stateful_widget(list, area, &mut state);
return;
}
let items: Vec<ListItem> = app
.stations
.iter()
.map(|s| {
let playing = app.current_station.as_deref() == Some(s.url.as_str());
let marker = if playing { "" } else { " " };
ListItem::new(format!("{marker}{}", s.name))
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(
" Radio (Enter: play, o: search online, u: add by URL, d: remove custom) ",
))
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.radio_selected));
frame.render_stateful_widget(list, area, &mut state);
}
/// Компактный рендер спектра для маленькой панели (внутри блока Cover/Spectrum) —
/// простые вертикальные бары символами блоков, без BarChart (там подписи не влезли бы
/// в такую узкую область).
fn draw_spectrum_bars(frame: &mut Frame, spectrum: &[f32], area: Rect) {
if area.height == 0 || area.width == 0 || spectrum.is_empty() {
return;
}
let n = spectrum.len();
let col_width = (area.width / n as u16).max(1);
let mut lines: Vec<Line> = Vec::with_capacity(area.height as usize);
for row in 0..area.height {
let level_at_row = 1.0 - (row as f32 + 1.0) / area.height as f32; // 0 внизу, 1 наверху
let mut spans = Vec::with_capacity(n);
for &v in spectrum {
let filled = v >= level_at_row;
let ch = if filled { "" } else { " " };
spans.push(Span::raw(ch.repeat(col_width as usize)));
}
lines.push(Line::from(spans));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(Color::Green)),
area,
);
}
fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12), // обложка + инфо
Constraint::Length(1), // транспорт (speed/shuffle/repeat)
Constraint::Percentage(50), // эквалайзер
Constraint::Percentage(50), // танцующие Шумовые Десантники
])
.split(area);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(chunks[0]);
let protocol_name = app
.picker
.as_ref()
.map(|p| protocol_to_str(p.protocol_type))
.unwrap_or("none");
let cover_title = match app.right_panel_view {
RightPanelView::Cover => format!(" Cover [{protocol_name}] (v/z: view, I: cycle protocol) "),
RightPanelView::Spectrum => " Spectrum (v/z: view) ".to_string(),
};
let cover_block = Block::default().borders(Borders::ALL).title(cover_title);
let cover_inner = cover_block.inner(top[0]);
frame.render_widget(cover_block, top[0]);
match app.right_panel_view {
RightPanelView::Cover => {
if let Some(protocol) = app.cover_protocol.as_mut() {
let image = StatefulImage::new(None);
frame.render_stateful_widget(image, cover_inner, protocol);
} else {
let placeholder = Paragraph::new("(no cover)").alignment(Alignment::Center);
frame.render_widget(placeholder, cover_inner);
}
}
RightPanelView::Spectrum => draw_spectrum_bars(frame, &app.spectrum, cover_inner),
}
draw_track_info(frame, app, top[1]);
draw_transport(frame, app, chunks[1]);
draw_eq(frame, app, chunks[2]);
draw_noise_marines(frame, app, chunks[3]);
}
fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default().borders(Borders::ALL).title(" Now Playing ");
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)])
.split(inner);
if let Some(np) = &app.now_playing {
frame.render_widget(Paragraph::new(Line::from(Span::styled(
np.title.clone(),
Style::default().add_modifier(Modifier::BOLD),
))), chunks[0]);
frame.render_widget(Paragraph::new(np.artist.clone()), chunks[1]);
frame.render_widget(Paragraph::new(np.album.clone()), chunks[2]);
if let Some(song) = &np.radio_song {
frame.render_widget(
Paragraph::new(format!("{song}")).style(Style::default().fg(Color::Cyan)),
chunks[3],
);
}
let total = np.duration.unwrap_or_default().as_secs_f64().max(1.0);
let pos = np.position.as_secs_f64().min(total);
let ratio = (pos / total).clamp(0.0, 1.0);
let label = if np.duration.is_some() {
format!("{} / {}", fmt_time(np.position), fmt_time(np.duration.unwrap_or_default()))
} else {
format!("{} / live", fmt_time(np.position))
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green))
.ratio(if np.duration.is_some() { ratio } else { 0.0 })
.label(label);
frame.render_widget(gauge, chunks[4]);
} else {
frame.render_widget(Paragraph::new("Nothing playing"), chunks[0]);
}
}
fn draw_transport(frame: &mut Frame, app: &App, area: Rect) {
let repeat_label = match app.repeat {
player::RepeatMode::Off => "Off",
player::RepeatMode::Track => "Track",
player::RepeatMode::All => "All",
};
let line = format!(
"v/z:view {:.2}x Shuf:{} Rep:{} Mute:{} Fade:{} Norm:{} ←→:seek",
app.speed.0,
if app.shuffle { "On" } else { "Off" },
repeat_label,
if app.muted { "On" } else { "Off" },
if app.fade_out_enabled { "On" } else { "Off" },
if app.loudness_norm_enabled { "On" } else { "Off" }
);
frame.render_widget(Paragraph::new(line), area);
}
/// Пасхалка: Шумовые Десантники (Noise Marines) из Warhammer 40000 — культовая "музыкальная"
/// фракция Хаоса, поклоняющаяся Слаанешу через звук и звуковое оружие (Doom Siren/Blastmaster).
/// Поза синхронизирована с басом ТЕКУЩЕГО трека через уже существующий спектроанализатор —
/// буквально пляшут под музыку, а не просто крутят произвольную анимацию.
fn draw_noise_marines(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Noise Marines ");
let inner = block.inner(area);
frame.render_widget(block, area);
// Басовая энергия усредняется по нескольким нижним полосам спектра, а не берётся из
// одной-единственной (полоса 0 при размере FFT-окна 1024 — это всего один узкий бин
// ~43Гц, слишком шумно и не отражает реальный бас трека). "Удар баса" детектируется
// относительно недавнего среднего (bass_ema), а не фиксированным порогом — так реакция
// подстраивается под конкретный трек, а не пробивается либо всегда, либо никогда.
let n = app.spectrum.len().min(7);
let bass_now = if n > 0 {
app.spectrum[..n].iter().sum::<f32>() / n as f32
} else {
0.0
};
let pose_a = crate::app::is_bass_hit(bass_now, app.bass_ema);
// Тело и рог/сирена — в одной строке за раз, как в изначальной, подтверждённо рабочей
// версии (была проверена вживую на реальном терминале). Отдельные "гитарные" аксессуары
// с другими символами не прижились — рассинхронизировали ширину колонок в разных
// шрифтах/терминалах, поэтому вернулись к простому и надёжному.
const FRAME_A: [&str; 9] = [
" ▄██▄ ",
" ◢█◉◉█◣ ",
" █▀▀▀▀█ ",
"▟████████▙ ╭─◯ ))) ",
" █▓▓▓▓█ ",
" █▓▓▓▓█ ",
" ▐▓▓▓▓▌ ",
" █ █ ",
" ▟█▙▟█▙ ",
];
const FRAME_B: [&str; 9] = [
" ▄██▄ ",
" ◢█◉◉█◣ ",
" █▀▀▀▀█ ",
"▟████████▙ ",
" █▓▓▓▓█ ╲ ",
" █▓▓▓▓█ ╲ ",
" ▐▓▓▓▓▌╭─◯ ))) ",
" ▟█▙ █ ",
" █ ▟█▙ ",
];
let sprite = if pose_a { &FRAME_A } else { &FRAME_B };
const MARINE_WIDTH: usize = 24;
let count = ((inner.width as usize) / MARINE_WIDTH).clamp(1, 4);
let lines: Vec<Line> = sprite
.iter()
.map(|row| {
let mut s = String::new();
for _ in 0..count {
let visible_len = row.chars().count();
s.push_str(row);
s.push_str(&" ".repeat(MARINE_WIDTH.saturating_sub(visible_len)));
}
Line::from(s)
})
.collect();
frame.render_widget(
Paragraph::new(lines)
.style(Style::default().fg(Color::Magenta))
.alignment(Alignment::Center),
inner,
);
}
fn draw_eq(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Equalizer (e: focus, ←/→ band, ↑/↓ gain, 0: reset band) ");
let inner = block.inner(area);
frame.render_widget(block, area);
let n = player::EQ_BANDS;
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(inner);
for band in 0..n {
let gain = app.eq_gains[band];
let selected = app.focus == Focus::Eq && app.eq_selected == band;
draw_eq_band(frame, cols[band], band, gain, selected);
}
}
fn draw_eq_band(frame: &mut Frame, area: Rect, band: usize, gain: f32, selected: bool) {
// Вертикальная полоса: делим высоту на верх (положительный gain) и низ (отрицательный),
// с нулевой линией посередине.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(1)])
.split(area);
let bar_area = rows[0];
let half = bar_area.height as f32 / 2.0;
let filled = ((gain.abs() / 12.0) * half).round() as u16;
let style = if selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if gain > 0.0 {
Style::default().fg(Color::Green)
} else if gain < 0.0 {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::DarkGray)
};
let mut lines: Vec<Line> = Vec::new();
let total_rows = bar_area.height;
let mid = total_rows / 2;
for row in 0..total_rows {
let is_mid = row == mid;
let filled_here = if gain >= 0.0 {
row >= mid.saturating_sub(filled) && row < mid
} else {
row > mid && row <= mid + filled
};
let ch = if is_mid {
""
} else if filled_here {
""
} else {
" "
};
lines.push(Line::from(Span::styled(ch, style)));
}
frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), bar_area);
let freq_label = App::eq_band_freq_label(band);
frame.render_widget(
Paragraph::new(freq_label).alignment(Alignment::Center),
rows[1],
);
let gain_label = format!("{:+.0}", gain);
frame.render_widget(
Paragraph::new(gain_label)
.alignment(Alignment::Center)
.style(style),
rows[2],
);
}
fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
let text = match app.focus {
Focus::Search => format!("SEARCH: {} (Enter: confirm, Esc: cancel)", app.search_query),
Focus::NewPlaylist => format!(
"NEW PLAYLIST: {} (Enter: create, Esc: cancel)",
app.new_playlist_name
),
Focus::NewStationName => format!(
"NEW STATION NAME: {} (Enter: next, Esc: cancel)",
app.new_station_name
),
Focus::NewStationUrl => format!(
"NEW STATION URL: {} (Enter: save, Esc: cancel)",
app.new_station_url
),
Focus::StationSearch => format!(
"SEARCH STATIONS ONLINE: {} (Enter: search, Esc: cancel)",
app.station_query
),
Focus::SmbServer => format!(
"SMB — SERVER: {} (Enter: next, Esc: cancel)",
app.smb_server
),
Focus::SmbShare => format!(
"SMB — SHARE NAME: {} (Enter: next, Esc: cancel)",
app.smb_share
),
Focus::SmbUsername => format!(
"SMB — USERNAME (empty = anonymous/guest): {} (Enter: next)",
app.smb_username
),
Focus::SmbPassword => format!(
"SMB — PASSWORD (optional): {} (Enter: connect, Esc: cancel)",
"*".repeat(app.smb_password.chars().count())
),
Focus::LibraryPathInput => format!(
"ADD FOLDER: {} (Enter: scan, Esc: cancel)",
app.library_path_input
),
Focus::NewReleaseArtistInput => format!(
"CHECK ARTIST: {} (Enter: check, Esc: cancel)",
app.new_release_artist_input
),
_ => app.status_message.clone(),
};
frame.render_widget(Paragraph::new(text), area);
}
fn fmt_time(d: std::time::Duration) -> String {
let secs = d.as_secs();
format!("{:02}:{:02}", secs / 60, secs % 60)
}