fix: track info desyncs and hanging metadata
the websocket kept falling behind and leaving stale song info on the screen for way too long. octogiraffe (https://github.com/kikeijuu) determined this could be fixed by having grrs listen to mpv as the source of truth, plus logic to force a reconnect when things *do* drift. many thanks, octo! additionally, there were some minor background threading fixes. things should work slightly better now! co-authored-by: octogiraffe (https://github.com/kikeijuu)
diff --git a/src/main.rs b/src/main.rs
@@ -4,7 +4,6 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::{SinkExt, StreamExt};
-use image::load_from_memory;
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect, Alignment},
@@ -15,17 +14,18 @@ use ratatui::{
};
use ratatui_image::{picker::Picker, protocol::StatefulProtocol, StatefulImage};
use serde::{Deserialize, Serialize};
+use serde_json::Value;
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},
};
-use tokio::sync::mpsc;
+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);
-
-// --- API & Websocket Payloads ---
+const REFRESH_WINDOW: Duration = Duration::from_secs(30);
#[derive(Deserialize, Default)]
struct WsPayload {
@@ -36,9 +36,9 @@ struct WsPayload {
#[serde(default)] circle: Option<String>,
#[serde(default)] vocal: Option<String>,
#[serde(default)] original: Option<String>,
- #[serde(default)] year: Option<serde_json::Value>,
+ #[serde(default)] year: Option<Value>,
#[serde(default)] duration: Option<u64>,
- #[serde(default)] played: Option<serde_json::Value>,
+ #[serde(default)] played: Option<Value>,
#[serde(default)] albumart: Option<String>,
}
@@ -56,7 +56,23 @@ struct ApiInfo {
#[serde(rename = "TIMESRATED", default)] times_rated: u64,
}
-// --- Types & Data Structures ---
+#[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 {
@@ -78,6 +94,8 @@ 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 {
@@ -86,6 +104,8 @@ struct AppState {
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 {
@@ -127,6 +147,8 @@ impl AppState {
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,
}
}
@@ -155,9 +177,26 @@ impl AppState {
self.theme_color = self.available_colors[self.color_idx];
let _ = fs::write(&self.theme_file, self.color_idx.to_string());
}
-}
-// --- UI Rendering ---
+ 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()
@@ -187,7 +226,7 @@ fn draw_ui(f: &mut Frame, state: &mut AppState) {
}
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); }
+ 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![
@@ -214,7 +253,17 @@ fn draw_player(f: &mut Frame, state: &mut AppState, content: std::rc::Rc<[Rect]>
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 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::Min(2)]).split(content[2]);
+ 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;
@@ -223,6 +272,12 @@ fn draw_player(f: &mut Frame, state: &mut AppState, content: std::rc::Rc<[Rect]>
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))); }
@@ -232,12 +287,12 @@ fn draw_player(f: &mut Frame, state: &mut AppState, content: std::rc::Rc<[Rect]>
}
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); }
+ 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())])) }
+ 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();
@@ -255,12 +310,12 @@ fn draw_help_popup(f: &mut Frame, state: &AppState) {
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(" [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);
}
@@ -270,7 +325,7 @@ fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
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")]),
+ 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);
}
@@ -297,20 +352,20 @@ fn draw_song_info(f: &mut Frame, state: &mut AppState) {
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)]));
@@ -319,19 +374,19 @@ fn draw_song_info(f: &mut Frame, state: &mut AppState) {
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")]));
@@ -343,8 +398,6 @@ fn draw_song_info(f: &mut Frame, state: &mut AppState) {
if !txt.is_empty() { f.render_widget(Paragraph::new(txt).wrap(Wrap { trim: true }), chunks[1]); }
}
-// --- Input Handling ---
-
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 {
@@ -358,7 +411,10 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
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(); }
- fs::remove_file(state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id))).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;
@@ -372,11 +428,24 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
state.liked_list_state.select(Some(next));
let id = sorted[next].id;
let tx_local = tx.clone();
- let mut p = picker.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) = fs::read(path) {
- if let Ok(img) = image::load_from_memory(&bytes) { let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(p.new_resize_protocol(img)))).await; return; }
+ 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;
});
@@ -407,9 +476,22 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
if !sorted.is_empty() {
state.liked_list_state.select(Some(0));
let id = sorted[0].id;
- let tx_local = tx.clone(); let mut p = picker.clone(); let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", 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) = fs::read(path) { if let Ok(img) = image::load_from_memory(&bytes) { let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(p.new_resize_protocol(img)))).await; return; } }
+ 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;
});
}
@@ -417,12 +499,23 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
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 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 { fs::write(path, bytes).ok(); } } });
+ 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 {
- fs::remove_file(state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", state.song.id))).ok();
+ 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;
@@ -436,7 +529,13 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
} 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 { if let Ok(img) = image::load_from_memory(bytes) { state.art_protocol = Some(loop_picker.clone().new_resize_protocol(img)); } }
+ 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() {
@@ -444,9 +543,23 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
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 mut p = picker.clone(); let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+ 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) = fs::read(path) { if let Ok(img) = image::load_from_memory(&bytes) { let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(p.new_resize_protocol(img)))).await; return; } }
+ 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;
});
}
@@ -458,9 +571,23 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
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 mut p = picker.clone(); let path = state.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id));
+ 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) = fs::read(path) { if let Ok(img) = image::load_from_memory(&bytes) { let _ = tx_local.send(AppEvent::LocalArtLoaded(id, Some(p.new_resize_protocol(img)))).await; return; } }
+ 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;
});
}
@@ -469,12 +596,12 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
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() {
+ 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; }
+ 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; }
+ } 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) => {
@@ -489,12 +616,10 @@ async fn handle_input(evt: CEvent, state: &mut AppState, tx: &mpsc::Sender<AppEv
InputOutcome { should_quit: false, should_clear: clear }
}
-// --- Main Execution ---
-
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket_path = "/tmp/grrs.sock";
- let _ = fs::remove_file(socket_path);
+ 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"])
@@ -518,7 +643,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let picker = Picker::from_query_stdio().expect("failed to query terminal for image support");
- let loop_picker = picker.clone();
+ let loop_picker = picker.clone();
let http_client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build().unwrap();
let tx_input = tx.clone();
@@ -545,44 +670,47 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
- let tx_ws = tx.clone(); let ws_picker = picker.clone(); let ws_client = http_client.clone();
+ 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(());
+
+ let tx_ws = tx.clone(); let art_tx_ws = art_tx.clone();
tokio::spawn(async move {
loop {
- match connect_async(tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request("wss://gensokyoradio.net/wss").unwrap()).await {
+ 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())).await;
- while let Some(msg) = ws_stream.next().await {
- if let Ok(Message::Text(text)) = msg {
- 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.unwrap_or_default(), listeners: 0, rating: "".to_string(), times_rated: 0,
- };
-
- if tx_ws.send(AppEvent::SongUpdate(song.clone())).await.is_err() { return; }
-
- if !song.art_url.is_empty() {
- let url = if song.art_url.starts_with("http") { song.art_url.clone() } else { format!("https://gensokyoradio.net{}", song.art_url) };
- let tx_art = tx_ws.clone(); let mut p = ws_picker.clone(); let client = ws_client.clone();
- tokio::spawn(async move {
- if let Ok(resp) = client.get(&url).send().await {
- if let Ok(bytes) = resp.bytes().await {
- let bytes_vec = bytes.to_vec();
- if let Ok(img) = load_from_memory(&bytes_vec) { let _ = tx_art.send(AppEvent::ArtUpdate(p.new_resize_protocol(img), bytes_vec)).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,
}
}
+ res = ws_nudge_rx.changed() => {
+ if res.is_err() { return; }
+ break;
+ }
}
}
}
@@ -594,6 +722,86 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
+ 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 mut 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.to_string();
+
+ 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::<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();
+ }
+ }
+ }
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ }
+ });
+
+ let mut last_nudge = Instant::now() - Duration::from_secs(10);
+
loop {
terminal.draw(|f| draw_ui(f, &mut state))?;
@@ -613,10 +821,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
AppEvent::SongUpdate(song) => {
let cached_listeners = state.song.listeners;
if song.id != state.song.id {
- state.art_protocol = None;
+ 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 {
@@ -628,8 +836,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
-
+
state.song = song; state.song.listeners = cached_listeners; state.status_message = "".to_string();
+ state.refresh_until = None;
if let Some(ref mut c) = controls {
let art_path = state.likes_file.parent().unwrap().join("current_art.jpg");
@@ -648,8 +857,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 _ = fs::write(state.likes_file.parent().unwrap().join("current_art.jpg"), bytes);
+ state.live_art_bytes = Some(bytes.clone());
+ state.art_protocol = Some(protocol);
+ let path = state.likes_file.parent().unwrap().join("current_art.jpg");
+ tokio::spawn(async move {
+ let _ = tokio::fs::write(path, bytes).await;
+ });
}
AppEvent::LocalArtLoaded(id, protocol) => {
if let Some(idx) = state.liked_list_state.selected() {
@@ -658,6 +871,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
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); }
+ else { state.refresh_until = None; }
+ }
AppEvent::Tick => {}
}
}
@@ -665,9 +896,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
-
+
if let Some(mut child) = mpv_process { let _ = child.kill(); let _ = child.wait(); }
let _ = fs::remove_file(socket_path);
-
+
Ok(())
}