Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

feat: add interactive TUI mode with favorites system

- Implement full-featured terminal UI with menu navigation, search, and station browsing
- Add persistent favorites storage using JSON configuration files
- Create audio controller with playback state management and metadata polling
- Support volume control, station metadata display, and now-playing updates
- Enable direct station playback and resume last played station functionality
- Add keyboard shortcuts for common actions (play, stop, favorites, volume)
- Integrate with existing providers (tunein, radiobrowser) for station discovery
- Update decoder to handle optional frame transmission for audio worker thread
- Modify main CLI to launch interactive mode when no subcommand is specified

.cargo-home/.crates.toml

This is a binary file and will not be displayed.

.cargo-home/.crates2.json

This is a binary file and will not be displayed.

.cargo-home/.package-cache

This is a binary file and will not be displayed.

+3
.cargo-home/registry/CACHEDIR.TAG
··· 1 + Signature: 8a477f597d28d172789f06886806bc55 2 + # This file is a cache directory tag created by cargo. 3 + # For information about cache directory tags see https://bford.info/cachedir/
+2 -1
.gitignore
··· 1 1 /target 2 - /result 2 + /result 3 + *.md
+51 -1
Cargo.lock
··· 1065 1065 ] 1066 1066 1067 1067 [[package]] 1068 + name = "directories" 1069 + version = "5.0.1" 1070 + source = "registry+https://github.com/rust-lang/crates.io-index" 1071 + checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 1072 + dependencies = [ 1073 + "dirs-sys", 1074 + ] 1075 + 1076 + [[package]] 1077 + name = "dirs-sys" 1078 + version = "0.4.1" 1079 + source = "registry+https://github.com/rust-lang/crates.io-index" 1080 + checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 1081 + dependencies = [ 1082 + "libc", 1083 + "option-ext", 1084 + "redox_users", 1085 + "windows-sys 0.48.0", 1086 + ] 1087 + 1088 + [[package]] 1068 1089 name = "discard" 1069 1090 version = "1.0.4" 1070 1091 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2147 2168 ] 2148 2169 2149 2170 [[package]] 2171 + name = "libredox" 2172 + version = "0.1.10" 2173 + source = "registry+https://github.com/rust-lang/crates.io-index" 2174 + checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 2175 + dependencies = [ 2176 + "bitflags 2.8.0", 2177 + "libc", 2178 + ] 2179 + 2180 + [[package]] 2150 2181 name = "linked-hash-map" 2151 2182 version = "0.5.6" 2152 2183 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2599 2630 checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 2600 2631 2601 2632 [[package]] 2633 + name = "option-ext" 2634 + version = "0.2.0" 2635 + source = "registry+https://github.com/rust-lang/crates.io-index" 2636 + checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 2637 + 2638 + [[package]] 2602 2639 name = "os_str_bytes" 2603 2640 version = "6.6.1" 2604 2641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3024 3061 checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" 3025 3062 3026 3063 [[package]] 3064 + name = "redox_users" 3065 + version = "0.4.6" 3066 + source = "registry+https://github.com/rust-lang/crates.io-index" 3067 + checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 3068 + dependencies = [ 3069 + "getrandom 0.2.15", 3070 + "libredox 0.1.10", 3071 + "thiserror 1.0.69", 3072 + ] 3073 + 3074 + [[package]] 3027 3075 name = "regex" 3028 3076 version = "1.11.1" 3029 3077 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3963 4011 checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d" 3964 4012 dependencies = [ 3965 4013 "libc", 3966 - "libredox", 4014 + "libredox 0.0.2", 3967 4015 "numtoa", 3968 4016 "redox_termios", 3969 4017 ] ··· 4366 4414 "cpal", 4367 4415 "crossterm", 4368 4416 "derive_more", 4417 + "directories", 4369 4418 "futures", 4370 4419 "futures-util", 4371 4420 "hyper 0.14.32", ··· 4381 4430 "rodio", 4382 4431 "rustfft", 4383 4432 "serde", 4433 + "serde_json", 4384 4434 "souvlaki", 4385 4435 "surf", 4386 4436 "symphonia",
+3 -1
Cargo.toml
··· 45 45 m3u = "1.0.0" 46 46 minimp3 = "0.6" 47 47 owo-colors = "3.5.0" 48 + directories = "5.0.1" 48 49 pls = "0.2.2" 49 50 prost = "0.13.2" 50 51 radiobrowser = { version = "0.6.1", features = [ ··· 58 59 ], default-features = false } 59 60 rodio = { version = "0.16" } 60 61 rustfft = "6.2.0" 61 - serde = "1.0.197" 62 + serde = { version = "1.0.197", features = ["derive"] } 63 + serde_json = "1.0.117" 62 64 surf = { version = "2.3.2", features = [ 63 65 "h1-client-rustls", 64 66 ], default-features = false }
+1
build.rs
··· 2 2 tonic_build::configure() 3 3 .out_dir("src/api") 4 4 .file_descriptor_set_path("src/api/descriptor.bin") 5 + .protoc_arg("--experimental_allow_proto3_optional") 5 6 .compile_protos( 6 7 &[ 7 8 "proto/objects/v1alpha1/category.proto",
+223
src/audio.rs
··· 1 + use std::sync::Arc; 2 + use std::thread; 3 + use std::time::Duration; 4 + 5 + use anyhow::{Context, Error}; 6 + use hyper::header::HeaderValue; 7 + use rodio::{OutputStream, OutputStreamHandle, Sink}; 8 + use tokio::sync::mpsc; 9 + 10 + use crate::decoder::Mp3Decoder; 11 + use crate::types::Station; 12 + 13 + /// Commands sent to the audio worker thread. 14 + #[derive(Debug)] 15 + enum AudioCommand { 16 + Play { 17 + station: Station, 18 + volume_percent: f32, 19 + }, 20 + SetVolume(f32), 21 + Stop, 22 + } 23 + 24 + /// Playback events emitted by the audio worker. 25 + #[derive(Debug, Clone)] 26 + pub enum PlaybackEvent { 27 + Started(PlaybackState), 28 + Error(String), 29 + Stopped, 30 + } 31 + 32 + /// Public interface for receiving playback events. 33 + pub struct PlaybackEvents { 34 + rx: mpsc::UnboundedReceiver<PlaybackEvent>, 35 + } 36 + 37 + impl PlaybackEvents { 38 + pub async fn recv(&mut self) -> Option<PlaybackEvent> { 39 + self.rx.recv().await 40 + } 41 + } 42 + 43 + /// Snapshot of the current playback metadata. 44 + #[derive(Debug, Clone)] 45 + pub struct PlaybackState { 46 + pub station: Station, 47 + pub stream_name: String, 48 + pub now_playing: String, 49 + pub genre: String, 50 + pub description: String, 51 + pub bitrate: String, 52 + } 53 + 54 + /// Controller that owns the command channel to the audio worker. 55 + pub struct AudioController { 56 + cmd_tx: mpsc::UnboundedSender<AudioCommand>, 57 + } 58 + 59 + impl AudioController { 60 + /// Spawn a new audio worker thread and return a controller plus event receiver. 61 + pub fn new() -> Result<(Self, PlaybackEvents), Error> { 62 + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<AudioCommand>(); 63 + let (event_tx, event_rx) = mpsc::unbounded_channel::<PlaybackEvent>(); 64 + 65 + thread::Builder::new() 66 + .name("tunein-audio-worker".into()) 67 + .spawn({ 68 + let events = event_tx.clone(); 69 + move || { 70 + let mut worker = AudioWorker::new(event_tx); 71 + if let Err(err) = worker.run(&mut cmd_rx) { 72 + let _ = events.send(PlaybackEvent::Error(err.to_string())); 73 + } 74 + } 75 + }) 76 + .context("failed to spawn audio worker thread")?; 77 + 78 + Ok((Self { cmd_tx }, PlaybackEvents { rx: event_rx })) 79 + } 80 + 81 + pub fn play(&self, station: Station, volume_percent: f32) -> Result<(), Error> { 82 + self.cmd_tx 83 + .send(AudioCommand::Play { 84 + station, 85 + volume_percent, 86 + }) 87 + .map_err(|e| Error::msg(e.to_string())) 88 + } 89 + 90 + pub fn set_volume(&self, volume_percent: f32) -> Result<(), Error> { 91 + self.cmd_tx 92 + .send(AudioCommand::SetVolume(volume_percent)) 93 + .map_err(|e| Error::msg(e.to_string())) 94 + } 95 + 96 + pub fn stop(&self) -> Result<(), Error> { 97 + self.cmd_tx 98 + .send(AudioCommand::Stop) 99 + .map_err(|e| Error::msg(e.to_string())) 100 + } 101 + } 102 + 103 + struct AudioWorker { 104 + _stream: OutputStream, 105 + handle: OutputStreamHandle, 106 + sink: Option<Arc<Sink>>, 107 + current_volume: f32, 108 + events: mpsc::UnboundedSender<PlaybackEvent>, 109 + } 110 + 111 + impl AudioWorker { 112 + fn new(events: mpsc::UnboundedSender<PlaybackEvent>) -> Self { 113 + let (stream, handle) = 114 + OutputStream::try_default().expect("failed to acquire default audio output device"); 115 + Self { 116 + _stream: stream, 117 + handle, 118 + sink: None, 119 + current_volume: 100.0, 120 + events, 121 + } 122 + } 123 + 124 + fn run(&mut self, cmd_rx: &mut mpsc::UnboundedReceiver<AudioCommand>) -> Result<(), Error> { 125 + while let Some(cmd) = cmd_rx.blocking_recv() { 126 + match cmd { 127 + AudioCommand::Play { 128 + station, 129 + volume_percent, 130 + } => self.handle_play(station, volume_percent)?, 131 + AudioCommand::SetVolume(volume_percent) => { 132 + self.current_volume = volume_percent.max(0.0); 133 + if let Some(sink) = &self.sink { 134 + sink.set_volume(self.current_volume / 100.0); 135 + } 136 + } 137 + AudioCommand::Stop => { 138 + if let Some(sink) = self.sink.take() { 139 + sink.stop(); 140 + } 141 + let _ = self.events.send(PlaybackEvent::Stopped); 142 + } 143 + } 144 + } 145 + 146 + Ok(()) 147 + } 148 + 149 + fn handle_play(&mut self, station: Station, volume_percent: f32) -> Result<(), Error> { 150 + if let Some(sink) = self.sink.take() { 151 + sink.stop(); 152 + thread::sleep(Duration::from_millis(50)); 153 + } 154 + 155 + let stream_url = station.stream_url.clone(); 156 + let client = reqwest::blocking::Client::new(); 157 + let response = client 158 + .get(&stream_url) 159 + .send() 160 + .with_context(|| format!("failed to open stream {}", stream_url))?; 161 + 162 + let headers = response.headers().clone(); 163 + let now_playing = station.playing.clone().unwrap_or_default(); 164 + 165 + let display_name = header_to_string(headers.get("icy-name")) 166 + .filter(|name| name != "Unknown") 167 + .unwrap_or_else(|| station.name.clone()); 168 + let genre = header_to_string(headers.get("icy-genre")).unwrap_or_default(); 169 + let description = header_to_string(headers.get("icy-description")).unwrap_or_default(); 170 + let bitrate = header_to_string(headers.get("icy-br")).unwrap_or_default(); 171 + 172 + let response = follow_redirects(client, response)?; 173 + 174 + let sink = Arc::new(Sink::try_new(&self.handle)?); 175 + sink.set_volume(volume_percent.max(0.0) / 100.0); 176 + 177 + let decoder = Mp3Decoder::new(response, None).map_err(|_| { 178 + Error::msg("stream is not in MP3 format or failed to initialize decoder") 179 + })?; 180 + sink.append(decoder); 181 + sink.play(); 182 + 183 + self.current_volume = volume_percent; 184 + self.sink = Some(sink.clone()); 185 + 186 + let state = PlaybackState { 187 + station, 188 + stream_name: display_name, 189 + now_playing, 190 + genre, 191 + description, 192 + bitrate, 193 + }; 194 + 195 + let _ = self.events.send(PlaybackEvent::Started(state)); 196 + 197 + Ok(()) 198 + } 199 + } 200 + 201 + fn follow_redirects( 202 + client: reqwest::blocking::Client, 203 + response: reqwest::blocking::Response, 204 + ) -> Result<reqwest::blocking::Response, Error> { 205 + let mut current = response; 206 + for _ in 0..3 { 207 + if let Some(location) = current.headers().get("location") { 208 + let url = location 209 + .to_str() 210 + .map_err(|_| Error::msg("invalid redirect location header"))?; 211 + current = client.get(url).send()?; 212 + } else { 213 + return Ok(current); 214 + } 215 + } 216 + Ok(current) 217 + } 218 + 219 + fn header_to_string(value: Option<&HeaderValue>) -> Option<String> { 220 + value 221 + .and_then(|header| header.to_str().ok()) 222 + .map(|s| s.to_string()) 223 + }
+6 -5
src/decoder.rs
··· 11 11 decoder: Decoder<R>, 12 12 current_frame: Frame, 13 13 current_frame_offset: usize, 14 - tx: Sender<Frame>, 14 + tx: Option<Sender<Frame>>, 15 15 } 16 16 17 17 impl<R> Mp3Decoder<R> 18 18 where 19 19 R: Read, 20 20 { 21 - pub fn new(mut data: R, tx: Sender<Frame>) -> Result<Self, R> { 21 + pub fn new(mut data: R, tx: Option<Sender<Frame>>) -> Result<Self, R> { 22 22 if !is_mp3(data.by_ref()) { 23 23 return Err(data); 24 24 } ··· 70 70 if self.current_frame_offset == self.current_frame.data.len() { 71 71 match self.decoder.next_frame() { 72 72 Ok(frame) => { 73 - match self.tx.send(frame.clone()) { 74 - Ok(_) => {} 75 - Err(_) => return None, 73 + if let Some(tx) = &self.tx { 74 + if tx.send(frame.clone()).is_err() { 75 + return None; 76 + } 76 77 } 77 78 self.current_frame = frame 78 79 }
+6 -1
src/extract.rs
··· 65 65 .await 66 66 .map_err(|e| Error::msg(e.to_string()))?; 67 67 68 - Ok(response.header.subtitle) 68 + let subtitle = response.header.subtitle.trim(); 69 + if subtitle.is_empty() { 70 + Ok(response.header.title.trim().to_string()) 71 + } else { 72 + Ok(subtitle.to_string()) 73 + } 69 74 }
+108
src/favorites.rs
··· 1 + use std::fs; 2 + use std::path::{Path, PathBuf}; 3 + 4 + use anyhow::{Context, Error}; 5 + use directories::ProjectDirs; 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// Metadata describing a favourited station. 9 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 10 + pub struct FavoriteStation { 11 + pub id: String, 12 + pub name: String, 13 + pub provider: String, 14 + } 15 + 16 + /// File-backed favourites store. 17 + pub struct FavoritesStore { 18 + path: PathBuf, 19 + favorites: Vec<FavoriteStation>, 20 + } 21 + 22 + impl FavoritesStore { 23 + /// Load favourites from disk, falling back to an empty list when the file 24 + /// does not exist or is corrupted. 25 + pub fn load() -> Result<Self, Error> { 26 + let path = favorites_path()?; 27 + ensure_parent(&path)?; 28 + 29 + let favorites = match fs::read_to_string(&path) { 30 + Ok(content) => match serde_json::from_str::<Vec<FavoriteStation>>(&content) { 31 + Ok(entries) => entries, 32 + Err(err) => { 33 + eprintln!( 34 + "warning: favourites file corrupted ({}), starting fresh", 35 + err 36 + ); 37 + Vec::new() 38 + } 39 + }, 40 + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), 41 + Err(err) => return Err(Error::from(err).context("failed to read favourites file")), 42 + }; 43 + 44 + Ok(Self { path, favorites }) 45 + } 46 + 47 + /// Return a snapshot of all favourite stations. 48 + pub fn all(&self) -> &[FavoriteStation] { 49 + &self.favorites 50 + } 51 + 52 + /// Check whether the provided station is already a favourite. 53 + pub fn is_favorite(&self, id: &str, provider: &str) -> bool { 54 + self.favorites 55 + .iter() 56 + .any(|fav| fav.id == id && fav.provider == provider) 57 + } 58 + 59 + /// Add a station to favourites if it is not already present. 60 + pub fn add(&mut self, favorite: FavoriteStation) -> Result<(), Error> { 61 + if !self.is_favorite(&favorite.id, &favorite.provider) { 62 + self.favorites.push(favorite); 63 + self.save()?; 64 + } 65 + Ok(()) 66 + } 67 + 68 + /// Remove a station from favourites. 69 + pub fn remove(&mut self, id: &str, provider: &str) -> Result<(), Error> { 70 + let initial_len = self.favorites.len(); 71 + self.favorites 72 + .retain(|fav| !(fav.id == id && fav.provider == provider)); 73 + if self.favorites.len() != initial_len { 74 + self.save()?; 75 + } 76 + Ok(()) 77 + } 78 + 79 + /// Toggle a station in favourites, returning whether it was added (`true`) or removed (`false`). 80 + pub fn toggle(&mut self, favorite: FavoriteStation) -> Result<bool, Error> { 81 + if self.is_favorite(&favorite.id, &favorite.provider) { 82 + self.remove(&favorite.id, &favorite.provider)?; 83 + Ok(false) 84 + } else { 85 + self.add(favorite)?; 86 + Ok(true) 87 + } 88 + } 89 + 90 + fn save(&self) -> Result<(), Error> { 91 + let serialized = serde_json::to_string_pretty(&self.favorites) 92 + .context("failed to serialize favourites list")?; 93 + fs::write(&self.path, serialized).context("failed to write favourites file") 94 + } 95 + } 96 + 97 + fn favorites_path() -> Result<PathBuf, Error> { 98 + let dirs = ProjectDirs::from("io", "tunein-cli", "tunein-cli") 99 + .ok_or_else(|| Error::msg("unable to determine configuration directory"))?; 100 + Ok(dirs.config_dir().join("favorites.json")) 101 + } 102 + 103 + fn ensure_parent(path: &Path) -> Result<(), Error> { 104 + if let Some(parent) = path.parent() { 105 + fs::create_dir_all(parent).context("failed to create favourites directory")?; 106 + } 107 + Ok(()) 108 + }
+1217
src/interactive.rs
··· 1 + use std::thread; 2 + use std::time::{Duration, Instant}; 3 + 4 + use anyhow::{anyhow, Error}; 5 + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; 6 + use ratatui::layout::{Constraint, Direction, Layout}; 7 + use ratatui::prelude::*; 8 + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; 9 + use tokio::sync::mpsc; 10 + 11 + use crate::audio::{AudioController, PlaybackEvent, PlaybackState}; 12 + use crate::extract::get_currently_playing; 13 + use crate::favorites::{FavoriteStation, FavoritesStore}; 14 + use crate::provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}; 15 + use crate::tui; 16 + use crate::types::Station; 17 + 18 + const MENU_OPTIONS: &[&str] = &[ 19 + "Search Stations", 20 + "Browse Categories", 21 + "Play Station", 22 + "Favourites", 23 + "Resume Last Station", 24 + "Quit", 25 + ]; 26 + 27 + const STATUS_TIMEOUT: Duration = Duration::from_secs(3); 28 + const NOW_PLAYING_POLL_INTERVAL: Duration = Duration::from_secs(10); 29 + 30 + enum HubMessage { 31 + NowPlaying(String), 32 + } 33 + 34 + pub async fn run(provider_name: &str) -> Result<(), Error> { 35 + let provider = resolve_provider(provider_name).await?; 36 + let (audio, mut audio_events) = AudioController::new()?; 37 + let favorites = FavoritesStore::load()?; 38 + let (metadata_tx, mut metadata_rx) = mpsc::unbounded_channel::<HubMessage>(); 39 + 40 + let mut terminal = tui::init()?; 41 + 42 + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); 43 + spawn_input_thread(input_tx.clone()); 44 + 45 + let mut app = HubApp::new( 46 + provider_name.to_string(), 47 + provider, 48 + audio, 49 + favorites, 50 + metadata_tx, 51 + ); 52 + 53 + let result = loop { 54 + terminal.draw(|frame| app.render(frame))?; 55 + 56 + tokio::select! { 57 + Some(event) = input_rx.recv() => { 58 + match app.handle_event(event).await? { 59 + Action::Quit => break Ok(()), 60 + Action::Task(task) => app.perform_task(task).await?, 61 + Action::None => {} 62 + } 63 + } 64 + Some(event) = audio_events.recv() => { 65 + app.handle_playback_event(event); 66 + } 67 + Some(message) = metadata_rx.recv() => { 68 + app.handle_metadata(message); 69 + } 70 + } 71 + 72 + app.tick(); 73 + }; 74 + 75 + tui::restore()?; 76 + 77 + result 78 + } 79 + 80 + fn spawn_input_thread(tx: mpsc::UnboundedSender<Event>) { 81 + thread::spawn(move || loop { 82 + if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { 83 + if let Ok(event) = crossterm::event::read() { 84 + if tx.send(event).is_err() { 85 + break; 86 + } 87 + } 88 + } 89 + }); 90 + } 91 + 92 + struct HubApp { 93 + provider_name: String, 94 + provider: Box<dyn Provider>, 95 + audio: AudioController, 96 + favorites: FavoritesStore, 97 + ui: UiState, 98 + current_station: Option<StationRecord>, 99 + current_playback: Option<PlaybackState>, 100 + last_station: Option<StationRecord>, 101 + volume: f32, 102 + status: Option<StatusMessage>, 103 + metadata_tx: mpsc::UnboundedSender<HubMessage>, 104 + now_playing_station_id: Option<String>, 105 + next_now_playing_poll: Instant, 106 + } 107 + 108 + impl HubApp { 109 + fn new( 110 + provider_name: String, 111 + provider: Box<dyn Provider>, 112 + audio: AudioController, 113 + favorites: FavoritesStore, 114 + metadata_tx: mpsc::UnboundedSender<HubMessage>, 115 + ) -> Self { 116 + let mut ui = UiState::default(); 117 + ui.menu_state.select(Some(0)); 118 + Self { 119 + provider_name, 120 + provider, 121 + audio, 122 + favorites, 123 + ui, 124 + current_station: None, 125 + current_playback: None, 126 + last_station: None, 127 + volume: 100.0, 128 + status: None, 129 + metadata_tx, 130 + now_playing_station_id: None, 131 + next_now_playing_poll: Instant::now(), 132 + } 133 + } 134 + 135 + fn render(&mut self, frame: &mut Frame) { 136 + let areas = Layout::default() 137 + .direction(Direction::Vertical) 138 + .constraints( 139 + [ 140 + Constraint::Length(8), 141 + Constraint::Length(1), 142 + Constraint::Min(0), 143 + Constraint::Length(1), 144 + ] 145 + .as_ref(), 146 + ) 147 + .split(frame.size()); 148 + 149 + self.render_header(frame, areas[0]); 150 + self.render_divider(frame, areas[1]); 151 + self.render_main(frame, areas[2]); 152 + frame.render_widget(self.render_footer(), areas[3]); 153 + } 154 + 155 + fn render_header(&self, frame: &mut Frame, area: Rect) { 156 + frame.render_widget( 157 + Block::new() 158 + .borders(Borders::TOP) 159 + .title(" TuneIn CLI ") 160 + .title_alignment(Alignment::Center), 161 + Rect { 162 + x: area.x, 163 + y: area.y, 164 + width: area.width, 165 + height: 1, 166 + }, 167 + ); 168 + 169 + let mut row = area.y + 1; 170 + 171 + frame.render_widget( 172 + Paragraph::new(format!("Provider {}", self.provider_name)), 173 + Rect { 174 + x: area.x, 175 + y: row, 176 + width: area.width, 177 + height: 1, 178 + }, 179 + ); 180 + row += 1; 181 + 182 + let station_name = self 183 + .current_playback 184 + .as_ref() 185 + .and_then(|p| { 186 + let name = p.stream_name.trim(); 187 + if name.is_empty() || name.eq_ignore_ascii_case("unknown") { 188 + let fallback = p.station.name.trim(); 189 + if fallback.is_empty() { 190 + None 191 + } else { 192 + Some(fallback.to_string()) 193 + } 194 + } else { 195 + Some(name.to_string()) 196 + } 197 + }) 198 + .or_else(|| { 199 + self.current_station.as_ref().and_then(|s| { 200 + let name = s.station.name.trim(); 201 + (!name.is_empty()).then_some(name.to_string()) 202 + }) 203 + }) 204 + .unwrap_or_else(|| "Unknown".to_string()); 205 + self.render_labeled_line(frame, area, row, "Station ", &station_name); 206 + row += 1; 207 + 208 + let now_playing = self 209 + .current_playback 210 + .as_ref() 211 + .and_then(|p| { 212 + let np = p.now_playing.trim(); 213 + (!np.is_empty()).then_some(np.to_string()) 214 + }) 215 + .or_else(|| { 216 + self.current_station 217 + .as_ref() 218 + .and_then(|s| s.station.playing.as_ref()) 219 + .map(|s| s.trim().to_string()) 220 + .filter(|s| !s.is_empty()) 221 + }) 222 + .unwrap_or_else(|| "โ€”".to_string()); 223 + self.render_labeled_line(frame, area, row, "Now Playing ", &now_playing); 224 + row += 1; 225 + 226 + let genre = self 227 + .current_playback 228 + .as_ref() 229 + .and_then(|p| { 230 + let genre = p.genre.trim(); 231 + (!genre.is_empty()).then_some(genre.to_string()) 232 + }) 233 + .unwrap_or_else(|| "Unknown".to_string()); 234 + self.render_labeled_line(frame, area, row, "Genre ", &genre); 235 + row += 1; 236 + 237 + let description = self 238 + .current_playback 239 + .as_ref() 240 + .and_then(|p| { 241 + let desc = p.description.trim(); 242 + (!desc.is_empty()).then_some(desc.to_string()) 243 + }) 244 + .unwrap_or_else(|| "Unknown".to_string()); 245 + self.render_labeled_line(frame, area, row, "Description ", &description); 246 + row += 1; 247 + 248 + let bitrate = self 249 + .current_playback 250 + .as_ref() 251 + .and_then(|p| { 252 + let br = p.bitrate.trim(); 253 + (!br.is_empty()).then_some(format!("{} kbps", br)) 254 + }) 255 + .or_else(|| { 256 + self.current_station.as_ref().and_then(|s| { 257 + (s.station.bitrate > 0).then_some(format!("{} kbps", s.station.bitrate)) 258 + }) 259 + }) 260 + .unwrap_or_else(|| "Unknown".to_string()); 261 + self.render_labeled_line(frame, area, row, "Bitrate ", &bitrate); 262 + row += 1; 263 + 264 + let volume_display = format!("{}%", self.volume as u32); 265 + self.render_labeled_line(frame, area, row, "Volume ", &volume_display); 266 + } 267 + 268 + fn render_labeled_line(&self, frame: &mut Frame, area: Rect, y: u16, label: &str, value: &str) { 269 + let span_label = Span::styled(label, Style::default().fg(Color::LightBlue)); 270 + let span_value = Span::raw(value); 271 + let line = Line::from(vec![span_label, span_value]); 272 + frame.render_widget( 273 + Paragraph::new(line), 274 + Rect { 275 + x: area.x, 276 + y, 277 + width: area.width, 278 + height: 1, 279 + }, 280 + ); 281 + } 282 + 283 + fn render_main(&mut self, frame: &mut Frame, area: Rect) { 284 + if matches!(self.ui.screen, Screen::Menu) { 285 + self.render_menu_area(frame, area); 286 + return; 287 + } 288 + 289 + let sections = Layout::default() 290 + .direction(Direction::Vertical) 291 + .constraints( 292 + [ 293 + Constraint::Min(0), 294 + Constraint::Length(1), 295 + Constraint::Length(5), 296 + ] 297 + .as_ref(), 298 + ) 299 + .split(area); 300 + 301 + self.render_non_menu_content(frame, sections[0]); 302 + self.render_divider(frame, sections[1]); 303 + self.render_feature_panel(frame, sections[2]); 304 + } 305 + 306 + fn render_non_menu_content(&mut self, frame: &mut Frame, area: Rect) { 307 + match &mut self.ui.screen { 308 + Screen::Menu => {} 309 + Screen::SearchInput => { 310 + let text = format!( 311 + "Search query: {}\n\nPress Enter to submit, Esc to cancel", 312 + self.ui.search_input 313 + ); 314 + let paragraph = Paragraph::new(text) 315 + .block(Block::default().title("Search").borders(Borders::ALL)); 316 + frame.render_widget(paragraph, area); 317 + } 318 + Screen::PlayInput => { 319 + let text = format!( 320 + "Station name or ID: {}\n\nPress Enter to submit, Esc to cancel", 321 + self.ui.play_input 322 + ); 323 + let paragraph = Paragraph::new(text) 324 + .block(Block::default().title("Play Station").borders(Borders::ALL)); 325 + frame.render_widget(paragraph, area); 326 + } 327 + Screen::SearchResults => { 328 + let items = Self::station_items(&self.ui.search_results); 329 + let list = List::new(items) 330 + .block( 331 + Block::default() 332 + .title(String::from("Search Results")) 333 + .borders(Borders::ALL), 334 + ) 335 + .highlight_symbol("โžœ ") 336 + .highlight_style( 337 + Style::default() 338 + .fg(Color::Yellow) 339 + .add_modifier(Modifier::BOLD), 340 + ); 341 + frame.render_stateful_widget(list, area, &mut self.ui.search_results_state); 342 + } 343 + Screen::Categories => { 344 + let items = Self::category_items(&self.ui.categories); 345 + let list = List::new(items) 346 + .block(Block::default().title("Categories").borders(Borders::ALL)) 347 + .highlight_symbol("โžœ ") 348 + .highlight_style( 349 + Style::default() 350 + .fg(Color::Yellow) 351 + .add_modifier(Modifier::BOLD), 352 + ); 353 + frame.render_stateful_widget(list, area, &mut self.ui.categories_state); 354 + } 355 + Screen::BrowseStations { category } => { 356 + let items = Self::station_items(&self.ui.browse_results); 357 + let list = List::new(items) 358 + .block( 359 + Block::default() 360 + .title(format!("Stations in {}", category)) 361 + .borders(Borders::ALL), 362 + ) 363 + .highlight_symbol("โžœ ") 364 + .highlight_style( 365 + Style::default() 366 + .fg(Color::Yellow) 367 + .add_modifier(Modifier::BOLD), 368 + ); 369 + frame.render_stateful_widget(list, area, &mut self.ui.browse_state); 370 + } 371 + Screen::Favourites => { 372 + let items = Self::favourite_items(self.favorites.all()); 373 + let list = List::new(items) 374 + .block(Block::default().title("Favourites").borders(Borders::ALL)) 375 + .highlight_symbol("โžœ ") 376 + .highlight_style( 377 + Style::default() 378 + .fg(Color::Yellow) 379 + .add_modifier(Modifier::BOLD), 380 + ); 381 + frame.render_stateful_widget(list, area, &mut self.ui.favourites_state); 382 + } 383 + Screen::Loading => { 384 + let message = self 385 + .ui 386 + .loading_message 387 + .as_deref() 388 + .unwrap_or("Loading, please waitโ€ฆ"); 389 + let paragraph = Paragraph::new(message) 390 + .block(Block::default().title("Loading").borders(Borders::ALL)) 391 + .alignment(Alignment::Center); 392 + frame.render_widget(paragraph, area); 393 + } 394 + } 395 + } 396 + 397 + fn render_divider(&self, frame: &mut Frame, area: Rect) { 398 + if area.width == 0 || area.height == 0 { 399 + return; 400 + } 401 + let width = area.width as usize; 402 + if width == 0 { 403 + return; 404 + } 405 + let mut line = String::with_capacity(width + 3); 406 + while line.len() < width { 407 + line.push_str("---"); 408 + } 409 + line.truncate(width); 410 + frame.render_widget(Paragraph::new(line), area); 411 + } 412 + 413 + fn render_feature_panel(&self, frame: &mut Frame, area: Rect) { 414 + if area.height == 0 || area.width == 0 { 415 + return; 416 + } 417 + 418 + let lines = self.feature_panel_lines(); 419 + let text = lines.join("\n"); 420 + let paragraph = 421 + Paragraph::new(text).block(Block::default().title("Actions").borders(Borders::ALL)); 422 + frame.render_widget(paragraph, area); 423 + } 424 + 425 + fn render_menu_area(&mut self, frame: &mut Frame, area: Rect) { 426 + if area.height == 0 || area.width == 0 { 427 + return; 428 + } 429 + let disable_resume = self.last_station.is_none(); 430 + let items: Vec<ListItem> = MENU_OPTIONS 431 + .iter() 432 + .map(|option| { 433 + if *option == "Resume Last Station" && disable_resume { 434 + ListItem::new(Line::from(Span::styled( 435 + *option, 436 + Style::default().fg(Color::DarkGray), 437 + ))) 438 + } else { 439 + ListItem::new(*option) 440 + } 441 + }) 442 + .collect(); 443 + let list = List::new(items) 444 + .block(Block::default().borders(Borders::ALL).title("Main Menu")) 445 + .highlight_style( 446 + Style::default() 447 + .fg(Color::Yellow) 448 + .add_modifier(Modifier::BOLD), 449 + ) 450 + .highlight_symbol("โžœ "); 451 + frame.render_stateful_widget(list, area, &mut self.ui.menu_state); 452 + } 453 + 454 + fn station_items(stations: &[Station]) -> Vec<ListItem<'_>> { 455 + if stations.is_empty() { 456 + vec![ListItem::new("No stations found")] 457 + } else { 458 + stations 459 + .iter() 460 + .map(|station| { 461 + let mut line = station.name.clone(); 462 + if let Some(now) = &station.playing { 463 + if !now.is_empty() { 464 + line.push_str(&format!(" โ€” {}", now)); 465 + } 466 + } 467 + ListItem::new(line) 468 + }) 469 + .collect() 470 + } 471 + } 472 + 473 + fn category_items(categories: &[String]) -> Vec<ListItem<'_>> { 474 + if categories.is_empty() { 475 + vec![ListItem::new("No categories available")] 476 + } else { 477 + categories 478 + .iter() 479 + .map(|category| ListItem::new(category.clone())) 480 + .collect() 481 + } 482 + } 483 + 484 + fn favourite_items(favourites: &[FavoriteStation]) -> Vec<ListItem<'_>> { 485 + if favourites.is_empty() { 486 + vec![ListItem::new("No favourites saved yet")] 487 + } else { 488 + favourites 489 + .iter() 490 + .map(|fav| ListItem::new(format!("{} ({})", fav.name, fav.provider))) 491 + .collect() 492 + } 493 + } 494 + 495 + fn handle_favourite_action(&mut self) -> Result<bool, Error> { 496 + match self.ui.screen { 497 + Screen::SearchResults => { 498 + let Some(index) = self.ui.search_results_state.selected() else { 499 + self.set_status("No search result selected"); 500 + return Ok(true); 501 + }; 502 + let station = self 503 + .ui 504 + .search_results 505 + .get(index) 506 + .cloned() 507 + .ok_or_else(|| anyhow!("Search result missing at index {}", index))?; 508 + self.add_station_to_favourites(station)?; 509 + Ok(true) 510 + } 511 + Screen::BrowseStations { .. } => { 512 + let Some(index) = self.ui.browse_state.selected() else { 513 + self.set_status("No station selected"); 514 + return Ok(true); 515 + }; 516 + let station = self 517 + .ui 518 + .browse_results 519 + .get(index) 520 + .cloned() 521 + .ok_or_else(|| anyhow!("Browse result missing at index {}", index))?; 522 + self.add_station_to_favourites(station)?; 523 + Ok(true) 524 + } 525 + Screen::Favourites => { 526 + let Some(index) = self.ui.favourites_state.selected() else { 527 + self.set_status("No favourite selected"); 528 + return Ok(true); 529 + }; 530 + self.remove_favourite_at(index)?; 531 + Ok(true) 532 + } 533 + _ => { 534 + self.toggle_current_favourite()?; 535 + Ok(true) 536 + } 537 + } 538 + } 539 + 540 + fn add_station_to_favourites(&mut self, station: Station) -> Result<(), Error> { 541 + if station.id.is_empty() { 542 + self.set_status("Cannot favourite station without an id"); 543 + return Ok(()); 544 + } 545 + 546 + let entry = FavoriteStation { 547 + id: station.id.clone(), 548 + name: station.name.clone(), 549 + provider: self.provider_name.clone(), 550 + }; 551 + 552 + if self.favorites.is_favorite(&entry.id, &entry.provider) { 553 + self.set_status("Already in favourites"); 554 + } else { 555 + self.favorites.add(entry)?; 556 + self.set_status(&format!("Added \"{}\" to favourites", station.name)); 557 + } 558 + Ok(()) 559 + } 560 + 561 + fn remove_favourite_at(&mut self, index: usize) -> Result<(), Error> { 562 + let Some(favourite) = self.favorites.all().get(index).cloned() else { 563 + self.set_status("Favourite not found"); 564 + return Ok(()); 565 + }; 566 + self.favorites.remove(&favourite.id, &favourite.provider)?; 567 + self.set_status(&format!("Removed \"{}\" from favourites", favourite.name)); 568 + 569 + let len = self.favorites.all().len(); 570 + if len == 0 { 571 + self.ui.favourites_state.select(None); 572 + } else { 573 + let new_index = index.min(len - 1); 574 + self.ui.favourites_state.select(Some(new_index)); 575 + } 576 + 577 + Ok(()) 578 + } 579 + 580 + fn stop_playback(&mut self) -> Result<(), Error> { 581 + self.audio.stop()?; 582 + self.set_status("Playback stopped"); 583 + Ok(()) 584 + } 585 + 586 + fn default_footer_hint(&self) -> String { 587 + match self.ui.screen { 588 + Screen::SearchResults => { 589 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 590 + .to_string() 591 + } 592 + Screen::Favourites => { 593 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f remove favourite โ€ข d/Delete remove โ€ข x stop playback โ€ข Esc back โ€ข +/- volume" 594 + .to_string() 595 + } 596 + Screen::Categories => { 597 + "โ†‘/โ†“ navigate โ€ข Enter open โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 598 + } 599 + Screen::BrowseStations { .. } => { 600 + "โ†‘/โ†“ navigate โ€ข Enter play โ€ข f add to favourites โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 601 + } 602 + Screen::SearchInput | Screen::PlayInput => { 603 + "Type to edit โ€ข Enter submit โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string() 604 + } 605 + Screen::Loading => "Please waitโ€ฆ โ€ข x stop playback โ€ข Esc cancel โ€ข +/- volume".to_string(), 606 + Screen::Menu => { 607 + "โ†‘/โ†“ navigate โ€ข Enter select โ€ข x stop playback โ€ข Esc back โ€ข +/- volume".to_string() 608 + } 609 + } 610 + } 611 + 612 + fn feature_panel_lines(&self) -> Vec<String> { 613 + let mut lines = match self.ui.screen { 614 + Screen::SearchResults => vec![ 615 + "Search Results".to_string(), 616 + "Enter โ€ข Play highlighted station".to_string(), 617 + "f โ€ข Add highlighted station to favourites".to_string(), 618 + "Esc โ€ข Return to main menu".to_string(), 619 + ], 620 + Screen::Favourites => vec![ 621 + "Favourites".to_string(), 622 + "Enter โ€ข Play selected favourite".to_string(), 623 + "f โ€ข Remove highlighted favourite".to_string(), 624 + "d/Del โ€ข Remove highlighted favourite".to_string(), 625 + "Esc โ€ข Return to main menu".to_string(), 626 + ], 627 + Screen::BrowseStations { .. } => vec![ 628 + "Browse Stations".to_string(), 629 + "Enter โ€ข Play highlighted station".to_string(), 630 + "f โ€ข Add highlighted station to favourites".to_string(), 631 + "Esc โ€ข Back to categories".to_string(), 632 + ], 633 + Screen::Categories => vec![ 634 + "Categories".to_string(), 635 + "Enter โ€ข Drill into selected category".to_string(), 636 + "Esc โ€ข Return to main menu".to_string(), 637 + ], 638 + Screen::SearchInput => vec![ 639 + "Search".to_string(), 640 + "Enter โ€ข Run search".to_string(), 641 + "Esc โ€ข Cancel".to_string(), 642 + ], 643 + Screen::PlayInput => vec![ 644 + "Play Station".to_string(), 645 + "Enter โ€ข Start playback".to_string(), 646 + "Esc โ€ข Cancel".to_string(), 647 + ], 648 + Screen::Loading => vec!["Loadingโ€ฆ".to_string(), "Esc โ€ข Cancel".to_string()], 649 + Screen::Menu => vec![ 650 + "Main Menu".to_string(), 651 + "Enter โ€ข Activate highlighted option".to_string(), 652 + "Esc โ€ข Quit or back".to_string(), 653 + ], 654 + }; 655 + 656 + if self.current_station.is_some() { 657 + lines.insert(1, "x โ€ข Stop playback".to_string()); 658 + } else { 659 + lines.insert(1, "x โ€ข Stop playback (no active stream)".to_string()); 660 + } 661 + 662 + lines 663 + } 664 + 665 + fn render_footer(&self) -> Paragraph<'_> { 666 + let hint = self.default_footer_hint(); 667 + let text = if let Some(status) = &self.status { 668 + format!("{} โ€ข {}", status.message, hint) 669 + } else { 670 + hint 671 + }; 672 + Paragraph::new(text) 673 + } 674 + 675 + async fn handle_event(&mut self, event: Event) -> Result<Action, Error> { 676 + match event { 677 + Event::Key(key) => self.handle_key_event(key).await, 678 + Event::Resize(_, _) => Ok(Action::None), 679 + _ => Ok(Action::None), 680 + } 681 + } 682 + 683 + async fn handle_key_event(&mut self, key: KeyEvent) -> Result<Action, Error> { 684 + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { 685 + return Ok(Action::Quit); 686 + } 687 + 688 + match key.code { 689 + KeyCode::Char('+') | KeyCode::Char('=') => { 690 + self.adjust_volume(5.0)?; 691 + return Ok(Action::None); 692 + } 693 + KeyCode::Char('-') => { 694 + self.adjust_volume(-5.0)?; 695 + return Ok(Action::None); 696 + } 697 + KeyCode::Char('x') => { 698 + self.stop_playback()?; 699 + return Ok(Action::None); 700 + } 701 + KeyCode::Char('f') => { 702 + if self.handle_favourite_action()? { 703 + return Ok(Action::None); 704 + } 705 + } 706 + KeyCode::Esc if !matches!(self.ui.screen, Screen::Menu) => { 707 + self.ui.screen = Screen::Menu; 708 + return Ok(Action::None); 709 + } 710 + _ => {} 711 + } 712 + 713 + match self.ui.screen { 714 + Screen::Menu => self.handle_menu_keys(key), 715 + Screen::SearchInput => self.handle_text_input(key, true), 716 + Screen::PlayInput => self.handle_text_input(key, false), 717 + Screen::SearchResults => self.handle_station_list_keys(key, ListKind::Search), 718 + Screen::Categories => self.handle_categories_keys(key), 719 + Screen::BrowseStations { .. } => self.handle_station_list_keys(key, ListKind::Browse), 720 + Screen::Favourites => self.handle_favourites_keys(key), 721 + Screen::Loading => Ok(Action::None), 722 + } 723 + } 724 + 725 + fn handle_menu_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 726 + let current = self.ui.menu_state.selected().unwrap_or(0); 727 + match key.code { 728 + KeyCode::Up => { 729 + let new = current.saturating_sub(1); 730 + self.ui.menu_state.select(Some(new)); 731 + Ok(Action::None) 732 + } 733 + KeyCode::Down => { 734 + let max = MENU_OPTIONS.len().saturating_sub(1); 735 + let new = (current + 1).min(max); 736 + self.ui.menu_state.select(Some(new)); 737 + Ok(Action::None) 738 + } 739 + KeyCode::Enter => match MENU_OPTIONS[current] { 740 + "Search Stations" => { 741 + self.ui.search_input.clear(); 742 + self.ui.screen = Screen::SearchInput; 743 + Ok(Action::None) 744 + } 745 + "Browse Categories" => { 746 + self.ui.loading_message = Some("Fetching categoriesโ€ฆ".to_string()); 747 + self.ui.screen = Screen::Loading; 748 + Ok(Action::Task(PendingTask::LoadCategories)) 749 + } 750 + "Play Station" => { 751 + self.ui.play_input.clear(); 752 + self.ui.screen = Screen::PlayInput; 753 + Ok(Action::None) 754 + } 755 + "Favourites" => { 756 + self.ui.screen = Screen::Favourites; 757 + if self.favorites.all().is_empty() { 758 + self.ui.favourites_state.select(None); 759 + } else { 760 + self.ui.favourites_state.select(Some(0)); 761 + } 762 + Ok(Action::None) 763 + } 764 + "Resume Last Station" => { 765 + if let Some(station) = self.last_station.clone() { 766 + Ok(Action::Task(PendingTask::PlayStation(station))) 767 + } else { 768 + self.set_status("No station played yet to resume"); 769 + Ok(Action::None) 770 + } 771 + } 772 + "Quit" => Ok(Action::Quit), 773 + _ => Ok(Action::None), 774 + }, 775 + _ => Ok(Action::None), 776 + } 777 + } 778 + 779 + fn handle_text_input(&mut self, key: KeyEvent, is_search: bool) -> Result<Action, Error> { 780 + let buffer = if is_search { 781 + &mut self.ui.search_input 782 + } else { 783 + &mut self.ui.play_input 784 + }; 785 + 786 + match key.code { 787 + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { 788 + buffer.push(c); 789 + Ok(Action::None) 790 + } 791 + KeyCode::Backspace => { 792 + buffer.pop(); 793 + Ok(Action::None) 794 + } 795 + KeyCode::Enter => { 796 + if buffer.trim().is_empty() { 797 + self.set_status("Input cannot be empty"); 798 + return Ok(Action::None); 799 + } 800 + let query = buffer.trim().to_string(); 801 + self.ui.loading_message = Some("Searching stationsโ€ฆ".to_string()); 802 + self.ui.screen = Screen::Loading; 803 + if is_search { 804 + Ok(Action::Task(PendingTask::Search(query))) 805 + } else { 806 + Ok(Action::Task(PendingTask::PlayDirect(query))) 807 + } 808 + } 809 + _ => Ok(Action::None), 810 + } 811 + } 812 + 813 + fn handle_station_list_keys(&mut self, key: KeyEvent, kind: ListKind) -> Result<Action, Error> { 814 + let (items_len, state) = match kind { 815 + ListKind::Search => ( 816 + self.ui.search_results.len(), 817 + &mut self.ui.search_results_state, 818 + ), 819 + ListKind::Browse => (self.ui.browse_results.len(), &mut self.ui.browse_state), 820 + }; 821 + 822 + if items_len == 0 { 823 + if key.code == KeyCode::Esc { 824 + self.ui.screen = Screen::Menu; 825 + } 826 + return Ok(Action::None); 827 + } 828 + 829 + let current = state.selected().unwrap_or(0); 830 + match key.code { 831 + KeyCode::Up => { 832 + let new = current.saturating_sub(1); 833 + state.select(Some(new)); 834 + Ok(Action::None) 835 + } 836 + KeyCode::Down => { 837 + let max = items_len.saturating_sub(1); 838 + let new = (current + 1).min(max); 839 + state.select(Some(new)); 840 + Ok(Action::None) 841 + } 842 + KeyCode::Enter => { 843 + let station = match kind { 844 + ListKind::Search => self.ui.search_results[current].clone(), 845 + ListKind::Browse => self.ui.browse_results[current].clone(), 846 + }; 847 + Ok(Action::Task(PendingTask::PlayStation(StationRecord { 848 + provider: self.provider_name.clone(), 849 + station, 850 + }))) 851 + } 852 + KeyCode::Esc => { 853 + self.ui.screen = Screen::Menu; 854 + Ok(Action::None) 855 + } 856 + _ => Ok(Action::None), 857 + } 858 + } 859 + 860 + fn handle_categories_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 861 + let len = self.ui.categories.len(); 862 + if len == 0 { 863 + if key.code == KeyCode::Esc { 864 + self.ui.screen = Screen::Menu; 865 + } 866 + return Ok(Action::None); 867 + } 868 + 869 + let current = self.ui.categories_state.selected().unwrap_or(0); 870 + match key.code { 871 + KeyCode::Up => { 872 + let new = current.saturating_sub(1); 873 + self.ui.categories_state.select(Some(new)); 874 + Ok(Action::None) 875 + } 876 + KeyCode::Down => { 877 + let max = len.saturating_sub(1); 878 + let new = (current + 1).min(max); 879 + self.ui.categories_state.select(Some(new)); 880 + Ok(Action::None) 881 + } 882 + KeyCode::Enter => { 883 + let category = self.ui.categories[current].clone(); 884 + self.ui.loading_message = Some(format!("Loading stations for {}โ€ฆ", category)); 885 + self.ui.screen = Screen::Loading; 886 + Ok(Action::Task(PendingTask::LoadCategoryStations { category })) 887 + } 888 + KeyCode::Esc => { 889 + self.ui.screen = Screen::Menu; 890 + Ok(Action::None) 891 + } 892 + _ => Ok(Action::None), 893 + } 894 + } 895 + 896 + fn handle_favourites_keys(&mut self, key: KeyEvent) -> Result<Action, Error> { 897 + let len = self.favorites.all().len(); 898 + if len == 0 { 899 + if key.code == KeyCode::Esc { 900 + self.ui.screen = Screen::Menu; 901 + } 902 + return Ok(Action::None); 903 + } 904 + 905 + let current = self.ui.favourites_state.selected().unwrap_or(0); 906 + match key.code { 907 + KeyCode::Up => { 908 + let new = current.saturating_sub(1); 909 + self.ui.favourites_state.select(Some(new)); 910 + Ok(Action::None) 911 + } 912 + KeyCode::Down => { 913 + let max = len.saturating_sub(1); 914 + let new = (current + 1).min(max); 915 + self.ui.favourites_state.select(Some(new)); 916 + Ok(Action::None) 917 + } 918 + KeyCode::Enter => { 919 + let favourite = self.favorites.all()[current].clone(); 920 + Ok(Action::Task(PendingTask::PlayFavourite(favourite))) 921 + } 922 + KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('f') => { 923 + self.remove_favourite_at(current)?; 924 + Ok(Action::None) 925 + } 926 + KeyCode::Esc => { 927 + self.ui.screen = Screen::Menu; 928 + Ok(Action::None) 929 + } 930 + _ => Ok(Action::None), 931 + } 932 + } 933 + 934 + fn adjust_volume(&mut self, delta: f32) -> Result<(), Error> { 935 + self.volume = (self.volume + delta).clamp(0.0, 150.0); 936 + self.audio.set_volume(self.volume)?; 937 + self.set_status(&format!("Volume set to {}%", self.volume as u32)); 938 + Ok(()) 939 + } 940 + 941 + fn toggle_current_favourite(&mut self) -> Result<(), Error> { 942 + let Some(station) = &self.current_station else { 943 + self.set_status("No active station to favourite"); 944 + return Ok(()); 945 + }; 946 + 947 + if station.station.id.is_empty() { 948 + self.set_status("Current station cannot be favourited"); 949 + return Ok(()); 950 + } 951 + 952 + let entry = FavoriteStation { 953 + id: station.station.id.clone(), 954 + name: station.station.name.clone(), 955 + provider: station.provider.clone(), 956 + }; 957 + let added = self.favorites.toggle(entry)?; 958 + if added { 959 + self.set_status("Added to favourites"); 960 + } else { 961 + self.set_status("Removed from favourites"); 962 + } 963 + Ok(()) 964 + } 965 + 966 + fn handle_playback_event(&mut self, event: PlaybackEvent) { 967 + match event { 968 + PlaybackEvent::Started(state) => { 969 + self.current_playback = Some(state.clone()); 970 + if let Some(station) = self.current_station.as_mut() { 971 + station.station.playing = Some(state.now_playing.clone()); 972 + } 973 + self.set_status(&format!("Now playing {}", state.stream_name)); 974 + self.prepare_now_playing_poll(); 975 + } 976 + PlaybackEvent::Error(err) => { 977 + self.current_playback = None; 978 + self.set_status(&format!("Playback error: {}", err)); 979 + self.now_playing_station_id = None; 980 + } 981 + PlaybackEvent::Stopped => { 982 + self.current_playback = None; 983 + self.set_status("Playback stopped"); 984 + self.now_playing_station_id = None; 985 + } 986 + } 987 + } 988 + 989 + fn handle_metadata(&mut self, message: HubMessage) { 990 + match message { 991 + HubMessage::NowPlaying(now_playing) => { 992 + if let Some(playback) = self.current_playback.as_mut() { 993 + playback.now_playing = now_playing.clone(); 994 + } 995 + if let Some(station) = self.current_station.as_mut() { 996 + station.station.playing = Some(now_playing.clone()); 997 + } 998 + self.set_status(&format!("Now Playing {}", now_playing)); 999 + } 1000 + } 1001 + } 1002 + 1003 + async fn perform_task(&mut self, task: PendingTask) -> Result<(), Error> { 1004 + self.ui.loading_message = None; 1005 + match task { 1006 + PendingTask::Search(query) => { 1007 + let results = self.provider.search(query.clone()).await?; 1008 + self.ui.search_results = results; 1009 + self.ui.search_results_state.select(Some(0)); 1010 + self.ui.screen = Screen::SearchResults; 1011 + self.set_status(&format!("Search complete for \"{}\"", query)); 1012 + } 1013 + PendingTask::LoadCategories => { 1014 + let categories = self.provider.categories(0, 100).await?; 1015 + self.ui.categories = categories; 1016 + self.ui.categories_state.select(Some(0)); 1017 + self.ui.screen = Screen::Categories; 1018 + self.set_status("Categories loaded"); 1019 + } 1020 + PendingTask::LoadCategoryStations { category } => { 1021 + let stations = self.provider.browse(category.clone(), 0, 100).await?; 1022 + self.ui.browse_results = stations; 1023 + self.ui.browse_state.select(Some(0)); 1024 + self.ui.screen = Screen::BrowseStations { category }; 1025 + self.set_status("Stations loaded"); 1026 + } 1027 + PendingTask::PlayDirect(input) => { 1028 + let provider = resolve_provider(&self.provider_name).await?; 1029 + match provider.get_station(input.clone()).await? { 1030 + Some(mut station) => { 1031 + if station.stream_url.is_empty() { 1032 + station = fetch_station(&self.provider_name, &station.id) 1033 + .await? 1034 + .ok_or_else(|| anyhow!("Unable to locate stream for station"))?; 1035 + } 1036 + self.play_station(StationRecord { 1037 + provider: self.provider_name.clone(), 1038 + station, 1039 + }) 1040 + .await?; 1041 + } 1042 + None => { 1043 + self.ui.screen = Screen::Menu; 1044 + self.set_status(&format!("Station \"{}\" not found", input)); 1045 + } 1046 + } 1047 + } 1048 + PendingTask::PlayStation(record) => { 1049 + self.play_station(record).await?; 1050 + } 1051 + PendingTask::PlayFavourite(favourite) => { 1052 + let station = fetch_station(&favourite.provider, &favourite.id) 1053 + .await? 1054 + .ok_or_else(|| anyhow!("Failed to load favourite station"))?; 1055 + self.play_station(StationRecord { 1056 + provider: favourite.provider, 1057 + station, 1058 + }) 1059 + .await?; 1060 + } 1061 + } 1062 + Ok(()) 1063 + } 1064 + 1065 + async fn play_station(&mut self, mut record: StationRecord) -> Result<(), Error> { 1066 + if record.station.stream_url.is_empty() { 1067 + if let Some(enriched) = fetch_station(&record.provider, &record.station.id).await? { 1068 + record.station = enriched; 1069 + } else { 1070 + return Err(anyhow!("Unable to resolve station stream")); 1071 + } 1072 + } 1073 + 1074 + self.audio.play(record.station.clone(), self.volume)?; 1075 + self.current_station = Some(record.clone()); 1076 + self.last_station = Some(record); 1077 + self.prepare_now_playing_poll(); 1078 + self.ui.screen = Screen::Menu; 1079 + Ok(()) 1080 + } 1081 + 1082 + fn prepare_now_playing_poll(&mut self) { 1083 + if let Some(station) = &self.current_station { 1084 + if station.provider == "tunein" && !station.station.id.is_empty() { 1085 + self.now_playing_station_id = Some(station.station.id.clone()); 1086 + self.next_now_playing_poll = Instant::now(); 1087 + } else { 1088 + self.now_playing_station_id = None; 1089 + } 1090 + } 1091 + } 1092 + 1093 + fn tick(&mut self) { 1094 + if let Some(status) = &self.status { 1095 + if status.expires_at <= Instant::now() { 1096 + self.status = None; 1097 + } 1098 + } 1099 + self.poll_now_playing_if_needed(); 1100 + } 1101 + 1102 + fn poll_now_playing_if_needed(&mut self) { 1103 + let Some(station_id) = self.now_playing_station_id.clone() else { 1104 + return; 1105 + }; 1106 + 1107 + if Instant::now() < self.next_now_playing_poll { 1108 + return; 1109 + } 1110 + 1111 + let tx = self.metadata_tx.clone(); 1112 + tokio::spawn(async move { 1113 + if let Ok(now) = get_currently_playing(&station_id).await { 1114 + let _ = tx.send(HubMessage::NowPlaying(now)); 1115 + } 1116 + }); 1117 + 1118 + self.next_now_playing_poll = Instant::now() + NOW_PLAYING_POLL_INTERVAL; 1119 + } 1120 + 1121 + fn set_status<S: Into<String>>(&mut self, message: S) { 1122 + self.status = Some(StatusMessage { 1123 + message: message.into(), 1124 + expires_at: Instant::now() + STATUS_TIMEOUT, 1125 + }); 1126 + } 1127 + } 1128 + 1129 + struct UiState { 1130 + screen: Screen, 1131 + menu_state: ListState, 1132 + search_input: String, 1133 + play_input: String, 1134 + search_results: Vec<Station>, 1135 + search_results_state: ListState, 1136 + categories: Vec<String>, 1137 + categories_state: ListState, 1138 + browse_results: Vec<Station>, 1139 + browse_state: ListState, 1140 + favourites_state: ListState, 1141 + loading_message: Option<String>, 1142 + } 1143 + 1144 + impl Default for UiState { 1145 + fn default() -> Self { 1146 + Self { 1147 + screen: Screen::Menu, 1148 + menu_state: ListState::default(), 1149 + search_input: String::new(), 1150 + play_input: String::new(), 1151 + search_results: Vec::new(), 1152 + search_results_state: ListState::default(), 1153 + categories: Vec::new(), 1154 + categories_state: ListState::default(), 1155 + browse_results: Vec::new(), 1156 + browse_state: ListState::default(), 1157 + favourites_state: ListState::default(), 1158 + loading_message: None, 1159 + } 1160 + } 1161 + } 1162 + 1163 + #[derive(Clone)] 1164 + enum Screen { 1165 + Menu, 1166 + SearchInput, 1167 + PlayInput, 1168 + SearchResults, 1169 + Categories, 1170 + BrowseStations { category: String }, 1171 + Favourites, 1172 + Loading, 1173 + } 1174 + 1175 + enum ListKind { 1176 + Search, 1177 + Browse, 1178 + } 1179 + 1180 + enum PendingTask { 1181 + Search(String), 1182 + LoadCategories, 1183 + LoadCategoryStations { category: String }, 1184 + PlayDirect(String), 1185 + PlayStation(StationRecord), 1186 + PlayFavourite(FavoriteStation), 1187 + } 1188 + 1189 + enum Action { 1190 + None, 1191 + Quit, 1192 + Task(PendingTask), 1193 + } 1194 + 1195 + struct StatusMessage { 1196 + message: String, 1197 + expires_at: Instant, 1198 + } 1199 + 1200 + #[derive(Clone)] 1201 + struct StationRecord { 1202 + provider: String, 1203 + station: Station, 1204 + } 1205 + 1206 + async fn resolve_provider(name: &str) -> Result<Box<dyn Provider>, Error> { 1207 + match name { 1208 + "tunein" => Ok(Box::new(Tunein::new())), 1209 + "radiobrowser" => Ok(Box::new(Radiobrowser::new().await)), 1210 + other => Err(anyhow!("Unsupported provider '{}'", other)), 1211 + } 1212 + } 1213 + 1214 + async fn fetch_station(provider_name: &str, id: &str) -> Result<Option<Station>, Error> { 1215 + let provider = resolve_provider(provider_name).await?; 1216 + provider.get_station(id.to_string()).await 1217 + }
+17 -8
src/main.rs
··· 5 5 use clap::{arg, builder::ValueParser, Command}; 6 6 7 7 mod app; 8 + mod audio; 8 9 mod browse; 9 10 mod cfg; 10 11 mod decoder; 11 12 mod extract; 13 + mod favorites; 12 14 mod input; 15 + mod interactive; 13 16 mod music; 14 17 mod play; 15 18 mod player; ··· 39 42 .arg( 40 43 arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") 41 44 ) 42 - .subcommand_required(true) 43 45 .subcommand( 44 46 Command::new("search") 45 47 .about("Search for a radio station") ··· 88 90 #[tokio::main] 89 91 async fn main() -> Result<(), Error> { 90 92 let matches = cli().get_matches(); 93 + let provider = matches.value_of("provider").unwrap().to_string(); 91 94 92 95 match matches.subcommand() { 93 96 Some(("search", args)) => { 94 97 let query = args.value_of("query").unwrap(); 95 - let provider = matches.value_of("provider").unwrap(); 96 - search::exec(query, provider).await?; 98 + search::exec(query, provider.as_str()).await?; 97 99 } 98 100 Some(("play", args)) => { 99 101 let station = args.value_of("station").unwrap(); 100 - let provider = matches.value_of("provider").unwrap(); 101 102 let volume = args.value_of("volume").unwrap().parse::<f32>().unwrap(); 102 103 let display_mode = args 103 104 .value_of("display-mode") ··· 115 116 ); 116 117 play::exec( 117 118 station, 118 - provider, 119 + provider.as_str(), 119 120 volume, 120 121 display_mode, 121 122 *enable_os_media_controls, ··· 128 129 let category = args.value_of("category"); 129 130 let offset = args.value_of("offset").unwrap(); 130 131 let limit = args.value_of("limit").unwrap(); 131 - let provider = matches.value_of("provider").unwrap(); 132 132 browse::exec( 133 133 category, 134 134 offset.parse::<u32>()?, 135 135 limit.parse::<u32>()?, 136 - provider, 136 + provider.as_str(), 137 137 ) 138 138 .await?; 139 139 } ··· 151 151 std::process::exit(1); 152 152 } 153 153 }, 154 - _ => unreachable!(), 154 + None => { 155 + interactive::run(provider.as_str()).await?; 156 + } 157 + Some((other, _)) => { 158 + eprintln!( 159 + "Unknown subcommand '{}'. Use `tunein --help` for available commands.", 160 + other 161 + ); 162 + std::process::exit(1); 163 + } 155 164 } 156 165 157 166 Ok(())
+1 -1
src/play.rs
··· 143 143 let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 144 144 let sink = rodio::Sink::try_new(&handle).unwrap(); 145 145 sink.set_volume(volume.volume_ratio()); 146 - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 146 + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 147 147 sink.append(decoder); 148 148 149 149 loop {
+2 -2
src/player.rs
··· 61 61 let sink = self.sink.clone(); 62 62 63 63 thread::spawn(move || { 64 - let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 64 + let (frame_tx, _frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 65 65 let client = reqwest::blocking::Client::new(); 66 66 67 67 let response = client.get(url.clone()).send().unwrap(); ··· 80 80 } 81 81 None => response, 82 82 }; 83 - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 83 + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); 84 84 85 85 { 86 86 let sink = sink.lock().unwrap();
+2 -1
src/visualization/mod.rs
··· 20 20 pub struct GraphConfig { 21 21 pub pause: bool, 22 22 pub samples: u32, 23 + #[allow(dead_code)] 23 24 pub sampling_rate: u32, 24 25 pub scale: f64, 25 26 pub width: u32, ··· 47 48 fn from_args(args: &crate::cfg::SourceOptions) -> Self 48 49 where 49 50 Self: Sized; 50 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this 51 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_>; // TODO simplify this 51 52 fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>; 52 53 fn mode_str(&self) -> &'static str; 53 54
+1 -1
src/visualization/oscilloscope.rs
··· 55 55 } 56 56 } 57 57 58 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 58 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 59 59 let (name, bounds) = match dimension { 60 60 Dimension::X => ("time -", [0.0, cfg.samples as f64]), 61 61 Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]),
+1 -1
src/visualization/spectroscope.rs
··· 84 84 } 85 85 } 86 86 87 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 87 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 88 88 let (name, bounds) = match dimension { 89 89 Dimension::X => ( 90 90 "frequency -",
+1 -1
src/visualization/vectorscope.rs
··· 28 28 "live".into() 29 29 } 30 30 31 - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 31 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { 32 32 let (name, bounds) = match dimension { 33 33 Dimension::X => ("left -", [-cfg.scale, cfg.scale]), 34 34 Dimension::Y => ("| right", [-cfg.scale, cfg.scale]),