diff --git a/src/radio/app/draw.rs b/src/radio/app/draw.rs index 96d48aa..87113a7 100644 --- a/src/radio/app/draw.rs +++ b/src/radio/app/draw.rs @@ -2,10 +2,10 @@ use super::{App, InputMode}; use crate::radio::station::StationInfo; use ratatui::{ Frame, - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, List, ListItem, Paragraph}, + widgets::{Block, Clear, List, ListItem, Paragraph, Wrap}, }; impl App { @@ -31,6 +31,12 @@ impl App { frame.render_widget(self.info_panel(), top_right); frame.render_widget(self.played_songs_list(), bottom_right); frame.render_widget(Paragraph::new(self.footer_hints()), footer); + + if self.input_mode == InputMode::Info { + let popup_area = centered_rect(area, 62, 8); + frame.render_widget(Clear, popup_area); + frame.render_widget(self.station_info_popup(), popup_area); + } } fn info_panel(&self) -> Paragraph<'static> { @@ -153,24 +159,71 @@ impl App { List::new(items).block(Block::bordered().title("Played Songs")) } + fn station_info_popup(&self) -> Paragraph<'static> { + let station = self + .station_list_state + .selected() + .and_then(|i| self.filtered_stations.get(i)); + + let lines = if let Some(s) = station { + let status_line = if let Some(msg) = &self.clipboard_message { + let color = if msg.starts_with("Error") { Color::Red } else { Color::Green }; + Line::from(Span::styled(msg.clone(), Style::default().fg(color))) + } else { + Line::from(Span::styled( + "c copy url Esc / Enter / i close", + Style::default().add_modifier(Modifier::DIM), + )) + }; + vec![ + Line::from(Span::styled( + s.name.clone(), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled(s.url.clone(), Style::default().fg(Color::Cyan))), + Line::from(""), + status_line, + ] + } else { + vec![Line::from(Span::styled( + "No station selected", + Style::default().add_modifier(Modifier::DIM), + ))] + }; + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block( + Block::bordered() + .title("Station Info [i]") + .border_style(Style::default().fg(Color::Cyan)), + ) + } + fn footer_hints(&self) -> Line<'static> { - if self.input_mode == InputMode::Search { - Line::from(vec![ + match self.input_mode { + InputMode::Search => Line::from(vec![ key_hint(" Esc "), Span::raw(" cancel "), key_hint(" Enter "), Span::raw(" confirm"), - ]) - } else { - Line::from(vec![ + ]), + InputMode::Info => Line::from(vec![ + key_hint(" Esc "), Span::raw(" / "), + key_hint(" Enter "), Span::raw(" / "), + key_hint(" i "), Span::raw(" close"), + ]), + InputMode::Normal => Line::from(vec![ key_hint(" / "), Span::raw(" search "), key_hint(" Esc "), Span::raw(" clear "), key_hint(" j/k "), Span::raw(" navigate "), key_hint(" ^U/^D "), Span::raw(" half page "), key_hint(" Enter "), Span::raw(" play "), key_hint(" s "), Span::raw(" stop "), + key_hint(" i "), Span::raw(" info "), key_hint(" f "), Span::raw(" favorite "), key_hint(" Tab "), Span::raw(" all/favs "), key_hint(" q "), Span::raw(" quit"), - ]) + ]), } } } @@ -184,3 +237,14 @@ fn key_hint(label: &'static str) -> Span<'static> { .add_modifier(Modifier::BOLD), ) } + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + Rect { + x, + y, + width: width.min(area.width), + height: height.min(area.height), + } +} diff --git a/src/radio/app/input.rs b/src/radio/app/input.rs index 609f94c..1744f29 100644 --- a/src/radio/app/input.rs +++ b/src/radio/app/input.rs @@ -19,6 +19,7 @@ impl App { match self.input_mode { InputMode::Normal => self.handle_normal_key(key_event).await, InputMode::Search => self.handle_search_key(key_event), + InputMode::Info => self.handle_info_key(key_event), } } @@ -33,6 +34,7 @@ impl App { self.show_favorites_only = !self.show_favorites_only; self.update_filter(); } + KeyCode::Char('i') => self.input_mode = InputMode::Info, KeyCode::Char('f') => self.toggle_favorite(), KeyCode::Char('s') => self.stop_playback().await, KeyCode::Esc if !self.search_query.is_empty() => { @@ -75,6 +77,30 @@ impl App { } } + fn handle_info_key(&mut self, key_event: KeyEvent) { + match key_event.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('i') | KeyCode::Char('q') => { + self.clipboard_message = None; + self.input_mode = InputMode::Normal; + self.needs_clear = true; + } + KeyCode::Char('c') => { + let url = self + .station_list_state + .selected() + .and_then(|i| self.filtered_stations.get(i)) + .map(|s| s.url.clone()); + if let Some(url) = url { + self.clipboard_message = Some(match self.copy_url_to_clipboard(&url) { + Ok(()) => "Copied!".into(), + Err(e) => format!("Error: {e}"), + }); + } + } + _ => {} + } + } + fn handle_search_key(&mut self, key_event: KeyEvent) { match key_event.code { KeyCode::Char(c) => { diff --git a/src/radio/app/mod.rs b/src/radio/app/mod.rs index 32d041e..5bb1970 100644 --- a/src/radio/app/mod.rs +++ b/src/radio/app/mod.rs @@ -17,10 +17,12 @@ mod player; #[cfg(test)] mod tests; + #[derive(Debug, PartialEq)] pub enum InputMode { Normal, Search, + Info, } #[derive(Debug)] @@ -40,6 +42,9 @@ pub struct App { spawn_player: Option, favorites: HashSet, show_favorites_only: bool, + clipboard_message: Option, + clipboard_child: Option, + needs_clear: bool, } impl Default for App { @@ -64,11 +69,48 @@ impl Default for App { spawn_player: Some(player::new_mpv_spawn()), favorites: favorites::load_favorites(), show_favorites_only: false, + clipboard_message: None, + clipboard_child: None, + needs_clear: false, } } } impl App { + pub(super) fn copy_url_to_clipboard(&mut self, url: &str) -> Result<(), String> { + use std::io::Write; + use std::process::{Command, Stdio}; + + if let Some(mut old) = self.clipboard_child.take() { + old.kill().ok(); + } + + let tools: &[(&str, &[&str])] = &[ + ("wl-copy", &[]), + ("xclip", &["-selection", "clipboard"]), + ("xsel", &["--clipboard", "--input"]), + ]; + + for (cmd, args) in tools { + let Ok(mut child) = Command::new(cmd) + .args(*args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + continue; + }; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(url.as_bytes()).ok(); + } + self.clipboard_child = Some(child); + return Ok(()); + } + + Err("No clipboard tool found — install wl-copy, xclip, or xsel".into()) + } + pub fn without_player(mut self) -> Self { self.spawn_player = None; self @@ -76,6 +118,10 @@ impl App { pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { while !self.exit { + if self.needs_clear { + terminal.clear().wrap_err("terminal clear failed")?; + self.needs_clear = false; + } terminal.draw(|frame| self.draw(frame))?; self.handle_events() .await