обновление
This commit is contained in:
119
src/app.rs
119
src/app.rs
@@ -1,68 +1,80 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::audio::AudioPlayer;
|
||||
use crate::track::{scan_dir, Track};
|
||||
use crate::library::{Library, SortMode};
|
||||
use crate::track::scan_dir;
|
||||
|
||||
/// Какая панель активна
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum Panel {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub tracks: Vec<Track>,
|
||||
pub list_state: ListState,
|
||||
pub library: Library,
|
||||
pub player: AudioPlayer,
|
||||
pub status: String,
|
||||
pub playing_index: Option<usize>,
|
||||
pub active_panel: Panel,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let tracks = scan_dir(music_dir);
|
||||
let mut list_state = ListState::default();
|
||||
if !tracks.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
let library = Library::build(tracks, SortMode::ByArtist);
|
||||
|
||||
Ok(Self {
|
||||
tracks,
|
||||
list_state,
|
||||
library,
|
||||
player: AudioPlayer::new()?,
|
||||
status: "Нажмите Enter для воспроизведения".into(),
|
||||
status: "Enter — play, t — сортировка, Tab — панель".into(),
|
||||
playing_index: None,
|
||||
active_panel: Panel::Left,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.tracks.is_empty() { return; }
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) if i >= self.tracks.len() - 1 => 0,
|
||||
Some(i) => i + 1,
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
/// Навигация вверх с учётом активной панели
|
||||
pub fn up(&mut self) {
|
||||
match self.active_panel {
|
||||
Panel::Left => self.library.left_move(-1),
|
||||
Panel::Right => self.library.right_move(-1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&mut self) {
|
||||
if self.tracks.is_empty() { return; }
|
||||
let i = match self.list_state.selected() {
|
||||
Some(0) => self.tracks.len() - 1,
|
||||
Some(i) => i - 1,
|
||||
None => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
pub fn down(&mut self) {
|
||||
match self.active_panel {
|
||||
Panel::Left => self.library.left_move(1),
|
||||
Panel::Right => self.library.right_move(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_index(&mut self, index: usize) {
|
||||
let Some(track) = self.tracks.get(index) else { return; };
|
||||
pub fn toggle_panel(&mut self) {
|
||||
self.active_panel = match self.active_panel {
|
||||
Panel::Left => Panel::Right,
|
||||
Panel::Right => Panel::Left,
|
||||
};
|
||||
self.status = match self.active_panel {
|
||||
Panel::Left => "Активна: левая панель".into(),
|
||||
Panel::Right => "Активна: правая панель".into(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn play_index(&mut self, index_in_library: usize) {
|
||||
let Some(track) = self.library.track(index_in_library).cloned() else {
|
||||
return;
|
||||
};
|
||||
match self.player.play(&track.path) {
|
||||
Ok(_) => {
|
||||
self.status = format!("▶ {} — {}", track.artist, track.title);
|
||||
self.playing_index = Some(index);
|
||||
self.playing_index = Some(index_in_library);
|
||||
}
|
||||
Err(e) => self.status = format!("Ошибка: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_selected(&mut self) {
|
||||
if let Some(i) = self.list_state.selected() {
|
||||
self.play_index(i);
|
||||
if let Some(idx) = self.library.right_track_index() {
|
||||
self.play_index(idx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +93,11 @@ impl App {
|
||||
return;
|
||||
}
|
||||
let next = match self.playing_index {
|
||||
Some(i) if i + 1 < self.tracks.len() => i + 1,
|
||||
Some(i) if i + 1 < self.library.all_tracks.len() => i + 1,
|
||||
_ => 0,
|
||||
};
|
||||
self.play_index(next);
|
||||
self.list_state.select(Some(next));
|
||||
self.library.sync_with_playing(next);
|
||||
}
|
||||
|
||||
pub fn volume_up(&mut self) {
|
||||
@@ -105,7 +117,7 @@ impl App {
|
||||
}
|
||||
let total = self
|
||||
.playing_index
|
||||
.and_then(|i| self.tracks.get(i))
|
||||
.and_then(|i| self.library.track(i))
|
||||
.map(|t| t.duration)
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
@@ -114,20 +126,47 @@ impl App {
|
||||
let sign = if delta_secs >= 0 { "+" } else { "" };
|
||||
self.status = format!(
|
||||
"Перемотка {}{}с → {:02}:{:02}",
|
||||
sign, delta_secs,
|
||||
sign,
|
||||
delta_secs,
|
||||
new_pos.as_secs() / 60,
|
||||
new_pos.as_secs() % 60,
|
||||
);
|
||||
}
|
||||
Err(_) => self.status = "Перемотка недоступна для этого формата".into(),
|
||||
Err(_) => self.status = "Перемотка недоступна".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Текущая позиция и длина играющего трека (для прогресс-бара)
|
||||
pub fn progress(&self) -> (Duration, Duration) {
|
||||
match self.playing_index.and_then(|i| self.tracks.get(i)) {
|
||||
match self.playing_index.and_then(|i| self.library.track(i)) {
|
||||
Some(t) => (self.player.position(), t.duration),
|
||||
None => (Duration::ZERO, Duration::ZERO),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_sort(&mut self) {
|
||||
let new_mode = match self.library.sort_mode {
|
||||
SortMode::ByArtist => SortMode::ByFilename,
|
||||
SortMode::ByFilename => SortMode::ByArtist,
|
||||
};
|
||||
|
||||
let playing_path = self
|
||||
.playing_index
|
||||
.and_then(|i| self.library.track(i))
|
||||
.map(|t| t.path.clone());
|
||||
|
||||
let all = self.library.all_tracks.clone();
|
||||
self.library = Library::build(all, new_mode);
|
||||
|
||||
self.playing_index = playing_path
|
||||
.and_then(|p| self.library.all_tracks.iter().position(|t| t.path == p));
|
||||
|
||||
if let Some(i) = self.playing_index {
|
||||
self.library.sync_with_playing(i);
|
||||
}
|
||||
|
||||
self.status = match new_mode {
|
||||
SortMode::ByArtist => "Сортировка: по артисту/альбому".into(),
|
||||
SortMode::ByFilename => "Сортировка: по имени файла".into(),
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/audio.rs
12
src/audio.rs
@@ -16,7 +16,11 @@ impl AudioPlayer {
|
||||
let stream_handle = OutputStreamBuilder::open_default_stream()?;
|
||||
let sink = Sink::connect_new(&stream_handle.mixer());
|
||||
sink.set_volume(1.0);
|
||||
Ok(Self { _stream: stream_handle, sink, paused_pos: None })
|
||||
Ok(Self {
|
||||
_stream: stream_handle,
|
||||
sink,
|
||||
paused_pos: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn play(&mut self, path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -65,7 +69,11 @@ impl AudioPlayer {
|
||||
self.sink.set_volume(v);
|
||||
}
|
||||
|
||||
pub fn seek_by(&mut self, delta: i64, total: Duration) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||
pub fn seek_by(
|
||||
&mut self,
|
||||
delta: i64,
|
||||
total: Duration,
|
||||
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||
let current = self.position();
|
||||
let new_secs = (current.as_secs() as i64 + delta).max(0) as u64;
|
||||
let mut new_pos = Duration::from_secs(new_secs);
|
||||
|
||||
202
src/library.rs
Normal file
202
src/library.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use crate::track::Track;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum LeftRow {
|
||||
Artist { name: String },
|
||||
Album { name: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RightRow {
|
||||
Track { index_in_library: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum SortMode {
|
||||
ByArtist,
|
||||
ByFilename,
|
||||
}
|
||||
|
||||
pub struct Library {
|
||||
pub all_tracks: Vec<Track>,
|
||||
/// Левая панель: артисты и альбомы
|
||||
pub left_rows: Vec<LeftRow>,
|
||||
/// Для каждой строки левой панели — список индексов треков в all_tracks
|
||||
pub left_to_tracks: Vec<Vec<usize>>,
|
||||
/// Индекс выбранной строки левой панели
|
||||
pub left_selected: usize,
|
||||
/// Индекс выбранного трека в правой панели (внутри текущего набора)
|
||||
pub right_selected: usize,
|
||||
pub sort_mode: SortMode,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn build(mut tracks: Vec<Track>, mode: SortMode) -> Self {
|
||||
match mode {
|
||||
SortMode::ByArtist => {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut map: BTreeMap<String, BTreeMap<String, Vec<Track>>> = BTreeMap::new();
|
||||
for t in tracks.drain(..) {
|
||||
map.entry(t.artist.clone())
|
||||
.or_default()
|
||||
.entry(t.album.clone())
|
||||
.or_default()
|
||||
.push(t);
|
||||
}
|
||||
|
||||
let mut all_tracks = Vec::new();
|
||||
let mut left_rows = Vec::new();
|
||||
let mut left_to_tracks: Vec<Vec<usize>> = Vec::new();
|
||||
|
||||
for (artist_name, albums_map) in map {
|
||||
// Строка артиста: содержит все треки всех его альбомов
|
||||
let mut artist_track_indices = Vec::new();
|
||||
let mut album_entries = Vec::new();
|
||||
|
||||
for (album_name, mut album_tracks) in albums_map {
|
||||
album_tracks.sort_by(|a, b| {
|
||||
a.track_number
|
||||
.unwrap_or(u32::MAX)
|
||||
.cmp(&b.track_number.unwrap_or(u32::MAX))
|
||||
.then(a.title.cmp(&b.title))
|
||||
});
|
||||
|
||||
let mut album_indices = Vec::new();
|
||||
for t in album_tracks {
|
||||
let idx = all_tracks.len();
|
||||
all_tracks.push(t);
|
||||
album_indices.push(idx);
|
||||
artist_track_indices.push(idx);
|
||||
}
|
||||
album_entries.push((album_name, album_indices));
|
||||
}
|
||||
|
||||
// 1) строка артиста
|
||||
left_rows.push(LeftRow::Artist {
|
||||
name: artist_name,
|
||||
});
|
||||
left_to_tracks.push(artist_track_indices);
|
||||
|
||||
// 2) строки альбомов
|
||||
for (album_name, indices) in album_entries {
|
||||
left_rows.push(LeftRow::Album { name: album_name });
|
||||
left_to_tracks.push(indices);
|
||||
}
|
||||
}
|
||||
|
||||
Library {
|
||||
all_tracks,
|
||||
left_rows,
|
||||
left_to_tracks,
|
||||
left_selected: 0,
|
||||
right_selected: 0,
|
||||
sort_mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
SortMode::ByFilename => {
|
||||
tracks.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
let all_tracks = tracks;
|
||||
|
||||
let left_rows = vec![LeftRow::Album {
|
||||
name: "Все файлы".into(),
|
||||
}];
|
||||
let left_to_tracks = vec![(0..all_tracks.len()).collect()];
|
||||
|
||||
Library {
|
||||
all_tracks,
|
||||
left_rows,
|
||||
left_to_tracks,
|
||||
left_selected: 0,
|
||||
right_selected: 0,
|
||||
sort_mode: mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track(&self, index: usize) -> Option<&Track> {
|
||||
self.all_tracks.get(index)
|
||||
}
|
||||
|
||||
/// Индексы треков, относящихся к текущей выделенной строке левой панели
|
||||
pub fn current_right_indices(&self) -> &[usize] {
|
||||
self.left_to_tracks
|
||||
.get(self.left_selected)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Индекс трека в all_tracks, выделенного в правой панели
|
||||
pub fn right_track_index(&self) -> Option<usize> {
|
||||
self.current_right_indices()
|
||||
.get(self.right_selected)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Найти строку левой панели, к которой относится трек (для синхронизации)
|
||||
pub fn left_row_of_track(&self, index_in_library: usize) -> Option<usize> {
|
||||
// Приоритет — альбом (более точное совпадение)
|
||||
let mut album_match = None;
|
||||
for (i, row) in self.left_rows.iter().enumerate() {
|
||||
let contains = self
|
||||
.left_to_tracks
|
||||
.get(i)
|
||||
.map(|v| v.contains(&index_in_library))
|
||||
.unwrap_or(false);
|
||||
if contains {
|
||||
match row {
|
||||
LeftRow::Album { .. } => return Some(i),
|
||||
LeftRow::Artist { .. } => album_match = Some(i),
|
||||
}
|
||||
}
|
||||
}
|
||||
album_match
|
||||
}
|
||||
|
||||
/// Передвинуть курсор левой панели вверх/вниз, синхронизировать правую
|
||||
pub fn left_move(&mut self, delta: isize) {
|
||||
if self.left_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = self.left_rows.len() as isize;
|
||||
let mut new = self.left_selected as isize + delta;
|
||||
if new < 0 {
|
||||
new = len - 1;
|
||||
} else if new >= len {
|
||||
new = 0;
|
||||
}
|
||||
self.left_selected = new as usize;
|
||||
self.right_selected = 0;
|
||||
}
|
||||
|
||||
/// Передвинуть курсор правой панели вверх/вниз
|
||||
pub fn right_move(&mut self, delta: isize) {
|
||||
let len = self.current_right_indices().len() as isize;
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let mut new = self.right_selected as isize + delta;
|
||||
if new < 0 {
|
||||
new = len - 1;
|
||||
} else if new >= len {
|
||||
new = 0;
|
||||
}
|
||||
self.right_selected = new as usize;
|
||||
}
|
||||
|
||||
/// Синхронизировать правую панель с играющим треком
|
||||
pub fn sync_with_playing(&mut self, index_in_library: usize) {
|
||||
if let Some(row) = self.left_row_of_track(index_in_library) {
|
||||
self.left_selected = row;
|
||||
if let Some(pos) = self
|
||||
.left_to_tracks
|
||||
.get(row)
|
||||
.and_then(|v| v.iter().position(|&i| i == index_in_library))
|
||||
{
|
||||
self.right_selected = pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/main.rs
74
src/main.rs
@@ -1,5 +1,6 @@
|
||||
mod app;
|
||||
mod audio;
|
||||
mod library;
|
||||
mod track;
|
||||
mod ui;
|
||||
|
||||
@@ -25,22 +26,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
//навигация
|
||||
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||
KeyCode::Up | KeyCode::Char('w') => app.prev(),
|
||||
KeyCode::Down | KeyCode::Char('s') => app.next(),
|
||||
KeyCode::Left | KeyCode::Char('a') => app.seek(-5),
|
||||
KeyCode::Right | KeyCode::Char('d') => app.seek(5),
|
||||
KeyCode::Enter => app.play_selected(),
|
||||
KeyCode::Char(' ') => app.toggle_pause(),
|
||||
KeyCode::Char('z') => app.stop(),
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => app.volume_up(),
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => app.volume_down(),
|
||||
_ => {}
|
||||
if handle_key(key.code, &mut app) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,4 +38,60 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key(code: KeyCode, app: &mut App) -> bool {
|
||||
match code {
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
app.volume_up();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Char('-') | KeyCode::Char('_') => {
|
||||
app.volume_down();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.up();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.down();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.seek(-5);
|
||||
return false;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.seek(5);
|
||||
return false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.play_selected();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
app.toggle_pause();
|
||||
return false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.toggle_panel();
|
||||
return false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let KeyCode::Char(c) = code {
|
||||
match c.to_ascii_lowercase() {
|
||||
'q' => return true,
|
||||
'w' => app.up(),
|
||||
's' => app.down(),
|
||||
'a' => app.seek(-5),
|
||||
'd' => app.seek(5),
|
||||
'z' => app.stop(),
|
||||
't' => app.toggle_sort(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
52
src/track.rs
52
src/track.rs
@@ -4,11 +4,13 @@ use std::time::Duration;
|
||||
use lofty::file::{AudioFile, TaggedFileExt};
|
||||
use lofty::tag::Accessor;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Track {
|
||||
pub path: PathBuf,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub track_number: Option<u32>,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
@@ -23,48 +25,70 @@ impl Track {
|
||||
match lofty::read_from_path(path) {
|
||||
Ok(tagged) => {
|
||||
let duration = tagged.properties().duration();
|
||||
let (title, artist, album) = match tagged
|
||||
let (title, artist, album, track_number) = match tagged
|
||||
.primary_tag()
|
||||
.or_else(|| tagged.first_tag())
|
||||
{
|
||||
Some(tag) => (
|
||||
tag.title().map(|s| s.to_string()).unwrap_or_else(|| fallback_name.clone()),
|
||||
tag.artist().map(|s| s.to_string()).unwrap_or_else(|| "Unknown Artist".into()),
|
||||
tag.album().map(|s| s.to_string()).unwrap_or_else(|| "Unknown Album".into()),
|
||||
tag.title()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| fallback_name.clone()),
|
||||
tag.artist()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown Artist".into()),
|
||||
tag.album()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Unknown Album".into()),
|
||||
tag.track(),
|
||||
),
|
||||
None => (
|
||||
fallback_name,
|
||||
"Unknown Artist".into(),
|
||||
"Unknown Album".into(),
|
||||
None,
|
||||
),
|
||||
None => (fallback_name, "Unknown Artist".into(), "Unknown Album".into()),
|
||||
};
|
||||
Track { path: path.clone(), title, artist, album, duration }
|
||||
Track {
|
||||
path: path.clone(),
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
track_number,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
Err(_) => Track {
|
||||
path: path.clone(),
|
||||
title: fallback_name,
|
||||
artist: "Unknown Artist".into(),
|
||||
album: "Unknown Album".into(),
|
||||
track_number: None,
|
||||
duration: Duration::ZERO,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Рекурсивно собирает все аудиофайлы в директории
|
||||
/// Собирает все аудиофайлы в директории (без сортировки)
|
||||
pub fn scan_dir(dir: &str) -> Vec<Track> {
|
||||
use walkdir::WalkDir;
|
||||
let mut tracks = Vec::new();
|
||||
for entry in WalkDir::new(dir).follow_links(true).into_iter().filter_map(|e| e.ok()) {
|
||||
for entry in WalkDir::new(dir)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
if matches!(ext.to_lowercase().as_str(), "mp3" | "flac" | "ogg" | "wav" | "m4a") {
|
||||
if matches!(
|
||||
ext.to_lowercase().as_str(),
|
||||
"mp3" | "flac" | "ogg" | "wav" | "m4a"
|
||||
) {
|
||||
tracks.push(Track::from_path(&path.to_path_buf()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracks.sort_by(|a, b| {
|
||||
a.artist.cmp(&b.artist)
|
||||
.then(a.album.cmp(&b.album))
|
||||
.then(a.title.cmp(&b.title))
|
||||
});
|
||||
tracks
|
||||
}
|
||||
130
src/ui.rs
130
src/ui.rs
@@ -1,7 +1,8 @@
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph};
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{App, Panel};
|
||||
use crate::library::{LeftRow, SortMode};
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::default()
|
||||
@@ -14,29 +15,114 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
])
|
||||
.split(f.area());
|
||||
|
||||
draw_track_list(f, app, chunks[0]);
|
||||
// Горизонтальное разделение верхней области: 40% слева, 60% справа
|
||||
let top = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
|
||||
.split(chunks[0]);
|
||||
|
||||
draw_left_panel(f, app, top[0]);
|
||||
draw_right_panel(f, app, top[1]);
|
||||
draw_progress(f, app, chunks[1]);
|
||||
draw_volume(f, app, chunks[2]);
|
||||
draw_status(f, app, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_track_list(f: &mut Frame, app: &mut App, area: Rect) {
|
||||
fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.tracks
|
||||
.library
|
||||
.left_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let marker = if Some(i) == app.playing_index { "♪ " } else { " " };
|
||||
ListItem::new(format!("{}{} - {}", marker, t.artist, t.title))
|
||||
.map(|row| match row {
|
||||
LeftRow::Artist { name } => ListItem::new(Line::from(vec![
|
||||
Span::styled("▶ ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(
|
||||
name.clone(),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])),
|
||||
LeftRow::Album { name } => ListItem::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(name.clone(), Style::default().fg(Color::Cyan)),
|
||||
])),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let title = match app.library.sort_mode {
|
||||
SortMode::ByArtist => " Артисты / Альбомы ",
|
||||
SortMode::ByFilename => " Источник ",
|
||||
};
|
||||
|
||||
let highlight = if app.active_panel == Panel::Left {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
};
|
||||
|
||||
let mut list_state = ratatui::widgets::ListState::default();
|
||||
if !app.library.left_rows.is_empty() {
|
||||
list_state.select(Some(app.library.left_selected));
|
||||
}
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Треки "))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.highlight_style(highlight)
|
||||
.highlight_symbol("> ");
|
||||
|
||||
f.render_stateful_widget(list, area, &mut app.list_state);
|
||||
f.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
|
||||
let indices = app.library.current_right_indices();
|
||||
|
||||
let items: Vec<ListItem> = indices
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let is_playing = Some(idx) == app.playing_index;
|
||||
let marker = if is_playing { "♪ " } else { " " };
|
||||
|
||||
let (title, num) = match app.library.track(idx) {
|
||||
Some(t) => (
|
||||
t.title.clone(),
|
||||
t.track_number
|
||||
.map(|n| format!("{:02}. ", n))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
None => ("<unknown>".into(), String::new()),
|
||||
};
|
||||
|
||||
let style = if is_playing {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(format!("{}{}", num, title), style),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let highlight = if app.active_panel == Panel::Right {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
};
|
||||
|
||||
let mut list_state = ratatui::widgets::ListState::default();
|
||||
if !indices.is_empty() {
|
||||
list_state.select(Some(app.library.right_selected));
|
||||
}
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title(" Треки "))
|
||||
.highlight_style(highlight)
|
||||
.highlight_symbol("> ");
|
||||
|
||||
f.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
||||
@@ -53,8 +139,10 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
||||
.ratio(ratio)
|
||||
.label(format!(
|
||||
"{:02}:{:02} / {:02}:{:02}",
|
||||
pos.as_secs() / 60, pos.as_secs() % 60,
|
||||
total.as_secs() / 60, total.as_secs() % 60,
|
||||
pos.as_secs() / 60,
|
||||
pos.as_secs() % 60,
|
||||
total.as_secs() / 60,
|
||||
total.as_secs() % 60,
|
||||
));
|
||||
|
||||
f.render_widget(gauge, area);
|
||||
@@ -63,12 +151,20 @@ fn draw_progress(f: &mut Frame, app: &App, area: Rect) {
|
||||
fn draw_volume(f: &mut Frame, app: &App, area: Rect) {
|
||||
let vol = app.player.volume();
|
||||
let ratio = (vol as f64 / 2.0).clamp(0.0, 1.0);
|
||||
let color = if vol > 1.0 { Color::Yellow }
|
||||
else if vol > 0.5 { Color::Cyan }
|
||||
else { Color::Blue };
|
||||
let color = if vol > 1.0 {
|
||||
Color::Yellow
|
||||
} else if vol > 0.5 {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Blue
|
||||
};
|
||||
|
||||
let gauge = Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(" Громкость (+/-) "))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Громкость (+/-) "),
|
||||
)
|
||||
.gauge_style(Style::default().fg(color))
|
||||
.ratio(ratio)
|
||||
.label(format!("{}%", (vol * 100.0) as u32));
|
||||
|
||||
Reference in New Issue
Block a user