852 lines
32 KiB
Rust
852 lines
32 KiB
Rust
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)
|
||
}
|