git/grrs/main
src/app.rs
279 lines9 KB last change raw · history
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)
    }
}