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<AppEvent>,
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<AppEvent>, 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;
});
}