530 lines
16 KiB
Rust
530 lines
16 KiB
Rust
use std::time::{Duration, Instant};
|
||
|
||
use crate::audio::AudioPlayer;
|
||
use crate::library::{Library, SortMode};
|
||
use crate::state::{RepeatMode, SavedState};
|
||
use crate::track::{scan_dir, Track};
|
||
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
pub enum Panel {
|
||
Left,
|
||
Right,
|
||
}
|
||
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
pub enum InputMode {
|
||
Normal,
|
||
Search,
|
||
}
|
||
|
||
pub struct App {
|
||
pub library: Library,
|
||
pub player: AudioPlayer,
|
||
pub status: String,
|
||
pub playing_index: Option<usize>,
|
||
pub active_panel: Panel,
|
||
pub input_mode: InputMode,
|
||
pub search_buffer: String,
|
||
raw_tracks: Vec<Track>,
|
||
music_dir: String,
|
||
pub repeat: RepeatMode,
|
||
pub shuffle: bool,
|
||
pub show_help: bool,
|
||
status_set_at: Instant,
|
||
}
|
||
|
||
const STATUS_RESET_AFTER: Duration = Duration::from_secs(2);
|
||
|
||
impl App {
|
||
pub fn new(music_dir: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||
let tracks = match crate::state::load_cache(music_dir) {
|
||
Some(cached) => cached,
|
||
None => {
|
||
let scanned = scan_dir(music_dir);
|
||
crate::state::save_cache(music_dir, &scanned);
|
||
scanned
|
||
}
|
||
};
|
||
|
||
let library = Library::build(tracks.clone(), SortMode::ByArtist);
|
||
|
||
let mut app = Self {
|
||
library,
|
||
player: AudioPlayer::new()?,
|
||
status: "F1 — помощь".into(),
|
||
playing_index: None,
|
||
active_panel: Panel::Left,
|
||
input_mode: InputMode::Normal,
|
||
search_buffer: String::new(),
|
||
raw_tracks: tracks,
|
||
music_dir: music_dir.to_string(),
|
||
repeat: RepeatMode::Off,
|
||
shuffle: false,
|
||
show_help: false,
|
||
status_set_at: Instant::now(),
|
||
};
|
||
|
||
let saved = crate::state::load();
|
||
if let Some(vol) = saved.volume {
|
||
app.player.set_volume(vol);
|
||
}
|
||
if let Some(rep) = saved.repeat {
|
||
app.repeat = rep;
|
||
}
|
||
if let Some(sh) = saved.shuffle {
|
||
app.shuffle = sh;
|
||
}
|
||
if let Some(mode) = saved.sort_mode {
|
||
if mode != SortMode::ByArtist {
|
||
app.library = Library::build(app.raw_tracks.clone(), mode);
|
||
}
|
||
}
|
||
|
||
app.restore_playing(&saved);
|
||
Ok(app)
|
||
}
|
||
|
||
fn set_status(&mut self, text: String) {
|
||
self.status = text;
|
||
self.status_set_at = Instant::now();
|
||
}
|
||
|
||
fn default_status(&self) -> String {
|
||
match self.playing_index.and_then(|i| self.library.track(i)) {
|
||
Some(t) => format!("▶ {} — {}", t.artist, t.title),
|
||
None => "F1 — помощь".into(),
|
||
}
|
||
}
|
||
|
||
pub fn tick_status(&mut self) {
|
||
if self.input_mode == InputMode::Search {
|
||
return;
|
||
}
|
||
if self.status_set_at.elapsed() < STATUS_RESET_AFTER {
|
||
return;
|
||
}
|
||
let default = self.default_status();
|
||
if self.status != default {
|
||
self.status = default;
|
||
}
|
||
}
|
||
|
||
fn restore_playing(&mut self, saved: &SavedState) {
|
||
let Some(path_str) = &saved.last_playing_path else {
|
||
return;
|
||
};
|
||
|
||
let target_path = std::path::PathBuf::from(path_str);
|
||
let Some(idx) = self
|
||
.library
|
||
.all_tracks
|
||
.iter()
|
||
.position(|t| t.path == target_path)
|
||
else {
|
||
return;
|
||
};
|
||
|
||
let start_pos = saved.last_playing_pos_secs.map(Duration::from_secs);
|
||
|
||
if let Some(track) = self.library.track(idx).cloned() {
|
||
if self.player.play(&track.path, start_pos).is_ok() {
|
||
self.playing_index = Some(idx);
|
||
self.library.sync_with_playing(idx);
|
||
self.player.toggle_pause();
|
||
self.set_status(format!("Восстановлен: {} — {}", track.artist, track.title));
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn toggle_help(&mut self) {
|
||
self.show_help = !self.show_help;
|
||
}
|
||
|
||
pub fn next_track(&mut self) {
|
||
let total = self.library.all_tracks.len();
|
||
if total == 0 {
|
||
self.set_status("Библиотека пуста".into());
|
||
return;
|
||
}
|
||
|
||
let next = match self.playing_index {
|
||
Some(i) if i + 1 < total => i + 1,
|
||
Some(_) => {
|
||
if self.repeat == RepeatMode::Off {
|
||
self.set_status("Конец плейлиста".into());
|
||
return;
|
||
}
|
||
0
|
||
}
|
||
None => 0,
|
||
};
|
||
|
||
self.play_index(next);
|
||
self.library.sync_with_playing(next);
|
||
}
|
||
|
||
pub fn prev_track(&mut self) {
|
||
let total = self.library.all_tracks.len();
|
||
if total == 0 {
|
||
self.set_status("Библиотека пуста".into());
|
||
return;
|
||
}
|
||
|
||
let prev = match self.playing_index {
|
||
Some(0) => {
|
||
if self.repeat == RepeatMode::Off {
|
||
self.set_status("Это первый трек".into());
|
||
return;
|
||
}
|
||
total - 1
|
||
}
|
||
Some(i) => i - 1,
|
||
None => 0,
|
||
};
|
||
|
||
self.play_index(prev);
|
||
self.library.sync_with_playing(prev);
|
||
}
|
||
|
||
fn persist(&self) {
|
||
let playing_path = self
|
||
.playing_index
|
||
.and_then(|i| self.library.track(i))
|
||
.map(|t| t.path.to_string_lossy().to_string());
|
||
|
||
let playing_pos = if self.playing_index.is_some() {
|
||
Some(self.player.position().as_secs())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let state = SavedState {
|
||
last_path: Some(self.music_dir.clone()),
|
||
volume: Some(self.player.volume()),
|
||
sort_mode: Some(self.library.sort_mode),
|
||
repeat: Some(self.repeat),
|
||
shuffle: Some(self.shuffle),
|
||
last_playing_path: playing_path,
|
||
last_playing_pos_secs: playing_pos,
|
||
};
|
||
crate::state::save(&state);
|
||
}
|
||
|
||
pub fn on_exit(&self) {
|
||
self.persist();
|
||
}
|
||
|
||
pub fn start_search(&mut self) {
|
||
self.input_mode = InputMode::Search;
|
||
self.search_buffer.clear();
|
||
self.status_set_at = Instant::now();
|
||
}
|
||
|
||
pub fn cancel_search(&mut self) {
|
||
self.input_mode = InputMode::Normal;
|
||
self.search_buffer.clear();
|
||
self.rebuild_library();
|
||
self.set_status("Поиск отменён".into());
|
||
}
|
||
|
||
pub fn commit_search(&mut self) {
|
||
self.input_mode = InputMode::Normal;
|
||
let msg = if self.search_buffer.is_empty() {
|
||
"Фильтр сброшен".to_string()
|
||
} else {
|
||
format!("Фильтр: {}", self.search_buffer)
|
||
};
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn search_push(&mut self, c: char) {
|
||
self.search_buffer.push(c);
|
||
self.rebuild_library();
|
||
self.status_set_at = Instant::now();
|
||
}
|
||
|
||
pub fn search_pop(&mut self) {
|
||
self.search_buffer.pop();
|
||
self.rebuild_library();
|
||
self.status_set_at = Instant::now();
|
||
}
|
||
|
||
fn rebuild_library(&mut self) {
|
||
let mode = self.library.sort_mode;
|
||
self.library = Library::build_with_filter(
|
||
self.raw_tracks.clone(),
|
||
mode,
|
||
self.search_buffer.clone(),
|
||
);
|
||
if let Some(i) = self.playing_index {
|
||
self.library.sync_with_playing(i);
|
||
}
|
||
}
|
||
|
||
pub fn up(&mut self) {
|
||
match self.active_panel {
|
||
Panel::Left => self.library.artist_move(-1),
|
||
Panel::Right => self.library.right_move(-1),
|
||
}
|
||
}
|
||
|
||
pub fn down(&mut self) {
|
||
match self.active_panel {
|
||
Panel::Left => self.library.artist_move(1),
|
||
Panel::Right => self.library.right_move(1),
|
||
}
|
||
}
|
||
|
||
pub fn toggle_panel(&mut self) {
|
||
self.active_panel = match self.active_panel {
|
||
Panel::Left => Panel::Right,
|
||
Panel::Right => Panel::Left,
|
||
};
|
||
let msg = match self.active_panel {
|
||
Panel::Left => "Активна: левая панель".to_string(),
|
||
Panel::Right => "Активна: правая панель".to_string(),
|
||
};
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn jump_to_playing(&mut self) {
|
||
if let Some(i) = self.playing_index {
|
||
self.library.sync_with_playing(i);
|
||
self.active_panel = Panel::Right;
|
||
self.set_status("→ к играющему треку".into());
|
||
} else {
|
||
self.set_status("Ничего не играет".into());
|
||
}
|
||
}
|
||
|
||
pub fn goto_start(&mut self) {
|
||
match self.active_panel {
|
||
Panel::Left => {
|
||
self.library.artist_selected = 0;
|
||
self.library.left_state.select(Some(0));
|
||
}
|
||
Panel::Right => self.library.right_goto_start(),
|
||
}
|
||
}
|
||
|
||
pub fn goto_end(&mut self) {
|
||
match self.active_panel {
|
||
Panel::Left => {
|
||
if !self.library.artists.is_empty() {
|
||
self.library.artist_selected = self.library.artists.len() - 1;
|
||
self.library
|
||
.left_state
|
||
.select(Some(self.library.artist_selected));
|
||
}
|
||
}
|
||
Panel::Right => self.library.right_goto_end(),
|
||
}
|
||
}
|
||
|
||
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, None) {
|
||
Ok(_) => {
|
||
self.playing_index = Some(index_in_library);
|
||
self.set_status(format!("▶ {} — {}", track.artist, track.title));
|
||
}
|
||
Err(e) => self.set_status(format!("Ошибка: {}", e)),
|
||
}
|
||
}
|
||
|
||
pub fn play_selected(&mut self) {
|
||
if let Some(idx) = self.library.right_track_index() {
|
||
self.play_index(idx);
|
||
} else {
|
||
self.set_status("Выделен заголовок группы".into());
|
||
}
|
||
}
|
||
|
||
pub fn stop(&mut self) {
|
||
self.player.stop();
|
||
self.playing_index = None;
|
||
self.set_status("Остановлено".into());
|
||
}
|
||
|
||
pub fn toggle_pause(&mut self) {
|
||
self.player.toggle_pause();
|
||
}
|
||
|
||
pub fn maybe_advance(&mut self) {
|
||
if self.playing_index.is_none() || !self.player.is_finished() {
|
||
return;
|
||
}
|
||
|
||
if self.repeat == RepeatMode::One {
|
||
if let Some(i) = self.playing_index {
|
||
self.play_index(i);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let total = self.library.all_tracks.len();
|
||
if total == 0 {
|
||
return;
|
||
}
|
||
|
||
let next = if self.shuffle {
|
||
let seed = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos() as usize)
|
||
.unwrap_or(0);
|
||
seed % total
|
||
} else {
|
||
match self.playing_index {
|
||
Some(i) if i + 1 < total => i + 1,
|
||
_ => {
|
||
if self.repeat == RepeatMode::All {
|
||
0
|
||
} else {
|
||
self.set_status("Конец плейлиста".into());
|
||
self.playing_index = None;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
self.play_index(next);
|
||
self.library.sync_with_playing(next);
|
||
}
|
||
|
||
pub fn cycle_repeat(&mut self) {
|
||
self.repeat = match self.repeat {
|
||
RepeatMode::Off => RepeatMode::All,
|
||
RepeatMode::All => RepeatMode::One,
|
||
RepeatMode::One => RepeatMode::Off,
|
||
};
|
||
let msg = match self.repeat {
|
||
RepeatMode::Off => "Repeat: off".to_string(),
|
||
RepeatMode::All => "Repeat: all".to_string(),
|
||
RepeatMode::One => "Repeat: one".to_string(),
|
||
};
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn toggle_shuffle(&mut self) {
|
||
self.shuffle = !self.shuffle;
|
||
let msg = if self.shuffle {
|
||
"Shuffle: on".to_string()
|
||
} else {
|
||
"Shuffle: off".to_string()
|
||
};
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn volume_up(&mut self) {
|
||
self.player.change_volume(0.05);
|
||
let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn volume_down(&mut self) {
|
||
self.player.change_volume(-0.05);
|
||
let msg = format!("Громкость: {}%", (self.player.volume() * 100.0) as u32);
|
||
self.set_status(msg);
|
||
}
|
||
|
||
pub fn seek(&mut self, delta_secs: i64) {
|
||
if self.playing_index.is_none() {
|
||
self.set_status("Ничего не воспроизводится".into());
|
||
return;
|
||
}
|
||
let total = self
|
||
.playing_index
|
||
.and_then(|i| self.library.track(i))
|
||
.map(|t| t.duration)
|
||
.unwrap_or(Duration::ZERO);
|
||
|
||
match self.player.seek_by(delta_secs, total) {
|
||
Ok(new_pos) => {
|
||
let sign = if delta_secs >= 0 { "+" } else { "" };
|
||
let msg = format!(
|
||
"Перемотка {}{}с → {:02}:{:02}",
|
||
sign,
|
||
delta_secs,
|
||
new_pos.as_secs() / 60,
|
||
new_pos.as_secs() % 60,
|
||
);
|
||
self.set_status(msg);
|
||
}
|
||
Err(_) => self.set_status("Перемотка недоступна".into()),
|
||
}
|
||
}
|
||
|
||
pub fn seek_percent(&mut self, percent: u8) {
|
||
if self.playing_index.is_none() {
|
||
self.set_status("Ничего не воспроизводится".into());
|
||
return;
|
||
}
|
||
let total = match self
|
||
.playing_index
|
||
.and_then(|i| self.library.track(i))
|
||
.map(|t| t.duration)
|
||
{
|
||
Some(d) if !d.is_zero() => d,
|
||
_ => {
|
||
self.set_status("Длительность неизвестна".into());
|
||
return;
|
||
}
|
||
};
|
||
let target = total.mul_f32(percent as f32 / 100.0);
|
||
match self.player.seek_to(target) {
|
||
Ok(_) => {
|
||
let msg = format!(
|
||
"→ {}% ({:02}:{:02})",
|
||
percent,
|
||
target.as_secs() / 60,
|
||
target.as_secs() % 60,
|
||
);
|
||
self.set_status(msg);
|
||
}
|
||
Err(_) => self.set_status("Перемотка недоступна".into()),
|
||
}
|
||
}
|
||
|
||
pub fn progress(&self) -> (Duration, Duration) {
|
||
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::ByAlbum,
|
||
SortMode::ByAlbum => SortMode::ByFilename,
|
||
SortMode::ByFilename => SortMode::ByArtist,
|
||
};
|
||
|
||
let playing_path = self
|
||
.playing_index
|
||
.and_then(|i| self.library.track(i))
|
||
.map(|t| t.path.clone());
|
||
|
||
self.library = Library::build_with_filter(
|
||
self.raw_tracks.clone(),
|
||
new_mode,
|
||
self.search_buffer.clone(),
|
||
);
|
||
|
||
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);
|
||
}
|
||
|
||
let msg = match new_mode {
|
||
SortMode::ByArtist => "Сортировка: по артисту/альбому".to_string(),
|
||
SortMode::ByAlbum => "Сортировка: по альбому/артисту".to_string(),
|
||
SortMode::ByFilename => "Сортировка: по имени файла".to_string(),
|
||
};
|
||
self.set_status(msg);
|
||
}
|
||
} |