nm/crates/tui/src/app.rs
2026-07-13 09:31:20 +03:00

1046 lines
43 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,
}
pub enum LibraryView {
Albums(Vec<AlbumRow>),
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,
}
}
}
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_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>>>>,
// --- 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>,
/// Если 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_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<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);
} 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;
}
/// Монтирует введённую 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<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();
}
}
}
}
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::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;
}
}