use std::{ collections::HashMap, env, fs, path::PathBuf, time::{Instant, SystemTime, UNIX_EPOCH}, }; use crossterm::event::Event as CEvent; use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; use ratatui::{style::Color, widgets::ListState}; use ratatui_image::protocol::StatefulProtocol; use souvlaki::MediaControlEvent; use crate::{ config::AppConfig, models::{LikedSong, MpvSong, SongData}, }; pub const REFRESH_WINDOW: std::time::Duration = std::time::Duration::from_secs(30); #[derive(PartialEq)] pub enum AppMode { Playing, LikedSongs, ConfigError(Vec), } pub enum AppEvent { Input(CEvent), Tick, SongUpdate(SongData), ArtUpdate(StatefulProtocol, Vec), ConnectionStatus(String), LocalArtLoaded(i64, Option), MediaControl(MediaControlEvent), ApiUpdate { listeners: u64, circle_link: String, rating: String, times_rated: u64 }, MpvUpdate(MpvSong), KosuzuBusy(bool), } pub struct AppState { pub config: AppConfig, pub song: SongData, pub song_start_time: Instant, pub art_protocol: Option, pub live_art_bytes: Option>, pub likes: HashMap, pub filtered_likes: Vec, pub likes_file: PathBuf, pub status_message: String, pub status_message_time: Option, pub is_muted: bool, pub mode: AppMode, pub is_searching: bool, pub search_query: String, pub liked_list_state: ListState, pub selected_art: Option, pub show_help_overlay: bool, pub show_unlike_confirm: Option, pub help_tab: usize, pub theme_color: Color, pub error_color: Color, pub warning_color: Color, pub highlight_bg_color: Color, pub highlight_fg_color: Color, pub secondary_color: Color, pub mpv_song: MpvSong, pub refresh_until: Option, pub volume: u8, pub volume_time: Option, pub config_scroll: u16, pub notified_song_id: i64, pub nav_time: Option, pub mpv_retries: u8, } impl AppState { pub fn new() -> Self { let home = env::var("HOME").unwrap_or_default(); let config_dir = env::var("XDG_CONFIG_HOME") .map_or_else(|_| PathBuf::from(&home).join(".config"), PathBuf::from) .join("grrs"); let config_file = config_dir.join("config.toml"); fs::create_dir_all(&config_dir).ok(); let (mut config, warnings) = match AppConfig::load_or_create(&config_file) { Ok(cfg) => { let w = cfg.get_warnings(); (cfg, w) }, Err(e) => (AppConfig::default(), vec![e]), }; if config.system.data_dir.starts_with("~/") { config.system.data_dir = config.system.data_dir.replacen('~', &home, 1); } if config.system.socket_path.starts_with("~/") { config.system.socket_path = config.system.socket_path.replacen('~', &home, 1); } let data_dir = if config.system.data_dir.is_empty() { env::var("XDG_DATA_HOME") .map_or_else(|_| PathBuf::from(&home).join(".local/share"), PathBuf::from) .join("grrs") } else { PathBuf::from(&config.system.data_dir) }; let cache_dir = env::var("XDG_CACHE_HOME").map_or_else(|_| PathBuf::from(&home).join(".cache"), PathBuf::from).join("grrs"); fs::create_dir_all(&data_dir).ok(); fs::create_dir_all(&cache_dir).ok(); let old_likes = config_dir.join("likes.json"); let new_likes = data_dir.join("likes.json"); let old_art = config_dir.join("art"); let new_art = data_dir.join("art"); if old_likes.exists() && !new_likes.exists() { let _ = fs::rename(&old_likes, &new_likes); } if old_art.exists() && !new_art.exists() { let _ = fs::rename(&old_art, &new_art); } let _ = fs::remove_file(config_dir.join("current_art.jpg")); let _ = fs::remove_file(cache_dir.join("theme.txt")); let likes_file = new_likes; let likes: HashMap = fs::read_to_string(&likes_file).ok().and_then(|d| serde_json::from_str(&d).ok()).unwrap_or_default(); let mut filtered_likes: Vec<_> = likes.values().cloned().collect(); filtered_likes.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); let mode = if !warnings.is_empty() { AppMode::ConfigError(warnings) } else if config.ui.default_mode.to_lowercase() == "library" { AppMode::LikedSongs } else { AppMode::Playing }; let theme_color = config.get_theme_color(); let error_color = config.get_error_color(); let warning_color = config.get_warning_color(); let highlight_bg_color = config.get_highlight_bg(); let highlight_fg_color = config.get_highlight_fg(); let secondary_color = config.get_secondary_text(); let startup_volume = config.player.startup_volume.min(100); let mpv_retries = config.player.connection_retries; Self { config, song: SongData::default(), song_start_time: Instant::now(), art_protocol: None, live_art_bytes: None, likes, filtered_likes, likes_file, status_message: "Connecting to Gensokyo Radio...".to_string(), status_message_time: Some(Instant::now()), is_muted: false, mode, is_searching: false, search_query: String::new(), liked_list_state: ListState::default(), selected_art: None, show_help_overlay: false, show_unlike_confirm: None, help_tab: 0, theme_color, error_color, warning_color, highlight_bg_color, highlight_fg_color, secondary_color, mpv_song: MpvSong::default(), refresh_until: None, volume: startup_volume, volume_time: None, config_scroll: 0, notified_song_id: 0, nav_time: None, mpv_retries, } } pub fn toggle_like(&mut self) { if self.song.id == 0 { return; } if self.likes.remove(&self.song.id).is_none() { let ts = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time went backwards").as_secs(); self.likes.insert( self.song.id, LikedSong { id: self.song.id, title: self.song.title.clone(), artist: self.song.artist.clone(), album: self.song.album.clone(), duration: self.song.duration, timestamp: ts, circle: self.song.circle.clone(), vocal: self.song.vocal.clone(), original: self.song.original.clone(), year: self.song.year.clone(), }, ); } if let Ok(data) = serde_json::to_string_pretty(&self.likes) { fs::write(&self.likes_file, data).ok(); } self.update_search(); } pub fn update_search(&mut self) { let mut sorted: Vec<_> = self.likes.values().cloned().collect(); sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); if self.search_query.is_empty() { self.filtered_likes = sorted; } else { let matcher = SkimMatcherV2::default(); let mut scored = Vec::new(); for song in sorted { let haystack = format!("{} {}", song.artist, song.title); if let Some(score) = matcher.fuzzy_match(&haystack, &self.search_query) { scored.push((score, song)); } } scored.sort_by(|a, b| b.0.cmp(&a.0)); self.filtered_likes = scored.into_iter().map(|(_, s)| s).collect(); } if let Some(selected) = self.liked_list_state.selected() { if !self.filtered_likes.is_empty() && selected >= self.filtered_likes.len() { self.liked_list_state.select(Some(self.filtered_likes.len() - 1)); } else if self.filtered_likes.is_empty() { self.liked_list_state.select(None); } } else if !self.filtered_likes.is_empty() { self.liked_list_state.select(Some(0)); } } pub fn art_path(&self, id: i64) -> PathBuf { self.likes_file.parent().expect("likes_file has no parent directory").join("art").join(format!("{id}.jpg")) } pub fn matches_mpv(&self) -> bool { if self.mpv_song.title.is_empty() { return true; } let tui_title = self.song.title.trim().to_lowercase(); let tui_artist = self.song.artist.trim().to_lowercase(); let mpv_title = self.mpv_song.title.trim().to_lowercase(); let mpv_artist = self.mpv_song.artist.trim().to_lowercase(); if tui_title == mpv_title && (tui_artist == mpv_artist || mpv_artist.is_empty()) { return true; } let combined = format!("{} - {}", self.song.artist, self.song.title).to_lowercase(); if combined == mpv_title || combined == mpv_artist { return true; } false } pub fn is_refreshing(&self) -> bool { self.refresh_until.is_some_and(|t| Instant::now() < t) } }