ui: help menu and library tweaks
reworked the help menu to utilize tabs for controls, removed the song info popup in the library and replaced it with `o` and `Enter`, which after issue b8aff40 is resolved, will open album info pages in the browser. additionally, i fixed the rustfmt integration for helix today, so all source code has been reformatted with rustfmt in this update
diff --git a/src/app.rs b/src/app.rs
@@ -52,7 +52,7 @@ pub struct AppState {
pub selected_art: Option<StatefulProtocol>,
pub show_help_overlay: bool,
pub show_unlike_confirm: Option<i64>,
- pub show_song_info: bool,
+ pub help_tab: usize,
pub theme_color: Color,
pub available_colors: Vec<Color>,
pub color_idx: usize,
@@ -66,17 +66,17 @@ pub struct AppState {
impl AppState {
pub fn new() -> Self {
let home = env::var("HOME").unwrap_or_default();
-
+
let config_dir = env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".config"))
.join("grrs");
-
+
let data_dir = env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".local/share"))
.join("grrs");
-
+
let cache_dir = env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(&home).join(".cache"))
@@ -91,11 +91,15 @@ 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"));
-
+
let likes_file = new_likes;
let likes = fs::read_to_string(&likes_file)
.ok()
@@ -103,9 +107,20 @@ impl AppState {
.unwrap_or_default();
let available_colors = vec![
- Color::Red, Color::LightRed, Color::Green, Color::LightGreen, Color::Yellow, Color::LightYellow,
- Color::Blue, Color::LightBlue, Color::Magenta, Color::LightMagenta, Color::Cyan, Color::LightCyan,
- Color::Gray, Color::White,
+ Color::Red,
+ Color::LightRed,
+ Color::Green,
+ Color::LightGreen,
+ Color::Yellow,
+ Color::LightYellow,
+ Color::Blue,
+ Color::LightBlue,
+ Color::Magenta,
+ Color::LightMagenta,
+ Color::Cyan,
+ Color::LightCyan,
+ Color::Gray,
+ Color::White,
];
let theme_file = cache_dir.join("theme.txt");
@@ -136,7 +151,7 @@ impl AppState {
selected_art: None,
show_help_overlay: false,
show_unlike_confirm: None,
- show_song_info: false,
+ help_tab: 0,
theme_color,
available_colors,
color_idx,
@@ -149,9 +164,14 @@ impl AppState {
}
pub fn toggle_like(&mut self) {
- if self.song.id == 0 { return; }
+ if self.song.id == 0 {
+ return;
+ }
if self.likes.remove(&self.song.id).is_none() {
- let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
self.likes.insert(
self.song.id,
LikedSong {
@@ -168,7 +188,9 @@ 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> {
@@ -178,7 +200,11 @@ impl AppState {
}
pub fn art_path(&self, id: i64) -> PathBuf {
- self.likes_file.parent().unwrap().join("art").join(format!("{}.jpg", id))
+ self.likes_file
+ .parent()
+ .unwrap()
+ .join("art")
+ .join(format!("{}.jpg", id))
}
pub fn cycle_theme(&mut self) {
@@ -188,19 +214,27 @@ impl AppState {
}
pub fn matches_mpv(&self) -> bool {
- if self.mpv_song.title.is_empty() { return true; }
+ 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; }
+ 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; }
+ if combined == mpv_title || combined == mpv_artist {
+ return true;
+ }
false
}
pub fn is_refreshing(&self) -> bool {
- self.refresh_until.map(|t| Instant::now() < t).unwrap_or(false)
+ self.refresh_until
+ .map(|t| Instant::now() < t)
+ .unwrap_or(false)
}
}
diff --git a/src/input.rs b/src/input.rs
@@ -21,15 +21,26 @@ pub async fn handle_input(
let mut clear = false;
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 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(); }
+ 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; });
+ tokio::spawn(async move {
+ let _ = tokio::fs::remove_file(path_to_remove).await;
+ });
}
state.show_unlike_confirm = None;
clear = true;
@@ -39,54 +50,105 @@ pub async fn handle_input(
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));
+ 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());
+ 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 {
+ } 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 };
+ 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; clear = true; }
- return InputOutcome { should_quit: false, should_clear: clear };
+ 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;
+ clear = true;
+ }
+ return InputOutcome {
+ should_quit: false,
+ should_clear: clear,
+ };
}
- if state.show_song_info {
- 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,
+ };
}
- 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 };
+ 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 cmd = format!(
+ "{{\"command\": [\"set_property\", \"volume\", {}]}}\n",
+ state.volume
+ );
let _ = stream.write_all(cmd.as_bytes());
}
- return InputOutcome { should_quit: false, should_clear: clear };
+ 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 cmd = format!(
+ "{{\"command\": [\"set_property\", \"volume\", {}]}}\n",
+ state.volume
+ );
let _ = stream.write_all(cmd.as_bytes());
}
- return InputOutcome { should_quit: false, should_clear: clear };
+ 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 };
+ return InputOutcome {
+ should_quit: false,
+ should_clear: clear,
+ };
+ } else if key.code == KeyCode::Char('o') || key.code == KeyCode::Enter {
+ state.status_message = "album web links coming! (wip)".to_string();
+ state.status_message_time = Some(std::time::Instant::now());
+ return InputOutcome {
+ should_quit: false,
+ should_clear: clear,
+ };
}
// MODE SPECIFIC KEYBINDS
@@ -97,66 +159,113 @@ pub async fn handle_input(
let sorted = state.get_sorted_likes();
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());
+ 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);
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");
+ 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 url = if state.song.art_url.starts_with("http") { state.song.art_url.clone() } else { format!("https://gensokyoradio.net{}", state.song.art_url) };
+ 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 {
- if let Ok(bytes) = resp.bytes().await { let _ = tokio::fs::write(path, bytes).await; }
- }
+ 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 {
let path_to_remove = state.art_path(state.song.id);
- tokio::spawn(async move { let _ = tokio::fs::remove_file(path_to_remove).await; });
+ 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() {
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)
+ 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 = 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; clear = true;
- if let Some(bytes) = &state.live_art_bytes {
+ state.mode = AppMode::Playing;
+ state.selected_art = None;
+ clear = true;
+ 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));
- }
+ 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() {
let current = state.liked_list_state.selected().unwrap_or(0);
- let next = current.saturating_sub(1);
+ let next = if current == 0 {
+ sorted.len() - 1
+ } else {
+ current - 1
+ };
state.liked_list_state.select(Some(next));
if current != next {
- load_local_art(sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone());
+ 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.get_sorted_likes();
if !sorted.is_empty() {
let current = state.liked_list_state.selected().unwrap_or(0);
- let next = (current + 1).min(sorted.len() - 1);
+ let next = if current == sorted.len() - 1 {
+ 0
+ } else {
+ current + 1
+ };
state.liked_list_state.select(Some(next));
if current != next {
- load_local_art(sorted[next].id, state.art_path(sorted[next].id), tx.clone(), picker.clone());
+ 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') {
@@ -167,10 +276,17 @@ pub async fn handle_input(
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)
+ 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 = 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());
}
}
@@ -178,7 +294,10 @@ pub async fn handle_input(
} else if key.code == KeyCode::Char('l') {
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;
+ }
}
}
}
@@ -186,13 +305,22 @@ pub async fn handle_input(
CEvent::Mouse(mouse) => {
if mouse.kind == MouseEventKind::Down(MouseButton::Left) {
if let Ok(size) = crossterm::terminal::size() {
- if mouse.row == size.1.saturating_sub(3) && mouse.column >= 2 && mouse.column <= 10 { state.show_help_overlay = !state.show_help_overlay; clear = true; }
+ if mouse.row == size.1.saturating_sub(3)
+ && mouse.column >= 2
+ && mouse.column <= 10
+ {
+ state.show_help_overlay = !state.show_help_overlay;
+ clear = true;
+ }
}
}
}
_ => {}
}
- InputOutcome { should_quit: false, should_clear: clear }
+ InputOutcome {
+ should_quit: false,
+ should_clear: clear,
+ }
}
fn load_local_art(id: i64, path: std::path::PathBuf, tx: mpsc::Sender<AppEvent>, picker: Picker) {
@@ -200,9 +328,15 @@ fn load_local_art(id: i64, path: std::path::PathBuf, tx: mpsc::Sender<AppEvent>,
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 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;
diff --git a/src/main.rs b/src/main.rs
@@ -12,15 +12,15 @@ use crate::{
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent},
execute,
- terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures_util::{SinkExt, StreamExt};
-use ratatui::{backend::CrosstermBackend, Terminal};
+use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui_image::picker::Picker;
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
use std::{
fs,
- io::{stdout, Write},
+ io::{Write, stdout},
os::unix::net::UnixStream,
process::{Command, Stdio},
time::{Duration, Instant},
@@ -32,7 +32,9 @@ use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if Command::new("mpv").arg("--version").output().is_err() {
- eprintln!("error: missing required dependency 'mpv'. please install it using your system package manager.");
+ eprintln!(
+ "error: missing required dependency 'mpv'. please install it using your system package manager."
+ );
std::process::exit(1);
}
@@ -40,8 +42,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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"])
- .stdout(Stdio::null()).stderr(Stdio::null()).stdin(Stdio::null()).spawn().ok();
+ .args([
+ "--no-video",
+ "--really-quiet",
+ "--no-terminal",
+ &format!("--input-ipc-server={}", socket_path),
+ "https://stream.gensokyoradio.net/3",
+ ])
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .stdin(Stdio::null())
+ .spawn()
+ .ok();
enable_raw_mode()?;
let mut stdout = stdout();
@@ -53,34 +65,66 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (tx, mut rx) = mpsc::channel(32);
let mut state = AppState::new();
- let mut controls = MediaControls::new(PlatformConfig { dbus_name: "grrs", display_name: "Gensokyo Radio TUI", hwnd: None }).ok();
+ let mut 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();
+ 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().timeout(Duration::from_secs(5)).build().unwrap();
+ let http_client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
let tx_input = tx.clone();
std::thread::spawn(move || {
loop {
if event::poll(Duration::from_millis(250)).unwrap_or(false) {
- if let Ok(evt) = event::read() { if matches!(evt, CEvent::Key(_) | CEvent::Mouse(_)) { if tx_input.blocking_send(AppEvent::Input(evt)).is_err() { break; } } }
+ if let Ok(evt) = event::read() {
+ if matches!(evt, CEvent::Key(_) | CEvent::Mouse(_)) {
+ if tx_input.blocking_send(AppEvent::Input(evt)).is_err() {
+ break;
+ }
+ }
+ }
+ }
+ if tx_input.blocking_send(AppEvent::Tick).is_err() {
+ break;
}
- if tx_input.blocking_send(AppEvent::Tick).is_err() { break; }
}
});
- let tx_api = tx.clone(); let api_client = http_client.clone();
+ 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 {
+ if let Ok(resp) = api_client
+ .get("https://gensokyoradio.net/api/station/playing/")
+ .send()
+ .await
+ {
if let Ok(text) = resp.text().await {
if let Ok(info) = serde_json::from_str::<ApiInfo>(&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;
+ 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;
}
}
}
@@ -88,16 +132,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
- let (art_tx, mut art_rx) = watch::channel::<(i64, String, String, String)>((0, String::new(), String::new(), String::new()));
+ 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();
+ let tx_ws = tx.clone();
+ let art_tx_ws = art_tx.clone();
tokio::spawn(async move {
loop {
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;
+ 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;
loop {
tokio::select! {
@@ -133,63 +193,84 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
Err(_) => {
- if tx_ws.send(AppEvent::ConnectionStatus("websocket dropped. reconnecting...".to_string())).await.is_err() { return; }
+ 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 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; }
+ 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 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;
+ })
+ .await;
if let Ok(Some((protocol, original_bytes))) = decode_result {
- let _ = tx_art_local.send(AppEvent::ArtUpdate(protocol, original_bytes)).await;
- ok = true;
+ 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; }
-
+ if ok {
+ break;
+ }
+
tokio::time::sleep(Duration::from_secs(3)).await;
- if art_rx.has_changed().unwrap_or(false) { current = art_rx.borrow().clone(); }
+ 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 {
@@ -197,15 +278,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 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 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;
+ let _ =
+ tx_patchouli.send(AppEvent::MpvUpdate(mpv_song)).await;
}
}
}
@@ -226,36 +312,76 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 { let _ = terminal.clear(); }
+ 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 {
+ let _ = terminal.clear();
+ }
}
AppEvent::MediaControl(m_event) => {
- if matches!(m_event, MediaControlEvent::Toggle | MediaControlEvent::Play | MediaControlEvent::Pause) {
+ 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; }
+ 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();
+ 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();
+ 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 {
+ if let Ok(resp) = quick_client
+ .get("https://gensokyoradio.net/api/station/playing/")
+ .send()
+ .await
+ {
if let Ok(text) = resp.text().await {
if let Ok(info) = serde_json::from_str::<ApiInfo>(&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;
+ 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 = "".to_string();
+ state.song = song;
+ state.song.listeners = cached_listeners;
+ state.status_message = "".to_string();
state.refresh_until = None;
if let Some(ref mut c) = controls {
@@ -268,14 +394,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
duration: Some(Duration::from_secs(state.song.duration)),
cover_url: Some(art_url.as_str()),
..Default::default()
- }).ok();
+ })
+ .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::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.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 {
@@ -285,12 +420,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
AppEvent::LocalArtLoaded(id, protocol) => {
if let Some(idx) = state.liked_list_state.selected() {
let sorted_likes = state.get_sorted_likes();
- if let Some(song) = sorted_likes.get(idx) { if song.id == id { state.selected_art = protocol; } }
+ if let Some(song) = sorted_likes.get(idx) {
+ if 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;
+ 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;
@@ -304,8 +444,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
AppEvent::KosuzuBusy(busy) => {
- if busy { state.refresh_until = Some(Instant::now() + REFRESH_WINDOW); }
- else { state.refresh_until = None; }
+ if busy {
+ state.refresh_until = Some(Instant::now() + REFRESH_WINDOW);
+ } else {
+ state.refresh_until = None;
+ }
}
AppEvent::Tick => {
if let Some(t) = state.status_message_time {
@@ -325,9 +468,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
disable_raw_mode()?;
- execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
- if let Some(mut child) = mpv_process { let _ = child.kill(); let _ = child.wait(); }
+ if let Some(mut child) = mpv_process {
+ let _ = child.kill();
+ let _ = child.wait();
+ }
let _ = fs::remove_file(socket_path);
Ok(())
diff --git a/src/models.rs b/src/models.rs
@@ -3,40 +3,58 @@ use serde_json::Value;
#[derive(Deserialize, Default, Clone)]
pub struct WsPayload {
- #[serde(default)] pub songid: i64,
- #[serde(default)] pub title: Option<String>,
- #[serde(default)] pub artist: Option<String>,
- #[serde(default)] pub album: Option<String>,
- #[serde(default)] pub circle: Option<String>,
- #[serde(default)] pub vocal: Option<String>,
- #[serde(default)] pub original: Option<String>,
- #[serde(default)] pub year: Option<Value>,
- #[serde(default)] pub duration: Option<u64>,
- #[serde(default)] pub played: Option<Value>,
- #[serde(default)] pub albumart: Option<String>,
+ #[serde(default)]
+ pub songid: i64,
+ #[serde(default)]
+ pub title: Option<String>,
+ #[serde(default)]
+ pub artist: Option<String>,
+ #[serde(default)]
+ pub album: Option<String>,
+ #[serde(default)]
+ pub circle: Option<String>,
+ #[serde(default)]
+ pub vocal: Option<String>,
+ #[serde(default)]
+ pub original: Option<String>,
+ #[serde(default)]
+ pub year: Option<Value>,
+ #[serde(default)]
+ pub duration: Option<u64>,
+ #[serde(default)]
+ pub played: Option<Value>,
+ #[serde(default)]
+ pub albumart: Option<String>,
}
#[derive(Deserialize, Default, Clone)]
pub struct ApiInfo {
- #[serde(rename = "SERVERINFO", default)] pub server_info: ServerInfo,
- #[serde(rename = "MISC", default)] pub misc: MiscInfo,
- #[serde(rename = "SONGDATA", default)] pub song_data: ApiSongData,
+ #[serde(rename = "SERVERINFO", default)]
+ pub server_info: ServerInfo,
+ #[serde(rename = "MISC", default)]
+ pub misc: MiscInfo,
+ #[serde(rename = "SONGDATA", default)]
+ pub song_data: ApiSongData,
}
-#[derive(Deserialize, Default, Clone)]
-pub struct ServerInfo {
- #[serde(rename = "LISTENERS", default)] pub listeners: u64
+#[derive(Deserialize, Default, Clone)]
+pub struct ServerInfo {
+ #[serde(rename = "LISTENERS", default)]
+ pub listeners: u64,
}
-#[derive(Deserialize, Default, Clone)]
-pub struct MiscInfo {
- #[serde(rename = "CIRCLELINK", default)] pub circle_link: String
+#[derive(Deserialize, Default, Clone)]
+pub struct MiscInfo {
+ #[serde(rename = "CIRCLELINK", default)]
+ pub circle_link: String,
}
-#[derive(Deserialize, Default, Clone)]
+#[derive(Deserialize, Default, Clone)]
pub struct ApiSongData {
- #[serde(rename = "RATING", default)] pub rating: String,
- #[serde(rename = "TIMESRATED", default)] pub times_rated: u64,
+ #[serde(rename = "RATING", default)]
+ pub rating: String,
+ #[serde(rename = "TIMESRATED", default)]
+ pub times_rated: u64,
}
#[derive(Default, Clone, Debug)]
@@ -59,14 +77,37 @@ pub fn parse_mpv_media_title(title: &str) -> Option<MpvSong> {
#[derive(Default, Clone)]
pub struct SongData {
- pub id: i64, pub title: String, pub artist: String, pub album: String, pub circle: String, pub circle_link: String,
- pub vocal: String, pub original: String, pub year: String, pub duration: u64, pub played: u64, pub art_url: String,
- pub listeners: u64, pub rating: String, pub times_rated: u64,
+ pub id: i64,
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub circle: String,
+ pub circle_link: String,
+ pub vocal: String,
+ pub original: String,
+ pub year: String,
+ pub duration: u64,
+ pub played: u64,
+ pub art_url: String,
+ pub listeners: u64,
+ pub rating: String,
+ pub times_rated: u64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct LikedSong {
- pub id: i64, pub title: String, pub artist: String, pub album: String, pub duration: u64, pub timestamp: u64,
- #[serde(default)] pub circle: String, #[serde(default)] pub vocal: String,
- #[serde(default)] pub original: String, #[serde(default)] pub year: String,
+ pub id: i64,
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub duration: u64,
+ pub timestamp: u64,
+ #[serde(default)]
+ pub circle: String,
+ #[serde(default)]
+ pub vocal: String,
+ #[serde(default)]
+ pub original: String,
+ #[serde(default)]
+ pub year: String,
}
diff --git a/src/ui.rs b/src/ui.rs
@@ -1,118 +1,274 @@
use crate::app::{AppMode, AppState};
use ratatui::{
+ Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
- widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Wrap},
- Frame,
+ widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
};
use ratatui_image::StatefulImage;
use std::rc::Rc;
-fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
+fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
- .constraints([Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2)])
+ .constraints([
+ Constraint::Length(r.height.saturating_sub(height) / 2),
+ Constraint::Length(height.min(r.height)),
+ Constraint::Min(0),
+ ])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
- .constraints([Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2)])
+ .constraints([
+ Constraint::Length(r.width.saturating_sub(width) / 2),
+ Constraint::Length(width.min(r.width)),
+ Constraint::Min(0),
+ ])
.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))); }
-
+ 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)));
+ 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)));
+ 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 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);
- let content_chunks = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Length(dynamic_width), Constraint::Length(2), Constraint::Min(20)]).split(main_chunks[0]);
+ let content_chunks = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([
+ Constraint::Length(dynamic_width),
+ Constraint::Length(2),
+ Constraint::Min(20),
+ ])
+ .split(main_chunks[0]);
match state.mode {
- AppMode::Playing => draw_player(f, state, content_chunks, main_chunks[1], main_chunks[0].height),
+ AppMode::Playing => draw_player(
+ f,
+ state,
+ content_chunks,
+ main_chunks[1],
+ main_chunks[0].height,
+ ),
AppMode::LikedSongs => draw_library(f, state, content_chunks, main_chunks[1]),
}
- if state.show_help_overlay { draw_help_popup(f, state); }
- if state.show_unlike_confirm.is_some() { draw_confirm_popup(f, state); }
- if state.show_song_info { draw_song_info(f, state); }
+ if state.show_help_overlay {
+ draw_help_popup(f, state);
+ }
+ if state.show_unlike_confirm.is_some() {
+ draw_confirm_popup(f, state);
+ }
}
-fn draw_player(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom: Rect, available_height: u16) {
+fn draw_player(
+ f: &mut Frame,
+ state: &mut AppState,
+ content: Rc<[Rect]>,
+ bottom: Rect,
+ available_height: u16,
+) {
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]); }
+ 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())]),
- Line::from(vec![Span::styled("Artist: ", Style::default().bold()), Span::raw(&state.song.artist)]),
- Line::from(vec![Span::styled("Album: ", Style::default().bold()), Span::raw(&state.song.album)]),
+ Line::from(vec![Span::styled(
+ &state.song.title,
+ Style::default().fg(state.theme_color).bold(),
+ )]),
+ Line::from(vec![
+ Span::styled("Artist: ", Style::default().bold()),
+ Span::raw(&state.song.artist),
+ ]),
+ Line::from(vec![
+ Span::styled("Album: ", Style::default().bold()),
+ Span::raw(&state.song.album),
+ ]),
];
let mut ext_text = vec![];
if available_height >= 16 {
if !state.song.circle.is_empty() && state.song.circle != "Unknown" {
- let mut spans = vec![Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.circle, Style::default().fg(Color::DarkGray))];
- if !state.song.circle_link.is_empty() { spans.push(Span::styled(format!(" ({})", state.song.circle_link), Style::default().fg(Color::DarkGray))); }
+ let mut spans = vec![
+ Span::styled("Circle: ", Style::default().bold().fg(Color::DarkGray)),
+ Span::styled(&state.song.circle, Style::default().fg(Color::DarkGray)),
+ ];
+ if !state.song.circle_link.is_empty() {
+ spans.push(Span::styled(
+ format!(" ({})", state.song.circle_link),
+ Style::default().fg(Color::DarkGray),
+ ));
+ }
ext_text.push(Line::from(spans));
}
- if !state.song.vocal.is_empty() && state.song.vocal != "Unknown" && state.song.vocal != "-" { ext_text.push(Line::from(vec![Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.vocal, Style::default().fg(Color::DarkGray))])); }
- if !state.song.original.is_empty() && state.song.original != "Unknown" && state.song.original != "-" { ext_text.push(Line::from(vec![Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.original, Style::default().fg(Color::DarkGray))])); }
- if !state.song.year.is_empty() && state.song.year != "Unknown" { ext_text.push(Line::from(vec![Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)), Span::styled(&state.song.year, Style::default().fg(Color::DarkGray))])); }
- 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))])); }
+ if !state.song.vocal.is_empty() && state.song.vocal != "Unknown" && state.song.vocal != "-"
+ {
+ ext_text.push(Line::from(vec![
+ Span::styled("Vocal: ", Style::default().bold().fg(Color::DarkGray)),
+ Span::styled(&state.song.vocal, Style::default().fg(Color::DarkGray)),
+ ]));
+ }
+ if !state.song.original.is_empty()
+ && state.song.original != "Unknown"
+ && state.song.original != "-"
+ {
+ ext_text.push(Line::from(vec![
+ Span::styled("Original: ", Style::default().bold().fg(Color::DarkGray)),
+ Span::styled(&state.song.original, Style::default().fg(Color::DarkGray)),
+ ]));
+ }
+ if !state.song.year.is_empty() && state.song.year != "Unknown" {
+ ext_text.push(Line::from(vec![
+ Span::styled("Year: ", Style::default().bold().fg(Color::DarkGray)),
+ Span::styled(&state.song.year, Style::default().fg(Color::DarkGray)),
+ ]));
+ }
+ 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 refresh_height = state.is_refreshing() as u16;
- let right_chunks = Layout::default().direction(Direction::Vertical).constraints([
- Constraint::Length(3),
- 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]);
+ let right_chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3),
+ 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;
let elapsed = state.song_start_time.elapsed().as_secs();
- let (ratio, label) = if total > 0 { ((elapsed as f64 / total as f64).clamp(0.0, 1.0), format!("{:02}:{:02} / {:02}:{:02}", elapsed / 60, elapsed % 60, total / 60, total % 60)) } else { (1.0, format!("{:02}:{:02} / LIVE", elapsed / 60, elapsed % 60)) };
- 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]); }
+ let (ratio, label) = if total > 0 {
+ (
+ (elapsed as f64 / total as f64).clamp(0.0, 1.0),
+ format!(
+ "{:02}:{:02} / {:02}:{:02}",
+ elapsed / 60,
+ elapsed % 60,
+ total / 60,
+ total % 60
+ ),
+ )
+ } else {
+ (
+ 1.0,
+ format!("{:02}:{:02} / LIVE", elapsed / 60, elapsed % 60),
+ )
+ };
+ 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]);
+ 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);
-
- 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]);
+ let bottom_layout = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Min(40), Constraint::Length(25)])
+ .split(bottom);
+
+ 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) {
@@ -122,112 +278,170 @@ fn draw_library(f: &mut Frame, state: &mut AppState, content: Rc<[Rect]>, bottom
.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]); }
+ 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)| {
- 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();
-
- 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);
- f.render_widget(Paragraph::new(build_status_bar(state)).alignment(Alignment::Left), bottom);
+ 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(),
+ )]))
+ } 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();
+
+ 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,
+ );
+ f.render_widget(
+ Paragraph::new(build_status_bar(state)).alignment(Alignment::Left),
+ bottom,
+ );
}
fn draw_help_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(50, 50, f.area());
+ let area = centered_rect(44, 14, f.area());
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(" [-] ", Style::default().bold()), Span::raw("volume down")]),
- Line::from(vec![Span::styled(" [=]/[+] ", Style::default().bold()), Span::raw("volume up")]),
- Line::from(vec![Span::styled(" [c]/[C] ", Style::default().bold()), Span::raw("copy short/full metadata")]),
- 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);
-}
-fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
- let area = centered_rect(40, 15, f.area());
- 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")]),
- ]).block(Block::default().title(" Confirm ").borders(Borders::ALL).style(Style::default().fg(Color::Red))).alignment(Alignment::Center), area);
-}
+ let block = Block::default()
+ .title(" Controls ")
+ .title_alignment(Alignment::Center)
+ .borders(Borders::ALL)
+ .border_type(ratatui::widgets::BorderType::Rounded)
+ .style(Style::default().fg(state.theme_color));
-fn draw_song_info(f: &mut Frame, state: &mut AppState) {
- let area = centered_rect(80, 80, f.area());
- f.render_widget(Clear, area);
- let block = Block::default().title(" Song Info (Esc to close) ").borders(Borders::ALL).style(Style::default().fg(state.theme_color));
- let inner = block.inner(area);
+ let inner_area = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
- .direction(Direction::Horizontal)
- .margin(2)
- .spacing(4)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
- .split(inner);
-
- let sorted_likes = state.get_sorted_likes();
- let mut txt = vec![];
- let mut has_art = false;
-
- if state.mode == AppMode::Playing {
- if let Some(ref mut p) = state.art_protocol {
- 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)]));
-
- } else {
- if let Some(ref mut p) = state.selected_art {
- 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")]));
- }
- }
- }
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(2), Constraint::Min(0)])
+ .margin(1)
+ .split(inner_area);
+
+ let tab_titles: Vec<_> = vec![" General ", " Library "]
+ .into_iter()
+ .map(Line::from)
+ .collect();
+ let tabs = Tabs::new(tab_titles)
+ .select(state.help_tab)
+ .highlight_style(
+ Style::default()
+ .fg(Color::Black)
+ .bg(state.theme_color)
+ .bold(),
+ )
+ .divider("│");
+
+ f.render_widget(tabs, chunks[0]);
+
+ let rows = match state.help_tab {
+ 0 => vec![
+ Row::new(vec![
+ Span::styled("[Tab]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Switch library / player"),
+ ]),
+ Row::new(vec![
+ Span::styled("[m]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Mute / Unmute stream"),
+ ]),
+ Row::new(vec![
+ Span::styled("[-/=]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Volume down / up"),
+ ]),
+ Row::new(vec![
+ Span::styled("[t]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Cycle theme colors"),
+ ]),
+ Row::new(vec![
+ Span::styled("[?]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Close help menu"),
+ ]),
+ Row::new(vec![
+ Span::styled("[q/^c]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Quit application"),
+ ]),
+ ],
+ 1 => vec![
+ Row::new(vec![
+ Span::styled("[l]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Like / Unlike song"),
+ ]),
+ Row::new(vec![
+ Span::styled("[c/C]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Copy short / full metadata"),
+ ]),
+ Row::new(vec![
+ Span::styled("[o/Enter]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Open album info (WIP)"),
+ ]),
+ Row::new(vec![
+ Span::styled("[j/k]", Style::default().bold().fg(state.theme_color)),
+ Span::raw("Scroll library down / up"),
+ ]),
+ ],
+ _ => vec![],
+ };
+
+ let table = Table::new(rows, [Constraint::Length(12), Constraint::Min(20)]).column_spacing(2);
+
+ f.render_widget(table, chunks[1]);
+}
- if !has_art { f.render_widget(Paragraph::new("No Art Saved").alignment(Alignment::Center), chunks[0]); }
- if !txt.is_empty() { f.render_widget(Paragraph::new(txt).wrap(Wrap { trim: true }), chunks[1]); }
+fn draw_confirm_popup(f: &mut Frame, state: &AppState) {
+ let area = centered_rect(36, 7, f.area());
+ 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"),
+ ]),
+ ])
+ .block(
+ Block::default()
+ .title(" Confirm ")
+ .borders(Borders::ALL)
+ .style(Style::default().fg(Color::Red)),
+ )
+ .alignment(Alignment::Center),
+ area,
+ );
}