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

1219 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

use std::path::PathBuf;
use std::time::Duration;
use chrono::{DateTime, Local};
use library::{Library, Playlist, PlaylistStore, RadioStore, Station};
use library::history::{HistoryStore, StatsPeriod};
use library::new_releases::{FetchProgress, NewRelease};
use library::settings::Settings;
use player::{PlaybackStatus, Player, QueueItem, RepeatMode, Speed, EQ_BANDS, EQ_FREQS};
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
const ENGINE_DEAD_MSG: &str =
"Player engine is not running (audio device disconnected?) — restart the app.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Library,
Playlists,
Queue,
Radio,
Stats,
NewReleases,
}
impl Tab {
pub fn title(self) -> &'static str {
match self {
Tab::Library => "Library",
Tab::Playlists => "Playlists",
Tab::Queue => "Queue",
Tab::Radio => "Radio",
Tab::Stats => "Stats",
Tab::NewReleases => "New Releases",
}
}
pub fn next(self) -> Self {
match self {
Tab::Library => Tab::Playlists,
Tab::Playlists => Tab::Queue,
Tab::Queue => Tab::Radio,
Tab::Radio => Tab::Stats,
Tab::Stats => Tab::NewReleases,
Tab::NewReleases => Tab::Library,
}
}
}
/// Превращает трек библиотеки (обычный файл или виртуальный cue-трек) в элемент очереди
/// плеера. Title/artist переопределяются только для cue-треков — для обычных файлов
/// движок сам читает актуальные теги при открытии.
fn track_to_queue_item(t: &library::Track) -> QueueItem {
let is_cue = t.cue_start.is_some();
QueueItem {
path: t.path.clone(),
start: t.cue_start.unwrap_or_default(),
end: t.cue_end,
title_override: is_cue.then(|| t.title.clone()),
artist_override: is_cue.then(|| t.artist.clone()),
replay_gain_db: t.replay_gain_db,
}
}
/// Строка списка в библиотеке: либо альбом (сворачиваемая группа), либо трек результата поиска.
pub struct AlbumRow {
pub artist: String,
pub album: String,
pub tracks: Vec<QueueItem>,
}
pub struct SearchRow {
pub path: PathBuf,
/// Начало cue-диапазона, если это виртуальный трек — нужно, чтобы отличить несколько
/// треков, ссылающихся на один и тот же физический файл.
pub cue_start: Option<Duration>,
pub title: String,
pub artist: String,
pub album: String,
}
/// Строка в плоском списке "все треки" (режим Tracks) — в отличие от SearchRow, сразу
/// хранит готовый QueueItem, чтобы не искать Track по пути повторно при проигрывании.
pub struct TrackRow {
pub item: QueueItem,
pub title: String,
pub artist: String,
pub album: String,
}
/// Что показывать, когда не идёт поиск: альбомами (сгруппировано) или плоским списком
/// всех треков библиотеки — переключается клавишей `t`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LibraryMode {
Albums,
Tracks,
}
impl LibraryMode {
pub fn toggle(self) -> Self {
match self {
LibraryMode::Albums => LibraryMode::Tracks,
LibraryMode::Tracks => LibraryMode::Albums,
}
}
}
pub enum LibraryView {
Albums(Vec<AlbumRow>),
Tracks(Vec<TrackRow>),
Search(Vec<SearchRow>),
}
#[derive(Debug, Clone, Default)]
pub struct NowPlaying {
pub title: String,
pub artist: String,
pub album: String,
pub duration: Option<Duration>,
pub position: Duration,
pub status_playing: bool,
/// Текст "сейчас играет" из ICY-метаданных радиопотока, если станция такие отдаёт.
pub radio_song: Option<String>,
}
/// Режим фокуса ввода: обычная навигация по спискам, ввод текста поиска, либо эквалайзер.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Normal,
Search,
Eq,
NewPlaylist,
NewStationName,
NewStationUrl,
StationSearch,
SmbServer,
SmbShare,
SmbUsername,
SmbPassword,
/// Модальный вопрос при самом первом запуске: запоминать ли прослушанное.
FirstRunPrompt,
/// Ввод дополнительной папки для сканирования (добавляется к уже загруженной библиотеке).
LibraryPathInput,
/// Ручной ввод имени артиста для проверки новых релизов (не обязательно есть в библиотеке).
NewReleaseArtistInput,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RightPanelView {
Cover,
Spectrum,
}
impl RightPanelView {
pub fn toggle(self) -> Self {
match self {
RightPanelView::Cover => RightPanelView::Spectrum,
RightPanelView::Spectrum => RightPanelView::Cover,
}
}
}
/// Обновляет экспоненциальное скользящее среднее басовой энергии на один новый отсчёт.
/// `alpha` — насколько быстро среднее "забывает" старые значения (0..1; больше = быстрее).
pub fn update_bass_ema(ema: f32, current: f32, alpha: f32) -> f32 {
ema * (1.0 - alpha) + current * alpha
}
/// "Удар баса" — текущее значение заметно выше недавнего среднего, а не просто выше
/// произвольной фиксированной константы. Так реакция подстраивается под конкретный трек:
/// и тихая акустика, и громкий метал будут давать срабатывания на СВОИХ собственных ударах,
/// а не либо никогда, либо постоянно.
pub fn is_bass_hit(current: f32, ema: f32) -> bool {
current > ema * 1.3 + 0.02
}
pub fn protocol_to_str(p: ProtocolType) -> &'static str {
match p {
ProtocolType::Halfblocks => "halfblocks",
ProtocolType::Sixel => "sixel",
ProtocolType::Kitty => "kitty",
ProtocolType::Iterm2 => "iterm2",
}
}
fn protocol_from_str(s: &str) -> Option<ProtocolType> {
match s {
"halfblocks" => Some(ProtocolType::Halfblocks),
"sixel" => Some(ProtocolType::Sixel),
"kitty" => Some(ProtocolType::Kitty),
"iterm2" => Some(ProtocolType::Iterm2),
_ => None,
}
}
pub struct App {
pub library: Library,
pub player: Player,
pub playlist_store: PlaylistStore,
pub radio_store: RadioStore,
pub history_store: HistoryStore,
pub settings: Settings,
pub settings_path: PathBuf,
pub tab: Tab,
pub focus: Focus,
pub should_quit: bool,
// --- Library tab ---
pub search_query: String,
pub library_view: LibraryView,
pub library_mode: LibraryMode,
pub library_selected: usize,
/// Текущий путь трека/альбома, для которого показывается список треков (None = список альбомов).
pub expanded_album: Option<usize>,
pub smb_server: String,
pub smb_share: String,
pub smb_username: String,
pub smb_password: String,
pub library_path_input: String,
pub new_release_artist_input: String,
// --- Playlists tab ---
pub playlist_names: Vec<String>,
pub playlists_selected: usize,
pub open_playlist: Option<Playlist>,
pub playlist_track_selected: usize,
pub new_playlist_name: String,
// --- Queue tab: то, что сама TUI поставила плееру в очередь ---
pub current_queue: Vec<QueueItem>,
pub queue_selected: usize,
// --- Radio tab ---
pub stations: Vec<Station>, // curated + custom, в этом порядке
pub custom_station_count: usize,
pub radio_selected: usize,
pub new_station_name: String,
pub new_station_url: String,
pub current_station: Option<String>, // URL текущей играющей станции, для подсветки
/// Результаты последнего онлайн-поиска через Radio Browser API. Пока не пусто —
/// вкладка Radio показывает их вместо основного списка (curated+custom).
pub online_results: Vec<Station>,
pub online_selected: usize,
pub station_query: String,
pub station_search_in_progress: bool,
station_search_rx: Option<std::sync::mpsc::Receiver<anyhow::Result<Vec<Station>>>>,
/// Фоновое подключение+сканирование SMB — сеть может быть медленной (или зависнуть),
/// не должна морозить весь интерфейс. Результат: (server, share, треки или ошибка).
smb_connect_rx: Option<std::sync::mpsc::Receiver<(String, String, anyhow::Result<Vec<library::Track>>)>>,
pub smb_connecting: bool,
// --- Now playing / transport ---
pub now_playing: Option<NowPlaying>,
pub speed: Speed,
pub shuffle: bool,
pub repeat: RepeatMode,
pub muted: bool,
pub fade_out_enabled: bool,
pub loudness_norm_enabled: bool,
pub eq_gains: [f32; EQ_BANDS],
pub eq_selected: usize,
// --- Stats tab ---
pub stats_period: StatsPeriod,
/// true после первого нажатия клавиши сброса — требуем повторного нажатия,
/// чтобы случайно не стереть всю историю.
pub reset_armed: bool,
// --- New Releases tab ---
pub new_releases: Vec<NewRelease>,
pub new_releases_checked_at: Option<DateTime<Local>>,
pub new_releases_period: StatsPeriod,
pub new_releases_fetching: bool,
pub new_releases_progress: Option<(usize, usize, String)>,
new_releases_rx: Option<std::sync::mpsc::Receiver<FetchProgress>>,
// --- Cover art ---
pub picker: Option<Picker>,
pub cover_protocol: Option<Box<dyn StatefulProtocol>>,
/// Последние байты обложки — нужны, чтобы пересобрать StatefulProtocol при ручном
/// переключении протокола отображения (клавиша `I`), не перечитывая файл заново.
last_cover_bytes: Option<Vec<u8>>,
/// Что показывать в правом верхнем блоке: обложку или анализатор спектра.
pub right_panel_view: RightPanelView,
/// Текущие значения полос спектра (0..1), обновляются событиями от плеера.
pub spectrum: Vec<f32>,
/// Скользящее среднее басовой энергии — для адаптивного "детектора удара баса"
/// (Noise Marines реагируют на ВСПЛЕСК относительно недавнего среднего, а не на
/// фиксированный порог, который у одних треков никогда не пробивается, а у других
/// пробит всегда).
pub bass_ema: f32,
/// Если true — выбранный right_panel_view занимает весь экран.
pub fullscreen: bool,
pub status_message: String,
}
impl App {
pub fn new(root: PathBuf) -> anyhow::Result<Self> {
let library = Library::scan(&root)?;
let playlists_dir = library::default_playlists_dir();
let playlist_store = PlaylistStore::new(playlists_dir)?;
let playlist_names = playlist_store.list().unwrap_or_default();
let radio_store = RadioStore::new(library::default_radio_dir())?;
let mut stations = library::radio::curated_stations();
let custom = radio_store.list();
let custom_station_count = custom.len();
stations.extend(custom);
let player = Player::new();
let settings_path = library::default_settings_path();
let settings = Settings::load(&settings_path);
let history_store = HistoryStore::new(library::default_history_path())?;
let initial_focus = if settings.first_run_done {
Focus::Normal
} else {
Focus::FirstRunPrompt
};
let cached_new_releases = library::new_releases::load_cached(
&library::default_new_releases_cache_path(),
);
// `Picker::from_termios()` доступен только на Unix (использует termios-иоктлы для
// определения размера ячейки терминала в пикселях) — на Windows такого API в этом
// крейте нет, используем разумный дефолт (8x16 — типичный размер моноширинной
// ячейки), протокол всё равно определяется через guess_protocol() кросс-платформенно.
#[cfg(unix)]
let base_picker = Picker::from_termios().ok();
#[cfg(not(unix))]
let base_picker = Some(Picker::new((8, 16)));
let picker = base_picker.map(|mut p| {
p.guess_protocol();
if let Some(saved) = settings.cover_protocol.as_deref().and_then(protocol_from_str) {
p.protocol_type = saved;
}
p
});
let mut app = App {
library,
player,
playlist_store,
radio_store,
history_store,
settings,
settings_path,
tab: Tab::Library,
focus: initial_focus,
should_quit: false,
search_query: String::new(),
library_view: LibraryView::Albums(Vec::new()),
library_mode: LibraryMode::Albums,
library_selected: 0,
expanded_album: None,
smb_server: String::new(),
smb_share: String::new(),
smb_username: String::new(),
smb_password: String::new(),
library_path_input: String::new(),
new_release_artist_input: String::new(),
playlist_names,
playlists_selected: 0,
open_playlist: None,
playlist_track_selected: 0,
new_playlist_name: String::new(),
current_queue: Vec::new(),
queue_selected: 0,
stations,
custom_station_count,
radio_selected: 0,
new_station_name: String::new(),
new_station_url: String::new(),
current_station: None,
online_results: Vec::new(),
online_selected: 0,
station_query: String::new(),
station_search_in_progress: false,
station_search_rx: None,
smb_connect_rx: None,
smb_connecting: false,
now_playing: None,
speed: Speed::default(),
shuffle: false,
repeat: RepeatMode::Off,
muted: false,
fade_out_enabled: false,
loudness_norm_enabled: false,
eq_gains: [0.0; EQ_BANDS],
eq_selected: 0,
stats_period: StatsPeriod::Week,
reset_armed: false,
new_releases: cached_new_releases.as_ref().map(|(r, _)| r.clone()).unwrap_or_default(),
new_releases_checked_at: cached_new_releases.map(|(_, t)| t),
new_releases_period: StatsPeriod::Week,
new_releases_fetching: false,
new_releases_progress: None,
new_releases_rx: None,
picker,
cover_protocol: None,
last_cover_bytes: None,
right_panel_view: RightPanelView::Cover,
spectrum: vec![0.0; player::SPECTRUM_BANDS],
bass_ema: 0.0,
fullscreen: false,
status_message: format!("nsm — loaded from {}", root.display()),
};
app.rebuild_library_view();
Ok(app)
}
pub fn rebuild_library_view(&mut self) {
if self.search_query.trim().is_empty() {
match self.library_mode {
LibraryMode::Albums => {
let rows: Vec<AlbumRow> = self
.library
.albums()
.into_iter()
.map(|a| AlbumRow {
artist: a.artist,
album: a.album,
tracks: a.tracks.iter().map(|t| track_to_queue_item(t)).collect(),
})
.collect();
self.library_view = LibraryView::Albums(rows);
}
LibraryMode::Tracks => {
let mut tracks: Vec<&library::Track> = self.library.tracks().iter().collect();
tracks.sort_by(|a, b| {
(&a.artist, &a.album, a.track_no.unwrap_or(0), a.file_name())
.cmp(&(&b.artist, &b.album, b.track_no.unwrap_or(0), b.file_name()))
});
let rows: Vec<TrackRow> = tracks
.into_iter()
.map(|t| TrackRow {
item: track_to_queue_item(t),
title: t.title.clone(),
artist: t.artist.clone(),
album: t.album.clone(),
})
.collect();
self.library_view = LibraryView::Tracks(rows);
}
}
} else {
let rows: Vec<SearchRow> = self
.library
.search(&self.search_query)
.into_iter()
.map(|t| SearchRow {
path: t.path.clone(),
cue_start: t.cue_start,
title: t.title.clone(),
artist: t.artist.clone(),
album: t.album.clone(),
})
.collect();
self.library_view = LibraryView::Search(rows);
}
self.library_selected = 0;
self.expanded_album = None;
}
pub fn toggle_library_mode(&mut self) {
self.library_mode = self.library_mode.toggle();
self.rebuild_library_view();
self.status_message = match self.library_mode {
LibraryMode::Albums => "View: Albums".to_string(),
LibraryMode::Tracks => "View: All Tracks".to_string(),
};
}
/// Монтирует введённую SMB-шару (через gvfs) и добавляет её содержимое к библиотеке.
/// Сначала проверяет, не смонтирована ли она уже (например, вручную через файловый
/// менеджер) — тогда пароль вообще не понадобится.
///
/// Если поле логина оставлено пустым, пробуем анонимный доступ явно через логин
/// "guest" — gvfs почти никогда не подключается по-настоящему без логина вообще,
/// даже если шара разрешает гостевой доступ (`guest ok = yes` в конфиге Samba),
/// а "guest" — общепринятое соглашение, которое такие шары понимают.
/// Запускает подключение+сканирование SMB-шары в фоновом потоке — сеть (или сам сервер)
/// может быть медленной или зависнуть, это не должно морозить весь интерфейс (как раньше,
/// когда это было синхронным вызовом прямо в обработчике клавиши).
pub fn connect_smb_share(&mut self) {
let server = self.smb_server.trim().to_string();
let share = self.smb_share.trim().to_string();
let username_input = self.smb_username.trim();
let password = (!self.smb_password.is_empty()).then(|| self.smb_password.clone());
let (username, password, anonymous) = if username_input.is_empty() {
(None, None, true) // smbfs::connect сама подставляет "guest" для анонимного входа
} else {
(Some(username_input.to_string()), password, false)
};
if server.is_empty() || share.is_empty() {
self.status_message = "SMB: server and share name are required".to_string();
self.clear_smb_inputs();
return;
}
self.status_message = if anonymous {
format!("Connecting to smb://{server}/{share} (anonymous)…")
} else {
format!("Connecting to smb://{server}/{share}")
};
self.smb_connecting = true;
self.clear_smb_inputs();
let (tx, rx) = std::sync::mpsc::channel();
self.smb_connect_rx = Some(rx);
std::thread::spawn(move || {
// Свой Rust SMB-клиент (smbfs) — не зависит от системного gio/gvfs и его багов.
// connect() сама переиспользует уже открытое соединение, если оно уже было для
// этой server+share (не переспрашивает пароль каждый раз).
let result = smbfs::connect(&server, &share, username.as_deref(), password.as_deref())
.and_then(|()| library::scanner::scan_smb(&server, &share, ""));
let _ = tx.send((server, share, result));
});
}
/// Опрашивает фоновое подключение SMB — вызывать каждый тик, как и остальные фоновые задачи.
pub fn poll_smb_connect(&mut self) {
let Some(rx) = self.smb_connect_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok((server, share, Ok(tracks))) => {
let added = self.library.merge_tracks(tracks);
self.status_message = format!("Connected to \\\\{server}\\{share} — added {added} track(s)");
self.rebuild_library_view();
self.smb_connecting = false;
self.smb_connect_rx = None;
}
Ok((_, _, Err(e))) => {
self.status_message = format!("SMB connect failed: {e}");
self.smb_connecting = false;
self.smb_connect_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// ещё не готово — подождём следующего тика
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.smb_connecting = false;
self.smb_connect_rx = None;
}
}
}
fn clear_smb_inputs(&mut self) {
self.smb_server.clear();
self.smb_share.clear();
self.smb_username.clear();
self.smb_password.clear();
self.focus = Focus::Normal;
}
/// Добавляет содержимое указанной папки к уже загруженной библиотеке (не заменяет —
/// как и для SMB, можно иметь несколько источников одновременно).
pub fn add_library_folder(&mut self, raw_path: &str) {
let path = std::path::Path::new(raw_path.trim());
if !path.exists() {
self.status_message = format!("Path does not exist: {}", path.display());
return;
}
if !path.is_dir() {
self.status_message = format!("Not a directory: {}", path.display());
return;
}
match self.library.add_root(path) {
Ok(added) => {
self.status_message = format!("Added {added} track(s) from {}", path.display());
self.rebuild_library_view();
}
Err(e) => {
self.status_message = format!("Failed to scan {}: {e}", path.display());
}
}
}
/// Полностью очищает библиотеку (например, чтобы временно послушать только один
/// альбом: очистить, потом добавить нужную папку через `L`).
pub fn clear_library(&mut self) {
self.library.clear();
self.rebuild_library_view();
self.status_message = "Library cleared — add a folder with 'L'".to_string();
}
/// Обрабатывает события, пришедшие от движка плеера. Вызывается каждый тик.
pub fn drain_player_events(&mut self) {
for event in self.player.poll_events() {
self.apply_event(event);
}
}
fn apply_event(&mut self, event: player::PlayerEvent) {
use player::PlayerEvent::*;
match event {
TrackChanged(info) => {
if self.settings.history_enabled && self.current_station.is_none() {
let _ = self.history_store.record(&info.artist, &info.title);
}
self.now_playing = Some(NowPlaying {
title: info.title,
artist: info.artist,
album: info.album,
duration: info.duration,
position: Duration::ZERO,
status_playing: true,
radio_song: None,
});
self.update_cover(info.cover);
}
QueueIndexChanged(idx) => {
self.queue_selected = idx;
}
Position(pos) => {
if let Some(np) = self.now_playing.as_mut() {
np.position = pos;
}
}
StatusChanged(status) => {
if let Some(np) = self.now_playing.as_mut() {
np.status_playing = status == PlaybackStatus::Playing;
}
}
SpeedChanged(s) => self.speed = Speed(s),
ShuffleChanged(s) => self.shuffle = s,
RepeatChanged(r) => self.repeat = r,
EqChanged(gains) => self.eq_gains = gains,
MuteChanged(m) => self.muted = m,
FadeOutChanged(v) => self.fade_out_enabled = v,
LoudnessNormChanged(v) => self.loudness_norm_enabled = v,
RadioMetadata(text) => {
if let Some(np) = self.now_playing.as_mut() {
np.radio_song = Some(text);
}
}
Spectrum(bands) => {
// Скользящее среднее по нескольким нижним полосам — та же "басовая энергия",
// что использует детектор удара в draw_noise_marines (см. там комментарий,
// почему усредняем несколько полос, а не берём одну).
let n = bands.len().min(7);
if n > 0 {
let bass_now = bands[..n].iter().sum::<f32>() / n as f32;
self.bass_ema = update_bass_ema(self.bass_ema, bass_now, 0.15);
}
self.spectrum = bands;
}
QueueFinished => {
self.status_message = "Queue finished".to_string();
self.now_playing = None;
}
Error(e) => {
self.status_message = format!("Error: {e}");
}
}
}
fn update_cover(&mut self, cover: Option<Vec<u8>>) {
self.cover_protocol = None;
self.last_cover_bytes = cover.clone();
let (Some(picker), Some(bytes)) = (self.picker.as_mut(), cover) else {
return;
};
if let Ok(img) = image::load_from_memory(&bytes) {
self.cover_protocol = Some(picker.new_resize_protocol(img));
}
}
/// Вручную переключает протокол отображения обложки (halfblocks → sixel → kitty →
/// iterm2 → ...) — на случай, если автоопределение угадало неверно. Выбор сохраняется
/// и применяется автоматически при следующих запусках.
pub fn cycle_cover_protocol(&mut self) {
let Some(picker) = self.picker.as_mut() else {
self.status_message = "No image picker available on this terminal".to_string();
return;
};
let new_proto = picker.cycle_protocols();
self.status_message = format!("Cover protocol: {} (saved)", protocol_to_str(new_proto));
self.settings.cover_protocol = Some(protocol_to_str(new_proto).to_string());
let _ = self.settings.save(&self.settings_path);
if let Some(bytes) = self.last_cover_bytes.clone() {
if let Ok(img) = image::load_from_memory(&bytes) {
self.cover_protocol = Some(picker.new_resize_protocol(img));
}
}
}
// ---- Transport actions, called from key handling ----
pub fn play_album_from_selected(&mut self) {
self.current_station = None;
if let LibraryView::Albums(rows) = &self.library_view {
if let Some(row) = rows.get(self.library_selected) {
self.current_queue = row.tracks.clone();
if self.player.set_queue(self.current_queue.clone(), 0) {
self.status_message =
format!("Playing album: {}{}", row.artist, row.album);
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
} else if let LibraryView::Search(rows) = &self.library_view {
if let Some(row) = rows.get(self.library_selected) {
let album_tracks = self.library.album_tracks_for_path(&row.path);
self.current_queue = album_tracks.iter().map(|t| track_to_queue_item(t)).collect();
let start = self
.current_queue
.iter()
.position(|item| item.path == row.path && item.start == row.cue_start.unwrap_or_default())
.unwrap_or(0);
if self.player.set_queue(self.current_queue.clone(), start) {
self.status_message = format!("Playing: {}", row.title);
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
}
}
/// Ставит в очередь ВЕСЬ плоский список треков (режим Tracks), начиная с выбранного —
/// то самое "просто включить всю библиотеку", без необходимости проваливаться в альбом.
pub fn play_all_tracks_from_selected(&mut self) {
self.current_station = None;
if let LibraryView::Tracks(rows) = &self.library_view {
if rows.is_empty() {
return;
}
self.current_queue = rows.iter().map(|r| r.item.clone()).collect();
let start = self.library_selected.min(self.current_queue.len() - 1);
if self.player.set_queue(self.current_queue.clone(), start) {
self.status_message = format!("Playing all {} tracks", self.current_queue.len());
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
}
pub fn play_expanded_track(&mut self, track_idx: usize) {
self.current_station = None;
if let Some(album_idx) = self.expanded_album {
if let LibraryView::Albums(rows) = &self.library_view {
if let Some(row) = rows.get(album_idx) {
if track_idx < row.tracks.len() {
self.current_queue = row.tracks.clone();
if !self.player.set_queue(self.current_queue.clone(), track_idx) {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
}
}
}
}
pub fn add_selected_to_playlist(&mut self) {
let Some(path) = self.selected_track_path() else {
self.status_message = "No track selected".to_string();
return;
};
let Some(name) = self.playlist_names.get(self.playlists_selected).cloned() else {
self.status_message = "No playlist selected — create one with 'n'".to_string();
return;
};
if let Ok(mut pl) = self.playlist_store.load(&name) {
if !pl.tracks.contains(&path) {
pl.tracks.push(path);
let _ = self.playlist_store.save(&pl);
self.status_message = format!("Added to playlist '{name}'");
}
}
}
pub fn selected_track_path(&self) -> Option<PathBuf> {
match &self.library_view {
LibraryView::Albums(rows) => {
if let Some(album_idx) = self.expanded_album {
rows.get(album_idx).and_then(|r| r.tracks.first()).map(|t| t.path.clone())
} else {
rows.get(self.library_selected)
.and_then(|r| r.tracks.first())
.map(|t| t.path.clone())
}
}
LibraryView::Tracks(rows) => rows.get(self.library_selected).map(|r| r.item.path.clone()),
LibraryView::Search(rows) => rows.get(self.library_selected).map(|r| r.path.clone()),
}
}
pub fn create_playlist(&mut self, name: &str) {
if name.trim().is_empty() {
return;
}
if let Ok(_) = self.playlist_store.create(name) {
self.playlist_names = self.playlist_store.list().unwrap_or_default();
self.status_message = format!("Created playlist '{name}'");
}
}
pub fn play_open_playlist_from(&mut self, idx: usize) {
self.current_station = None;
if let Some(pl) = &self.open_playlist {
self.current_queue = pl
.tracks
.iter()
.cloned()
.map(QueueItem::whole_file)
.collect();
if self.player.set_queue(self.current_queue.clone(), idx) {
self.status_message = format!("Playing playlist '{}'", pl.name);
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
}
/// Перемещает выбранный трек в открытом плейлисте на `delta` позиций (-1 вверх, +1 вниз)
/// и сохраняет плейлист на диск.
pub fn playlist_move_selected(&mut self, delta: i32) {
let Some(pl) = self.open_playlist.as_mut() else {
return;
};
let len = pl.tracks.len();
if len < 2 {
return;
}
let from = self.playlist_track_selected;
let to = (from as i32 + delta).rem_euclid(len as i32) as usize;
pl.tracks.swap(from, to);
self.playlist_track_selected = to;
let _ = self.playlist_store.save(pl);
}
/// Удаляет выбранный трек из открытого плейлиста и сохраняет изменения.
pub fn playlist_delete_selected(&mut self) {
let Some(pl) = self.open_playlist.as_mut() else {
return;
};
if pl.tracks.is_empty() {
return;
}
pl.tracks.remove(self.playlist_track_selected);
if self.playlist_track_selected >= pl.tracks.len() && !pl.tracks.is_empty() {
self.playlist_track_selected = pl.tracks.len() - 1;
}
let _ = self.playlist_store.save(pl);
self.status_message = "Track removed from playlist".to_string();
}
// ---- Radio ----
pub fn play_selected_station(&mut self) {
let Some(station) = self.stations.get(self.radio_selected).cloned() else {
return;
};
self.current_station = Some(station.url.clone());
if self.player.play_radio(station.name.clone(), station.url) {
self.status_message = "Tuning in…".to_string();
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
/// true, если выбранная станция была добавлена пользователем (не из встроенного списка),
/// а значит её можно удалить.
pub fn selected_station_is_custom(&self) -> bool {
let curated_len = self.stations.len() - self.custom_station_count;
self.radio_selected >= curated_len
}
pub fn add_station(&mut self, name: &str, url: &str) {
if name.trim().is_empty() || url.trim().is_empty() {
return;
}
let station = Station {
name: name.to_string(),
url: url.to_string(),
};
if self.radio_store.add(station.clone()).is_ok() {
self.stations.push(station);
self.custom_station_count += 1;
self.status_message = "Station added".to_string();
}
}
pub fn remove_selected_station(&mut self) {
if !self.selected_station_is_custom() {
self.status_message = "Can't remove a built-in station".to_string();
return;
}
let Some(station) = self.stations.get(self.radio_selected).cloned() else {
return;
};
if self.radio_store.remove(&station.url).is_ok() {
self.stations.remove(self.radio_selected);
self.custom_station_count = self.custom_station_count.saturating_sub(1);
if self.radio_selected >= self.stations.len() && self.radio_selected > 0 {
self.radio_selected -= 1;
}
self.status_message = format!("Removed station '{}'", station.name);
}
}
/// Запускает поиск станций через Radio Browser API в фоновом потоке — не блокирует UI
/// на время сетевого запроса (раньше блокировал, это было заметным багом: весь
/// интерфейс подвисал на время поиска). Результат забирается через `poll_station_search`.
pub fn search_stations_online(&mut self, query: &str) {
if query.trim().is_empty() {
return;
}
if self.station_search_in_progress {
self.status_message = "Search already in progress…".to_string();
return;
}
self.status_message = format!("Searching for '{query}'…");
self.station_search_in_progress = true;
self.station_search_rx = Some(library::radio::spawn_search(query.to_string()));
}
/// Опрашивает канал фонового поиска станций — вызывать каждый тик, как и события плеера.
pub fn poll_station_search(&mut self) {
let Some(rx) = self.station_search_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok(results)) if results.is_empty() => {
self.status_message = "No stations found".to_string();
self.online_results.clear();
self.station_search_in_progress = false;
self.station_search_rx = None;
}
Ok(Ok(results)) => {
self.status_message = format!("Found {} stations", results.len());
self.online_results = results;
self.online_selected = 0;
self.station_search_in_progress = false;
self.station_search_rx = None;
}
Ok(Err(e)) => {
self.status_message = format!("Search failed: {e}");
self.online_results.clear();
self.station_search_in_progress = false;
self.station_search_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// ещё не готово — подождём следующего тика
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.station_search_in_progress = false;
self.station_search_rx = None;
}
}
}
pub fn play_online_result(&mut self) {
let Some(station) = self.online_results.get(self.online_selected).cloned() else {
return;
};
self.current_station = Some(station.url.clone());
if self.player.play_radio(station.name.clone(), station.url) {
self.status_message = "Tuning in…".to_string();
} else {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
pub fn add_online_result_to_custom(&mut self) {
let Some(station) = self.online_results.get(self.online_selected).cloned() else {
return;
};
if self.radio_store.add(station.clone()).is_ok() {
self.stations.push(station.clone());
self.custom_station_count += 1;
self.status_message = format!("Added '{}' to your stations", station.name);
}
}
/// Закрывает результаты онлайн-поиска и возвращает к обычному списку станций.
pub fn close_online_results(&mut self) {
self.online_results.clear();
self.station_query.clear();
}
pub fn adjust_eq(&mut self, delta: f32) {
let band = self.eq_selected;
let new_gain = (self.eq_gains[band] + delta).clamp(-12.0, 12.0);
self.player.set_eq_band(band, new_gain);
self.eq_gains[band] = new_gain; // отражаем сразу, событие подтвердит то же самое
}
pub fn eq_band_freq_label(band: usize) -> String {
let f = EQ_FREQS[band];
if f >= 1000.0 {
format!("{:.1}k", f / 1000.0)
} else {
format!("{:.0}", f)
}
}
/// Перемотка на `delta` секунд от текущей позиции (может быть отрицательным — назад).
/// Позиция относительно начала ТЕКУЩЕГО трека — так же, как она показана на прогресс-баре.
pub fn seek_relative(&mut self, delta_secs: i64) {
let Some(np) = self.now_playing.as_ref() else {
return;
};
let current = np.position.as_secs_f64();
let target = (current + delta_secs as f64).max(0.0);
if !self.player.seek(Duration::from_secs_f64(target)) {
self.status_message = ENGINE_DEAD_MSG.to_string();
}
}
// ---- Listening history / stats ----
/// Ответ на вопрос при первом запуске "запоминать ли прослушанное".
pub fn answer_first_run_prompt(&mut self, enable: bool) {
self.settings.history_enabled = enable;
self.settings.first_run_done = true;
let _ = self.settings.save(&self.settings_path);
self.focus = Focus::Normal;
self.status_message = if enable {
"History tracking enabled — toggle anytime with 'h'".to_string()
} else {
"History tracking disabled — toggle anytime with 'h'".to_string()
};
}
/// Включает/выключает запись истории — доступно в любой момент, не только при первом
/// запуске.
pub fn toggle_history(&mut self) {
self.settings.history_enabled = !self.settings.history_enabled;
self.settings.first_run_done = true;
let _ = self.settings.save(&self.settings_path);
self.status_message = format!(
"History tracking: {}",
if self.settings.history_enabled { "ON" } else { "OFF" }
);
}
pub fn cycle_stats_period(&mut self) {
self.stats_period = self.stats_period.next();
self.reset_armed = false;
}
/// Требует два нажатия подряд: первое "вооружает" сброс и предупреждает,
/// второе — реально стирает всю историю.
pub fn request_reset_stats(&mut self) {
if self.reset_armed {
match self.history_store.clear() {
Ok(()) => self.status_message = "Listening history cleared".to_string(),
Err(e) => self.status_message = format!("Failed to clear history: {e}"),
}
self.reset_armed = false;
} else {
self.reset_armed = true;
self.status_message =
"Press 'x' again to permanently erase all listening history".to_string();
}
}
pub fn cancel_reset_arm(&mut self) {
self.reset_armed = false;
}
// ---- New releases (MusicBrainz, только для артистов из библиотеки) ----
/// Запускает фоновую проверку новых релизов у артистов, уже имеющихся в библиотеке.
/// Не делает ничего, если проверка уже идёт. Из-за rate-limit MusicBrainz (~1 запрос/сек
/// на артиста) может занять заметное время для большой библиотеки — прогресс отражается
/// в интерфейсе, а не блокирует его полностью (работа идёт в отдельном потоке).
/// Это полный рескан — начинает список заново (не сохраняет предыдущие ручные добавления
/// отдельных артистов, которые не входят в библиотеку).
pub fn start_new_releases_fetch(&mut self) {
if self.new_releases_fetching {
self.status_message = "Already checking for new releases…".to_string();
return;
}
let artists = self.library.distinct_artists();
if artists.is_empty() {
self.status_message = "No artists in library to check".to_string();
return;
}
self.new_releases_fetching = true;
self.new_releases_progress = Some((0, artists.len(), String::new()));
self.status_message = format!("Checking {} artists for new releases…", artists.len());
self.new_releases_rx = Some(library::new_releases::spawn_fetch(
artists,
library::default_mbid_cache_path(),
library::default_new_releases_cache_path(),
Vec::new(),
));
}
/// Проверяет новые релизы для ВРУЧНУЮ введённого имени артиста (не обязательно есть
/// в библиотеке) и добавляет результат к уже показанному списку, а не заменяет его.
pub fn check_manual_artist_new_releases(&mut self, artist: &str) {
let artist = artist.trim();
if artist.is_empty() {
return;
}
if self.new_releases_fetching {
self.status_message = "Already checking for new releases…".to_string();
return;
}
self.new_releases_fetching = true;
self.new_releases_progress = Some((0, 1, artist.to_string()));
self.status_message = format!("Checking '{artist}'…");
self.new_releases_rx = Some(library::new_releases::spawn_fetch(
vec![artist.to_string()],
library::default_mbid_cache_path(),
library::default_new_releases_cache_path(),
self.new_releases.clone(),
));
}
/// Опрашивает канал прогресса фонового обновления новинок — вызывать каждый тик,
/// как и события плеера.
pub fn poll_new_releases(&mut self) {
let Some(rx) = self.new_releases_rx.as_ref() else {
return;
};
let mut finished = false;
for progress in rx.try_iter() {
match progress {
FetchProgress::Started { total } => {
self.new_releases_progress = Some((0, total, String::new()));
}
FetchProgress::ArtistChecked { index, total, artist } => {
self.new_releases_progress = Some((index, total, artist));
}
FetchProgress::Finished { releases, checked_at, failed_artists, total_artists } => {
self.new_releases = releases;
self.new_releases_checked_at = Some(checked_at);
self.status_message = if total_artists > 0 && failed_artists == total_artists {
"Couldn't reach MusicBrainz for any artist — check your network connection"
.to_string()
} else if failed_artists > 0 {
format!(
"New releases check complete — {} found ({} artists couldn't be checked)",
self.new_releases.len(),
failed_artists
)
} else {
format!("New releases check complete — {} found", self.new_releases.len())
};
finished = true;
}
FetchProgress::Error(e) => {
self.status_message = format!("New releases check failed: {e}");
finished = true;
}
}
}
if finished {
self.new_releases_fetching = false;
self.new_releases_progress = None;
self.new_releases_rx = None;
}
}
pub fn cycle_new_releases_period(&mut self) {
self.new_releases_period = self.new_releases_period.next();
}
// ---- Cover/Spectrum view ----
pub fn toggle_right_panel_view(&mut self) {
self.right_panel_view = self.right_panel_view.toggle();
}
pub fn toggle_fullscreen(&mut self) {
self.fullscreen = !self.fullscreen;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Симулирует несколько "тихих" отсчётов (устанавливая ema), затем один резкий
/// всплеск — детектор должен среагировать на сам всплеск.
#[test]
fn detects_sudden_bass_hit_over_quiet_baseline() {
let mut ema = 0.0;
for _ in 0..20 {
ema = update_bass_ema(ema, 0.05, 0.15); // тихая "гитара" — база устаканивается низко
}
assert!(!is_bass_hit(0.05, ema), "ровный тихий уровень не должен считаться ударом");
// резкий удар баса поверх этой тихой базы
assert!(is_bass_hit(0.4, ema), "резкий всплеск над тихой базой должен сработать");
}
/// Трек, где бас стабильно громкий (тяжёлый метал) — после того, как ema устаканится
/// на этом уровне, ровный громкий бас НЕ должен постоянно триггерить "удар" на каждом
/// кадре — иначе десантники будут просто дёргаться без остановки, а не танцевать в такт.
#[test]
fn steady_loud_bass_does_not_trigger_constantly_once_settled() {
let mut ema = 0.0;
for _ in 0..50 {
ema = update_bass_ema(ema, 0.5, 0.15);
}
assert!(
!is_bass_hit(0.5, ema),
"устаканившийся ровный громкий бас не должен считаться 'ударом' каждый кадр"
);
}
#[test]
fn ema_moves_toward_target_but_not_instantly() {
let ema = update_bass_ema(0.0, 1.0, 0.15);
assert!(ema > 0.0 && ema < 1.0, "ema={ema}");
assert!((ema - 0.15).abs() < 0.001);
}
}