git/grrs/main

feature: add toml configuration support

implemented a config.toml parser covering player, ui, and system preferences. it has strict validation and deny_unknown_fields enforcement as well. added a force-stop config error screen that displays info on the malformed config file when syntax or values are bad. keeps all background tasks (websocket connection, ui rendering, etc) from spawning until the config file is healthy. closes e42b4c4
diff --git a/Cargo.lock b/Cargo.lock
index bcb696b..59e452a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1848,7 +1848,7 @@ dependencies = [
[[package]]
name = "myn-grrs"
-version = "0.1.5"
+version = "0.1.6"
dependencies = [
"arboard",
"crossterm",
@@ -1862,6 +1862,7 @@ dependencies = [
"souvlaki",
"tokio",
"tokio-tungstenite",
+ "toml",
"urlencoding",
"webbrowser",
]
@@ -3256,6 +3257,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "sha1"
version = "0.11.0"
@@ -3782,6 +3792,45 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "toml"
+version = "1.1.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
[[package]]
name = "tower"
version = "0.5.3"
@@ -4660,6 +4709,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
diff --git a/Cargo.toml b/Cargo.toml
index 99ad191..0368508 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "myn-grrs"
-version = "0.1.5"
+version = "0.1.6"
edition = "2024"
repository = "https://git.myn.sh/grrs.git"
description = "a minimal tui for gensokyo radio"
@@ -21,6 +21,7 @@ ratatui-image = { version = "11.0", features = ["crossterm"] }
arboard = { version = "3.6.1", features = ["wayland-data-control"] }
webbrowser = "1.2.4"
urlencoding = "2.1.3"
+toml = "1.1.4"
[[bin]]
name = "grrs"
diff --git a/src/app.rs b/src/app.rs
index 4fe1735..c56e5d2 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,4 +1,7 @@
-use crate::models::{LikedSong, MpvSong, SongData};
+use crate::{
+ config::AppConfig,
+ models::{LikedSong, MpvSong, SongData},
+};
use crossterm::event::Event as CEvent;
use ratatui::{style::Color, widgets::ListState};
use ratatui_image::protocol::StatefulProtocol;
@@ -10,13 +13,13 @@ use std::{
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,
+ ConfigError(Vec<String>),
}
pub enum AppEvent {
@@ -38,6 +41,7 @@ pub enum AppEvent {
}
pub struct AppState {
+ pub config: AppConfig,
pub song: SongData,
pub song_start_time: Instant,
pub art_protocol: Option<StatefulProtocol>,
@@ -54,13 +58,16 @@ pub struct AppState {
pub show_unlike_confirm: Option<i64>,
pub help_tab: usize,
pub theme_color: Color,
- pub available_colors: Vec<Color>,
- pub color_idx: usize,
- pub theme_file: PathBuf,
+ 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,
}
impl AppState {
@@ -72,17 +79,38 @@ impl AppState {
.unwrap_or_else(|_| PathBuf::from(&home).join(".config"))
.join("grrs");
- let data_dir = env::var("XDG_DATA_HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| PathBuf::from(&home).join(".local/share"))
- .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() {
+ PathBuf::from(&config.system.data_dir)
+ } else {
+ env::var("XDG_DATA_HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from(&home).join(".local/share"))
+ .join("grrs")
+ };
let cache_dir = env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".cache"))
.join("grrs");
- fs::create_dir_all(&config_dir).ok();
fs::create_dir_all(&data_dir).ok();
fs::create_dir_all(&cache_dir).ok();
@@ -99,6 +127,7 @@ impl AppState {
}
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 = fs::read_to_string(&likes_file)
@@ -106,37 +135,25 @@ impl AppState {
.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 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];
- }
- }
- }
+ 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);
Self {
+ config,
song: SongData::default(),
song_start_time: Instant::now(),
art_protocol: None,
@@ -146,20 +163,23 @@ impl AppState {
status_message: "Connecting to Gensokyo Radio...".to_string(),
status_message_time: Some(Instant::now()),
is_muted: false,
- mode: AppMode::Playing,
+ mode,
liked_list_state: ListState::default(),
selected_art: None,
show_help_overlay: false,
show_unlike_confirm: None,
help_tab: 0,
theme_color,
- available_colors,
- color_idx,
- theme_file,
+ error_color,
+ warning_color,
+ highlight_bg_color,
+ highlight_fg_color,
+ secondary_color,
mpv_song: MpvSong::default(),
refresh_until: None,
- volume: 100,
+ volume: startup_volume,
volume_time: None,
+ config_scroll: 0,
}
}
@@ -207,12 +227,6 @@ impl AppState {
.join(format!("{}.jpg", id))
}
- 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;
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..21c6136
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,189 @@
+use ratatui::style::Color;
+use serde::{Deserialize, Serialize};
+use std::{fs, path::PathBuf};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct AppConfig {
+ pub player: PlayerConfig,
+ pub ui: UiConfig,
+ pub system: SystemConfig,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct PlayerConfig {
+ pub startup_volume: u8,
+ pub stream_url: String,
+}
+
+impl Default for PlayerConfig {
+ fn default() -> Self {
+ Self {
+ startup_volume: 100,
+ stream_url: "https://stream.gensokyoradio.net/3".to_string(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct UiConfig {
+ pub default_mode: String,
+ pub theme_color: String,
+ pub error_color: String,
+ pub warning_color: String,
+ pub highlight_bg: String,
+ pub highlight_fg: String,
+ pub secondary_text: String,
+}
+
+impl Default for UiConfig {
+ fn default() -> Self {
+ Self {
+ default_mode: "playing".to_string(),
+ theme_color: "green".to_string(),
+ error_color: "red".to_string(),
+ warning_color: "yellow".to_string(),
+ highlight_bg: "darkgray".to_string(),
+ highlight_fg: "white".to_string(),
+ secondary_text: "darkgray".to_string(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct SystemConfig {
+ pub mpris_enabled: bool,
+ pub socket_path: String,
+ pub data_dir: String,
+}
+
+impl Default for SystemConfig {
+ fn default() -> Self {
+ Self {
+ mpris_enabled: true,
+ socket_path: "/tmp/grrs.sock".to_string(),
+ data_dir: String::new(),
+ }
+ }
+}
+
+impl Default for AppConfig {
+ fn default() -> Self {
+ Self {
+ player: PlayerConfig::default(),
+ ui: UiConfig::default(),
+ system: SystemConfig::default(),
+ }
+ }
+}
+
+impl AppConfig {
+ pub fn load_or_create(config_path: &PathBuf) -> Result<Self, String> {
+ if config_path.exists() {
+ let contents = fs::read_to_string(config_path)
+ .map_err(|e| format!("Failed to read file: {}", e))?;
+
+ match toml::from_str::<AppConfig>(&contents) {
+ Ok(cfg) => return Ok(cfg),
+ Err(e) => return Err(format!("TOML syntax error: {}", e)),
+ }
+ }
+
+ let default_config = AppConfig::default();
+ if let Ok(serialized) = toml::to_string_pretty(&default_config) {
+ let _ = fs::write(config_path, serialized);
+ }
+ Ok(default_config)
+ }
+
+ pub fn get_warnings(&self) -> Vec<String> {
+ let mut warnings = Vec::new();
+ if parse_color(&self.ui.theme_color).is_none() {
+ warnings.push("theme_color is not a valid color".to_string());
+ }
+ if parse_color(&self.ui.error_color).is_none() {
+ warnings.push("error_color is not a valid color".to_string());
+ }
+ if parse_color(&self.ui.warning_color).is_none() {
+ warnings.push("warning_color is not a valid color".to_string());
+ }
+ if parse_color(&self.ui.highlight_bg).is_none() {
+ warnings.push("highlight_bg is not a valid color".to_string());
+ }
+ if parse_color(&self.ui.highlight_fg).is_none() {
+ warnings.push("highlight_fg is not a valid color".to_string());
+ }
+ if parse_color(&self.ui.secondary_text).is_none() {
+ warnings.push("secondary_text is not a valid color".to_string());
+ }
+
+ let mode = self.ui.default_mode.to_lowercase();
+ if mode != "playing" && mode != "library" {
+ warnings.push("default_mode must be 'playing' or 'library'".to_string());
+ }
+
+ warnings
+ }
+
+ pub fn get_theme_color(&self) -> Color {
+ parse_color(&self.ui.theme_color).unwrap_or(Color::Green)
+ }
+
+ pub fn get_error_color(&self) -> Color {
+ parse_color(&self.ui.error_color).unwrap_or(Color::Red)
+ }
+
+ pub fn get_warning_color(&self) -> Color {
+ parse_color(&self.ui.warning_color).unwrap_or(Color::Yellow)
+ }
+
+ pub fn get_highlight_bg(&self) -> Color {
+ parse_color(&self.ui.highlight_bg).unwrap_or(Color::DarkGray)
+ }
+
+ pub fn get_highlight_fg(&self) -> Color {
+ parse_color(&self.ui.highlight_fg).unwrap_or(Color::White)
+ }
+
+ pub fn get_secondary_text(&self) -> Color {
+ parse_color(&self.ui.secondary_text).unwrap_or(Color::DarkGray)
+ }
+}
+
+pub fn parse_color(c: &str) -> Option<Color> {
+ let c = c.trim().to_lowercase();
+
+ if c.starts_with('#') {
+ let hex = c.trim_start_matches('#');
+ if hex.len() != 6 {
+ return None;
+ }
+ let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
+ return Some(Color::Rgb(r, g, b));
+ }
+
+ match c.as_str() {
+ "black" => Some(Color::Black),
+ "red" => Some(Color::Red),
+ "green" => Some(Color::Green),
+ "yellow" => Some(Color::Yellow),
+ "blue" => Some(Color::Blue),
+ "magenta" => Some(Color::Magenta),
+ "cyan" => Some(Color::Cyan),
+ "gray" | "grey" => Some(Color::Gray),
+ "darkgray" | "darkgrey" => Some(Color::DarkGray),
+ "lightred" => Some(Color::LightRed),
+ "lightgreen" => Some(Color::LightGreen),
+ "lightyellow" => Some(Color::LightYellow),
+ "lightblue" => Some(Color::LightBlue),
+ "lightmagenta" => Some(Color::LightMagenta),
+ "lightcyan" => Some(Color::LightCyan),
+ "white" => Some(Color::White),
+ _ => None,
+ }
+}
diff --git a/src/input.rs b/src/input.rs
index 29ab4a7..d24f943 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -20,6 +20,39 @@ pub async fn handle_input(
) -> InputOutcome {
let mut clear = false;
+ if let AppMode::ConfigError(_) = state.mode {
+ match evt {
+ CEvent::Key(key) => {
+ if key.code == KeyCode::Char('q')
+ || key.code == KeyCode::Esc
+ || (key.code == KeyCode::Char('c')
+ && key.modifiers.contains(KeyModifiers::CONTROL))
+ {
+ return InputOutcome {
+ should_quit: true,
+ should_clear: false,
+ };
+ } else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
+ state.config_scroll = state.config_scroll.saturating_add(1);
+ } else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
+ state.config_scroll = state.config_scroll.saturating_sub(1);
+ }
+ }
+ CEvent::Mouse(mouse) => {
+ if mouse.kind == MouseEventKind::ScrollDown {
+ state.config_scroll = state.config_scroll.saturating_add(1);
+ } else if mouse.kind == MouseEventKind::ScrollUp {
+ state.config_scroll = state.config_scroll.saturating_sub(1);
+ }
+ }
+ _ => {}
+ }
+ return InputOutcome {
+ should_quit: false,
+ should_clear: false,
+ };
+ }
+
match evt {
CEvent::Key(key) => {
if key.code == KeyCode::Char('q')
@@ -136,12 +169,6 @@ pub async fn handle_input(
should_quit: false,
should_clear: clear,
};
- } else if key.code == KeyCode::Char('t') {
- state.cycle_theme();
- return InputOutcome {
- should_quit: false,
- should_clear: clear,
- };
}
// MODE SPECIFIC KEYBINDS
@@ -227,6 +254,7 @@ pub async fn handle_input(
} else if state.mode == AppMode::LikedSongs {
if key.code == KeyCode::Tab || key.code == KeyCode::Esc {
state.mode = AppMode::Playing;
+ state.selected_art = None;
} else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') {
let sorted = state.get_sorted_likes();
if !sorted.is_empty() {
diff --git a/src/main.rs b/src/main.rs
index b7c227f..6096275 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,10 +1,11 @@
mod app;
+mod config;
mod input;
mod models;
mod ui;
use crate::{
- app::{AppEvent, AppState, REFRESH_WINDOW},
+ app::{AppEvent, AppMode, AppState, REFRESH_WINDOW},
input::handle_input,
models::{ApiInfo, SongData, WsPayload, parse_mpv_media_title},
ui::draw_ui,
@@ -38,22 +39,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
}
- let socket_path = "/tmp/grrs.sock";
- let _ = fs::remove_file(socket_path);
-
- let mpv_process = Command::new("mpv")
- .args([
- "--no-video",
- "--really-quiet",
- "--no-terminal",
- &format!("--input-ipc-server={}", socket_path),
- "https://stream.gensokyoradio.net/3",
- ])
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .stdin(Stdio::null())
- .spawn()
- .ok();
+ let mut state = AppState::new();
+ let is_config_error = matches!(state.mode, AppMode::ConfigError(_));
+
+ let socket_path = state.config.system.socket_path.clone();
+ let stream_url = state.config.player.stream_url.clone();
+ let volume_arg = format!("--volume={}", state.volume);
+
+ let mut mpv_process = None;
+
+ // Hard stop: Only spawn mpv if the config is valid
+ if !is_config_error {
+ let _ = fs::remove_file(&socket_path);
+ mpv_process = Command::new("mpv")
+ .args([
+ "--no-video",
+ "--really-quiet",
+ "--no-terminal",
+ &volume_arg,
+ &format!("--input-ipc-server={}", socket_path),
+ &stream_url,
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .stdin(Stdio::null())
+ .spawn()
+ .ok();
+ }
enable_raw_mode()?;
let mut stdout = stdout();
@@ -63,14 +75,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
terminal.clear()?;
let (tx, mut rx) = mpsc::channel(32);
- let mut state = AppState::new();
- let mut controls = MediaControls::new(PlatformConfig {
- dbus_name: "grrs",
- display_name: "Gensokyo Radio TUI",
- hwnd: None,
- })
- .ok();
+ let mut controls = None;
+ if !is_config_error && state.config.system.mpris_enabled {
+ controls = MediaControls::new(PlatformConfig {
+ dbus_name: "grrs",
+ display_name: "Gensokyo Radio TUI",
+ hwnd: None,
+ })
+ .ok();
+ }
+
if let Some(ref mut c) = controls {
let tx_media = tx.clone();
c.attach(move |event| {
@@ -106,32 +121,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
- let tx_api = tx.clone();
- let api_client = http_client.clone();
- tokio::spawn(async move {
- loop {
- if let Ok(resp) = api_client
- .get("https://gensokyoradio.net/api/station/playing/")
- .send()
- .await
- {
- if let Ok(text) = resp.text().await {
- if let Ok(info) = serde_json::from_str::<ApiInfo>(&text) {
- let _ = tx_api
- .send(AppEvent::ApiUpdate {
- listeners: info.server_info.listeners,
- circle_link: info.misc.circle_link,
- rating: info.song_data.rating,
- times_rated: info.song_data.times_rated,
- })
- .await;
- }
- }
- }
- tokio::time::sleep(Duration::from_secs(15)).await;
- }
- });
-
let (art_tx, mut art_rx) = watch::channel::<(i64, String, String, String)>((
0,
String::new(),
@@ -140,171 +129,213 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
));
let (ws_nudge_tx, mut ws_nudge_rx) = watch::channel(());
- let tx_ws = tx.clone();
- let art_tx_ws = art_tx.clone();
- tokio::spawn(async move {
- loop {
- match connect_async("wss://gensokyoradio.net/wss").await {
- Ok((mut ws_stream, _)) => {
- if tx_ws
- .send(AppEvent::ConnectionStatus("".to_string()))
- .await
- .is_err()
- {
- return;
+ // Hard stop: Only spawn tokio tasks if the config is valid
+ if !is_config_error {
+ let tx_api = tx.clone();
+ let api_client = http_client.clone();
+ tokio::spawn(async move {
+ loop {
+ if let Ok(resp) = api_client
+ .get("https://gensokyoradio.net/api/station/playing/")
+ .send()
+ .await
+ {
+ if let Ok(text) = resp.text().await {
+ if let Ok(info) = serde_json::from_str::<ApiInfo>(&text) {
+ let _ = tx_api
+ .send(AppEvent::ApiUpdate {
+ listeners: info.server_info.listeners,
+ circle_link: info.misc.circle_link,
+ rating: info.song_data.rating,
+ times_rated: info.song_data.times_rated,
+ })
+ .await;
+ }
}
- let _ = ws_stream
- .send(Message::Text(
- serde_json::json!({"message": "grInitialConnection"})
- .to_string()
- .into(),
- ))
- .await;
-
- loop {
- tokio::select! {
- msg = ws_stream.next() => {
- match msg {
- Some(Ok(Message::Text(text))) => {
- if let Ok(payload) = serde_json::from_str::<WsPayload>(&text) {
- if payload.songid != 0 {
- let year = payload.year.and_then(|y| if y.is_number() { y.as_i64().map(|n| n.to_string()) } else { y.as_str().map(|s| s.to_string()) }).unwrap_or_default();
- let played = payload.played.and_then(|p| if p.is_number() { p.as_u64() } else if let Some(s) = p.as_str() { s.parse().ok() } else { None }).unwrap_or(0);
-
- let song = SongData {
- id: payload.songid, title: payload.title.unwrap_or_else(|| "Unknown".to_string()), artist: payload.artist.unwrap_or_else(|| "Unknown".to_string()),
- album: payload.album.unwrap_or_else(|| "Unknown".to_string()), circle: payload.circle.unwrap_or_default(), circle_link: "".to_string(),
- vocal: payload.vocal.unwrap_or_default(), original: payload.original.unwrap_or_default(), year, duration: payload.duration.unwrap_or(0),
- played, art_url: payload.albumart.clone().unwrap_or_default(), listeners: 0, rating: "".to_string(), times_rated: 0,
- };
-
- let _ = art_tx_ws.send((song.id, song.art_url.clone(), song.title.clone(), song.artist.clone()));
- if tx_ws.send(AppEvent::SongUpdate(song)).await.is_err() { return; }
+ }
+ tokio::time::sleep(Duration::from_secs(15)).await;
+ }
+ });
+
+ let tx_ws = tx.clone();
+ let art_tx_ws = art_tx.clone();
+ tokio::spawn(async move {
+ loop {
+ match connect_async("wss://gensokyoradio.net/wss").await {
+ Ok((mut ws_stream, _)) => {
+ if tx_ws
+ .send(AppEvent::ConnectionStatus("".to_string()))
+ .await
+ .is_err()
+ {
+ return;
+ }
+ let _ = ws_stream
+ .send(Message::Text(
+ serde_json::json!({"message": "grInitialConnection"})
+ .to_string()
+ .into(),
+ ))
+ .await;
+
+ loop {
+ tokio::select! {
+ msg = ws_stream.next() => {
+ match msg {
+ Some(Ok(Message::Text(text))) => {
+ if let Ok(payload) = serde_json::from_str::<WsPayload>(&text) {
+ if payload.songid != 0 {
+ let year = payload.year.and_then(|y| if y.is_number() { y.as_i64().map(|n| n.to_string()) } else { y.as_str().map(|s| s.to_string()) }).unwrap_or_default();
+ let played = payload.played.and_then(|p| if p.is_number() { p.as_u64() } else if let Some(s) = p.as_str() { s.parse().ok() } else { None }).unwrap_or(0);
+
+ let song = SongData {
+ id: payload.songid,
+ title: payload.title.unwrap_or_else(|| "Unknown".to_string()),
+ artist: payload.artist.unwrap_or_else(|| "Unknown".to_string()),
+ album: payload.album.unwrap_or_else(|| "Unknown".to_string()),
+ circle: payload.circle.unwrap_or_default(),
+ circle_link: "".to_string(),
+ vocal: payload.vocal.unwrap_or_default(),
+ original: payload.original.unwrap_or_default(),
+ year,
+ duration: payload.duration.unwrap_or(0),
+ played,
+ art_url: payload.albumart.clone().unwrap_or_default(),
+ listeners: 0,
+ rating: "".to_string(),
+ times_rated: 0,
+ };
+
+ let _ = art_tx_ws.send((song.id, song.art_url.clone(), song.title.clone(), song.artist.clone()));
+ if tx_ws.send(AppEvent::SongUpdate(song)).await.is_err() { return; }
+ }
}
}
+ Some(_) => {}
+ None => break,
}
- Some(_) => {}
- None => break,
}
- }
- res = ws_nudge_rx.changed() => {
- if res.is_err() { return; }
- break;
+ res = ws_nudge_rx.changed() => {
+ if res.is_err() { return; }
+ break;
+ }
}
}
}
- }
- Err(_) => {
- if tx_ws
- .send(AppEvent::ConnectionStatus(
- "websocket dropped. reconnecting...".to_string(),
- ))
- .await
- .is_err()
- {
- return;
+ Err(_) => {
+ if tx_ws
+ .send(AppEvent::ConnectionStatus(
+ "websocket dropped. reconnecting...".to_string(),
+ ))
+ .await
+ .is_err()
+ {
+ return;
+ }
+ tokio::time::sleep(Duration::from_secs(5)).await;
}
- tokio::time::sleep(Duration::from_secs(5)).await;
}
}
- }
- });
+ });
- let tx_art = tx.clone();
- let art_picker = picker.clone();
- let art_client = http_client.clone();
+ let tx_art = tx.clone();
+ let art_picker = picker.clone();
+ let art_client = http_client.clone();
- tokio::spawn(async move {
- loop {
- if art_rx.changed().await.is_err() {
- return;
- }
- let mut current = art_rx.borrow().clone();
-
- while current.0 != 0 && !current.1.is_empty() {
- let url = if current.1.starts_with("http") {
- current.1.clone()
- } else {
- format!("https://gensokyoradio.net{}", current.1)
- };
- let mut ok = false;
-
- for _ in 0..3 {
- if let Ok(resp) = art_client.get(&url).send().await {
- if let Ok(bytes) = resp.bytes().await {
- let b = bytes.to_vec();
- let picker_clone = art_picker.clone();
- let tx_art_local = tx_art.clone();
-
- let decode_result = tokio::task::spawn_blocking(move || {
- if let Ok(img) = image::load_from_memory(&b) {
- let protocol = picker_clone.new_resize_protocol(img);
- return Some((protocol, b));
- }
- None
- })
- .await;
+ tokio::spawn(async move {
+ loop {
+ if art_rx.changed().await.is_err() {
+ return;
+ }
+ let mut current = art_rx.borrow().clone();
- if let Ok(Some((protocol, original_bytes))) = decode_result {
- let _ = tx_art_local
- .send(AppEvent::ArtUpdate(protocol, original_bytes))
- .await;
- ok = true;
- break;
+ while current.0 != 0 && !current.1.is_empty() {
+ let url = if current.1.starts_with("http") {
+ current.1.clone()
+ } else {
+ format!("https://gensokyoradio.net{}", current.1)
+ };
+ let mut ok = false;
+
+ for _ in 0..3 {
+ if let Ok(resp) = art_client.get(&url).send().await {
+ if let Ok(bytes) = resp.bytes().await {
+ let b = bytes.to_vec();
+ let picker_clone = art_picker.clone();
+ let tx_art_local = tx_art.clone();
+
+ let decode_result = tokio::task::spawn_blocking(move || {
+ if let Ok(img) = image::load_from_memory(&b) {
+ let protocol = picker_clone.new_resize_protocol(img);
+ return Some((protocol, b));
+ }
+ None
+ })
+ .await;
+
+ if let Ok(Some((protocol, original_bytes))) = decode_result {
+ let _ = tx_art_local
+ .send(AppEvent::ArtUpdate(protocol, original_bytes))
+ .await;
+ ok = true;
+ break;
+ }
}
}
+ tokio::time::sleep(Duration::from_secs(2)).await;
}
- tokio::time::sleep(Duration::from_secs(2)).await;
- }
- let _ = tx_art.send(AppEvent::KosuzuBusy(!ok)).await;
- if ok {
- break;
- }
+ let _ = tx_art.send(AppEvent::KosuzuBusy(!ok)).await;
+ if ok {
+ break;
+ }
- tokio::time::sleep(Duration::from_secs(3)).await;
- if art_rx.has_changed().unwrap_or(false) {
- current = art_rx.borrow().clone();
+ tokio::time::sleep(Duration::from_secs(3)).await;
+ if art_rx.has_changed().unwrap_or(false) {
+ current = art_rx.borrow().clone();
+ }
}
}
- }
- });
+ });
- let tx_patchouli = tx.clone();
- let patchouli_socket = socket_path.to_string();
+ let tx_patchouli = tx.clone();
+ let patchouli_socket = socket_path.clone();
- tokio::spawn(async move {
- loop {
- if let Ok(mut stream) = tokio::net::UnixStream::connect(&patchouli_socket).await {
- let cmd = "{\"command\": [\"observe_property\", 1, \"media-title\"]}\n";
- if stream.write_all(cmd.as_bytes()).await.is_ok() {
- let mut reader = tokio::io::BufReader::new(stream);
- let mut line = String::new();
-
- while let Ok(bytes) = reader.read_line(&mut line).await {
- if bytes == 0 {
- break;
- }
+ tokio::spawn(async move {
+ loop {
+ if let Ok(mut stream) = tokio::net::UnixStream::connect(&patchouli_socket).await {
+ let cmd = "{\"command\": [\"observe_property\", 1, \"media-title\"]}\n";
+ if stream.write_all(cmd.as_bytes()).await.is_ok() {
+ let mut reader = tokio::io::BufReader::new(stream);
+ let mut line = String::new();
- if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
- if value.get("event").and_then(|v| v.as_str())
- == Some("property-change")
- {
- if let Some(title) = value.get("data").and_then(|v| v.as_str()) {
- if let Some(mpv_song) = parse_mpv_media_title(title) {
- let _ =
- tx_patchouli.send(AppEvent::MpvUpdate(mpv_song)).await;
+ while let Ok(bytes) = reader.read_line(&mut line).await {
+ if bytes == 0 {
+ break;
+ }
+
+ if let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) {
+ if value.get("event").and_then(|v| v.as_str())
+ == Some("property-change")
+ {
+ if let Some(title) = value.get("data").and_then(|v| v.as_str())
+ {
+ if let Some(mpv_song) = parse_mpv_media_title(title) {
+ let _ = tx_patchouli
+ .send(AppEvent::MpvUpdate(mpv_song))
+ .await;
+ }
}
}
}
+ line.clear();
}
- line.clear();
}
}
+ tokio::time::sleep(Duration::from_secs(2)).await;
}
- tokio::time::sleep(Duration::from_secs(2)).await;
- }
- });
+ });
+ }
let mut last_nudge = Instant::now() - Duration::from_secs(10);
let mut hide_art = false;
@@ -321,7 +352,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
&tx,
&picker,
&loop_picker,
- socket_path,
+ &socket_path,
&http_client,
)
.await;
@@ -330,8 +361,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if outcome.should_clear {
- // The buffer wipe trick: instantly render a blank layout frame to erase popup text,
- // then seamlessly redraw the art on the next immediate cycle!
hide_art = true;
let _ = terminal.draw(|f| draw_ui(f, &mut state, hide_art));
hide_art = false;
@@ -345,7 +374,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
| MediaControlEvent::Pause
) {
state.is_muted = !state.is_muted;
- if let Ok(mut stream) = UnixStream::connect(socket_path) {
+ if let Ok(mut stream) = UnixStream::connect(&socket_path) {
let _ = stream.write_all(b"{\"command\": [\"cycle\", \"mute\"]}\n");
}
} else if matches!(m_event, MediaControlEvent::Quit) {
@@ -486,7 +515,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = child.kill();
let _ = child.wait();
}
- let _ = fs::remove_file(socket_path);
+ if !is_config_error {
+ let _ = fs::remove_file(&socket_path);
+ }
Ok(())
}
diff --git a/src/ui.rs b/src/ui.rs
index d525efc..09359bd 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -4,7 +4,7 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
+ widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Tabs, Wrap},
};
use ratatui_image::StatefulImage;
use std::rc::Rc;
@@ -32,7 +32,7 @@ fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
fn build_status_bar<'a>(state: &'a AppState) -> Line<'a> {
let mut status_spans = vec![Span::styled(
"[?] Help",
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(state.secondary_color),
)];
if state.song.id != 0 && state.likes.contains_key(&state.song.id) {
status_spans.push(Span::styled(
@@ -42,14 +42,20 @@ fn build_status_bar<'a>(state: &'a AppState) -> Line<'a> {
}
if state.is_muted || state.volume == 0 {
- status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red)));
+ status_spans.push(Span::styled(
+ " [Muted]",
+ Style::default().fg(state.error_color),
+ ));
}
if !state.status_message.is_empty() {
- let msg_color = if state.status_message.contains("copied") {
- state.theme_color
+ let msg_color = if state.status_message.starts_with("failed")
+ || state.status_message.contains("dropped")
+ || state.status_message.contains("error")
+ {
+ state.error_color
} else {
- Color::Red
+ state.theme_color
};
status_spans.push(Span::styled(
format!(" [{}]", state.status_message),
@@ -59,9 +65,9 @@ fn build_status_bar<'a>(state: &'a AppState) -> Line<'a> {
let vol_color = if state.volume >= 66 {
state.theme_color
} else if state.volume >= 33 {
- Color::Yellow
+ state.warning_color
} else {
- Color::Red
+ state.error_color
};
status_spans.push(Span::styled(
format!(" [Vol: {}%]", state.volume),
@@ -73,6 +79,72 @@ fn build_status_bar<'a>(state: &'a AppState) -> Line<'a> {
}
pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
+ if let AppMode::ConfigError(ref warnings) = state.mode {
+ let area = centered_rect(58, 14, f.area());
+ f.render_widget(Clear, area);
+
+ let mut lines = vec![
+ Line::from(""),
+ Line::from(vec![Span::styled(
+ " Invalid Configuration Detected ",
+ Style::default().fg(Color::Red).bold(),
+ )]),
+ Line::from(""),
+ Line::from(vec![Span::styled(
+ " The following issues were found in config.toml:",
+ Style::default().fg(Color::White),
+ )]),
+ Line::from(""),
+ ];
+
+ let mut has_color_error = false;
+ for w in warnings {
+ if w.contains("valid color") {
+ has_color_error = true;
+ }
+ lines.push(Line::from(vec![Span::styled(
+ format!(" - {}", w),
+ Style::default().fg(Color::Yellow),
+ )]));
+ }
+
+ if has_color_error {
+ lines.push(Line::from(""));
+ lines.push(Line::from(vec![Span::styled(
+ " Supported color formats:",
+ Style::default().fg(Color::White).bold(),
+ )]));
+ lines.push(Line::from(vec![Span::styled(
+ " - Base16 names (e.g., \"red\", \"cyan\", \"darkgray\")",
+ Style::default().fg(Color::White),
+ )]));
+ lines.push(Line::from(vec![Span::styled(
+ " - Hex codes (e.g., \"#ffffff\", \"#5bc0ae\")",
+ Style::default().fg(Color::White),
+ )]));
+ }
+
+ lines.push(Line::from(""));
+ lines.push(Line::from(vec![Span::styled(
+ " Press 'q' or 'Esc' to quit.",
+ Style::default().fg(Color::DarkGray),
+ )]));
+
+ f.render_widget(
+ Paragraph::new(lines)
+ .wrap(Wrap { trim: true })
+ .scroll((state.config_scroll, 0))
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .style(Style::default().fg(Color::Red)),
+ )
+ .alignment(Alignment::Center),
+ area,
+ );
+ return;
+ }
+
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
@@ -102,6 +174,7 @@ pub fn draw_ui(f: &mut Frame, state: &mut AppState, hide_art: bool) {
AppMode::LikedSongs => {
draw_library(f, state, content_chunks, main_chunks[1], hide_art);
}
+ _ => {}
}
if state.show_help_overlay {
@@ -156,13 +229,19 @@ fn draw_player(
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)),
+ Span::styled(
+ "Circle: ",
+ Style::default().bold().fg(state.secondary_color),
+ ),
+ Span::styled(
+ &state.song.circle,
+ Style::default().fg(state.secondary_color),
+ ),
];
if !state.song.circle_link.is_empty() {
spans.push(Span::styled(
format!(" ({})", state.song.circle_link),
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(state.secondary_color),
));
}
ext_text.push(Line::from(spans));
@@ -170,8 +249,11 @@ fn draw_player(
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)),
+ Span::styled("Vocal: ", Style::default().bold().fg(state.secondary_color)),
+ Span::styled(
+ &state.song.vocal,
+ Style::default().fg(state.secondary_color),
+ ),
]));
}
if !state.song.original.is_empty()
@@ -179,22 +261,31 @@ fn draw_player(
&& 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)),
+ Span::styled(
+ "Original: ",
+ Style::default().bold().fg(state.secondary_color),
+ ),
+ Span::styled(
+ &state.song.original,
+ Style::default().fg(state.secondary_color),
+ ),
]));
}
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)),
+ Span::styled("Year: ", Style::default().bold().fg(state.secondary_color)),
+ Span::styled(&state.song.year, Style::default().fg(state.secondary_color)),
]));
}
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(
+ "Rating: ",
+ Style::default().bold().fg(state.secondary_color),
+ ),
Span::styled(
format!("{} ({})", state.song.rating, state.song.times_rated),
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(state.secondary_color),
),
]));
}
@@ -237,7 +328,11 @@ fn draw_player(
};
f.render_widget(
Gauge::default()
- .gauge_style(Style::default().fg(state.theme_color).bg(Color::DarkGray))
+ .gauge_style(
+ Style::default()
+ .fg(state.theme_color)
+ .bg(state.secondary_color),
+ )
.ratio(ratio)
.label(label),
right_chunks[2],
@@ -311,7 +406,7 @@ fn draw_library(
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(),
+ Style::default().fg(state.highlight_fg_color).bold(),
)]))
} else {
ListItem::new(Line::from(vec![
@@ -321,7 +416,7 @@ fn draw_library(
),
Span::styled(
format!("- {}", s.artist),
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(state.secondary_color),
),
]))
}
@@ -331,12 +426,12 @@ fn draw_library(
if items.is_empty() {
items.push(ListItem::new(Line::from(vec![Span::styled(
"No liked songs yet.",
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(state.secondary_color),
)])));
}
f.render_stateful_widget(
List::new(items)
- .highlight_style(Style::default().bg(Color::Rgb(50, 50, 50)))
+ .highlight_style(Style::default().bg(state.highlight_bg_color))
.highlight_symbol("> "),
content[2],
&mut state.liked_list_state,
@@ -397,10 +492,6 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
Span::styled("[-/=]", Style::default().bold().fg(state.theme_color)),
Span::raw("Volume down / up"),
]),
- Row::new(vec![
- Span::styled("[t]", Style::default().bold().fg(state.theme_color)),
- Span::raw("Cycle theme colors"),
- ]),
Row::new(vec![
Span::styled("[?]", Style::default().bold().fg(state.theme_color)),
Span::raw("Close help menu"),
@@ -447,7 +538,7 @@ fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
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::styled(" [n] ", Style::default().fg(state.error_color).bold()),
Span::raw("No"),
]),
])
@@ -455,7 +546,7 @@ fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
- .style(Style::default().fg(Color::Red)),
+ .style(Style::default().fg(state.error_color)),
)
.alignment(Alignment::Center),
area,