git/grrs/main

refactor: deduplicate input handlers and UI components

closes 09fbe77
diff --git a/Cargo.toml b/Cargo.toml
index d95b6fd..b11ed58 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "myn-grrs"
-version = "0.1.2"
+version = "0.1.3"
edition = "2024"
repository = "https://seed.radicle.xyz/rad:z3dnwE7h9y4ndGdqeafNLP8eDPn3Y.git"
description = "a minimal tui for gensokyo radio"
diff --git a/src/app.rs b/src/app.rs
index 2e59d98..9a1286f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -91,12 +91,8 @@ impl AppState {
let old_art = config_dir.join("art");
let new_art = data_dir.join("art");
- if old_likes.exists() && !new_likes.exists() {
- let _ = fs::rename(&old_likes, &new_likes);
- }
- if old_art.exists() && !new_art.exists() {
- let _ = fs::rename(&old_art, &new_art);
- }
+ if old_likes.exists() && !new_likes.exists() { let _ = fs::rename(&old_likes, &new_likes); }
+ if old_art.exists() && !new_art.exists() { let _ = fs::rename(&old_art, &new_art); }
let _ = fs::remove_file(config_dir.join("current_art.jpg"));
@@ -172,16 +168,18 @@ impl AppState {
},
);
}
- if let Ok(data) = serde_json::to_string_pretty(&self.likes) {
- fs::write(&self.likes_file, data).ok();
- }
+ 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 art_path(&self, id: i64) -> PathBuf {
+ self.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id))
+ }
pub fn cycle_theme(&mut self) {
self.color_idx = (self.color_idx + 1) % self.available_colors.len();
@@ -197,10 +195,8 @@ impl AppState {
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
}
diff --git a/src/input.rs b/src/input.rs
index eb460bb..588a626 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -28,10 +28,8 @@ pub async fn handle_input(
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;
- });
+ let path_to_remove = state.art_path(id);
+ tokio::spawn(async move { let _ = tokio::fs::remove_file(path_to_remove).await; });
}
state.show_unlike_confirm = None;
clear = true;
@@ -43,29 +41,7 @@ pub async fn handle_input(
} 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;
- });
+ load_local_art(sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone());
}
} else if key.code == KeyCode::Char('n') || key.code == KeyCode::Char('N') || key.code == KeyCode::Esc {
state.show_unlike_confirm = None;
@@ -82,9 +58,38 @@ pub async fn handle_input(
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 }; }
+ // GLOBAL KEYBINDS
+ 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"); }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ } else if key.code == KeyCode::Char('-') {
+ state.volume = state.volume.saturating_sub(5);
+ state.volume_time = Some(std::time::Instant::now());
+ if let Ok(mut stream) = UnixStream::connect(socket_path) {
+ let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
+ let _ = stream.write_all(cmd.as_bytes());
+ }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ } else if key.code == KeyCode::Char('=') || key.code == KeyCode::Char('+') {
+ state.volume = state.volume.saturating_add(5).min(100);
+ state.volume_time = Some(std::time::Instant::now());
+ if let Ok(mut stream) = UnixStream::connect(socket_path) {
+ let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
+ let _ = stream.write_all(cmd.as_bytes());
+ }
+ return InputOutcome { should_quit: false, should_clear: clear };
+ } else if key.code == KeyCode::Char('t') {
+ state.cycle_theme();
+ return InputOutcome { should_quit: false, should_clear: clear };
+ } else if key.code == KeyCode::Enter {
+ state.show_song_info = true;
+ return InputOutcome { should_quit: false, should_clear: true };
+ }
+
+ // MODE SPECIFIC KEYBINDS
if state.mode == AppMode::Playing {
if key.code == KeyCode::Tab {
state.mode = AppMode::LikedSongs;
@@ -92,25 +97,7 @@ pub async fn handle_input(
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;
- });
+ load_local_art(sorted[0].id, state.art_path(sorted[0].id), tx.clone(), picker.clone());
}
} else if key.code == KeyCode::Char('l') {
let was_liked = state.likes.contains_key(&state.song.id);
@@ -119,37 +106,16 @@ pub async fn handle_input(
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 path = state.art_path(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;
- }
+ 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('-') {
- state.volume = state.volume.saturating_sub(5);
- state.volume_time = Some(std::time::Instant::now());
- if let Ok(mut stream) = UnixStream::connect(socket_path) {
- let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
- let _ = stream.write_all(cmd.as_bytes());
- }
- } else if key.code == KeyCode::Char('=') || key.code == KeyCode::Char('+') {
- state.volume = state.volume.saturating_add(5).min(100);
- state.volume_time = Some(std::time::Instant::now());
- if let Ok(mut stream) = UnixStream::connect(socket_path) {
- let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
- let _ = stream.write_all(cmd.as_bytes());
+ let path_to_remove = state.art_path(state.song.id);
+ tokio::spawn(async move { let _ = tokio::fs::remove_file(path_to_remove).await; });
}
} else if key.code == KeyCode::Char('c') || key.code == KeyCode::Char('C') {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
@@ -162,11 +128,6 @@ pub async fn handle_input(
state.status_message = if key.code == KeyCode::Char('c') { "copied track/artist!".to_string() } else { "copied full metadata!".to_string() };
state.status_message_time = Some(std::time::Instant::now());
}
- } 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 {
@@ -185,25 +146,7 @@ pub async fn handle_input(
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;
- });
+ load_local_art(sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone());
}
}
} else if key.code == KeyCode::Down || key.code == KeyCode::Char('j') {
@@ -213,44 +156,9 @@ pub async fn handle_input(
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;
- });
+ load_local_art(sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone());
}
}
- } 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('-') {
- state.volume = state.volume.saturating_sub(5);
- state.volume_time = Some(std::time::Instant::now());
- if let Ok(mut stream) = UnixStream::connect(socket_path) {
- let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
- let _ = stream.write_all(cmd.as_bytes());
- }
- } else if key.code == KeyCode::Char('=') || key.code == KeyCode::Char('+') {
- state.volume = state.volume.saturating_add(5).min(100);
- state.volume_time = Some(std::time::Instant::now());
- if let Ok(mut stream) = UnixStream::connect(socket_path) {
- let cmd = format!("{{\"command\": [\"set_property\", \"volume\", {}]}}\n", state.volume);
- let _ = stream.write_all(cmd.as_bytes());
- }
} else if key.code == KeyCode::Char('c') || key.code == KeyCode::Char('C') {
if let Some(idx) = state.liked_list_state.selected() {
let sorted_likes = state.get_sorted_likes();
@@ -272,8 +180,7 @@ pub async fn handle_input(
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) => {
@@ -287,3 +194,20 @@ pub async fn handle_input(
}
InputOutcome { should_quit: false, should_clear: clear }
}
+
+fn load_local_art(id: i64, path: std::path::PathBuf, tx: mpsc::Sender<AppEvent>, picker: Picker) {
+ tokio::spawn(async move {
+ if let Ok(bytes) = tokio::fs::read(&path).await {
+ let mut p_clone = picker.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.send(AppEvent::LocalArtLoaded(id, Some(protocol))).await;
+ return;
+ }
+ }
+ let _ = tx.send(AppEvent::LocalArtLoaded(id, None)).await;
+ });
+}
diff --git a/src/ui.rs b/src/ui.rs
index 930eb48..5e7fd7b 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -21,6 +21,25 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
.split(popup_layout[1])[1]
}
+fn build_status_bar<'a>(state: &'a AppState) -> Line<'a> {
+ 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 || state.volume == 0 {
+ status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red)));
+ }
+
+ if !state.status_message.is_empty() {
+ let msg_color = if state.status_message.contains("copied") { state.theme_color } else { Color::Red };
+ status_spans.push(Span::styled(format!(" [{}]", state.status_message), Style::default().fg(msg_color)));
+ } else if !(state.is_muted || state.volume == 0) && state.volume_time.is_some() {
+ let vol_color = if state.volume >= 66 { state.theme_color } else if state.volume >= 33 { Color::Yellow } else { Color::Red };
+ status_spans.push(Span::styled(format!(" [Vol: {}%]", state.volume), Style::default().fg(vol_color)));
+ }
+
+ Line::from(status_spans)
+}
+
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);
@@ -37,8 +56,14 @@ pub fn draw_ui(f: &mut Frame, state: &mut AppState) {
}
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...").alignment(Alignment::Center), content[0]); }
+ let image_height = content[0].width / 2;
+ let left_col = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(image_height), Constraint::Min(0)])
+ .split(content[0]);
+
+ if let Some(ref mut protocol) = state.art_protocol { f.render_stateful_widget(StatefulImage::new(None), left_col[0], protocol); }
+ else { f.render_widget(Paragraph::new("Loading Art...").alignment(Alignment::Center), left_col[0]); }
let basic_text = vec![
Line::from(vec![Span::styled(&state.song.title, Style::default().fg(state.theme_color).bold())]),
@@ -85,27 +110,20 @@ fn draw_player(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom:
}
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 || state.volume == 0 {
- status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red)));
- }
- if !state.status_message.is_empty() {
- status_spans.push(Span::styled(format!(" [{}]", state.status_message), Style::default().fg(state.theme_color)));
- } else if !(state.is_muted || state.volume == 0) && state.volume_time.is_some() {
- let vol_color = if state.volume >= 66 { state.theme_color } else if state.volume >= 33 { Color::Yellow } else { Color::Red };
- status_spans.push(Span::styled(format!(" [Vol: {}%]", state.volume), Style::default().fg(vol_color)));
- }
-
- f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom_layout[0]);
+ f.render_widget(Paragraph::new(build_status_bar(state)).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").alignment(Alignment::Center), content[0]); }
+ let image_height = content[0].width / 2;
+ let left_col = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(image_height), Constraint::Min(0)])
+ .split(content[0]);
+
+ if let Some(ref mut protocol) = state.selected_art { f.render_stateful_widget(StatefulImage::new(None), left_col[0], protocol); }
+ else { f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), left_col[0]); }
let sorted = state.get_sorted_likes();
let mut items: Vec<ListItem> = sorted.iter().enumerate().map(|(i, s)| {
@@ -115,22 +133,7 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
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 || state.volume == 0 {
- status_spans.push(Span::styled(" [Muted]", Style::default().fg(Color::Red)));
- }
-
- if !state.status_message.is_empty() {
- status_spans.push(Span::styled(format!(" [{}]", state.status_message), Style::default().fg(state.theme_color)));
- } else if !(state.is_muted || state.volume == 0) && state.volume_time.is_some() {
- let vol_color = if state.volume >= 66 { state.theme_color } else if state.volume >= 33 { Color::Yellow } else { Color::Red };
- status_spans.push(Span::styled(format!(" [Vol: {}%]", state.volume), Style::default().fg(vol_color)));
- }
-
- f.render_widget(Paragraph::new(Line::from(status_spans)).alignment(Alignment::Left), bottom);
+ f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
}
fn draw_help_popup(f: &mut Frame, state: &AppState) {