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, } pub struct SearchRow { pub path: PathBuf, /// Начало cue-диапазона, если это виртуальный трек — нужно, чтобы отличить несколько /// треков, ссылающихся на один и тот же физический файл. pub cue_start: Option, pub title: String, pub artist: String, pub album: String, } pub enum LibraryView { Albums(Vec), Search(Vec), } #[derive(Debug, Clone, Default)] pub struct NowPlaying { pub title: String, pub artist: String, pub album: String, pub duration: Option, pub position: Duration, pub status_playing: bool, /// Текст "сейчас играет" из ICY-метаданных радиопотока, если станция такие отдаёт. pub radio_song: Option, } /// Режим фокуса ввода: обычная навигация по спискам, ввод текста поиска, либо эквалайзер. #[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, } } } 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 { 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_selected: usize, /// Текущий путь трека/альбома, для которого показывается список треков (None = список альбомов). pub expanded_album: Option, 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, pub playlists_selected: usize, pub open_playlist: Option, pub playlist_track_selected: usize, pub new_playlist_name: String, // --- Queue tab: то, что сама TUI поставила плееру в очередь --- pub current_queue: Vec, pub queue_selected: usize, // --- Radio tab --- pub stations: Vec, // 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, // URL текущей играющей станции, для подсветки /// Результаты последнего онлайн-поиска через Radio Browser API. Пока не пусто — /// вкладка Radio показывает их вместо основного списка (curated+custom). pub online_results: Vec, pub online_selected: usize, pub station_query: String, pub station_search_in_progress: bool, station_search_rx: Option>>>, // --- Now playing / transport --- pub now_playing: Option, 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, pub new_releases_checked_at: Option>, pub new_releases_period: StatsPeriod, pub new_releases_fetching: bool, pub new_releases_progress: Option<(usize, usize, String)>, new_releases_rx: Option>, // --- Cover art --- pub picker: Option, pub cover_protocol: Option>, /// Последние байты обложки — нужны, чтобы пересобрать StatefulProtocol при ручном /// переключении протокола отображения (клавиша `I`), не перечитывая файл заново. last_cover_bytes: Option>, /// Что показывать в правом верхнем блоке: обложку или анализатор спектра. pub right_panel_view: RightPanelView, /// Текущие значения полос спектра (0..1), обновляются событиями от плеера. pub spectrum: Vec, /// Если true — выбранный right_panel_view занимает весь экран. pub fullscreen: bool, pub status_message: String, } impl App { pub fn new(root: PathBuf) -> anyhow::Result { 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_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, 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], 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() { let rows: Vec = 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); } else { let rows: Vec = 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; } /// Монтирует введённую SMB-шару (через gvfs) и добавляет её содержимое к библиотеке. /// Сначала проверяет, не смонтирована ли она уже (например, вручную через файловый /// менеджер) — тогда пароль вообще не понадобится. /// /// Если поле логина оставлено пустым, пробуем анонимный доступ явно через логин /// "guest" — gvfs почти никогда не подключается по-настоящему без логина вообще, /// даже если шара разрешает гостевой доступ (`guest ok = yes` в конфиге Samba), /// а "guest" — общепринятое соглашение, которое такие шары понимают. 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() { (Some("guest".to_string()), None, true) } 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}…") }; let mount_path = if let Some(path) = library::smb::find_existing_mount(&server, &share) { Ok(path) } else { library::smb::mount_share(&server, &share, username.as_deref(), password.as_deref()) .map(|info| info.mount_path) }; match mount_path { Ok(path) => match self.library.add_root(&path) { Ok(added) => { self.status_message = format!("Connected to \\\\{server}\\{share} — added {added} track(s)"); self.rebuild_library_view(); } Err(e) => { self.status_message = format!("Mounted, but scanning failed: {e}"); } }, Err(e) => { self.status_message = format!("SMB connect failed: {e}"); } } self.clear_smb_inputs(); } 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) => { 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>) { 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(); } } } } 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 { 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::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; } }