From 2bd2abc7d9fc3b5a72c0bd7337d511cd5df35734 Mon Sep 17 00:00:00 2001 From: Mert Demir Date: Tue, 21 Oct 2025 18:48:52 +0200 Subject: [PATCH] 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 | 0 .cargo-home/.crates2.json | 0 .cargo-home/.package-cache | 0 .cargo-home/registry/CACHEDIR.TAG | 3 + .gitignore | 3 +- Cargo.lock | 52 +- Cargo.toml | 4 +- build.rs | 1 + src/audio.rs | 223 ++++++ src/decoder.rs | 11 +- src/extract.rs | 7 +- src/favorites.rs | 108 +++ src/interactive.rs | 1217 +++++++++++++++++++++++++++++ src/main.rs | 25 +- src/play.rs | 2 +- src/player.rs | 4 +- src/visualization/mod.rs | 3 +- src/visualization/oscilloscope.rs | 2 +- src/visualization/spectroscope.rs | 2 +- src/visualization/vectorscope.rs | 2 +- 20 files changed, 1645 insertions(+), 24 deletions(-) create mode 100644 .cargo-home/.crates.toml create mode 100644 .cargo-home/.crates2.json create mode 100644 .cargo-home/.package-cache create mode 100644 .cargo-home/registry/CACHEDIR.TAG create mode 100644 src/audio.rs create mode 100644 src/favorites.rs create mode 100644 src/interactive.rs diff --git a/.cargo-home/.crates.toml b/.cargo-home/.crates.toml new file mode 100644 index 0000000..e69de29 diff --git a/.cargo-home/.crates2.json b/.cargo-home/.crates2.json new file mode 100644 index 0000000..e69de29 diff --git a/.cargo-home/.package-cache b/.cargo-home/.package-cache new file mode 100644 index 0000000..e69de29 diff --git a/.cargo-home/registry/CACHEDIR.TAG b/.cargo-home/registry/CACHEDIR.TAG new file mode 100644 index 0000000..20d7c31 --- /dev/null +++ b/.cargo-home/registry/CACHEDIR.TAG @@ -0,0 +1,3 @@ +Signature: 8a477f597d28d172789f06886806bc55 +# This file is a cache directory tag created by cargo. +# For information about cache directory tags see https://bford.info/cachedir/ diff --git a/.gitignore b/.gitignore index ae4d0ff..cd64159 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -/result \ No newline at end of file +/result +*.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index e3a8916..b4c3008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,6 +1064,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "discard" version = "1.0.4" @@ -2146,6 +2167,16 @@ dependencies = [ "redox_syscall 0.4.1", ] +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.8.0", + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2598,6 +2629,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -3023,6 +3060,17 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox 0.1.10", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -3963,7 +4011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d" dependencies = [ "libc", - "libredox", + "libredox 0.0.2", "numtoa", "redox_termios", ] @@ -4366,6 +4414,7 @@ dependencies = [ "cpal", "crossterm", "derive_more", + "directories", "futures", "futures-util", "hyper 0.14.32", @@ -4381,6 +4430,7 @@ dependencies = [ "rodio", "rustfft", "serde", + "serde_json", "souvlaki", "surf", "symphonia", diff --git a/Cargo.toml b/Cargo.toml index b67b8e4..0a9955e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ hyper = { version = "0.14.23", features = [ m3u = "1.0.0" minimp3 = "0.6" owo-colors = "3.5.0" +directories = "5.0.1" pls = "0.2.2" prost = "0.13.2" radiobrowser = { version = "0.6.1", features = [ @@ -58,7 +59,8 @@ reqwest = { version = "0.11.14", features = [ ], default-features = false } rodio = { version = "0.16" } rustfft = "6.2.0" -serde = "1.0.197" +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.117" surf = { version = "2.3.2", features = [ "h1-client-rustls", ], default-features = false } diff --git a/build.rs b/build.rs index 557b1fa..f844f10 100644 --- a/build.rs +++ b/build.rs @@ -2,6 +2,7 @@ fn main() -> Result<(), Box> { tonic_build::configure() .out_dir("src/api") .file_descriptor_set_path("src/api/descriptor.bin") + .protoc_arg("--experimental_allow_proto3_optional") .compile_protos( &[ "proto/objects/v1alpha1/category.proto", diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..7bc3888 --- /dev/null +++ b/src/audio.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Error}; +use hyper::header::HeaderValue; +use rodio::{OutputStream, OutputStreamHandle, Sink}; +use tokio::sync::mpsc; + +use crate::decoder::Mp3Decoder; +use crate::types::Station; + +/// Commands sent to the audio worker thread. +#[derive(Debug)] +enum AudioCommand { + Play { + station: Station, + volume_percent: f32, + }, + SetVolume(f32), + Stop, +} + +/// Playback events emitted by the audio worker. +#[derive(Debug, Clone)] +pub enum PlaybackEvent { + Started(PlaybackState), + Error(String), + Stopped, +} + +/// Public interface for receiving playback events. +pub struct PlaybackEvents { + rx: mpsc::UnboundedReceiver, +} + +impl PlaybackEvents { + pub async fn recv(&mut self) -> Option { + self.rx.recv().await + } +} + +/// Snapshot of the current playback metadata. +#[derive(Debug, Clone)] +pub struct PlaybackState { + pub station: Station, + pub stream_name: String, + pub now_playing: String, + pub genre: String, + pub description: String, + pub bitrate: String, +} + +/// Controller that owns the command channel to the audio worker. +pub struct AudioController { + cmd_tx: mpsc::UnboundedSender, +} + +impl AudioController { + /// Spawn a new audio worker thread and return a controller plus event receiver. + pub fn new() -> Result<(Self, PlaybackEvents), Error> { + let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::(); + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + + thread::Builder::new() + .name("tunein-audio-worker".into()) + .spawn({ + let events = event_tx.clone(); + move || { + let mut worker = AudioWorker::new(event_tx); + if let Err(err) = worker.run(&mut cmd_rx) { + let _ = events.send(PlaybackEvent::Error(err.to_string())); + } + } + }) + .context("failed to spawn audio worker thread")?; + + Ok((Self { cmd_tx }, PlaybackEvents { rx: event_rx })) + } + + pub fn play(&self, station: Station, volume_percent: f32) -> Result<(), Error> { + self.cmd_tx + .send(AudioCommand::Play { + station, + volume_percent, + }) + .map_err(|e| Error::msg(e.to_string())) + } + + pub fn set_volume(&self, volume_percent: f32) -> Result<(), Error> { + self.cmd_tx + .send(AudioCommand::SetVolume(volume_percent)) + .map_err(|e| Error::msg(e.to_string())) + } + + pub fn stop(&self) -> Result<(), Error> { + self.cmd_tx + .send(AudioCommand::Stop) + .map_err(|e| Error::msg(e.to_string())) + } +} + +struct AudioWorker { + _stream: OutputStream, + handle: OutputStreamHandle, + sink: Option>, + current_volume: f32, + events: mpsc::UnboundedSender, +} + +impl AudioWorker { + fn new(events: mpsc::UnboundedSender) -> Self { + let (stream, handle) = + OutputStream::try_default().expect("failed to acquire default audio output device"); + Self { + _stream: stream, + handle, + sink: None, + current_volume: 100.0, + events, + } + } + + fn run(&mut self, cmd_rx: &mut mpsc::UnboundedReceiver) -> Result<(), Error> { + while let Some(cmd) = cmd_rx.blocking_recv() { + match cmd { + AudioCommand::Play { + station, + volume_percent, + } => self.handle_play(station, volume_percent)?, + AudioCommand::SetVolume(volume_percent) => { + self.current_volume = volume_percent.max(0.0); + if let Some(sink) = &self.sink { + sink.set_volume(self.current_volume / 100.0); + } + } + AudioCommand::Stop => { + if let Some(sink) = self.sink.take() { + sink.stop(); + } + let _ = self.events.send(PlaybackEvent::Stopped); + } + } + } + + Ok(()) + } + + fn handle_play(&mut self, station: Station, volume_percent: f32) -> Result<(), Error> { + if let Some(sink) = self.sink.take() { + sink.stop(); + thread::sleep(Duration::from_millis(50)); + } + + let stream_url = station.stream_url.clone(); + let client = reqwest::blocking::Client::new(); + let response = client + .get(&stream_url) + .send() + .with_context(|| format!("failed to open stream {}", stream_url))?; + + let headers = response.headers().clone(); + let now_playing = station.playing.clone().unwrap_or_default(); + + let display_name = header_to_string(headers.get("icy-name")) + .filter(|name| name != "Unknown") + .unwrap_or_else(|| station.name.clone()); + let genre = header_to_string(headers.get("icy-genre")).unwrap_or_default(); + let description = header_to_string(headers.get("icy-description")).unwrap_or_default(); + let bitrate = header_to_string(headers.get("icy-br")).unwrap_or_default(); + + let response = follow_redirects(client, response)?; + + let sink = Arc::new(Sink::try_new(&self.handle)?); + sink.set_volume(volume_percent.max(0.0) / 100.0); + + let decoder = Mp3Decoder::new(response, None).map_err(|_| { + Error::msg("stream is not in MP3 format or failed to initialize decoder") + })?; + sink.append(decoder); + sink.play(); + + self.current_volume = volume_percent; + self.sink = Some(sink.clone()); + + let state = PlaybackState { + station, + stream_name: display_name, + now_playing, + genre, + description, + bitrate, + }; + + let _ = self.events.send(PlaybackEvent::Started(state)); + + Ok(()) + } +} + +fn follow_redirects( + client: reqwest::blocking::Client, + response: reqwest::blocking::Response, +) -> Result { + let mut current = response; + for _ in 0..3 { + if let Some(location) = current.headers().get("location") { + let url = location + .to_str() + .map_err(|_| Error::msg("invalid redirect location header"))?; + current = client.get(url).send()?; + } else { + return Ok(current); + } + } + Ok(current) +} + +fn header_to_string(value: Option<&HeaderValue>) -> Option { + value + .and_then(|header| header.to_str().ok()) + .map(|s| s.to_string()) +} diff --git a/src/decoder.rs b/src/decoder.rs index f01bf36..f4418f0 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -11,14 +11,14 @@ where decoder: Decoder, current_frame: Frame, current_frame_offset: usize, - tx: Sender, + tx: Option>, } impl Mp3Decoder where R: Read, { - pub fn new(mut data: R, tx: Sender) -> Result { + pub fn new(mut data: R, tx: Option>) -> Result { if !is_mp3(data.by_ref()) { return Err(data); } @@ -70,9 +70,10 @@ where if self.current_frame_offset == self.current_frame.data.len() { match self.decoder.next_frame() { Ok(frame) => { - match self.tx.send(frame.clone()) { - Ok(_) => {} - Err(_) => return None, + if let Some(tx) = &self.tx { + if tx.send(frame.clone()).is_err() { + return None; + } } self.current_frame = frame } diff --git a/src/extract.rs b/src/extract.rs index 6cd58aa..4a6c512 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -65,5 +65,10 @@ pub async fn get_currently_playing(station: &str) -> Result { .await .map_err(|e| Error::msg(e.to_string()))?; - Ok(response.header.subtitle) + let subtitle = response.header.subtitle.trim(); + if subtitle.is_empty() { + Ok(response.header.title.trim().to_string()) + } else { + Ok(subtitle.to_string()) + } } diff --git a/src/favorites.rs b/src/favorites.rs new file mode 100644 index 0000000..5f2c4c4 --- /dev/null +++ b/src/favorites.rs @@ -0,0 +1,108 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Error}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +/// Metadata describing a favourited station. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FavoriteStation { + pub id: String, + pub name: String, + pub provider: String, +} + +/// File-backed favourites store. +pub struct FavoritesStore { + path: PathBuf, + favorites: Vec, +} + +impl FavoritesStore { + /// Load favourites from disk, falling back to an empty list when the file + /// does not exist or is corrupted. + pub fn load() -> Result { + let path = favorites_path()?; + ensure_parent(&path)?; + + let favorites = match fs::read_to_string(&path) { + Ok(content) => match serde_json::from_str::>(&content) { + Ok(entries) => entries, + Err(err) => { + eprintln!( + "warning: favourites file corrupted ({}), starting fresh", + err + ); + Vec::new() + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(err) => return Err(Error::from(err).context("failed to read favourites file")), + }; + + Ok(Self { path, favorites }) + } + + /// Return a snapshot of all favourite stations. + pub fn all(&self) -> &[FavoriteStation] { + &self.favorites + } + + /// Check whether the provided station is already a favourite. + pub fn is_favorite(&self, id: &str, provider: &str) -> bool { + self.favorites + .iter() + .any(|fav| fav.id == id && fav.provider == provider) + } + + /// Add a station to favourites if it is not already present. + pub fn add(&mut self, favorite: FavoriteStation) -> Result<(), Error> { + if !self.is_favorite(&favorite.id, &favorite.provider) { + self.favorites.push(favorite); + self.save()?; + } + Ok(()) + } + + /// Remove a station from favourites. + pub fn remove(&mut self, id: &str, provider: &str) -> Result<(), Error> { + let initial_len = self.favorites.len(); + self.favorites + .retain(|fav| !(fav.id == id && fav.provider == provider)); + if self.favorites.len() != initial_len { + self.save()?; + } + Ok(()) + } + + /// Toggle a station in favourites, returning whether it was added (`true`) or removed (`false`). + pub fn toggle(&mut self, favorite: FavoriteStation) -> Result { + if self.is_favorite(&favorite.id, &favorite.provider) { + self.remove(&favorite.id, &favorite.provider)?; + Ok(false) + } else { + self.add(favorite)?; + Ok(true) + } + } + + fn save(&self) -> Result<(), Error> { + let serialized = serde_json::to_string_pretty(&self.favorites) + .context("failed to serialize favourites list")?; + fs::write(&self.path, serialized).context("failed to write favourites file") + } +} + +fn favorites_path() -> Result { + let dirs = ProjectDirs::from("io", "tunein-cli", "tunein-cli") + .ok_or_else(|| Error::msg("unable to determine configuration directory"))?; + Ok(dirs.config_dir().join("favorites.json")) +} + +fn ensure_parent(path: &Path) -> Result<(), Error> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("failed to create favourites directory")?; + } + Ok(()) +} diff --git a/src/interactive.rs b/src/interactive.rs new file mode 100644 index 0000000..08dac77 --- /dev/null +++ b/src/interactive.rs @@ -0,0 +1,1217 @@ +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Error}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use tokio::sync::mpsc; + +use crate::audio::{AudioController, PlaybackEvent, PlaybackState}; +use crate::extract::get_currently_playing; +use crate::favorites::{FavoriteStation, FavoritesStore}; +use crate::provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}; +use crate::tui; +use crate::types::Station; + +const MENU_OPTIONS: &[&str] = &[ + "Search Stations", + "Browse Categories", + "Play Station", + "Favourites", + "Resume Last Station", + "Quit", +]; + +const STATUS_TIMEOUT: Duration = Duration::from_secs(3); +const NOW_PLAYING_POLL_INTERVAL: Duration = Duration::from_secs(10); + +enum HubMessage { + NowPlaying(String), +} + +pub async fn run(provider_name: &str) -> Result<(), Error> { + let provider = resolve_provider(provider_name).await?; + let (audio, mut audio_events) = AudioController::new()?; + let favorites = FavoritesStore::load()?; + let (metadata_tx, mut metadata_rx) = mpsc::unbounded_channel::(); + + let mut terminal = tui::init()?; + + let (input_tx, mut input_rx) = mpsc::unbounded_channel(); + spawn_input_thread(input_tx.clone()); + + let mut app = HubApp::new( + provider_name.to_string(), + provider, + audio, + favorites, + metadata_tx, + ); + + let result = loop { + terminal.draw(|frame| app.render(frame))?; + + tokio::select! { + Some(event) = input_rx.recv() => { + match app.handle_event(event).await? { + Action::Quit => break Ok(()), + Action::Task(task) => app.perform_task(task).await?, + Action::None => {} + } + } + Some(event) = audio_events.recv() => { + app.handle_playback_event(event); + } + Some(message) = metadata_rx.recv() => { + app.handle_metadata(message); + } + } + + app.tick(); + }; + + tui::restore()?; + + result +} + +fn spawn_input_thread(tx: mpsc::UnboundedSender) { + thread::spawn(move || loop { + if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { + if let Ok(event) = crossterm::event::read() { + if tx.send(event).is_err() { + break; + } + } + } + }); +} + +struct HubApp { + provider_name: String, + provider: Box, + audio: AudioController, + favorites: FavoritesStore, + ui: UiState, + current_station: Option, + current_playback: Option, + last_station: Option, + volume: f32, + status: Option, + metadata_tx: mpsc::UnboundedSender, + now_playing_station_id: Option, + next_now_playing_poll: Instant, +} + +impl HubApp { + fn new( + provider_name: String, + provider: Box, + audio: AudioController, + favorites: FavoritesStore, + metadata_tx: mpsc::UnboundedSender, + ) -> Self { + let mut ui = UiState::default(); + ui.menu_state.select(Some(0)); + Self { + provider_name, + provider, + audio, + favorites, + ui, + current_station: None, + current_playback: None, + last_station: None, + volume: 100.0, + status: None, + metadata_tx, + now_playing_station_id: None, + next_now_playing_poll: Instant::now(), + } + } + + fn render(&mut self, frame: &mut Frame) { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(8), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ] + .as_ref(), + ) + .split(frame.size()); + + self.render_header(frame, areas[0]); + self.render_divider(frame, areas[1]); + self.render_main(frame, areas[2]); + frame.render_widget(self.render_footer(), areas[3]); + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + frame.render_widget( + Block::new() + .borders(Borders::TOP) + .title(" TuneIn CLI ") + .title_alignment(Alignment::Center), + Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }, + ); + + let mut row = area.y + 1; + + frame.render_widget( + Paragraph::new(format!("Provider {}", self.provider_name)), + Rect { + x: area.x, + y: row, + width: area.width, + height: 1, + }, + ); + row += 1; + + let station_name = self + .current_playback + .as_ref() + .and_then(|p| { + let name = p.stream_name.trim(); + if name.is_empty() || name.eq_ignore_ascii_case("unknown") { + let fallback = p.station.name.trim(); + if fallback.is_empty() { + None + } else { + Some(fallback.to_string()) + } + } else { + Some(name.to_string()) + } + }) + .or_else(|| { + self.current_station.as_ref().and_then(|s| { + let name = s.station.name.trim(); + (!name.is_empty()).then_some(name.to_string()) + }) + }) + .unwrap_or_else(|| "Unknown".to_string()); + self.render_labeled_line(frame, area, row, "Station ", &station_name); + row += 1; + + let now_playing = self + .current_playback + .as_ref() + .and_then(|p| { + let np = p.now_playing.trim(); + (!np.is_empty()).then_some(np.to_string()) + }) + .or_else(|| { + self.current_station + .as_ref() + .and_then(|s| s.station.playing.as_ref()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "—".to_string()); + self.render_labeled_line(frame, area, row, "Now Playing ", &now_playing); + row += 1; + + let genre = self + .current_playback + .as_ref() + .and_then(|p| { + let genre = p.genre.trim(); + (!genre.is_empty()).then_some(genre.to_string()) + }) + .unwrap_or_else(|| "Unknown".to_string()); + self.render_labeled_line(frame, area, row, "Genre ", &genre); + row += 1; + + let description = self + .current_playback + .as_ref() + .and_then(|p| { + let desc = p.description.trim(); + (!desc.is_empty()).then_some(desc.to_string()) + }) + .unwrap_or_else(|| "Unknown".to_string()); + self.render_labeled_line(frame, area, row, "Description ", &description); + row += 1; + + let bitrate = self + .current_playback + .as_ref() + .and_then(|p| { + let br = p.bitrate.trim(); + (!br.is_empty()).then_some(format!("{} kbps", br)) + }) + .or_else(|| { + self.current_station.as_ref().and_then(|s| { + (s.station.bitrate > 0).then_some(format!("{} kbps", s.station.bitrate)) + }) + }) + .unwrap_or_else(|| "Unknown".to_string()); + self.render_labeled_line(frame, area, row, "Bitrate ", &bitrate); + row += 1; + + let volume_display = format!("{}%", self.volume as u32); + self.render_labeled_line(frame, area, row, "Volume ", &volume_display); + } + + fn render_labeled_line(&self, frame: &mut Frame, area: Rect, y: u16, label: &str, value: &str) { + let span_label = Span::styled(label, Style::default().fg(Color::LightBlue)); + let span_value = Span::raw(value); + let line = Line::from(vec![span_label, span_value]); + frame.render_widget( + Paragraph::new(line), + Rect { + x: area.x, + y, + width: area.width, + height: 1, + }, + ); + } + + fn render_main(&mut self, frame: &mut Frame, area: Rect) { + if matches!(self.ui.screen, Screen::Menu) { + self.render_menu_area(frame, area); + return; + } + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Min(0), + Constraint::Length(1), + Constraint::Length(5), + ] + .as_ref(), + ) + .split(area); + + self.render_non_menu_content(frame, sections[0]); + self.render_divider(frame, sections[1]); + self.render_feature_panel(frame, sections[2]); + } + + fn render_non_menu_content(&mut self, frame: &mut Frame, area: Rect) { + match &mut self.ui.screen { + Screen::Menu => {} + Screen::SearchInput => { + let text = format!( + "Search query: {}\n\nPress Enter to submit, Esc to cancel", + self.ui.search_input + ); + let paragraph = Paragraph::new(text) + .block(Block::default().title("Search").borders(Borders::ALL)); + frame.render_widget(paragraph, area); + } + Screen::PlayInput => { + let text = format!( + "Station name or ID: {}\n\nPress Enter to submit, Esc to cancel", + self.ui.play_input + ); + let paragraph = Paragraph::new(text) + .block(Block::default().title("Play Station").borders(Borders::ALL)); + frame.render_widget(paragraph, area); + } + Screen::SearchResults => { + let items = Self::station_items(&self.ui.search_results); + let list = List::new(items) + .block( + Block::default() + .title(String::from("Search Results")) + .borders(Borders::ALL), + ) + .highlight_symbol("➜ ") + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut self.ui.search_results_state); + } + Screen::Categories => { + let items = Self::category_items(&self.ui.categories); + let list = List::new(items) + .block(Block::default().title("Categories").borders(Borders::ALL)) + .highlight_symbol("➜ ") + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut self.ui.categories_state); + } + Screen::BrowseStations { category } => { + let items = Self::station_items(&self.ui.browse_results); + let list = List::new(items) + .block( + Block::default() + .title(format!("Stations in {}", category)) + .borders(Borders::ALL), + ) + .highlight_symbol("➜ ") + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut self.ui.browse_state); + } + Screen::Favourites => { + let items = Self::favourite_items(self.favorites.all()); + let list = List::new(items) + .block(Block::default().title("Favourites").borders(Borders::ALL)) + .highlight_symbol("➜ ") + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_stateful_widget(list, area, &mut self.ui.favourites_state); + } + Screen::Loading => { + let message = self + .ui + .loading_message + .as_deref() + .unwrap_or("Loading, please wait…"); + let paragraph = Paragraph::new(message) + .block(Block::default().title("Loading").borders(Borders::ALL)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, area); + } + } + } + + fn render_divider(&self, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let width = area.width as usize; + if width == 0 { + return; + } + let mut line = String::with_capacity(width + 3); + while line.len() < width { + line.push_str("---"); + } + line.truncate(width); + frame.render_widget(Paragraph::new(line), area); + } + + fn render_feature_panel(&self, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width == 0 { + return; + } + + let lines = self.feature_panel_lines(); + let text = lines.join("\n"); + let paragraph = + Paragraph::new(text).block(Block::default().title("Actions").borders(Borders::ALL)); + frame.render_widget(paragraph, area); + } + + fn render_menu_area(&mut self, frame: &mut Frame, area: Rect) { + if area.height == 0 || area.width == 0 { + return; + } + let disable_resume = self.last_station.is_none(); + let items: Vec = MENU_OPTIONS + .iter() + .map(|option| { + if *option == "Resume Last Station" && disable_resume { + ListItem::new(Line::from(Span::styled( + *option, + Style::default().fg(Color::DarkGray), + ))) + } else { + ListItem::new(*option) + } + }) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Main Menu")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("➜ "); + frame.render_stateful_widget(list, area, &mut self.ui.menu_state); + } + + fn station_items(stations: &[Station]) -> Vec> { + if stations.is_empty() { + vec![ListItem::new("No stations found")] + } else { + stations + .iter() + .map(|station| { + let mut line = station.name.clone(); + if let Some(now) = &station.playing { + if !now.is_empty() { + line.push_str(&format!(" — {}", now)); + } + } + ListItem::new(line) + }) + .collect() + } + } + + fn category_items(categories: &[String]) -> Vec> { + if categories.is_empty() { + vec![ListItem::new("No categories available")] + } else { + categories + .iter() + .map(|category| ListItem::new(category.clone())) + .collect() + } + } + + fn favourite_items(favourites: &[FavoriteStation]) -> Vec> { + if favourites.is_empty() { + vec![ListItem::new("No favourites saved yet")] + } else { + favourites + .iter() + .map(|fav| ListItem::new(format!("{} ({})", fav.name, fav.provider))) + .collect() + } + } + + fn handle_favourite_action(&mut self) -> Result { + match self.ui.screen { + Screen::SearchResults => { + let Some(index) = self.ui.search_results_state.selected() else { + self.set_status("No search result selected"); + return Ok(true); + }; + let station = self + .ui + .search_results + .get(index) + .cloned() + .ok_or_else(|| anyhow!("Search result missing at index {}", index))?; + self.add_station_to_favourites(station)?; + Ok(true) + } + Screen::BrowseStations { .. } => { + let Some(index) = self.ui.browse_state.selected() else { + self.set_status("No station selected"); + return Ok(true); + }; + let station = self + .ui + .browse_results + .get(index) + .cloned() + .ok_or_else(|| anyhow!("Browse result missing at index {}", index))?; + self.add_station_to_favourites(station)?; + Ok(true) + } + Screen::Favourites => { + let Some(index) = self.ui.favourites_state.selected() else { + self.set_status("No favourite selected"); + return Ok(true); + }; + self.remove_favourite_at(index)?; + Ok(true) + } + _ => { + self.toggle_current_favourite()?; + Ok(true) + } + } + } + + fn add_station_to_favourites(&mut self, station: Station) -> Result<(), Error> { + if station.id.is_empty() { + self.set_status("Cannot favourite station without an id"); + return Ok(()); + } + + let entry = FavoriteStation { + id: station.id.clone(), + name: station.name.clone(), + provider: self.provider_name.clone(), + }; + + if self.favorites.is_favorite(&entry.id, &entry.provider) { + self.set_status("Already in favourites"); + } else { + self.favorites.add(entry)?; + self.set_status(&format!("Added \"{}\" to favourites", station.name)); + } + Ok(()) + } + + fn remove_favourite_at(&mut self, index: usize) -> Result<(), Error> { + let Some(favourite) = self.favorites.all().get(index).cloned() else { + self.set_status("Favourite not found"); + return Ok(()); + }; + self.favorites.remove(&favourite.id, &favourite.provider)?; + self.set_status(&format!("Removed \"{}\" from favourites", favourite.name)); + + let len = self.favorites.all().len(); + if len == 0 { + self.ui.favourites_state.select(None); + } else { + let new_index = index.min(len - 1); + self.ui.favourites_state.select(Some(new_index)); + } + + Ok(()) + } + + fn stop_playback(&mut self) -> Result<(), Error> { + self.audio.stop()?; + self.set_status("Playback stopped"); + Ok(()) + } + + fn default_footer_hint(&self) -> String { + match self.ui.screen { + Screen::SearchResults => { + "↑/↓ navigate • Enter play • f add to favourites • x stop playback • Esc back • +/- volume" + .to_string() + } + Screen::Favourites => { + "↑/↓ navigate • Enter play • f remove favourite • d/Delete remove • x stop playback • Esc back • +/- volume" + .to_string() + } + Screen::Categories => { + "↑/↓ navigate • Enter open • x stop playback • Esc back • +/- volume".to_string() + } + Screen::BrowseStations { .. } => { + "↑/↓ navigate • Enter play • f add to favourites • x stop playback • Esc back • +/- volume".to_string() + } + Screen::SearchInput | Screen::PlayInput => { + "Type to edit • Enter submit • x stop playback • Esc cancel • +/- volume".to_string() + } + Screen::Loading => "Please wait… • x stop playback • Esc cancel • +/- volume".to_string(), + Screen::Menu => { + "↑/↓ navigate • Enter select • x stop playback • Esc back • +/- volume".to_string() + } + } + } + + fn feature_panel_lines(&self) -> Vec { + let mut lines = match self.ui.screen { + Screen::SearchResults => vec![ + "Search Results".to_string(), + "Enter • Play highlighted station".to_string(), + "f • Add highlighted station to favourites".to_string(), + "Esc • Return to main menu".to_string(), + ], + Screen::Favourites => vec![ + "Favourites".to_string(), + "Enter • Play selected favourite".to_string(), + "f • Remove highlighted favourite".to_string(), + "d/Del • Remove highlighted favourite".to_string(), + "Esc • Return to main menu".to_string(), + ], + Screen::BrowseStations { .. } => vec![ + "Browse Stations".to_string(), + "Enter • Play highlighted station".to_string(), + "f • Add highlighted station to favourites".to_string(), + "Esc • Back to categories".to_string(), + ], + Screen::Categories => vec![ + "Categories".to_string(), + "Enter • Drill into selected category".to_string(), + "Esc • Return to main menu".to_string(), + ], + Screen::SearchInput => vec![ + "Search".to_string(), + "Enter • Run search".to_string(), + "Esc • Cancel".to_string(), + ], + Screen::PlayInput => vec![ + "Play Station".to_string(), + "Enter • Start playback".to_string(), + "Esc • Cancel".to_string(), + ], + Screen::Loading => vec!["Loading…".to_string(), "Esc • Cancel".to_string()], + Screen::Menu => vec![ + "Main Menu".to_string(), + "Enter • Activate highlighted option".to_string(), + "Esc • Quit or back".to_string(), + ], + }; + + if self.current_station.is_some() { + lines.insert(1, "x • Stop playback".to_string()); + } else { + lines.insert(1, "x • Stop playback (no active stream)".to_string()); + } + + lines + } + + fn render_footer(&self) -> Paragraph<'_> { + let hint = self.default_footer_hint(); + let text = if let Some(status) = &self.status { + format!("{} • {}", status.message, hint) + } else { + hint + }; + Paragraph::new(text) + } + + async fn handle_event(&mut self, event: Event) -> Result { + match event { + Event::Key(key) => self.handle_key_event(key).await, + Event::Resize(_, _) => Ok(Action::None), + _ => Ok(Action::None), + } + } + + async fn handle_key_event(&mut self, key: KeyEvent) -> Result { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Ok(Action::Quit); + } + + match key.code { + KeyCode::Char('+') | KeyCode::Char('=') => { + self.adjust_volume(5.0)?; + return Ok(Action::None); + } + KeyCode::Char('-') => { + self.adjust_volume(-5.0)?; + return Ok(Action::None); + } + KeyCode::Char('x') => { + self.stop_playback()?; + return Ok(Action::None); + } + KeyCode::Char('f') => { + if self.handle_favourite_action()? { + return Ok(Action::None); + } + } + KeyCode::Esc if !matches!(self.ui.screen, Screen::Menu) => { + self.ui.screen = Screen::Menu; + return Ok(Action::None); + } + _ => {} + } + + match self.ui.screen { + Screen::Menu => self.handle_menu_keys(key), + Screen::SearchInput => self.handle_text_input(key, true), + Screen::PlayInput => self.handle_text_input(key, false), + Screen::SearchResults => self.handle_station_list_keys(key, ListKind::Search), + Screen::Categories => self.handle_categories_keys(key), + Screen::BrowseStations { .. } => self.handle_station_list_keys(key, ListKind::Browse), + Screen::Favourites => self.handle_favourites_keys(key), + Screen::Loading => Ok(Action::None), + } + } + + fn handle_menu_keys(&mut self, key: KeyEvent) -> Result { + let current = self.ui.menu_state.selected().unwrap_or(0); + match key.code { + KeyCode::Up => { + let new = current.saturating_sub(1); + self.ui.menu_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Down => { + let max = MENU_OPTIONS.len().saturating_sub(1); + let new = (current + 1).min(max); + self.ui.menu_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Enter => match MENU_OPTIONS[current] { + "Search Stations" => { + self.ui.search_input.clear(); + self.ui.screen = Screen::SearchInput; + Ok(Action::None) + } + "Browse Categories" => { + self.ui.loading_message = Some("Fetching categories…".to_string()); + self.ui.screen = Screen::Loading; + Ok(Action::Task(PendingTask::LoadCategories)) + } + "Play Station" => { + self.ui.play_input.clear(); + self.ui.screen = Screen::PlayInput; + Ok(Action::None) + } + "Favourites" => { + self.ui.screen = Screen::Favourites; + if self.favorites.all().is_empty() { + self.ui.favourites_state.select(None); + } else { + self.ui.favourites_state.select(Some(0)); + } + Ok(Action::None) + } + "Resume Last Station" => { + if let Some(station) = self.last_station.clone() { + Ok(Action::Task(PendingTask::PlayStation(station))) + } else { + self.set_status("No station played yet to resume"); + Ok(Action::None) + } + } + "Quit" => Ok(Action::Quit), + _ => Ok(Action::None), + }, + _ => Ok(Action::None), + } + } + + fn handle_text_input(&mut self, key: KeyEvent, is_search: bool) -> Result { + let buffer = if is_search { + &mut self.ui.search_input + } else { + &mut self.ui.play_input + }; + + match key.code { + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + buffer.push(c); + Ok(Action::None) + } + KeyCode::Backspace => { + buffer.pop(); + Ok(Action::None) + } + KeyCode::Enter => { + if buffer.trim().is_empty() { + self.set_status("Input cannot be empty"); + return Ok(Action::None); + } + let query = buffer.trim().to_string(); + self.ui.loading_message = Some("Searching stations…".to_string()); + self.ui.screen = Screen::Loading; + if is_search { + Ok(Action::Task(PendingTask::Search(query))) + } else { + Ok(Action::Task(PendingTask::PlayDirect(query))) + } + } + _ => Ok(Action::None), + } + } + + fn handle_station_list_keys(&mut self, key: KeyEvent, kind: ListKind) -> Result { + let (items_len, state) = match kind { + ListKind::Search => ( + self.ui.search_results.len(), + &mut self.ui.search_results_state, + ), + ListKind::Browse => (self.ui.browse_results.len(), &mut self.ui.browse_state), + }; + + if items_len == 0 { + if key.code == KeyCode::Esc { + self.ui.screen = Screen::Menu; + } + return Ok(Action::None); + } + + let current = state.selected().unwrap_or(0); + match key.code { + KeyCode::Up => { + let new = current.saturating_sub(1); + state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Down => { + let max = items_len.saturating_sub(1); + let new = (current + 1).min(max); + state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Enter => { + let station = match kind { + ListKind::Search => self.ui.search_results[current].clone(), + ListKind::Browse => self.ui.browse_results[current].clone(), + }; + Ok(Action::Task(PendingTask::PlayStation(StationRecord { + provider: self.provider_name.clone(), + station, + }))) + } + KeyCode::Esc => { + self.ui.screen = Screen::Menu; + Ok(Action::None) + } + _ => Ok(Action::None), + } + } + + fn handle_categories_keys(&mut self, key: KeyEvent) -> Result { + let len = self.ui.categories.len(); + if len == 0 { + if key.code == KeyCode::Esc { + self.ui.screen = Screen::Menu; + } + return Ok(Action::None); + } + + let current = self.ui.categories_state.selected().unwrap_or(0); + match key.code { + KeyCode::Up => { + let new = current.saturating_sub(1); + self.ui.categories_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Down => { + let max = len.saturating_sub(1); + let new = (current + 1).min(max); + self.ui.categories_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Enter => { + let category = self.ui.categories[current].clone(); + self.ui.loading_message = Some(format!("Loading stations for {}…", category)); + self.ui.screen = Screen::Loading; + Ok(Action::Task(PendingTask::LoadCategoryStations { category })) + } + KeyCode::Esc => { + self.ui.screen = Screen::Menu; + Ok(Action::None) + } + _ => Ok(Action::None), + } + } + + fn handle_favourites_keys(&mut self, key: KeyEvent) -> Result { + let len = self.favorites.all().len(); + if len == 0 { + if key.code == KeyCode::Esc { + self.ui.screen = Screen::Menu; + } + return Ok(Action::None); + } + + let current = self.ui.favourites_state.selected().unwrap_or(0); + match key.code { + KeyCode::Up => { + let new = current.saturating_sub(1); + self.ui.favourites_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Down => { + let max = len.saturating_sub(1); + let new = (current + 1).min(max); + self.ui.favourites_state.select(Some(new)); + Ok(Action::None) + } + KeyCode::Enter => { + let favourite = self.favorites.all()[current].clone(); + Ok(Action::Task(PendingTask::PlayFavourite(favourite))) + } + KeyCode::Delete | KeyCode::Char('d') | KeyCode::Char('f') => { + self.remove_favourite_at(current)?; + Ok(Action::None) + } + KeyCode::Esc => { + self.ui.screen = Screen::Menu; + Ok(Action::None) + } + _ => Ok(Action::None), + } + } + + fn adjust_volume(&mut self, delta: f32) -> Result<(), Error> { + self.volume = (self.volume + delta).clamp(0.0, 150.0); + self.audio.set_volume(self.volume)?; + self.set_status(&format!("Volume set to {}%", self.volume as u32)); + Ok(()) + } + + fn toggle_current_favourite(&mut self) -> Result<(), Error> { + let Some(station) = &self.current_station else { + self.set_status("No active station to favourite"); + return Ok(()); + }; + + if station.station.id.is_empty() { + self.set_status("Current station cannot be favourited"); + return Ok(()); + } + + let entry = FavoriteStation { + id: station.station.id.clone(), + name: station.station.name.clone(), + provider: station.provider.clone(), + }; + let added = self.favorites.toggle(entry)?; + if added { + self.set_status("Added to favourites"); + } else { + self.set_status("Removed from favourites"); + } + Ok(()) + } + + fn handle_playback_event(&mut self, event: PlaybackEvent) { + match event { + PlaybackEvent::Started(state) => { + self.current_playback = Some(state.clone()); + if let Some(station) = self.current_station.as_mut() { + station.station.playing = Some(state.now_playing.clone()); + } + self.set_status(&format!("Now playing {}", state.stream_name)); + self.prepare_now_playing_poll(); + } + PlaybackEvent::Error(err) => { + self.current_playback = None; + self.set_status(&format!("Playback error: {}", err)); + self.now_playing_station_id = None; + } + PlaybackEvent::Stopped => { + self.current_playback = None; + self.set_status("Playback stopped"); + self.now_playing_station_id = None; + } + } + } + + fn handle_metadata(&mut self, message: HubMessage) { + match message { + HubMessage::NowPlaying(now_playing) => { + if let Some(playback) = self.current_playback.as_mut() { + playback.now_playing = now_playing.clone(); + } + if let Some(station) = self.current_station.as_mut() { + station.station.playing = Some(now_playing.clone()); + } + self.set_status(&format!("Now Playing {}", now_playing)); + } + } + } + + async fn perform_task(&mut self, task: PendingTask) -> Result<(), Error> { + self.ui.loading_message = None; + match task { + PendingTask::Search(query) => { + let results = self.provider.search(query.clone()).await?; + self.ui.search_results = results; + self.ui.search_results_state.select(Some(0)); + self.ui.screen = Screen::SearchResults; + self.set_status(&format!("Search complete for \"{}\"", query)); + } + PendingTask::LoadCategories => { + let categories = self.provider.categories(0, 100).await?; + self.ui.categories = categories; + self.ui.categories_state.select(Some(0)); + self.ui.screen = Screen::Categories; + self.set_status("Categories loaded"); + } + PendingTask::LoadCategoryStations { category } => { + let stations = self.provider.browse(category.clone(), 0, 100).await?; + self.ui.browse_results = stations; + self.ui.browse_state.select(Some(0)); + self.ui.screen = Screen::BrowseStations { category }; + self.set_status("Stations loaded"); + } + PendingTask::PlayDirect(input) => { + let provider = resolve_provider(&self.provider_name).await?; + match provider.get_station(input.clone()).await? { + Some(mut station) => { + if station.stream_url.is_empty() { + station = fetch_station(&self.provider_name, &station.id) + .await? + .ok_or_else(|| anyhow!("Unable to locate stream for station"))?; + } + self.play_station(StationRecord { + provider: self.provider_name.clone(), + station, + }) + .await?; + } + None => { + self.ui.screen = Screen::Menu; + self.set_status(&format!("Station \"{}\" not found", input)); + } + } + } + PendingTask::PlayStation(record) => { + self.play_station(record).await?; + } + PendingTask::PlayFavourite(favourite) => { + let station = fetch_station(&favourite.provider, &favourite.id) + .await? + .ok_or_else(|| anyhow!("Failed to load favourite station"))?; + self.play_station(StationRecord { + provider: favourite.provider, + station, + }) + .await?; + } + } + Ok(()) + } + + async fn play_station(&mut self, mut record: StationRecord) -> Result<(), Error> { + if record.station.stream_url.is_empty() { + if let Some(enriched) = fetch_station(&record.provider, &record.station.id).await? { + record.station = enriched; + } else { + return Err(anyhow!("Unable to resolve station stream")); + } + } + + self.audio.play(record.station.clone(), self.volume)?; + self.current_station = Some(record.clone()); + self.last_station = Some(record); + self.prepare_now_playing_poll(); + self.ui.screen = Screen::Menu; + Ok(()) + } + + fn prepare_now_playing_poll(&mut self) { + if let Some(station) = &self.current_station { + if station.provider == "tunein" && !station.station.id.is_empty() { + self.now_playing_station_id = Some(station.station.id.clone()); + self.next_now_playing_poll = Instant::now(); + } else { + self.now_playing_station_id = None; + } + } + } + + fn tick(&mut self) { + if let Some(status) = &self.status { + if status.expires_at <= Instant::now() { + self.status = None; + } + } + self.poll_now_playing_if_needed(); + } + + fn poll_now_playing_if_needed(&mut self) { + let Some(station_id) = self.now_playing_station_id.clone() else { + return; + }; + + if Instant::now() < self.next_now_playing_poll { + return; + } + + let tx = self.metadata_tx.clone(); + tokio::spawn(async move { + if let Ok(now) = get_currently_playing(&station_id).await { + let _ = tx.send(HubMessage::NowPlaying(now)); + } + }); + + self.next_now_playing_poll = Instant::now() + NOW_PLAYING_POLL_INTERVAL; + } + + fn set_status>(&mut self, message: S) { + self.status = Some(StatusMessage { + message: message.into(), + expires_at: Instant::now() + STATUS_TIMEOUT, + }); + } +} + +struct UiState { + screen: Screen, + menu_state: ListState, + search_input: String, + play_input: String, + search_results: Vec, + search_results_state: ListState, + categories: Vec, + categories_state: ListState, + browse_results: Vec, + browse_state: ListState, + favourites_state: ListState, + loading_message: Option, +} + +impl Default for UiState { + fn default() -> Self { + Self { + screen: Screen::Menu, + menu_state: ListState::default(), + search_input: String::new(), + play_input: String::new(), + search_results: Vec::new(), + search_results_state: ListState::default(), + categories: Vec::new(), + categories_state: ListState::default(), + browse_results: Vec::new(), + browse_state: ListState::default(), + favourites_state: ListState::default(), + loading_message: None, + } + } +} + +#[derive(Clone)] +enum Screen { + Menu, + SearchInput, + PlayInput, + SearchResults, + Categories, + BrowseStations { category: String }, + Favourites, + Loading, +} + +enum ListKind { + Search, + Browse, +} + +enum PendingTask { + Search(String), + LoadCategories, + LoadCategoryStations { category: String }, + PlayDirect(String), + PlayStation(StationRecord), + PlayFavourite(FavoriteStation), +} + +enum Action { + None, + Quit, + Task(PendingTask), +} + +struct StatusMessage { + message: String, + expires_at: Instant, +} + +#[derive(Clone)] +struct StationRecord { + provider: String, + station: Station, +} + +async fn resolve_provider(name: &str) -> Result, Error> { + match name { + "tunein" => Ok(Box::new(Tunein::new())), + "radiobrowser" => Ok(Box::new(Radiobrowser::new().await)), + other => Err(anyhow!("Unsupported provider '{}'", other)), + } +} + +async fn fetch_station(provider_name: &str, id: &str) -> Result, Error> { + let provider = resolve_provider(provider_name).await?; + provider.get_station(id.to_string()).await +} diff --git a/src/main.rs b/src/main.rs index ec9c175..0d9ad1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,11 +5,14 @@ use app::CurrentDisplayMode; use clap::{arg, builder::ValueParser, Command}; mod app; +mod audio; mod browse; mod cfg; mod decoder; mod extract; +mod favorites; mod input; +mod interactive; mod music; mod play; mod player; @@ -39,7 +42,6 @@ A simple CLI to listen to radio stations"#, .arg( arg!(-p --provider "The radio provider to use, can be 'tunein' or 'radiobrowser'. Default is 'tunein'").default_value("tunein") ) - .subcommand_required(true) .subcommand( Command::new("search") .about("Search for a radio station") @@ -88,16 +90,15 @@ A simple CLI to listen to radio stations"#, #[tokio::main] async fn main() -> Result<(), Error> { let matches = cli().get_matches(); + let provider = matches.value_of("provider").unwrap().to_string(); match matches.subcommand() { Some(("search", args)) => { let query = args.value_of("query").unwrap(); - let provider = matches.value_of("provider").unwrap(); - search::exec(query, provider).await?; + search::exec(query, provider.as_str()).await?; } Some(("play", args)) => { let station = args.value_of("station").unwrap(); - let provider = matches.value_of("provider").unwrap(); let volume = args.value_of("volume").unwrap().parse::().unwrap(); let display_mode = args .value_of("display-mode") @@ -115,7 +116,7 @@ async fn main() -> Result<(), Error> { ); play::exec( station, - provider, + provider.as_str(), volume, display_mode, *enable_os_media_controls, @@ -128,12 +129,11 @@ async fn main() -> Result<(), Error> { let category = args.value_of("category"); let offset = args.value_of("offset").unwrap(); let limit = args.value_of("limit").unwrap(); - let provider = matches.value_of("provider").unwrap(); browse::exec( category, offset.parse::()?, limit.parse::()?, - provider, + provider.as_str(), ) .await?; } @@ -151,7 +151,16 @@ async fn main() -> Result<(), Error> { std::process::exit(1); } }, - _ => unreachable!(), + None => { + interactive::run(provider.as_str()).await?; + } + Some((other, _)) => { + eprintln!( + "Unknown subcommand '{}'. Use `tunein --help` for available commands.", + other + ); + std::process::exit(1); + } } Ok(()) diff --git a/src/play.rs b/src/play.rs index b7b951d..aedc00e 100644 --- a/src/play.rs +++ b/src/play.rs @@ -143,7 +143,7 @@ pub async fn exec( let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); let sink = rodio::Sink::try_new(&handle).unwrap(); sink.set_volume(volume.volume_ratio()); - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); sink.append(decoder); loop { diff --git a/src/player.rs b/src/player.rs index 460a97e..9a2ea5e 100644 --- a/src/player.rs +++ b/src/player.rs @@ -61,7 +61,7 @@ impl PlayerInternal { let sink = self.sink.clone(); thread::spawn(move || { - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); + let (frame_tx, _frame_rx) = std::sync::mpsc::channel::(); let client = reqwest::blocking::Client::new(); let response = client.get(url.clone()).send().unwrap(); @@ -80,7 +80,7 @@ impl PlayerInternal { } None => response, }; - let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); + let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); { let sink = sink.lock().unwrap(); diff --git a/src/visualization/mod.rs b/src/visualization/mod.rs index 50cfa7e..6675205 100644 --- a/src/visualization/mod.rs +++ b/src/visualization/mod.rs @@ -20,6 +20,7 @@ pub enum Dimension { pub struct GraphConfig { pub pause: bool, pub samples: u32, + #[allow(dead_code)] pub sampling_rate: u32, pub scale: f64, pub width: u32, @@ -47,7 +48,7 @@ pub trait DisplayMode { fn from_args(args: &crate::cfg::SourceOptions) -> Self where Self: Sized; - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_>; // TODO simplify this fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec; fn mode_str(&self) -> &'static str; diff --git a/src/visualization/oscilloscope.rs b/src/visualization/oscilloscope.rs index bccae21..12c4f53 100644 --- a/src/visualization/oscilloscope.rs +++ b/src/visualization/oscilloscope.rs @@ -55,7 +55,7 @@ impl DisplayMode for Oscilloscope { } } - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { let (name, bounds) = match dimension { Dimension::X => ("time -", [0.0, cfg.samples as f64]), Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]), diff --git a/src/visualization/spectroscope.rs b/src/visualization/spectroscope.rs index 5c1bbb0..d878a0b 100644 --- a/src/visualization/spectroscope.rs +++ b/src/visualization/spectroscope.rs @@ -84,7 +84,7 @@ impl DisplayMode for Spectroscope { } } - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { let (name, bounds) = match dimension { Dimension::X => ( "frequency -", diff --git a/src/visualization/vectorscope.rs b/src/visualization/vectorscope.rs index 2a09e75..f908ab9 100644 --- a/src/visualization/vectorscope.rs +++ b/src/visualization/vectorscope.rs @@ -28,7 +28,7 @@ impl DisplayMode for Vectorscope { "live".into() } - fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis<'_> { let (name, bounds) = match dimension { Dimension::X => ("left -", [-cfg.scale, cfg.scale]), Dimension::Y => ("| right", [-cfg.scale, cfg.scale]), -- 2.51.2