git/grrs/main

refactor: break up main.rs into separate modules

closes 271d7f5
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..d8fb821
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,186 @@
+use crate::models::{LikedSong, MpvSong, SongData};
+use crossterm::event::Event as CEvent;
+use ratatui::{style::Color, widgets::ListState};
+use ratatui_image::protocol::StatefulProtocol;
+use souvlaki::MediaControlEvent;
+use std::{
+ collections::HashMap,
+ env, fs,
+ path::PathBuf,
+ time::{Instant, SystemTime, UNIX_EPOCH},
+};
+
+pub const SEAFOAM: Color = Color::Rgb(91, 192, 174);
+pub const REFRESH_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
+
+#[derive(PartialEq)]
+pub enum AppMode {
+ Playing,
+ LikedSongs,
+}
+
+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 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 likes_file: PathBuf,
+ pub status_message: String,
+ pub is_muted: bool,
+ pub mode: AppMode,
+ pub liked_list_state: ListState,
+ pub selected_art: Option<StatefulProtocol>,
+ pub show_help_overlay: bool,
+ pub show_unlike_confirm: Option<i64>,
+ pub show_song_info: bool,
+ pub theme_color: Color,
+ pub available_colors: Vec<Color>,
+ pub color_idx: usize,
+ pub theme_file: PathBuf,
+ pub mpv_song: MpvSong,
+ pub refresh_until: Option<Instant>,
+}
+
+impl AppState {
+ pub fn new() -> Self {
+ let config_dir = PathBuf::from(env::var("HOME").unwrap_or_default()).join(".config/grrs");
+ fs::create_dir_all(&config_dir).ok();
+ let likes_file = config_dir.join("likes.json");
+
+ let likes = fs::read_to_string(&likes_file)
+ .ok()
+ .and_then(|d| serde_json::from_str(&d).ok())
+ .unwrap_or_default();
+
+ let available_colors = vec![
+ Color::Red, Color::LightRed, Color::Green, Color::LightGreen, Color::Yellow, Color::LightYellow,
+ Color::Blue, Color::LightBlue, Color::Magenta, Color::LightMagenta, Color::Cyan, Color::LightCyan,
+ Color::Gray, Color::White,
+ ];
+
+ let cache_dir = env::var("XDG_CACHE_HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from(env::var("HOME").unwrap_or_default()).join(".cache"))
+ .join("grrs");
+ fs::create_dir_all(&cache_dir).ok();
+ let theme_file = cache_dir.join("theme.txt");
+
+ let mut theme_color = SEAFOAM;
+ let mut color_idx = 0;
+
+ if let Ok(idx_str) = fs::read_to_string(&theme_file) {
+ if let Ok(idx) = idx_str.trim().parse::<usize>() {
+ if idx < available_colors.len() {
+ color_idx = idx;
+ theme_color = available_colors[color_idx];
+ }
+ }
+ }
+
+ Self {
+ song: SongData::default(),
+ song_start_time: Instant::now(),
+ art_protocol: None,
+ live_art_bytes: None,
+ likes,
+ likes_file,
+ status_message: "Connecting to Gensokyo Radio...".to_string(),
+ is_muted: false,
+ mode: AppMode::Playing,
+ liked_list_state: ListState::default(),
+ selected_art: None,
+ show_help_overlay: false,
+ show_unlike_confirm: None,
+ show_song_info: false,
+ theme_color,
+ available_colors,
+ color_idx,
+ theme_file,
+ mpv_song: MpvSong::default(),
+ refresh_until: None,
+ }
+ }
+
+ 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).unwrap().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();
+ }
+ }
+
+ pub fn get_sorted_likes(&self) -> Vec<LikedSong> {
+ let mut v: Vec<_> = self.likes.values().cloned().collect();
+ v.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+ v
+ }
+
+ pub fn cycle_theme(&mut self) {
+ self.color_idx = (self.color_idx + 1) % self.available_colors.len();
+ self.theme_color = self.available_colors[self.color_idx];
+ let _ = fs::write(&self.theme_file, self.color_idx.to_string());
+ }
+
+ 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.map(|t| Instant::now() < t).unwrap_or(false)
+ }
+}
diff --git a/src/input.rs b/src/input.rs
new file mode 100644
index 0000000..936cb90
--- /dev/null
+++ b/src/input.rs
@@ -0,0 +1,234 @@
+use crate::app::{AppEvent, AppMode, AppState};
+use crossterm::event::{Event as CEvent, KeyCode, KeyModifiers, MouseButton, MouseEventKind};
+use ratatui_image::picker::Picker;
+use std::{fs, io::Write, os::unix::net::UnixStream};
+use tokio::sync::mpsc;
+
+pub struct InputOutcome {
+ pub should_quit: bool,
+ pub should_clear: bool,
+}
+
+pub async fn handle_input(
+ evt: CEvent,
+ state: &mut AppState,
+ tx: &mpsc::Sender<AppEvent>,
+ picker: &Picker,
+ loop_picker: &Picker,
+ socket_path: &str,
+ http_client: &reqwest::Client,
+) -> InputOutcome {
+ let mut clear = false;
+ match evt {
+ CEvent::Key(key) => {
+ if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) { return InputOutcome { should_quit: true, should_clear: false }; }
+
+ if state.show_unlike_confirm.is_some() {
+ if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') {
+ if let Some(id) = state.show_unlike_confirm {
+ state.likes.remove(&id);
+ if let Ok(data) = serde_json::to_string_pretty(&state.likes) { fs::write(&state.likes_file, data).ok(); }
+ let path_to_remove = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+ tokio::spawn(async move {
+ let _ = tokio::fs::remove_file(path_to_remove).await;
+ });
+ }
+ state.show_unlike_confirm = None;
+ clear = true;
+
+ let sorted = state.get_sorted_likes();
+ if sorted.is_empty() {
+ state.liked_list_state.select(None);
+ state.selected_art = None;
+ } else {
+ let next = state.liked_list_state.selected().unwrap_or(0).min(sorted.len().saturating_sub(1));
+ state.liked_list_state.select(Some(next));
+ let id = sorted[next].id;
+ let tx_local = tx.clone();
+ let p = picker.clone();
+ let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+
+ tokio::spawn(async move {
+ if let Ok(bytes) = tokio::fs::read(path).await {
+ let mut p_clone = p.clone();
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&bytes) {
+ Some(p_clone.new_resize_protocol(img))
+ } else {
+ None
+ }
+ }).await.unwrap_or(None);
+
+ if let Some(protocol) = decode_result {
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
+ return;
+ }
+ }
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
+ });
+ }
+ } else if key.code == KeyCode::Char('n') || key.code == KeyCode::Char('N') || key.code == KeyCode::Esc {
+ state.show_unlike_confirm = None;
+ clear = true;
+ }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ }
+
+ if state.show_help_overlay {
+ if key.code == KeyCode::Char('?') || key.code == KeyCode::Esc { state.show_help_overlay = false; clear = true; }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ }
+ if state.show_song_info {
+ if key.code == KeyCode::Esc || key.code == KeyCode::Enter { state.show_song_info = false; clear = true; }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ }
+
+ if key.code == KeyCode::Char('?') { state.show_help_overlay = true; return InputOutcome { should_quit: false, should_clear: true }; }
+
+ if state.mode == AppMode::Playing {
+ if key.code == KeyCode::Tab {
+ state.mode = AppMode::LikedSongs;
+ clear = true;
+ let sorted = state.get_sorted_likes();
+ if !sorted.is_empty() {
+ state.liked_list_state.select(Some(0));
+ let id = sorted[0].id;
+ let tx_local = tx.clone();
+ let p = picker.clone();
+ let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+
+ tokio::spawn(async move {
+ if let Ok(bytes) = tokio::fs::read(path).await {
+ let mut p_clone = p.clone();
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
+ }).await.unwrap_or(None);
+
+ if let Some(protocol) = decode_result {
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
+ return;
+ }
+ }
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
+ });
+ }
+ } else if key.code == KeyCode::Char('l') {
+ let was_liked = state.likes.contains_key(&state.song.id);
+ state.toggle_like();
+ if state.likes.contains_key(&state.song.id) && !was_liked && !state.song.art_url.is_empty() {
+ let art_dir = state.likes_file.parent().unwrap().join("art");
+ fs::create_dir_all(&art_dir).ok();
+ let url = if state.song.art_url.starts_with("http") { state.song.art_url.clone() } else { format!("https://gensokyoradio.net{}", state.song.art_url) };
+ let path = art_dir.join(format!("{}.jpg", state.song.id));
+ let client = http_client.clone();
+ tokio::spawn(async move {
+ if let Ok(resp) = client.get(&url).send().await {
+ if let Ok(bytes) = resp.bytes().await {
+ let _ = tokio::fs::write(path, bytes).await;
+ }
+ }
+ });
+ } else if !state.likes.contains_key(&state.song.id) && was_liked {
+ let path_to_remove = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", state.song.id));
+ tokio::spawn(async move {
+ let _ = tokio::fs::remove_file(path_to_remove).await;
+ });
+ }
+ } else if key.code == KeyCode::Char('m') {
+ state.is_muted = !state.is_muted;
+ if let Ok(mut stream) = UnixStream::connect(socket_path) { let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n"); }
+ } else if key.code == KeyCode::Char('t') {
+ state.cycle_theme();
+ } else if key.code == KeyCode::Enter {
+ state.show_song_info = true;
+ clear = true;
+ }
+ } else if state.mode == AppMode::LikedSongs {
+ if key.code == KeyCode::Tab || key.code == KeyCode::Esc {
+ state.mode = AppMode::Playing; state.selected_art = None; clear = true;
+ if let Some(bytes) = &state.live_art_bytes {
+ let bytes_clone = bytes.clone();
+ let mut loop_picker_clone = loop_picker.clone();
+ if let Ok(img) = image::load_from_memory(&bytes_clone) {
+ state.art_protocol = Some(loop_picker_clone.new_resize_protocol(img));
+ }
+ }
+ } else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
+ let sorted = state.get_sorted_likes();
+ if !sorted.is_empty() {
+ let current = state.liked_list_state.selected().unwrap_or(0);
+ let next = current.saturating_sub(1);
+ state.liked_list_state.select(Some(next));
+ if current != next {
+ let id = sorted[next].id;
+ let tx_local = tx.clone();
+ let p = picker.clone();
+ let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+
+ tokio::spawn(async move {
+ if let Ok(bytes) = tokio::fs::read(path).await {
+ let mut p_clone = p.clone();
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
+ }).await.unwrap_or(None);
+
+ if let Some(protocol) = decode_result {
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
+ return;
+ }
+ }
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
+ });
+ }
+ }
+ } else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
+ let sorted = state.get_sorted_likes();
+ if !sorted.is_empty() {
+ let current = state.liked_list_state.selected().unwrap_or(0);
+ let next = (current + 1).min(sorted.len() - 1);
+ state.liked_list_state.select(Some(next));
+ if current != next {
+ let id = sorted[next].id;
+ let tx_local = tx.clone();
+ let p = picker.clone();
+ let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+
+ tokio::spawn(async move {
+ if let Ok(bytes) = tokio::fs::read(path).await {
+ let mut p_clone = p.clone();
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
+ }).await.unwrap_or(None);
+
+ if let Some(protocol) = decode_result {
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
+ return;
+ }
+ }
+ let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
+ });
+ }
+ }
+ } else if key.code == KeyCode::Char('m') {
+ state.is_muted = !state.is_muted;
+ if let Ok(mut stream) = UnixStream::connect(socket_path) { let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n"); }
+ } else if key.code == KeyCode::Char('l') {
+ if let Some(idx) = state.liked_list_state.selected() {
+ let sorted_likes = state.get_sorted_likes();
+ if let Some(song) = sorted_likes.get(idx) { state.show_unlike_confirm = Some(song.id); clear = true; }
+ }
+ } else if key.code == KeyCode::Char('t') { state.cycle_theme(); }
+ else if key.code == KeyCode::Enter { state.show_song_info = true; clear = true; }
+ }
+ }
+ CEvent::Mouse(mouse) => {
+ if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
+ if let Ok(size) = crossterm::terminal::size() {
+ if mouse.row == size.1.saturating_sub(3) && mouse.column >= 2 && mouse.column <= 10 { state.show_help_overlay = !state.show_help_overlay; clear = true; }
+ }
+ }
+ }
+ _ => {}
+ }
+ InputOutcome { should_quit: false, should_clear: clear }
+}
diff --git a/src/main.rs b/src/main.rs
index d9e8dcc..c85a528 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,621 +1,34 @@
+mod app;
+mod input;
+mod models;
+mod ui;
+
+use crate::{
+ app::{AppEvent, AppState, REFRESH_WINDOW},
+ input::handle_input,
+ models::{ApiInfo, SongData, WsPayload, parse_mpv_media_title},
+ ui::draw_ui,
+};
use crossterm::{
- event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode, KeyModifiers, MouseButton, MouseEventKind},
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::{SinkExt, StreamExt};
-use ratatui::{
- backend::CrosstermBackend,
- layout::{Constraint, Direction, Layout, Rect, Alignment},
- style::{Color, Style, Stylize},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Wrap},
- Terminal, Frame,
-};
-use ratatui_image::{picker::Picker, protocol::StatefulProtocol, StatefulImage};
-use serde::{Deserialize, Serialize};
-use serde_json::Value;
+use ratatui::{backend::CrosstermBackend, Terminal};
+use ratatui_image::picker::Picker;
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use std::{
- collections::HashMap, env, fs, io::{stdout, Write}, os::unix::net::UnixStream,
- path::PathBuf, process::{Command, Stdio}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
+ fs,
+ io::{stdout, Write},
+ os::unix::net::UnixStream,
+ process::{Command, Stdio},
+ time::{Duration, Instant},
};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
-const SEAFOAM: Color = Color::Rgb(91, 192, 174);
-const REFRESH_WINDOW: Duration = Duration::from_secs(30);
-
-#[derive(Deserialize, Default)]
-struct WsPayload {
- #[serde(default)] songid: i64,
- #[serde(default)] title: Option<String>,
- #[serde(default)] artist: Option<String>,
- #[serde(default)] album: Option<String>,
- #[serde(default)] circle: Option<String>,
- #[serde(default)] vocal: Option<String>,
- #[serde(default)] original: Option<String>,
- #[serde(default)] year: Option<Value>,
- #[serde(default)] duration: Option<u64>,
- #[serde(default)] played: Option<Value>,
- #[serde(default)] albumart: Option<String>,
-}
-
-#[derive(Deserialize, Default)]
-struct ApiInfo {
- #[serde(rename = "SERVERINFO", default)] server_info: ServerInfo,
- #[serde(rename = "MISC", default)] misc: MiscInfo,
- #[serde(rename = "SONGDATA", default)] song_data: ApiSongData,
-}
-
-#[derive(Deserialize, Default)] struct ServerInfo { #[serde(rename = "LISTENERS", default)] listeners: u64 }
-#[derive(Deserialize, Default)] struct MiscInfo { #[serde(rename = "CIRCLELINK", default)] circle_link: String }
-#[derive(Deserialize, Default)] struct ApiSongData {
- #[serde(rename = "RATING", default)] rating: String,
- #[serde(rename = "TIMESRATED", default)] times_rated: u64,
-}
-
-#[derive(Default, Clone, Debug)]
-struct MpvSong {
- title: String,
- artist: String,
-}
-
-fn parse_mpv_media_title(title: &str) -> Option<MpvSong> {
- let trimmed = title.trim();
- if let Some(idx) = trimmed.find(" - ") {
- Some(MpvSong {
- artist: trimmed[..idx].trim().to_string(),
- title: trimmed[idx + 3..].trim().to_string(),
- })
- } else {
- None
- }
-}
-
-#[derive(Default, Clone)]
-struct SongData {
- id: i64, title: String, artist: String, album: String, circle: String, circle_link: String,
- vocal: String, original: String, year: String, duration: u64, played: u64, art_url: String,
- listeners: u64, rating: String, times_rated: u64,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-struct LikedSong {
- id: i64, title: String, artist: String, album: String, duration: u64, timestamp: u64,
- #[serde(default)] circle: String, #[serde(default)] vocal: String,
- #[serde(default)] original: String, #[serde(default)] year: String,
-}
-
-#[derive(PartialEq)] enum AppMode { Playing, LikedSongs }
-
-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),
-}
-
-struct AppState {
- song: SongData, song_start_time: Instant, art_protocol: Option<StatefulProtocol>, live_art_bytes: Option<Vec<u8>>,
- likes: HashMap<i64, LikedSong>, likes_file: PathBuf, status_message: String, is_muted: bool,
- mode: AppMode, liked_list_state: ListState, selected_art: Option<StatefulProtocol>,
- show_help_overlay: bool, show_unlike_confirm: Option<i64>, show_song_info: bool,
- theme_color: Color, available_colors: Vec<Color>, color_idx: usize, theme_file: PathBuf,
- mpv_song: MpvSong,
- refresh_until: Option<Instant>,
-}
-
-impl AppState {
- fn new() -> Self {
- let config_dir = PathBuf::from(env::var("HOME").unwrap_or_default()).join(".config/grrs");
- fs::create_dir_all(&config_dir).ok();
- let likes_file = config_dir.join("likes.json");
-
- let likes = fs::read_to_string(&likes_file).ok().and_then(|d| serde_json::from_str(&d).ok()).unwrap_or_default();
-
- let available_colors = vec![
- Color::Red, Color::LightRed, Color::Green, Color::LightGreen, Color::Yellow, Color::LightYellow,
- Color::Blue, Color::LightBlue, Color::Magenta, Color::LightMagenta, Color::Cyan, Color::LightCyan,
- Color::Gray, Color::White,
- ];
-
- let cache_dir = env::var("XDG_CACHE_HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| PathBuf::from(env::var("HOME").unwrap_or_default()).join(".cache"))
- .join("grrs");
- fs::create_dir_all(&cache_dir).ok();
- let theme_file = cache_dir.join("theme.txt");
-
- let mut theme_color = SEAFOAM;
- let mut color_idx = 0;
-
- if let Ok(idx_str) = fs::read_to_string(&theme_file) {
- if let Ok(idx) = idx_str.trim().parse::<usize>() {
- if idx < available_colors.len() {
- color_idx = idx;
- theme_color = available_colors[color_idx];
- }
- }
- }
-
- Self {
- song: SongData::default(), song_start_time: Instant::now(), art_protocol: None, live_art_bytes: None,
- likes, likes_file, status_message: "Connecting to Gensokyo Radio...".to_string(), is_muted: false,
- mode: AppMode::Playing, liked_list_state: ListState::default(), selected_art: None,
- show_help_overlay: false, show_unlike_confirm: None, show_song_info: false,
- theme_color, available_colors, color_idx, theme_file,
- mpv_song: MpvSong::default(),
- refresh_until: None,
- }
- }
-
- 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).unwrap().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(); }
- }
-
- fn get_sorted_likes(&self) -> Vec<LikedSong> {
- let mut v: Vec<_> = self.likes.values().cloned().collect();
- v.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
- v
- }
-
- fn cycle_theme(&mut self) {
- self.color_idx = (self.color_idx + 1) % self.available_colors.len();
- self.theme_color = self.available_colors[self.color_idx];
- let _ = fs::write(&self.theme_file, self.color_idx.to_string());
- }
-
- 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
- }
-
- fn is_refreshing(&self) -> bool {
- self.refresh_until.map(|t| Instant::now() < t).unwrap_or(false)
- }
-}
-
-fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
- let popup_layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2)])
- .split(r);
-
- Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2)])
- .split(popup_layout[1])[1]
-}
-
-fn draw_ui(f: &mut Frame, state: &mut AppState) {
- let main_chunks = Layout::default().direction(Direction::Vertical).margin(2).constraints([Constraint::Min(10), Constraint::Length(1)]).split(f.area());
- let dynamic_width = (main_chunks[0].height * 2).clamp(10, 45);
- let content_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Length(dynamic_width), Constraint::Length(2), Constraint::Min(20)]).split(main_chunks[0]);
-
- match state.mode {
- AppMode::Playing => draw_player(f, state, content_chunks, main_chunks[1], main_chunks[0].height),
- AppMode::LikedSongs => draw_library(f, state, content_chunks, main_chunks[1]),
- }
-
- if state.show_help_overlay { draw_help_popup(f, state); }
- if state.show_unlike_confirm.is_some() { draw_confirm_popup(f, state); }
- if state.show_song_info { draw_song_info(f, state); }
-}
-
-fn draw_player(f: &mut Frame, state: &mut AppState, content: std::rc::Rc<[Rect]>, bottom: Rect, available_height: u16) {
- if let Some(ref mut protocol) = state.art_protocol { f.render_stateful_widget(StatefulImage::new(None), content[0], protocol); }
- else { f.render_widget(Paragraph::new("Loading Art..."), content[0]); }
-
- let mut basic_text = vec![
- Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]),
- Line::from(vec![Span::styled("Artist: ", Style::default().bold()), Span::raw(&state.song.artist)]),
- Line::from(vec![Span::styled("Album: ", Style::default().bold()), Span::raw(&state.song.album)]),
- ];
- let mut basic_info_height = 3;
- if !state.status_message.is_empty() {
- basic_text.push(Line::from(vec![Span::styled(&state.status_message, Style::default().fg(Color::Red))]));
- basic_info_height += 1;
- }
-
- let mut ext_text = vec![];
- if available_height >= 16 {
- if !state.song.circle.is_empty() && state.song.circle != "Unknown" {
- let mut spans = vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.circle, Style::default().fg(Color::DarkGray))];
- if !state.song.circle_link.is_empty() { spans.push(Span::styled(format!(" ({})", state.song.circle_link), Style::default().fg(Color::DarkGray))); }
- ext_text.push(Line::from(spans));
- }
- if !state.song.vocal.is_empty() && state.song.vocal != "Unknown" && state.song.vocal != "-" { ext_text.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.vocal, Style::default().fg(Color::DarkGray))])); }
- if !state.song.original.is_empty() && state.song.original != "Unknown" && state.song.original != "-" { ext_text.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.original, Style::default().fg(Color::DarkGray))])); }
- if !state.song.year.is_empty() && state.song.year != "Unknown" { ext_text.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.year, Style::default().fg(Color::DarkGray))])); }
- if !state.song.rating.is_empty() && state.song.rating != "Unknown" { ext_text.push(Line::from(vec![Span::styled("Rating: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(format!("{} ({})", state.song.rating, state.song.times_rated), Style::default().fg(Color::DarkGray))])); }
- }
-
- let refresh_height = state.is_refreshing() as u16;
- let right_chunks = Layout::default().direction(Direction::Vertical).constraints([
- Constraint::Length(basic_info_height),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(ext_text.len() as u16),
- Constraint::Length(1),
- Constraint::Length(refresh_height),
- Constraint::Min(2),
- ]).split(content[2]);
- f.render_widget(Paragraph::new(basic_text), right_chunks[0]);
-
- let total = state.song.duration;
- let elapsed = state.song_start_time.elapsed().as_secs();
- let (ratio, label) = if total > 0 { ((elapsed as f64 / total as f64).clamp(0.0, 1.0), format!("{:02}:{:02} / {:02}:{:02}", elapsed / 60, elapsed % 60, total / 60, total % 60)) } else { (1.0, format!("{:02}:{:02} / LIVE", elapsed / 60, elapsed % 60)) };
- f.render_widget(Gauge::default().gauge_style(Style::default().fg(state.theme_color).bg(Color::DarkGray)).ratio(ratio).label(label), right_chunks[2]);
- if !ext_text.is_empty() { f.render_widget(Paragraph::new(ext_text), right_chunks[4]); }
-
- if refresh_height > 0 {
- f.render_widget(Paragraph::new(Line::from(vec![
- Span::styled("少女祈祷中…", Style::default().fg(state.theme_color))
- ])), right_chunks[6]);
- }
-
- let bottom_layout = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(40), Constraint::Length(25)]).split(bottom);
- let mut status_spans = vec![Span::styled("[?] Help", Style::default().fg(Color::DarkGray))];
- if state.song.id != 0 && state.likes.contains_key(&state.song.id) { status_spans.push(Span::styled(" [Liked]", Style::default().fg(state.theme_color))); }
- if state.is_muted { status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red))); }
- f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom_layout[0]);
- f.render_widget(Paragraph::new(Line::from(vec![Span::styled(if state.song.listeners > 0 { format!("{} Listeners ", state.song.listeners) } else { "LIVE ".to_string() }, Style::default().fg(state.theme_color).bold())])).alignment(Alignment::Right), bottom_layout[1]);
-}
-
-fn draw_library(f: &mut Frame, state: &mut AppState, content: std::rc::Rc<[Rect]>, bottom: Rect) {
- if let Some(ref mut protocol) = state.selected_art { f.render_stateful_widget(StatefulImage::new(None), content[0], protocol); }
- else { f.render_widget(Paragraph::new("No Art Saved"), content[0]); }
-
- let sorted = state.get_sorted_likes();
- let mut items: Vec<ListItem> = sorted.iter().enumerate().map(|(i, s)| {
- if Some(i) == state.liked_list_state.selected() { ListItem::new(Line::from(vec![Span::styled(format!("{} - {}", s.title, s.artist), Style::default().fg(Color::White).bold())])) }
- else { ListItem::new(Line::from(vec![Span::styled(format!("{} ", s.title), Style::default().fg(state.theme_color).bold()), Span::styled(format!("- {}", s.artist), Style::default().fg(Color::DarkGray))])) }
- }).collect();
-
- if items.is_empty() { items.push(ListItem::new(Line::from(vec![Span::styled("No liked songs yet.", Style::default().fg(Color::DarkGray))]))); }
- f.render_stateful_widget(List::new(items).highlight_style(Style::default().bg(Color::Rgb(50, 50, 50))).highlight_symbol("> "), content[2], &mut state.liked_list_state);
-
- let mut status_spans = vec![Span::styled("[?] Help", Style::default().fg(Color::DarkGray))];
- if state.song.id != 0 && state.likes.contains_key(&state.song.id) { status_spans.push(Span::styled(" [Liked]", Style::default().fg(state.theme_color))); }
- if state.is_muted { status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red))); }
- f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom);
-}
-
-fn draw_help_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(50, 40, f.area());
- f.render_widget(Clear, area);
- f.render_widget(Paragraph::new(vec![
- Line::from(""),
- Line::from(vec![Span::styled(" [l] ", Style::default().bold()), Span::raw("like/unlike song")]),
- Line::from(vec![Span::styled(" [m] ", Style::default().bold()), Span::raw("mute/unmute stream")]),
- Line::from(vec![Span::styled(" [t] ", Style::default().bold()), Span::raw("cycle theme color")]),
- Line::from(vec![Span::styled(" [enter] ", Style::default().bold()), Span::raw("open song info")]),
- Line::from(vec![Span::styled(" [tab] ", Style::default().bold()), Span::raw("switch between library and player")]),
- Line::from(vec![Span::styled(" [?] ", Style::default().bold()), Span::raw("show this help")]),
- Line::from(vec![Span::styled(" [q]/[^c] ", Style::default().bold()), Span::raw("close")]),
- ]).block(Block::default().title(" Help ").borders(Borders::ALL).style(Style::default().fg(state.theme_color))), area);
-}
-
-fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(40, 15, f.area());
- f.render_widget(Clear, area);
- f.render_widget(Paragraph::new(vec![
- Line::from(""), Line::from(vec![Span::raw(" Remove from library? ")]), Line::from(""),
- Line::from(vec![Span::styled(" [y] ", Style::default().fg(state.theme_color).bold()), Span::raw("Yes "), Span::styled(" [n] ", Style::default().fg(Color::Red).bold()), Span::raw("No")]),
- ]).block(Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().fg(Color::Red))).alignment(Alignment::Center), area);
-}
-
-fn draw_song_info(f: &mut Frame, state: &mut AppState) {
- let area = centered_rect(80, 80, f.area());
- f.render_widget(Clear, area);
- let block = Block::default().title(" Song Info (Esc to close) ").borders(Borders::ALL).style(Style::default().fg(state.theme_color));
- let inner = block.inner(area);
- f.render_widget(block, area);
-
- let chunks = Layout::default()
- .direction(Direction::Horizontal)
- .margin(2)
- .spacing(4)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
- .split(inner);
-
- let sorted_likes = state.get_sorted_likes();
- let mut txt = vec![];
- let mut has_art = false;
-
- if state.mode == AppMode::Playing {
- if let Some(ref mut p) = state.art_protocol {
- f.render_stateful_widget(StatefulImage::new(None), chunks[0], p);
- has_art = true;
- }
-
- txt.push(Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]));
- txt.push(Line::from(vec![Span::styled("Artist: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.artist)]));
- txt.push(Line::from(vec![Span::styled("Album: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.album)]));
- txt.push(Line::from(""));
-
- if !state.song.circle.is_empty() { txt.push(Line::from(vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.circle)])); }
- if !state.song.year.is_empty() { txt.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.year)])); }
- if !state.song.vocal.is_empty() && state.song.vocal != "-" { txt.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.vocal)])); }
- if !state.song.original.is_empty() && state.song.original != "-" { txt.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.original)])); }
-
- txt.push(Line::from(""));
- txt.push(Line::from(vec![Span::styled("Duration: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(format!("{:02}:{:02}", state.song.duration / 60, state.song.duration % 60))]));
-
- let saved_status = if state.likes.contains_key(&state.song.id) { "Yes" } else { "No" };
- txt.push(Line::from(vec![Span::styled("Saved: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(saved_status)]));
-
- } else {
- if let Some(ref mut p) = state.selected_art {
- f.render_stateful_widget(StatefulImage::new(None), chunks[0], p);
- has_art = true;
- }
-
- if let Some(idx) = state.liked_list_state.selected() {
- if let Some(song) = sorted_likes.get(idx) {
- txt.push(Line::from(vec![Span::styled(&song.title, Style::default().fg(state.theme_color).bold())]));
- txt.push(Line::from(vec![Span::styled("Artist: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.artist)]));
- txt.push(Line::from(vec![Span::styled("Album: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.album)]));
- txt.push(Line::from(""));
-
- if !song.circle.is_empty() { txt.push(Line::from(vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.circle)])); }
- if !song.year.is_empty() { txt.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.year)])); }
- if !song.vocal.is_empty() && song.vocal != "-" { txt.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.vocal)])); }
- if !song.original.is_empty() && song.original != "-" { txt.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.original)])); }
-
- txt.push(Line::from(""));
- txt.push(Line::from(vec![Span::styled("Duration: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(format!("{:02}:{:02}", song.duration / 60, song.duration % 60))]));
- txt.push(Line::from(vec![Span::styled("Saved: ", Style::default().bold().fg(Color::DarkGray)), Span::raw("In Library")]));
- }
- }
- }
-
- if !has_art { f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), chunks[0]); }
- if !txt.is_empty() { f.render_widget(Paragraph::new(txt).wrap(Wrap { trim: true }), chunks[1]); }
-}
-
-struct InputOutcome { should_quit: bool, should_clear: bool }
-
-async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEvent>, picker: &Picker, loop_picker: &Picker, socket_path: &str, http_client: &reqwest::Client) -> InputOutcome {
- let mut clear = false;
- match evt {
- CEvent::Key(key) => {
- if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) { return InputOutcome { should_quit: true, should_clear: false }; }
-
- if state.show_unlike_confirm.is_some() {
- if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') {
- if let Some(id) = state.show_unlike_confirm {
- state.likes.remove(&id);
- if let Ok(data) = serde_json::to_string_pretty(&state.likes) { fs::write(&state.likes_file, data).ok(); }
- let path_to_remove = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
- tokio::spawn(async move {
- let _ = tokio::fs::remove_file(path_to_remove).await;
- });
- }
- state.show_unlike_confirm = None;
- clear = true;
-
- let sorted = state.get_sorted_likes();
- if sorted.is_empty() {
- state.liked_list_state.select(None);
- state.selected_art = None;
- } else {
- let next = state.liked_list_state.selected().unwrap_or(0).min(sorted.len().saturating_sub(1));
- state.liked_list_state.select(Some(next));
- let id = sorted[next].id;
- let tx_local = tx.clone();
- let p = picker.clone();
- let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
-
- tokio::spawn(async move {
- if let Ok(bytes) = tokio::fs::read(path).await {
- let mut p_clone = p.clone();
- let decode_result = tokio::task::spawn_blocking(move || {
- if let Ok(img) = image::load_from_memory(&bytes) {
- Some(p_clone.new_resize_protocol(img))
- } else {
- None
- }
- }).await.unwrap_or(None);
-
- if let Some(protocol) = decode_result {
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
- return;
- }
- }
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
- });
- }
- } else if key.code == KeyCode::Char('n') || key.code == KeyCode::Char('N') || key.code == KeyCode::Esc {
- state.show_unlike_confirm = None;
- clear = true;
- }
- return InputOutcome { should_quit: false, should_clear: clear };
- }
-
- if state.show_help_overlay {
- if key.code == KeyCode::Char('?') || key.code == KeyCode::Esc { state.show_help_overlay = false; clear = true; }
- return InputOutcome { should_quit: false, should_clear: clear };
- }
- if state.show_song_info {
- if key.code == KeyCode::Esc || key.code == KeyCode::Enter { state.show_song_info = false; clear = true; }
- return InputOutcome { should_quit: false, should_clear: clear };
- }
-
- if key.code == KeyCode::Char('?') { state.show_help_overlay = true; return InputOutcome { should_quit: false, should_clear: true }; }
-
- if state.mode == AppMode::Playing {
- if key.code == KeyCode::Tab {
- state.mode = AppMode::LikedSongs;
- clear = true;
- let sorted = state.get_sorted_likes();
- if !sorted.is_empty() {
- state.liked_list_state.select(Some(0));
- let id = sorted[0].id;
- let tx_local = tx.clone();
- let p = picker.clone();
- let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
-
- tokio::spawn(async move {
- if let Ok(bytes) = tokio::fs::read(path).await {
- let mut p_clone = p.clone();
- let decode_result = tokio::task::spawn_blocking(move || {
- if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
- }).await.unwrap_or(None);
-
- if let Some(protocol) = decode_result {
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
- return;
- }
- }
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
- });
- }
- } else if key.code == KeyCode::Char('l') {
- let was_liked = state.likes.contains_key(&state.song.id);
- state.toggle_like();
- if state.likes.contains_key(&state.song.id) && !was_liked && !state.song.art_url.is_empty() {
- let art_dir = state.likes_file.parent().unwrap().join("art");
- fs::create_dir_all(&art_dir).ok();
- let url = if state.song.art_url.starts_with("http") { state.song.art_url.clone() } else { format!("https://gensokyoradio.net{}", state.song.art_url) };
- let path = art_dir.join(format!("{}.jpg", state.song.id));
- let client = http_client.clone();
- tokio::spawn(async move {
- if let Ok(resp) = client.get(&url).send().await {
- if let Ok(bytes) = resp.bytes().await {
- let _ = tokio::fs::write(path, bytes).await;
- }
- }
- });
- } else if !state.likes.contains_key(&state.song.id) && was_liked {
- let path_to_remove = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", state.song.id));
- tokio::spawn(async move {
- let _ = tokio::fs::remove_file(path_to_remove).await;
- });
- }
- } else if key.code == KeyCode::Char('m') {
- state.is_muted = !state.is_muted;
- if let Ok(mut stream) = UnixStream::connect(socket_path) { let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n"); }
- } else if key.code == KeyCode::Char('t') {
- state.cycle_theme();
- } else if key.code == KeyCode::Enter {
- state.show_song_info = true;
- clear = true;
- }
- } else if state.mode == AppMode::LikedSongs {
- if key.code == KeyCode::Tab || key.code == KeyCode::Esc {
- state.mode = AppMode::Playing; state.selected_art = None; clear = true;
- if let Some(bytes) = &state.live_art_bytes {
- let bytes_clone = bytes.clone();
- let mut loop_picker_clone = loop_picker.clone();
- if let Ok(img) = image::load_from_memory(&bytes_clone) {
- state.art_protocol = Some(loop_picker_clone.new_resize_protocol(img));
- }
- }
- } else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
- let sorted = state.get_sorted_likes();
- if !sorted.is_empty() {
- let current = state.liked_list_state.selected().unwrap_or(0);
- let next = current.saturating_sub(1);
- state.liked_list_state.select(Some(next));
- if current != next {
- let id = sorted[next].id;
- let tx_local = tx.clone();
- let p = picker.clone();
- let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
-
- tokio::spawn(async move {
- if let Ok(bytes) = tokio::fs::read(path).await {
- let mut p_clone = p.clone();
- let decode_result = tokio::task::spawn_blocking(move || {
- if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
- }).await.unwrap_or(None);
-
- if let Some(protocol) = decode_result {
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
- return;
- }
- }
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
- });
- }
- }
- } else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
- let sorted = state.get_sorted_likes();
- if !sorted.is_empty() {
- let current = state.liked_list_state.selected().unwrap_or(0);
- let next = (current + 1).min(sorted.len() - 1);
- state.liked_list_state.select(Some(next));
- if current != next {
- let id = sorted[next].id;
- let tx_local = tx.clone();
- let p = picker.clone();
- let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
-
- tokio::spawn(async move {
- if let Ok(bytes) = tokio::fs::read(path).await {
- let mut p_clone = p.clone();
- let decode_result = tokio::task::spawn_blocking(move || {
- if let Ok(img) = image::load_from_memory(&bytes) { Some(p_clone.new_resize_protocol(img)) } else { None }
- }).await.unwrap_or(None);
-
- if let Some(protocol) = decode_result {
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
- return;
- }
- }
- let _ = tx_local.send(AppEvent::LocalArtLoaded(id, None)).await;
- });
- }
- }
- } else if key.code == KeyCode::Char('m') {
- state.is_muted = !state.is_muted;
- if let Ok(mut stream) = UnixStream::connect(socket_path) { let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n"); }
- } else if key.code == KeyCode::Char('l') {
- if let Some(idx) = state.liked_list_state.selected() {
- let sorted_likes = state.get_sorted_likes();
- if let Some(song) = sorted_likes.get(idx) { state.show_unlike_confirm = Some(song.id); clear = true; }
- }
- } else if key.code == KeyCode::Char('t') { state.cycle_theme(); }
- else if key.code == KeyCode::Enter { state.show_song_info = true; clear = true; }
- }
- }
- CEvent::Mouse(mouse) => {
- if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
- if let Ok(size) = crossterm::terminal::size() {
- if mouse.row == size.1.saturating_sub(3) && mouse.column >= 2 && mouse.column <= 10 { state.show_help_overlay = !state.show_help_overlay; clear = true; }
- }
- }
- }
- _ => {}
- }
- InputOutcome { should_quit: false, should_clear: clear }
-}
-
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket_path = "/tmp/grrs.sock";
diff --git a/src/models.rs b/src/models.rs
new file mode 100644
index 0000000..b59acff
--- /dev/null
+++ b/src/models.rs
@@ -0,0 +1,72 @@
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+#[derive(Deserialize, Default, Clone)]
+pub struct WsPayload {
+ #[serde(default)] pub songid: i64,
+ #[serde(default)] pub title: Option<String>,
+ #[serde(default)] pub artist: Option<String>,
+ #[serde(default)] pub album: Option<String>,
+ #[serde(default)] pub circle: Option<String>,
+ #[serde(default)] pub vocal: Option<String>,
+ #[serde(default)] pub original: Option<String>,
+ #[serde(default)] pub year: Option<Value>,
+ #[serde(default)] pub duration: Option<u64>,
+ #[serde(default)] pub played: Option<Value>,
+ #[serde(default)] pub albumart: Option<String>,
+}
+
+#[derive(Deserialize, Default, Clone)]
+pub struct ApiInfo {
+ #[serde(rename = "SERVERINFO", default)] pub server_info: ServerInfo,
+ #[serde(rename = "MISC", default)] pub misc: MiscInfo,
+ #[serde(rename = "SONGDATA", default)] pub song_data: ApiSongData,
+}
+
+#[derive(Deserialize, Default, Clone)]
+pub struct ServerInfo {
+ #[serde(rename = "LISTENERS", default)] pub listeners: u64
+}
+
+#[derive(Deserialize, Default, Clone)]
+pub struct MiscInfo {
+ #[serde(rename = "CIRCLELINK", default)] pub circle_link: String
+}
+
+#[derive(Deserialize, Default, Clone)]
+pub struct ApiSongData {
+ #[serde(rename = "RATING", default)] pub rating: String,
+ #[serde(rename = "TIMESRATED", default)] pub times_rated: u64,
+}
+
+#[derive(Default, Clone, Debug)]
+pub struct MpvSong {
+ pub title: String,
+ pub artist: String,
+}
+
+pub fn parse_mpv_media_title(title: &str) -> Option<MpvSong> {
+ let trimmed = title.trim();
+ if let Some(idx) = trimmed.find(" - ") {
+ Some(MpvSong {
+ artist: trimmed[..idx].trim().to_string(),
+ title: trimmed[idx + 3..].trim().to_string(),
+ })
+ } else {
+ None
+ }
+}
+
+#[derive(Default, Clone)]
+pub struct SongData {
+ pub id: i64, pub title: String, pub artist: String, pub album: String, pub circle: String, pub circle_link: String,
+ pub vocal: String, pub original: String, pub year: String, pub duration: u64, pub played: u64, pub art_url: String,
+ pub listeners: u64, pub rating: String, pub times_rated: u64,
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+pub struct LikedSong {
+ pub id: i64, pub title: String, pub artist: String, pub album: String, pub duration: u64, pub timestamp: u64,
+ #[serde(default)] pub circle: String, #[serde(default)] pub vocal: String,
+ #[serde(default)] pub original: String, #[serde(default)] pub year: String,
+}
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100644
index 0000000..9a028b3
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,210 @@
+use crate::app::{AppMode, AppState};
+use ratatui::{
+ layout::{Alignment, Constraint, Direction, Layout, Rect},
+ style::{Color, Style, Stylize},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Wrap},
+ Frame,
+};
+use ratatui_image::StatefulImage;
+use std::rc::Rc;
+
+fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
+ let popup_layout = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2)])
+ .split(r);
+
+ Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2)])
+ .split(popup_layout[1])[1]
+}
+
+pub fn draw_ui(f: &mut Frame, state: &mut AppState) {
+ let main_chunks = Layout::default().direction(Direction::Vertical).margin(2).constraints([Constraint::Min(10), Constraint::Length(1)]).split(f.area());
+ let dynamic_width = (main_chunks[0].height * 2).clamp(10, 45);
+ let content_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Length(dynamic_width), Constraint::Length(2), Constraint::Min(20)]).split(main_chunks[0]);
+
+ match state.mode {
+ AppMode::Playing => draw_player(f, state, content_chunks, main_chunks[1], main_chunks[0].height),
+ AppMode::LikedSongs => draw_library(f, state, content_chunks, main_chunks[1]),
+ }
+
+ if state.show_help_overlay { draw_help_popup(f, state); }
+ if state.show_unlike_confirm.is_some() { draw_confirm_popup(f, state); }
+ if state.show_song_info { draw_song_info(f, state); }
+}
+
+fn draw_player(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect, available_height: u16) {
+ if let Some(ref mut protocol) = state.art_protocol { f.render_stateful_widget(StatefulImage::new(None), content[0], protocol); }
+ else { f.render_widget(Paragraph::new("Loading Art..."), content[0]); }
+
+ let mut basic_text = vec![
+ Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]),
+ Line::from(vec![Span::styled("Artist: ", Style::default().bold()), Span::raw(&state.song.artist)]),
+ Line::from(vec![Span::styled("Album: ", Style::default().bold()), Span::raw(&state.song.album)]),
+ ];
+ let mut basic_info_height = 3;
+ if !state.status_message.is_empty() {
+ basic_text.push(Line::from(vec![Span::styled(&state.status_message, Style::default().fg(Color::Red))]));
+ basic_info_height += 1;
+ }
+
+ let mut ext_text = vec![];
+ if available_height >= 16 {
+ if !state.song.circle.is_empty() && state.song.circle != "Unknown" {
+ let mut spans = vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.circle, Style::default().fg(Color::DarkGray))];
+ if !state.song.circle_link.is_empty() { spans.push(Span::styled(format!(" ({})", state.song.circle_link), Style::default().fg(Color::DarkGray))); }
+ ext_text.push(Line::from(spans));
+ }
+ if !state.song.vocal.is_empty() && state.song.vocal != "Unknown" && state.song.vocal != "-" { ext_text.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.vocal, Style::default().fg(Color::DarkGray))])); }
+ if !state.song.original.is_empty() && state.song.original != "Unknown" && state.song.original != "-" { ext_text.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.original, Style::default().fg(Color::DarkGray))])); }
+ if !state.song.year.is_empty() && state.song.year != "Unknown" { ext_text.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.year, Style::default().fg(Color::DarkGray))])); }
+ if !state.song.rating.is_empty() && state.song.rating != "Unknown" { ext_text.push(Line::from(vec![Span::styled("Rating: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(format!("{} ({})", state.song.rating, state.song.times_rated), Style::default().fg(Color::DarkGray))])); }
+ }
+
+ let refresh_height = state.is_refreshing() as u16;
+ let right_chunks = Layout::default().direction(Direction::Vertical).constraints([
+ Constraint::Length(basic_info_height),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(ext_text.len() as u16),
+ Constraint::Length(1),
+ Constraint::Length(refresh_height),
+ Constraint::Min(2),
+ ]).split(content[2]);
+ f.render_widget(Paragraph::new(basic_text), right_chunks[0]);
+
+ let total = state.song.duration;
+ let elapsed = state.song_start_time.elapsed().as_secs();
+ let (ratio, label) = if total > 0 { ((elapsed as f64 / total as f64).clamp(0.0, 1.0), format!("{:02}:{:02} / {:02}:{:02}", elapsed / 60, elapsed % 60, total / 60, total % 60)) } else { (1.0, format!("{:02}:{:02} / LIVE", elapsed / 60, elapsed % 60)) };
+ f.render_widget(Gauge::default().gauge_style(Style::default().fg(state.theme_color).bg(Color::DarkGray)).ratio(ratio).label(label), right_chunks[2]);
+ if !ext_text.is_empty() { f.render_widget(Paragraph::new(ext_text), right_chunks[4]); }
+
+ if refresh_height > 0 {
+ f.render_widget(Paragraph::new(Line::from(vec![
+ Span::styled("少女祈祷中…", Style::default().fg(state.theme_color))
+ ])), right_chunks[6]);
+ }
+
+ let bottom_layout = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(40), Constraint::Length(25)]).split(bottom);
+ let mut status_spans = vec![Span::styled("[?] Help", Style::default().fg(Color::DarkGray))];
+ if state.song.id != 0 && state.likes.contains_key(&state.song.id) { status_spans.push(Span::styled(" [Liked]", Style::default().fg(state.theme_color))); }
+ if state.is_muted { status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red))); }
+ f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom_layout[0]);
+ f.render_widget(Paragraph::new(Line::from(vec![Span::styled(if state.song.listeners > 0 { format!("{} Listeners ", state.song.listeners) } else { "LIVE ".to_string() }, Style::default().fg(state.theme_color).bold())])).alignment(Alignment::Right), bottom_layout[1]);
+}
+
+fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect) {
+ if let Some(ref mut protocol) = state.selected_art { f.render_stateful_widget(StatefulImage::new(None), content[0], protocol); }
+ else { f.render_widget(Paragraph::new("No Art Saved"), content[0]); }
+
+ let sorted = state.get_sorted_likes();
+ let mut items: Vec<ListItem> = sorted.iter().enumerate().map(|(i, s)| {
+ if Some(i) == state.liked_list_state.selected() { ListItem::new(Line::from(vec![Span::styled(format!("{} - {}", s.title, s.artist), Style::default().fg(Color::White).bold())])) }
+ else { ListItem::new(Line::from(vec![Span::styled(format!("{} ", s.title), Style::default().fg(state.theme_color).bold()), Span::styled(format!("- {}", s.artist), Style::default().fg(Color::DarkGray))])) }
+ }).collect();
+
+ if items.is_empty() { items.push(ListItem::new(Line::from(vec![Span::styled("No liked songs yet.", Style::default().fg(Color::DarkGray))]))); }
+ f.render_stateful_widget(List::new(items).highlight_style(Style::default().bg(Color::Rgb(50, 50, 50))).highlight_symbol("> "), content[2], &mut state.liked_list_state);
+
+ let mut status_spans = vec![Span::styled("[?] Help", Style::default().fg(Color::DarkGray))];
+ if state.song.id != 0 && state.likes.contains_key(&state.song.id) { status_spans.push(Span::styled(" [Liked]", Style::default().fg(state.theme_color))); }
+ if state.is_muted { status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red))); }
+ f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom);
+}
+
+fn draw_help_popup(f: &mut Frame, state: &AppState) {
+ let area = centered_rect(50, 40, f.area());
+ f.render_widget(Clear, area);
+ f.render_widget(Paragraph::new(vec![
+ Line::from(""),
+ Line::from(vec![Span::styled(" [l] ", Style::default().bold()), Span::raw("like/unlike song")]),
+ Line::from(vec![Span::styled(" [m] ", Style::default().bold()), Span::raw("mute/unmute stream")]),
+ Line::from(vec![Span::styled(" [t] ", Style::default().bold()), Span::raw("cycle theme color")]),
+ Line::from(vec![Span::styled(" [enter] ", Style::default().bold()), Span::raw("open song info")]),
+ Line::from(vec![Span::styled(" [tab] ", Style::default().bold()), Span::raw("switch between library and player")]),
+ Line::from(vec![Span::styled(" [?] ", Style::default().bold()), Span::raw("show this help")]),
+ Line::from(vec![Span::styled(" [q]/[^c] ", Style::default().bold()), Span::raw("close")]),
+ ]).block(Block::default().title(" Help ").borders(Borders::ALL).style(Style::default().fg(state.theme_color))), area);
+}
+
+fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
+ let area = centered_rect(40, 15, f.area());
+ f.render_widget(Clear, area);
+ f.render_widget(Paragraph::new(vec![
+ Line::from(""), Line::from(vec![Span::raw(" Remove from library? ")]), Line::from(""),
+ Line::from(vec![Span::styled(" [y] ", Style::default().fg(state.theme_color).bold()), Span::raw("Yes "), Span::styled(" [n] ", Style::default().fg(Color::Red).bold()), Span::raw("No")]),
+ ]).block(Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().fg(Color::Red))).alignment(Alignment::Center), area);
+}
+
+fn draw_song_info(f: &mut Frame, state: &mut AppState) {
+ let area = centered_rect(80, 80, f.area());
+ f.render_widget(Clear, area);
+ let block = Block::default().title(" Song Info (Esc to close) ").borders(Borders::ALL).style(Style::default().fg(state.theme_color));
+ let inner = block.inner(area);
+ f.render_widget(block, area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .margin(2)
+ .spacing(4)
+ .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .split(inner);
+
+ let sorted_likes = state.get_sorted_likes();
+ let mut txt = vec![];
+ let mut has_art = false;
+
+ if state.mode == AppMode::Playing {
+ if let Some(ref mut p) = state.art_protocol {
+ f.render_stateful_widget(StatefulImage::new(None), chunks[0], p);
+ has_art = true;
+ }
+
+ txt.push(Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]));
+ txt.push(Line::from(vec![Span::styled("Artist: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.artist)]));
+ txt.push(Line::from(vec![Span::styled("Album: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.album)]));
+ txt.push(Line::from(""));
+
+ if !state.song.circle.is_empty() { txt.push(Line::from(vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.circle)])); }
+ if !state.song.year.is_empty() { txt.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.year)])); }
+ if !state.song.vocal.is_empty() && state.song.vocal != "-" { txt.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.vocal)])); }
+ if !state.song.original.is_empty() && state.song.original != "-" { txt.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&state.song.original)])); }
+
+ txt.push(Line::from(""));
+ txt.push(Line::from(vec![Span::styled("Duration: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(format!("{:02}:{:02}", state.song.duration / 60, state.song.duration % 60))]));
+
+ let saved_status = if state.likes.contains_key(&state.song.id) { "Yes" } else { "No" };
+ txt.push(Line::from(vec![Span::styled("Saved: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(saved_status)]));
+
+ } else {
+ if let Some(ref mut p) = state.selected_art {
+ f.render_stateful_widget(StatefulImage::new(None), chunks[0], p);
+ has_art = true;
+ }
+
+ if let Some(idx) = state.liked_list_state.selected() {
+ if let Some(song) = sorted_likes.get(idx) {
+ txt.push(Line::from(vec![Span::styled(&song.title, Style::default().fg(state.theme_color).bold())]));
+ txt.push(Line::from(vec![Span::styled("Artist: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.artist)]));
+ txt.push(Line::from(vec![Span::styled("Album: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.album)]));
+ txt.push(Line::from(""));
+
+ if !song.circle.is_empty() { txt.push(Line::from(vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.circle)])); }
+ if !song.year.is_empty() { txt.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.year)])); }
+ if !song.vocal.is_empty() && song.vocal != "-" { txt.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.vocal)])); }
+ if !song.original.is_empty() && song.original != "-" { txt.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(&song.original)])); }
+
+ txt.push(Line::from(""));
+ txt.push(Line::from(vec![Span::styled("Duration: ", Style::default().bold().fg(Color::DarkGray)), Span::raw(format!("{:02}:{:02}", song.duration / 60, song.duration % 60))]));
+ txt.push(Line::from(vec![Span::styled("Saved: ", Style::default().bold().fg(Color::DarkGray)), Span::raw("In Library")]));
+ }
+ }
+ }
+
+ if !has_art { f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), chunks[0]); }
+ if !txt.is_empty() { f.render_widget(Paragraph::new(txt).wrap(Wrap { trim: true }), chunks[1]); }
+}