Compare commits

...

5 Commits

8 changed files with 388 additions and 79 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
/.idea

1
Cargo.lock generated
View File

@@ -986,6 +986,7 @@ dependencies = [
"bincode", "bincode",
"crossterm", "crossterm",
"dirs", "dirs",
"encoding_rs",
"lofty", "lofty",
"ratatui", "ratatui",
"rodio", "rodio",

View File

@@ -14,3 +14,4 @@ serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
rustfft = "6" rustfft = "6"
bincode = "1.3" bincode = "1.3"
encoding_rs = "0.8"

View File

@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use crate::audio::AudioPlayer; use crate::audio::AudioPlayer;
use crate::library::{Library, RightRow, SortMode}; use crate::library::{Library, RightRow, SortMode};
use crate::state::{RepeatMode, SavedState}; use crate::state::{RepeatMode, SavedState};
use crate::track::{scan_dir, Track}; use crate::track::{scan_source, Track};
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Panel { pub enum Panel {
@@ -28,19 +28,17 @@ pub struct App {
pub active_panel: Panel, pub active_panel: Panel,
pub input_mode: InputMode, pub input_mode: InputMode,
pub search_buffer: String, pub search_buffer: String,
/// Буфер командного режима (после ":")
pub command_buffer: String, pub command_buffer: String,
/// Флаг запроса выхода (выставляется командой :q)
pub request_quit: bool, pub request_quit: bool,
raw_tracks: Vec<Track>, raw_tracks: Vec<Track>,
music_dir: String, default_dir: String,
/// Дополнительные директории, добавленные через :add /// Единый список источников: папки и плейлисты.
extra_dirs: Vec<String>, sources: Vec<String>,
/// Пути, удалённые из списка (не с диска)
deleted_paths: HashSet<PathBuf>, deleted_paths: HashSet<PathBuf>,
pub repeat: RepeatMode, pub repeat: RepeatMode,
pub shuffle: bool, pub shuffle: bool,
pub show_help: bool, pub show_help: bool,
pub show_libs: bool,
status_set_at: Instant, status_set_at: Instant,
} }
@@ -50,20 +48,8 @@ impl App {
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> { pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
let saved = crate::state::load(); let saved = crate::state::load();
// Пробуем загрузить кэш. Если его нет или он пустой - сканируем папку.
let tracks = match crate::state::load_cache() {
Some(cached) if !cached.is_empty() => cached,
_ => {
let scanned = scan_dir(music_dir);
crate::state::save_cache(&scanned);
scanned
}
};
let library = Library::build(tracks.clone(), SortMode::ByArtist);
let mut app = Self { let mut app = Self {
library, library: Library::build(Vec::new(), SortMode::ByArtist),
player: AudioPlayer::new()?, player: AudioPlayer::new()?,
status: "F1 - помощь, : - команда".into(), status: "F1 - помощь, : - команда".into(),
playing_index: None, playing_index: None,
@@ -72,16 +58,39 @@ impl App {
search_buffer: String::new(), search_buffer: String::new(),
command_buffer: String::new(), command_buffer: String::new(),
request_quit: false, request_quit: false,
raw_tracks: tracks, raw_tracks: Vec::new(),
music_dir: music_dir.to_string(), default_dir: music_dir.to_string(),
extra_dirs: saved.extra_dirs.clone(), sources: saved.sources.clone(),
deleted_paths: HashSet::new(), deleted_paths: HashSet::new(),
repeat: RepeatMode::Off, repeat: RepeatMode::Off,
shuffle: false, shuffle: false,
show_help: false, show_help: false,
show_libs: false,
status_set_at: Instant::now(), status_set_at: Instant::now(),
}; };
// Если sources пуст — первый запуск, добавляем базовую директорию
if app.sources.is_empty() {
app.sources.push(music_dir.to_string());
}
// Пробуем загрузить кэш
let cached = crate::state::load_cache();
app.raw_tracks = match cached {
Some(c) if !c.is_empty() => c,
_ => {
let mut tracks = Vec::new();
for src in &app.sources {
let path = PathBuf::from(src);
tracks.extend(scan_source(&path));
}
crate::state::save_cache(&tracks);
tracks
}
};
app.rebuild_library();
if let Some(vol) = saved.volume { if let Some(vol) = saved.volume {
app.player.set_volume(vol); app.player.set_volume(vol);
} }
@@ -195,9 +204,17 @@ impl App {
let args = parts.next().unwrap_or("").trim(); let args = parts.next().unwrap_or("").trim();
match name { match name {
"add" => self.add_dir(args), "add" => self.add_source(args),
"load" => self.load_source(args),
"reload" => self.reload_library(), "reload" => self.reload_library(),
"save" => self.save_now(), "save" => self.save_now(),
"savepl" => self.save_playlist(args),
"libs" => {
self.show_libs = true;
self.set_status("Источники библиотеки".into());
}
"rm" => self.remove_source(args),
"clear" => self.clear_sources(),
"help" => { "help" => {
self.show_help = true; self.show_help = true;
self.set_status("Справка".into()); self.set_status("Справка".into());
@@ -211,7 +228,7 @@ impl App {
} }
} }
fn add_dir(&mut self, raw_path: &str) { fn add_source(&mut self, raw_path: &str) {
if raw_path.is_empty() { if raw_path.is_empty() {
self.set_status("Использование: :add <путь>".into()); self.set_status("Использование: :add <путь>".into());
return; return;
@@ -225,13 +242,8 @@ impl App {
return; return;
} }
if !path.is_dir() {
self.set_status(format!("Не директория: {}", expanded));
return;
}
self.set_status(format!("Сканирование {}...", expanded)); self.set_status(format!("Сканирование {}...", expanded));
let new_tracks = scan_dir(&expanded); let new_tracks = scan_source(&path);
if new_tracks.is_empty() { if new_tracks.is_empty() {
self.set_status(format!("Нет треков в {}", expanded)); self.set_status(format!("Нет треков в {}", expanded));
@@ -247,8 +259,8 @@ impl App {
} }
} }
if !self.extra_dirs.contains(&expanded) { if !self.sources.contains(&expanded) {
self.extra_dirs.push(expanded.clone()); self.sources.push(expanded.clone());
} }
self.rebuild_library(); self.rebuild_library();
@@ -256,14 +268,77 @@ impl App {
self.set_status(format!("Добавлено {} треков из {}", added, expanded)); self.set_status(format!("Добавлено {} треков из {}", added, expanded));
} }
pub fn reload_library(&mut self) { fn load_source(&mut self, raw_path: &str) {
let mut dirs = vec![self.music_dir.clone()]; if raw_path.is_empty() {
dirs.extend(self.extra_dirs.clone()); self.set_status("Использование: :load <путь>".into());
return;
}
let expanded = expand_tilde(raw_path);
let path = PathBuf::from(&expanded);
if !path.exists() {
self.set_status(format!("Путь не существует: {}", expanded));
return;
}
self.set_status(format!("Загрузка {}...", expanded));
let new_tracks = scan_source(&path);
if new_tracks.is_empty() {
self.set_status(format!("Нет треков в {}", expanded));
return;
}
// Очищаем текущую сессию
self.raw_tracks = new_tracks;
self.sources = vec![expanded.clone()];
self.deleted_paths.clear();
self.rebuild_library();
crate::state::save_cache(&self.raw_tracks);
self.set_status(format!("Загружено {} треков из {}", self.raw_tracks.len(), expanded));
}
fn remove_source(&mut self, arg: &str) {
let idx: usize = match arg.parse() {
Ok(i) => i,
Err(_) => {
self.set_status("Использование: :rm <индекс>".into());
return;
}
};
if idx >= self.sources.len() {
self.set_status(format!("Нет источника с индексом {}", idx));
return;
}
let removed = self.sources.remove(idx);
self.set_status(format!("Источник удалён: {}", removed));
self.reload_library();
}
fn clear_sources(&mut self) {
if self.sources.is_empty() {
self.set_status("Список источников уже пуст".into());
return;
}
self.sources.clear();
self.raw_tracks.clear();
self.deleted_paths.clear();
self.playing_index = None;
self.player.stop();
self.rebuild_library();
crate::state::save_cache(&self.raw_tracks);
self.set_status("Все источники удалены".into());
}
fn rebuild_from_sources(&mut self) {
let mut all = Vec::new(); let mut all = Vec::new();
for dir in &dirs { for src in &self.sources {
let expanded = expand_tilde(dir); let path = PathBuf::from(expand_tilde(src));
all.extend(scan_dir(&expanded)); all.extend(scan_source(&path));
} }
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -271,20 +346,54 @@ impl App {
self.raw_tracks = all; self.raw_tracks = all;
self.deleted_paths.clear(); self.deleted_paths.clear();
crate::state::save_cache(&self.raw_tracks);
crate::state::save_cache(&self.raw_tracks);
self.rebuild_library(); self.rebuild_library();
}
pub fn reload_library(&mut self) {
self.rebuild_from_sources();
self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len())); self.set_status(format!("Пересканировано {} треков", self.raw_tracks.len()));
} }
/// Принудительно сохранить кэш библиотеки и настройки.
/// Тот же `persist()`, но с подтверждением в статусе.
pub fn save_now(&mut self) { pub fn save_now(&mut self) {
self.persist(); self.persist();
let msg = format!("Сохранено {} треков", self.raw_tracks.len()); let msg = format!("Сохранено {} треков", self.raw_tracks.len());
self.set_status(msg); self.set_status(msg);
} }
fn save_playlist(&mut self, raw_path: &str) {
if raw_path.is_empty() {
self.set_status("Использование: :savepl <файл.m3u|.pls>".into());
return;
}
let path = PathBuf::from(expand_tilde(raw_path));
let is_pls = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("pls"))
.unwrap_or(false);
let tracks: Vec<Track> = self
.raw_tracks
.iter()
.filter(|t| !self.deleted_paths.contains(&t.path))
.cloned()
.collect();
let res = if is_pls {
crate::track::save_playlist_pls(&path, &tracks)
} else {
crate::track::save_playlist_m3u(&path, &tracks)
};
match res {
Ok(_) => self.set_status(format!("Плейлист сохранён: {}", path.display())),
Err(e) => self.set_status(format!("Ошибка сохранения: {}", e)),
}
}
// ── Удаление из списка (клавиша Del) ─────────────────────── // ── Удаление из списка (клавиша Del) ───────────────────────
pub fn delete_selected(&mut self) { pub fn delete_selected(&mut self) {
match self.active_panel { match self.active_panel {
@@ -435,8 +544,6 @@ impl App {
} }
fn persist(&self) { fn persist(&self) {
// Сохраняем кэш треков - чтобы при следующем запуске
// не сканировать директории заново.
crate::state::save_cache(&self.raw_tracks); crate::state::save_cache(&self.raw_tracks);
let playing_path = self let playing_path = self
@@ -451,14 +558,14 @@ impl App {
}; };
let state = SavedState { let state = SavedState {
last_path: Some(self.music_dir.clone()), last_path: Some(self.default_dir.clone()),
volume: Some(self.player.volume()), volume: Some(self.player.volume()),
sort_mode: Some(self.library.sort_mode), sort_mode: Some(self.library.sort_mode),
repeat: Some(self.repeat), repeat: Some(self.repeat),
shuffle: Some(self.shuffle), shuffle: Some(self.shuffle),
last_playing_path: playing_path, last_playing_path: playing_path,
last_playing_pos_secs: playing_pos, last_playing_pos_secs: playing_pos,
extra_dirs: self.extra_dirs.clone(), sources: self.sources.clone(),
}; };
crate::state::save(&state); crate::state::save(&state);
} }
@@ -795,9 +902,14 @@ impl App {
}; };
self.set_status(msg); self.set_status(msg);
} }
// ── Геттеры для UI ─────────────────────────────────────────
pub fn sources(&self) -> &[String] {
&self.sources
}
} }
/// Развернуть `~/...` в путь домашней директории.
fn expand_tilde(path: &str) -> String { fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") { if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {

View File

@@ -26,7 +26,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(&music_dir)?; let mut app = App::new(&music_dir)?;
loop { loop {
// Проверяем флаг выхода, выставленный командой :q
if app.request_quit { if app.request_quit {
app.on_exit(); app.on_exit();
break; break;
@@ -69,6 +68,13 @@ fn handle_key(key: KeyEvent, app: &mut App) -> bool {
return false; return false;
} }
if app.show_libs {
if key.code == KeyCode::Esc {
app.show_libs = false;
}
return false;
}
if app.input_mode == InputMode::Command { if app.input_mode == InputMode::Command {
match key.code { match key.code {
KeyCode::Esc => app.cancel_command(), KeyCode::Esc => app.cancel_command(),

View File

@@ -15,9 +15,9 @@ pub struct SavedState {
pub shuffle: Option<bool>, pub shuffle: Option<bool>,
pub last_playing_path: Option<String>, pub last_playing_path: Option<String>,
pub last_playing_pos_secs: Option<u64>, pub last_playing_pos_secs: Option<u64>,
/// Дополнительные директории, добавленные через :add /// Единый список источников: папки и плейлисты.
#[serde(default)] #[serde(default)]
pub extra_dirs: Vec<String>, pub sources: Vec<String>,
} }
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)] #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug, Default)]
@@ -58,9 +58,6 @@ pub fn save(state: &SavedState) {
} }
} }
// ── Кэш плейлиста (bincode) ────────────────────────────────────
/// Кэш просто хранит все известные треки. Без привязки к конкретной директории.
pub fn load_cache() -> Option<Vec<Track>> { pub fn load_cache() -> Option<Vec<Track>> {
let path = cache_path()?; let path = cache_path()?;
let bytes = fs::read(&path).ok()?; let bytes = fs::read(&path).ok()?;

View File

@@ -1,4 +1,4 @@
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use lofty::file::{AudioFile, TaggedFileExt}; use lofty::file::{AudioFile, TaggedFileExt};
@@ -92,3 +92,111 @@ pub fn scan_dir(dir: &str) -> Vec<Track> {
} }
tracks tracks
} }
/// Универсальный сканер источника: папка или файл плейлиста.
pub fn scan_source(path: &Path) -> Vec<Track> {
if path.is_dir() {
scan_dir(&path.to_string_lossy())
} else if path.is_file() {
read_playlist(path)
.into_iter()
.map(|p| Track::from_path(&p))
.collect()
} else {
Vec::new()
}
}
// ── Плейлисты (M3U / M3U8 / PLS) ───────────────────────────────
pub fn read_playlist(path: &Path) -> Vec<PathBuf> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let content = match std::str::from_utf8(&bytes) {
Ok(s) => s.to_string(),
Err(_) => {
let (decoded, _, _) = encoding_rs::WINDOWS_1251.decode(&bytes);
decoded.into_owned()
}
};
let content = content.trim_start_matches('\u{FEFF}');
let parent = path.parent().unwrap_or(Path::new(""));
let is_pls = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("pls"))
.unwrap_or(false);
if is_pls {
parse_pls(content, parent)
} else {
parse_m3u(content, parent)
}
}
fn parse_m3u(content: &str, parent: &Path) -> Vec<PathBuf> {
content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| resolve_path(l, parent))
.filter(|p| p.exists())
.collect()
}
fn parse_pls(content: &str, parent: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("file") {
if let Some((_, val)) = line.split_once('=') {
let p = resolve_path(val.trim(), parent);
if p.exists() {
files.push(p);
}
}
}
}
files
}
fn resolve_path(s: &str, parent: &Path) -> PathBuf {
let p = PathBuf::from(s);
if p.is_relative() {
parent.join(p)
} else {
p
}
}
pub fn save_playlist_m3u(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("#EXTM3U\n");
for t in tracks {
let dur = t.duration.as_secs();
out.push_str(&format!(
"#EXTINF:{},{} - {}\n{}\n",
dur,
t.artist,
t.title,
t.path.display()
));
}
std::fs::write(path, out)
}
pub fn save_playlist_pls(path: &Path, tracks: &[Track]) -> std::io::Result<()> {
let mut out = String::from("[playlist]\n");
for (i, t) in tracks.iter().enumerate() {
let num = i + 1;
out.push_str(&format!("File{}={}\n", num, t.path.display()));
out.push_str(&format!("Title{}={} - {}\n", num, t.artist, t.title));
out.push_str(&format!("Length{}={}\n", num, t.duration.as_secs()));
}
out.push_str(&format!("NumberOfEntries={}\nVersion=2\n", tracks.len()));
std::fs::write(path, out)
}

123
src/ui.rs
View File

@@ -1,6 +1,6 @@
use ratatui::prelude::*; use ratatui::prelude::*;
use ratatui::widgets::{ use ratatui::widgets::{
Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Bar, BarChart, BarGroup, Block, Borders, Clear, List, ListItem, Paragraph, Wrap,
}; };
use crate::app::{App, InputMode, Panel}; use crate::app::{App, InputMode, Panel};
@@ -8,27 +8,24 @@ use crate::library::{RightRow, SortMode};
use crate::state::RepeatMode; use crate::state::RepeatMode;
pub fn draw(f: &mut Frame, app: &mut App) { pub fn draw(f: &mut Frame, app: &mut App) {
// Вертикально: верхняя часть (панели + эквалайзер) и статус
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(3), // статус + время + mode + vol Constraint::Length(3),
]) ])
.split(f.area()); .split(f.area());
// Горизонтально: левая колонка (исполнители + эквалайзер) и правая (треки)
let top = Layout::default() let top = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.split(chunks[0]); .split(chunks[0]);
// Левая колонка делится вертикально: список сверху, эквалайзер снизу
let left_col = Layout::default() let left_col = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(1), Constraint::Min(1),
Constraint::Length(8), // эквалайзер Constraint::Length(8),
]) ])
.split(top[0]); .split(top[0]);
@@ -40,6 +37,9 @@ pub fn draw(f: &mut Frame, app: &mut App) {
if app.show_help { if app.show_help {
draw_help(f, f.area()); draw_help(f, f.area());
} }
if app.show_libs {
draw_dirs(f, app, f.area());
}
} }
fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) { fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
@@ -65,11 +65,13 @@ fn draw_left_panel(f: &mut Frame, app: &mut App, area: Rect) {
}; };
app.library.left_state.select(sel); app.library.left_state.select(sel);
let title = match app.library.sort_mode { let title_text = match app.library.sort_mode {
SortMode::ByArtist => " Исполнители ", SortMode::ByArtist => " Исполнители ",
SortMode::ByAlbum => " Альбомы ", SortMode::ByAlbum => " Альбомы ",
SortMode::ByFilename => " Источник ", 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) let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title)) .block(Block::default().borders(Borders::ALL).title(title))
@@ -156,8 +158,8 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
let left_len = left.chars().count(); let left_len = left.chars().count();
let dur_len = dur.chars().count(); let dur_len = dur.chars().count();
let pad = inner_width let pad = inner_width
.saturating_sub(left_len + dur_len) .saturating_sub(left_len + dur_len)
.max(1); .max(1);
ListItem::new(Line::from(vec![ ListItem::new(Line::from(vec![
Span::styled(left, style), Span::styled(left, style),
@@ -175,7 +177,7 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
.map(|a| a.name.as_str()) .map(|a| a.name.as_str())
.unwrap_or("—"); .unwrap_or("—");
let title = match app.library.sort_mode { let title_text = match app.library.sort_mode {
SortMode::ByArtist => { SortMode::ByArtist => {
if app.library.filter.is_empty() { if app.library.filter.is_empty() {
format!(" Альбомы и треки - {} ", selected_name) format!(" Альбомы и треки - {} ", selected_name)
@@ -204,6 +206,8 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
} }
} }
}; };
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 { let highlight = if app.active_panel == Panel::Right {
Style::default().add_modifier(Modifier::REVERSED) Style::default().add_modifier(Modifier::REVERSED)
@@ -227,14 +231,11 @@ fn draw_right_panel(f: &mut Frame, app: &mut App, area: Rect) {
} }
fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) { fn draw_equalizer(f: &mut Frame, app: &App, area: Rect) {
// Внутренняя ширина панели (минус 2 символа рамки) let inner_width = area.width.saturating_sub(1) as usize;
let inner_width = area.width.saturating_sub(2) as usize;
if inner_width == 0 { if inner_width == 0 {
return; return;
} }
// Каждый столбик занимает 2 ячейки: 1 символ + 1 gap.
// Значит число столбиков = ширина / 2.
let num_bars = (inner_width / 2).max(4); let num_bars = (inner_width / 2).max(4);
let bars_data = app.player.spectrum.bars(); let bars_data = app.player.spectrum.bars();
@@ -298,7 +299,12 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
}; };
let status = Paragraph::new(status_text) let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL).title(status_title)); .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]); f.render_widget(status, inner[0]);
let (pos, total) = app.progress(); let (pos, total) = app.progress();
@@ -311,7 +317,12 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
); );
let time = Paragraph::new(time_text) let time = Paragraph::new(time_text)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Время ")); .block(
Block::default()
.borders(Borders::ALL)
.title(" Время ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(time, inner[1]); f.render_widget(time, inner[1]);
let mode_str = match (app.repeat, app.shuffle) { let mode_str = match (app.repeat, app.shuffle) {
@@ -324,19 +335,86 @@ fn draw_status(f: &mut Frame, app: &App, area: Rect) {
}; };
let mode = Paragraph::new(mode_str) let mode = Paragraph::new(mode_str)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Mode ")); .block(
Block::default()
.borders(Borders::ALL)
.title(" Режим ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(mode, inner[2]); f.render_widget(mode, inner[2]);
let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32); let vol_text = format!("Vol: {}%", (app.player.volume() * 100.0) as u32);
let vol = Paragraph::new(vol_text) let vol = Paragraph::new(vol_text)
.alignment(Alignment::Center) .alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" ")); .block(
Block::default()
.borders(Borders::ALL)
.title(" Звук ")
.title_style(Style::default().fg(Color::Rgb(100, 149, 237)))
);
f.render_widget(vol, inner[3]); 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) { fn draw_help(f: &mut Frame, screen: Rect) {
let width = 62u16.min(screen.width.saturating_sub(4)); let width = 62u16.min(screen.width.saturating_sub(4));
let height = 40u16.min(screen.height.saturating_sub(4)); let height = 44u16.min(screen.height.saturating_sub(4));
let x = (screen.width.saturating_sub(width)) / 2; let x = (screen.width.saturating_sub(width)) / 2;
let y = (screen.height.saturating_sub(height)) / 2; let y = (screen.height.saturating_sub(height)) / 2;
@@ -377,9 +455,14 @@ fn draw_help(f: &mut Frame, screen: Rect) {
Line::from(" 1..9 перейти к 10%..90% трека"), Line::from(" 1..9 перейти к 10%..90% трека"),
Line::from(""), Line::from(""),
Line::from(Span::styled(" Команды", header)), Line::from(Span::styled(" Команды", header)),
Line::from(" :add <путь> добавить директорию"), Line::from(" :add <путь> добавить в сессию"),
Line::from(" :load <путь> заменить сессию"),
Line::from(" :reload пересканировать всё"), Line::from(" :reload пересканировать всё"),
Line::from(" :save сохранить библиотеку"), Line::from(" :save сохранить библиотеку"),
Line::from(" :savepl <файл> экспорт в M3U/PLS"),
Line::from(" :libs список источников"),
Line::from(" :rm <N> удалить источник"),
Line::from(" :clear очистить источники"),
Line::from(" :help эта справка"), Line::from(" :help эта справка"),
Line::from(" :q выход"), Line::from(" :q выход"),
Line::from(""), Line::from(""),