mod app; mod config; mod input; mod models; mod ui; use std::{ fs, io::{Write, stdout}, os::unix::net::UnixStream, process::{Command, Stdio}, time::{Duration, Instant}, }; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use futures_util::{SinkExt, StreamExt}; use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui_image::picker::Picker; use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig}; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt}, sync::{mpsc, watch}, }; use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; use crate::{ app::{AppEvent, AppMode, AppState, REFRESH_WINDOW}, input::handle_input, models::{ApiInfo, SongData, WsPayload, parse_mpv_media_title}, ui::draw_ui, }; #[tokio::main] async fn main() -> Result<(), Box> { if Command::new("mpv").arg("--version").output().is_err() { eprintln!("error: missing required dependency 'mpv'. please install it using your system package manager."); std::process::exit(1); } 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; if !is_config_error { let _ = fs::remove_file(&socket_path); let mut mpv_args = vec![ "--no-video".to_string(), "--really-quiet".to_string(), "--no-terminal".to_string(), "--user-agent=curl/8.4.0".to_string(), volume_arg, format!("--input-ipc-server={socket_path}"), ]; if state.config.player.loudness_normalization { mpv_args.push("--af=loudnorm=I=-16:TP=-1.5:LRA=11".to_string()); } mpv_args.push(stream_url); mpv_process = Command::new("mpv") .args(mpv_args) .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .ok(); } enable_raw_mode()?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let (tx, mut rx) = mpsc::channel(32); 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| { let _ = tx_media.blocking_send(AppEvent::MediaControl(event)); }) .ok(); c.set_playback(MediaPlayback::Playing { progress: None }).ok(); } let picker = Picker::from_query_stdio().expect("failed to query terminal for image support"); let loop_picker = picker.clone(); let http_client = reqwest::Client::builder() .user_agent("curl/8.4.0") .timeout(Duration::from_secs(30)) .build() .expect("failed to build http client"); let tx_input = tx.clone(); std::thread::spawn(move || { loop { if event::poll(Duration::from_millis(250)).unwrap_or(false) && let Ok(evt) = event::read() && matches!(evt, CEvent::Key(_) | CEvent::Mouse(_) | CEvent::Resize(_, _)) && tx_input.blocking_send(AppEvent::Input(evt)).is_err() { break; } if tx_input.blocking_send(AppEvent::Tick).is_err() { break; } } }); let (art_tx, mut art_rx) = watch::channel::<(i64, String, String, String)>((0, String::new(), String::new(), String::new())); let (ws_nudge_tx, mut ws_nudge_rx) = watch::channel(()); 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 && let Ok(text) = resp.text().await && let Ok(info) = serde_json::from_str::(&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 tx_ws = tx.clone(); let art_tx_ws = art_tx.clone(); tokio::spawn(async move { loop { if let Ok((mut ws_stream, _)) = connect_async("wss://gensokyoradio.net/wss").await { if tx_ws.send(AppEvent::ConnectionStatus(String::new())).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::(&text) && 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(std::string::ToString::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: String::new(), 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: String::new(), 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, } } res = ws_nudge_rx.changed() => { if res.is_err() { return; } break; } } } } else { if tx_ws .send(AppEvent::ConnectionStatus("websocket dropped. reconnecting...".to_string())) .await .is_err() { return; } tokio::time::sleep(Duration::from_secs(5)).await; } } }); 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 && 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; } 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(); } } } }); 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; } if let Ok(value) = serde_json::from_str::(&line) && value.get("event").and_then(|v| v.as_str()) == Some("property-change") && let Some(title) = value.get("data").and_then(|v| v.as_str()) && let Some(mpv_song) = parse_mpv_media_title(title) { let _ = tx_patchouli.send(AppEvent::MpvUpdate(mpv_song)).await; } line.clear(); } } } tokio::time::sleep(Duration::from_secs(2)).await; } }); } let mut last_nudge = Instant::now().checked_sub(Duration::from_secs(10)).unwrap_or_else(Instant::now); let mut hide_art = false; let mut mpv_respawn_time: Option = None; loop { terminal.draw(|f| draw_ui(f, &mut state, hide_art))?; if let Some(event) = rx.recv().await { match event { AppEvent::Input(evt) => { let outcome = handle_input(evt, &mut state, &tx, &picker, &loop_picker, &socket_path, &http_client).await; if outcome.should_quit { break; } if outcome.should_clear { hide_art = true; let _ = terminal.draw(|f| draw_ui(f, &mut state, hide_art)); hide_art = false; } }, AppEvent::MediaControl(m_event) => { if matches!(m_event, MediaControlEvent::Toggle | MediaControlEvent::Play | MediaControlEvent::Pause) { 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 matches!(m_event, MediaControlEvent::Quit) { break; } }, AppEvent::SongUpdate(song) => { let cached_listeners = state.song.listeners; if song.id != state.song.id { state.art_protocol = None; state.song_start_time = Instant::now().checked_sub(Duration::from_secs(song.played)).unwrap_or_else(Instant::now); state.song.rating = String::new(); state.song.times_rated = 0; state.song.circle_link = String::new(); let tx_api_quick = tx.clone(); let quick_client = http_client.clone(); tokio::spawn(async move { if let Ok(resp) = quick_client.get("https://gensokyoradio.net/api/station/playing/").send().await && let Ok(text) = resp.text().await && let Ok(info) = serde_json::from_str::(&text) { let _ = tx_api_quick .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; } }); } state.song = song; state.song.listeners = cached_listeners; state.status_message = String::new(); state.refresh_until = None; if state.config.system.notifications && state.song.id != 0 && state.notified_song_id != state.song.id && state.song.art_url.is_empty() { state.notified_song_id = state.song.id; let title = state.song.title.clone(); let artist = state.song.artist.clone(); tokio::task::spawn_blocking(move || { let mut notif = notify_rust::Notification::new(); notif.summary(&title).body(&artist).appname("grrs"); let _ = notif.show(); }); } if let Some(ref mut c) = controls { let art_path = state .likes_file .parent() .expect("likes_file has no parent directory") .join("current_art.jpg"); let art_url = format!("file://{}", art_path.to_str().unwrap_or("")); c.set_metadata(MediaMetadata { title: Some(state.song.title.as_str()), artist: Some(state.song.artist.as_str()), album: Some(state.song.album.as_str()), duration: Some(Duration::from_secs(state.song.duration)), cover_url: Some(art_url.as_str()), ..Default::default() }) .ok(); } }, AppEvent::ApiUpdate { listeners, circle_link, rating, times_rated } => { state.song.listeners = listeners; state.song.circle_link = circle_link; state.song.rating = rating; state.song.times_rated = times_rated; }, AppEvent::ArtUpdate(protocol, bytes) => { state.live_art_bytes = Some(bytes.clone()); state.art_protocol = Some(protocol); let path = state.likes_file.parent().expect("likes_file has no parent directory").join("current_art.jpg"); let title = state.song.title.clone(); let artist = state.song.artist.clone(); let send_notif = state.config.system.notifications && state.notified_song_id != state.song.id; if send_notif { state.notified_song_id = state.song.id; } tokio::spawn(async move { let _ = tokio::fs::write(&path, bytes).await; if send_notif { let _ = tokio::task::spawn_blocking(move || { let mut notif = notify_rust::Notification::new(); notif.summary(&title).body(&artist).appname("grrs"); notif.icon(path.to_str().unwrap_or("")); let _ = notif.show(); }) .await; } }); }, AppEvent::LocalArtLoaded(id, protocol) => { if let Some(idx) = state.liked_list_state.selected() { let sorted_likes = &state.filtered_likes; if let Some(song) = sorted_likes.get(idx) && song.id == id { state.selected_art = protocol; } } }, AppEvent::ConnectionStatus(msg) => state.status_message = msg, AppEvent::MpvUpdate(mpv) => { let changed = state.mpv_song.title != mpv.title || state.mpv_song.artist != mpv.artist; state.mpv_song = mpv; let elapsed = state.song_start_time.elapsed().as_secs(); let expired = state.song.duration > 0 && elapsed >= state.song.duration; if changed || !state.matches_mpv() || expired { state.refresh_until = Some(Instant::now() + REFRESH_WINDOW); if last_nudge.elapsed() > Duration::from_secs(2) { state.art_protocol = None; let _ = ws_nudge_tx.send(()); last_nudge = Instant::now(); } } }, AppEvent::KosuzuBusy(busy) => { if busy { state.refresh_until = Some(Instant::now() + REFRESH_WINDOW); if state.config.system.notifications && state.notified_song_id != state.song.id { state.notified_song_id = state.song.id; let title = state.song.title.clone(); let artist = state.song.artist.clone(); tokio::task::spawn_blocking(move || { let mut notif = notify_rust::Notification::new(); notif.summary(&title).body(&artist).appname("grrs"); let _ = notif.show(); }); } } else { state.refresh_until = None; } }, AppEvent::Tick => { if let Some(ref mut child) = mpv_process { if let Ok(Some(_status)) = child.try_wait() { mpv_process = None; if state.mpv_retries > 0 { state.mpv_retries -= 1; state.status_message = format!("stream dropped. reconnecting... ({} left)", state.mpv_retries); state.status_message_time = Some(Instant::now()); mpv_respawn_time = Some(Instant::now() + Duration::from_secs(5)); } else { state.status_message = "stream failed. max retries reached.".to_string(); state.status_message_time = Some(Instant::now()); } } } else if let Some(respawn_time) = mpv_respawn_time { if Instant::now() >= respawn_time { mpv_respawn_time = None; let _ = fs::remove_file(&state.config.system.socket_path); let mut args = vec![ "--no-video".to_string(), "--really-quiet".to_string(), "--no-terminal".to_string(), "--user-agent=curl/8.4.0".to_string(), format!("--volume={}", state.volume), format!("--input-ipc-server={}", state.config.system.socket_path), ]; if state.config.player.loudness_normalization { args.push("--af=loudnorm=I=-16:TP=-1.5:LRA=11".to_string()); } args.push(state.config.player.stream_url.clone()); mpv_process = Command::new("mpv") .args(args) .stdout(Stdio::null()) .stderr(Stdio::null()) .stdin(Stdio::null()) .spawn() .ok(); } } if let Some(t) = state.status_message_time && t.elapsed().as_secs() >= 3 { state.status_message.clear(); state.status_message_time = None; } if let Some(t) = state.volume_time && t.elapsed().as_secs() >= 3 { state.volume_time = None; } let mut clear_for_nav = false; if let Some(t) = state.nav_time && t.elapsed().as_secs() >= 3 { state.nav_time = None; clear_for_nav = true; } if clear_for_nav { hide_art = true; let _ = terminal.draw(|f| draw_ui(f, &mut state, hide_art)); hide_art = false; } }, } } } disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; if let Some(mut child) = mpv_process { let _ = child.kill(); let _ = child.wait(); } if !is_config_error { let _ = fs::remove_file(&socket_path); } Ok(()) }