1046 lines
43 KiB
Rust
1046 lines
43 KiB
Rust
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;
|
||
}
|
||
}
|