use std::{fs, io::Write, os::unix::net::UnixStream}; use crossterm::event::{Event as CEvent, KeyCode, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::{Position, Rect}; use ratatui_image::picker::Picker; use tokio::sync::mpsc; use crate::{ app::{AppEvent, AppMode, AppState}, ui::help_hint_rect, }; pub struct InputOutcome { pub should_quit: bool, pub should_clear: bool, } pub async fn handle_input( evt: CEvent, state: &mut AppState, tx: &mpsc::Sender, picker: &Picker, _loop_picker: &Picker, socket_path: &str, http_client: &reqwest::Client, ) -> 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 }; } if state.is_searching { match evt { CEvent::Key(key) => { if key.code == KeyCode::Esc || key.code == KeyCode::Enter { state.is_searching = false; if key.code == KeyCode::Esc { state.search_query.clear(); state.update_search(); if let Some(idx) = state.liked_list_state.selected() { if let Some(song) = state.filtered_likes.get(idx) { load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone()); } } } clear = true; } else if key.code == KeyCode::Backspace { let _ = state.search_query.pop(); state.update_search(); if let Some(idx) = state.liked_list_state.selected() { if let Some(song) = state.filtered_likes.get(idx) { load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone()); } } else { state.selected_art = None; } clear = true; } else if let KeyCode::Char(c) = key.code { state.search_query.push(c); state.update_search(); if let Some(idx) = state.liked_list_state.selected() { if let Some(song) = state.filtered_likes.get(idx) { load_local_art(song.id, state.art_path(song.id), tx.clone(), picker.clone()); } } else { state.selected_art = None; } clear = true; } return InputOutcome { should_quit: false, should_clear: clear }; }, _ => return InputOutcome { should_quit: false, should_clear: clear }, } } match evt { CEvent::Key(key) => { if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) { return InputOutcome { should_quit: true, should_clear: false }; } if state.show_unlike_confirm.is_some() { if key.code == KeyCode::Char('y') || key.code == KeyCode::Char('Y') { if let Some(id) = state.show_unlike_confirm { state.likes.remove(&id); if let Ok(data) = serde_json::to_string_pretty(&state.likes) { fs::write(&state.likes_file, data).ok(); } let path_to_remove = state.art_path(id); tokio::spawn(async move { let _ = tokio::fs::remove_file(path_to_remove).await; }); state.update_search(); } state.show_unlike_confirm = None; clear = true; let sorted = state.filtered_likes.clone(); if sorted.is_empty() { state.liked_list_state.select(None); state.selected_art = None; } else { let next = state.liked_list_state.selected().unwrap_or(0).min(sorted.len().saturating_sub(1)); state.liked_list_state.select(Some(next)); 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; clear = true; } return InputOutcome { should_quit: false, should_clear: clear }; } if state.show_help_overlay { if key.code == KeyCode::Char('?') || key.code == KeyCode::Esc { state.show_help_overlay = false; state.help_tab = 0; clear = true; } else if key.code == KeyCode::Tab { state.help_tab = (state.help_tab + 1) % 2; } 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: clear }; } 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('h') { if webbrowser::open("https://app.gensokyoradio.net/history").is_ok() { state.status_message = "opened history in browser!".to_string(); } else { state.status_message = "failed to open browser".to_string(); } state.status_message_time = Some(std::time::Instant::now()); return InputOutcome { should_quit: false, should_clear: clear }; } if state.mode == AppMode::Playing { if key.code == KeyCode::Tab { state.mode = AppMode::LikedSongs; state.nav_time = Some(std::time::Instant::now()); let sorted = state.filtered_likes.clone(); if !sorted.is_empty() { state.liked_list_state.select(Some(0)); load_local_art(sorted[0].id, state.art_path(sorted[0].id), tx.clone(), picker.clone()); } } else if key.code == KeyCode::Char('o') || key.code == KeyCode::Enter { if !state.song.album.is_empty() { let query = urlencoding::encode(&state.song.album); let url = format!("https://touhoudb.com/Search?searchType=Album&filter={query}"); if webbrowser::open(&url).is_ok() { state.status_message = "opened touhoudb in browser!".to_string(); } else { state.status_message = "failed to open browser".to_string(); } state.status_message_time = Some(std::time::Instant::now()); } } else if key.code == KeyCode::Char('l') { let was_liked = state.likes.contains_key(&state.song.id); state.toggle_like(); if state.likes.contains_key(&state.song.id) && !was_liked && !state.song.art_url.is_empty() { let art_dir = state.likes_file.parent().expect("likes_file has no parent directory").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 = state.art_path(state.song.id); let client = http_client.clone(); tokio::spawn(async move { if let Ok(resp) = client.get(&url).send().await && 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.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')) && let Ok(mut clipboard) = arboard::Clipboard::new() { let text = if key.code == KeyCode::Char('c') { format!("{} - {}", state.song.artist, state.song.title) } else { format!( "Title: {}\nArtist: {}\nAlbum: {}\nCircle: {}\nYear: {}", state.song.title, state.song.artist, state.song.album, state.song.circle, state.song.year ) }; let _ = clipboard.set_text(text); 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 state.mode == AppMode::LikedSongs { if key.code == KeyCode::Tab || key.code == KeyCode::Esc { state.mode = AppMode::Playing; state.selected_art = None; state.nav_time = Some(std::time::Instant::now()); } else if key.code == KeyCode::Char('/') { state.is_searching = true; clear = true; } else if key.code == KeyCode::Up || key.code == KeyCode::Char('k') { let sorted = state.filtered_likes.clone(); if !sorted.is_empty() { let current = state.liked_list_state.selected().unwrap_or(0); let next = if current == 0 { sorted.len() - 1 } else { current - 1 }; state.liked_list_state.select(Some(next)); if current != next { state.selected_art = None; 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') { let sorted = state.filtered_likes.clone(); if !sorted.is_empty() { let current = state.liked_list_state.selected().unwrap_or(0); let next = if current == sorted.len() - 1 { 0 } else { current + 1 }; state.liked_list_state.select(Some(next)); if current != next { state.selected_art = None; load_local_art( sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone(), ); } } } 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.filtered_likes.clone(); if let Some(song) = sorted_likes.get(idx) && let Ok(mut clipboard) = arboard::Clipboard::new() { let text = if key.code == KeyCode::Char('c') { format!("{} - {}", song.artist, song.title) } else { format!( "Title: {}\nArtist: {}\nAlbum: {}\nCircle: {}\nYear: {}", song.title, song.artist, song.album, song.circle, song.year ) }; let _ = clipboard.set_text(text); 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('l') { if let Some(idx) = state.liked_list_state.selected() { let sorted_likes = state.filtered_likes.clone(); if let Some(song) = sorted_likes.get(idx) { state.show_unlike_confirm = Some(song.id); } } } else if (key.code == KeyCode::Char('o') || key.code == KeyCode::Enter) && let Some(idx) = state.liked_list_state.selected() { let sorted_likes = state.filtered_likes.clone(); if let Some(song) = sorted_likes.get(idx) { let query = urlencoding::encode(&song.album); let url = format!("https://touhoudb.com/Search?searchType=Album&filter={query}"); if webbrowser::open(&url).is_ok() { state.status_message = "opened touhoudb in browser!".to_string(); } else { state.status_message = "failed to open browser".to_string(); } state.status_message_time = Some(std::time::Instant::now()); } } } }, CEvent::Mouse(mouse) => { if mouse.kind == MouseEventKind::Down(MouseButton::Left) && let Ok((width, height)) = crossterm::terminal::size() { let area = Rect::new(0, 0, width, height); let hint = help_hint_rect(area, state.config.get_layout_preference()); if hint.contains(Position::new(mouse.column, mouse.row)) { state.show_help_overlay = !state.show_help_overlay; if !state.show_help_overlay { clear = true; } } } }, _ => {}, } InputOutcome { should_quit: false, should_clear: clear } } fn load_local_art(id: i64, path: std::path::PathBuf, tx: mpsc::Sender, picker: Picker) { tokio::spawn(async move { if let Ok(bytes) = tokio::fs::read(&path).await { let 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; }); }