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<String>),
}
pub enum AppEvent {
Input(CEvent),
Tick,
SongUpdate(SongData),
ArtUpdate(StatefulProtocol, Vec<u8>),
ConnectionStatus(String),
LocalArtLoaded(i64, Option<StatefulProtocol>),
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<StatefulProtocol>,
pub live_art_bytes: Option<Vec<u8>>,
pub likes: HashMap<i64, LikedSong>,
pub filtered_likes: Vec<LikedSong>,
pub likes_file: PathBuf,
pub status_message: String,
pub status_message_time: Option<Instant>,
pub is_muted: bool,
pub mode: AppMode,
pub is_searching: bool,
pub search_query: String,
pub liked_list_state: ListState,
pub selected_art: Option<StatefulProtocol>,
pub show_help_overlay: bool,
pub show_unlike_confirm: Option<i64>,
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<Instant>,
pub volume: u8,
pub volume_time: Option<Instant>,
pub config_scroll: u16,
pub notified_song_id: i64,
pub nav_time: Option<Instant>,
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<i64, LikedSong> =
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)
}
}