diff --git a/src/main.rs b/src/main.rs index c9d2d37..95796d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,18 @@ pub enum State { Connecting, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViewMode { + Queue, + Favorites, +} + +#[derive(Debug)] +pub enum Direction { + Up, + Down, +} + #[derive(Debug)] pub enum Action { Play, @@ -25,6 +37,9 @@ pub enum Action { NextSpeaker, PrevSpeaker, VolAdjust(i16), + SwitchView(ViewMode), + NavigateFavorites(Direction), + PlayFavorite(usize), Nop, } diff --git a/src/sonos.rs b/src/sonos.rs index ec82663..bebda40 100644 --- a/src/sonos.rs +++ b/src/sonos.rs @@ -1,9 +1,10 @@ use std::collections::BTreeMap; use std::time::Duration; +use std::sync::Arc; use anyhow::{Context, Result}; use futures::TryStreamExt; -use sonor::{Speaker, SpeakerInfo, Track, TrackInfo}; +use sonor::{Speaker, SpeakerInfo, Track, TrackInfo, URN}; use std::net::Ipv4Addr; use tokio::{ select, @@ -11,7 +12,15 @@ use tokio::{ }; use tracing::{debug, error, info, warn}; -use crate::{Action, Update}; +use crate::{Action, Direction, Update, ViewMode}; + +#[derive(Debug, Clone)] +pub struct FavoritePlaylist { + pub title: String, + pub description: String, + pub uri: String, + pub metadata: String, +} #[derive(Debug)] pub struct SpeakerState { @@ -19,8 +28,11 @@ pub struct SpeakerState { pub current_volume: u16, pub group_names: Vec, pub selected_group: usize, - pub now_playing: Option, - pub queue: Vec, + pub now_playing: Option>, + pub queue: Arc>, + pub current_view: ViewMode, + pub favorites: Vec, + pub selected_favorite: usize, } impl SpeakerState { @@ -35,6 +47,14 @@ pub struct SonosService { speakers_by_uuid: BTreeMap, groups: Vec, selected_group: usize, + current_view: ViewMode, + favorites: Vec, + selected_favorite: usize, + // Cached state + cached_is_playing: bool, + cached_volume: u16, + cached_now_playing: Option>, + cached_queue: Arc>, } impl SonosService { @@ -45,6 +65,13 @@ impl SonosService { speakers_by_uuid: BTreeMap::new(), groups: vec![], selected_group: 0, + current_view: ViewMode::Queue, + favorites: vec![], + selected_favorite: 0, + cached_is_playing: false, + cached_volume: 0, + cached_now_playing: None, + cached_queue: Arc::new(vec![]), } } @@ -74,6 +101,18 @@ impl SonosService { let groups = speaker.zone_group_state().await?; debug!("Found {} groups", groups.len()); + // Fetch favorites from the speaker (before moving speakers_by_uuid) + debug!("Fetching favorites..."); + match fetch_favorite_playlists(speaker).await { + Ok(favs) => { + info!("Found {} favorite playlists", favs.len()); + self.favorites = favs; + } + Err(e) => { + warn!("Failed to fetch favorites: {}", e); + } + } + let group_list = groups .into_iter() .map(|(uuid, speaker_list)| SpeakerGroup::new(uuid, speaker_list)) @@ -81,6 +120,11 @@ impl SonosService { self.groups = group_list; self.speakers_by_uuid = speakers_by_uuid; + // Initial state fetch + if let Err(e) = self.refresh_state().await { + warn!("Failed to fetch initial state: {}", e); + } + let mut ticker = tokio::time::interval(Duration::from_secs(1)); debug!("Starting sonos loop"); @@ -88,11 +132,34 @@ impl SonosService { select! { _tick = ticker.tick() => { // time to refresh our state + if let Err(e) = self.refresh_state().await { + warn!("Failed to refresh state: {}", e); + } self.send_update().await; } cmd = self.cmd_rx.recv() => { if let Some(c) = cmd { - self.handle_command(c).await.unwrap_or_else(|e| warn!("{}", e)); + let mut needs_refresh = false; + + // Process the first command + match self.handle_command(c).await { + Ok(r) => if r { needs_refresh = true; }, + Err(e) => warn!("Error handling command: {}", e), + } + + // Drain pending commands + while let Ok(c) = self.cmd_rx.try_recv() { + match self.handle_command(c).await { + Ok(r) => if r { needs_refresh = true; }, + Err(e) => warn!("Error handling batched command: {}", e), + } + } + + if needs_refresh { + if let Err(e) = self.refresh_state().await { + warn!("Failed to refresh state after commands: {}", e); + } + } } else { warn!("Command channel was closed: exiting..."); break; @@ -104,30 +171,154 @@ impl SonosService { Ok(()) } - async fn handle_command(&mut self, cmd: Action) -> Result<()> { + async fn handle_command(&mut self, cmd: Action) -> Result { debug!(?cmd, "Handling command"); - let current_speaker = self.current_speaker().context("No selected group")?; match cmd { - Action::Play => current_speaker.play().await, - Action::Pause => current_speaker.pause().await, - Action::Next => current_speaker.next().await, - Action::Prev => current_speaker.previous().await, - Action::VolAdjust(v) => current_speaker.set_volume_relative(v).await.map(drop), + // Playback controls + Action::Play => { + let speaker = self.current_speaker().context("No selected group")?; + speaker.play().await?; + Ok(true) + } + Action::Pause => { + let speaker = self.current_speaker().context("No selected group")?; + speaker.pause().await?; + Ok(true) + } + Action::Next => { + let speaker = self.current_speaker().context("No selected group")?; + speaker.next().await?; + Ok(true) + } + Action::Prev => { + let speaker = self.current_speaker().context("No selected group")?; + speaker.previous().await?; + Ok(true) + } + Action::VolAdjust(v) => { + let speaker = self.current_speaker().context("No selected group")?; + speaker.set_volume_relative(v).await.map(drop)?; + Ok(true) + } + + // Group switching Action::NextSpeaker => { self.select_next_group(); - Ok(()) + Ok::(true) } Action::PrevSpeaker => { self.select_prev_group(); - Ok(()) + Ok::(true) } - Action::Nop => Ok(()), // Nop + + // View switching + Action::SwitchView(view_mode) => { + self.current_view = view_mode; + Ok(false) + } + + // Favorites navigation + Action::NavigateFavorites(direction) => { + match direction { + Direction::Up => { + if self.selected_favorite > 0 { + self.selected_favorite -= 1; + } + } + Direction::Down => { + if self.selected_favorite < self.favorites.len().saturating_sub(1) { + self.selected_favorite += 1; + } + } + } + Ok(false) + } + + // Play favorite + Action::PlayFavorite(index) => { + if let Some(favorite) = self.favorites.get(index) { + info!("Attempting to play favorite: {}", favorite.title); + debug!("Favorite URI: {}", favorite.uri); + + let speaker = self.current_speaker().context("No selected group")?; + + // Clear the queue first + debug!("Clearing queue..."); + if let Err(e) = speaker.clear_queue().await { + warn!("Failed to clear queue: {}", e); + } + + // Try different approaches based on URI type + let unescaped_uri = html_unescape(&favorite.uri); + let unescaped_metadata = html_unescape(&favorite.metadata); + + debug!("Unescaped URI: {}", unescaped_uri); + + // For containers (playlists), use AddURIToQueue + if unescaped_uri.starts_with("x-rincon-cpcontainer:") { + debug!("Using AddURIToQueue for container..."); + let service = URN::service("schemas-upnp-org", "AVTransport", 1); + let payload = format!( + r#"0 +{} +{} +0 +1"#, + favorite.uri, + favorite.metadata + ); + + match speaker.action(&service, "AddURIToQueue", &payload).await { + Ok(_) => { + debug!("AddURIToQueue succeeded"); + // Start playback + debug!("Starting playback..."); + speaker.play().await?; + info!("Successfully started playing: {}", favorite.title); + } + Err(e) => { + error!("AddURIToQueue failed: {:?}", e); + return Err(e).context("Failed to add playlist to queue"); + } + } + } else { + // For individual tracks, use queue_next + debug!("Using queue_next for track..."); + speaker.queue_next(&unescaped_uri, &unescaped_metadata).await?; + speaker.next().await?; + info!("Successfully started playing: {}", favorite.title); + } + + Ok(true) + } else { + warn!("Invalid favorite index: {}", index); + Ok(false) + } + } + + Action::Nop => Ok(false), } .context("Error while handling command") } + async fn refresh_state(&mut self) -> Result<()> { + let uuid = self.groups.get(self.selected_group) + .map(|g| g.coordinator.clone()) + .context("No selected group")?; + + let speaker = self.speakers_by_uuid.get(&uuid) + .context("Speaker not found")? + .clone(); + + self.cached_is_playing = speaker.is_playing().await?; + self.cached_volume = speaker.volume().await?; + self.cached_now_playing = speaker.track().await?.map(Arc::new); + self.cached_queue = Arc::new(speaker.queue().await?); + Ok(()) + } + async fn send_update(&self) { - match self.get_state().await { + match self.build_state() { Ok(speaker_state) => { if let Err(err) = self .update_tx @@ -137,7 +328,7 @@ impl SonosService { warn!(%err, "Updates channel was closed: exiting"); } } - Err(err) => warn!(%err, "Failed to get state from speaker"), + Err(err) => warn!(%err, "Failed to build state"), } } @@ -163,24 +354,22 @@ impl SonosService { .and_then(|group| self.speakers_by_uuid.get(&group.coordinator)) } - async fn get_state(&self) -> Result { - let speaker = self.current_speaker().context("No selected group")?; - let is_playing = speaker.is_playing().await?; - let current_volume = speaker.volume().await?; - let current_track = speaker.track().await?; - let queue = speaker.queue().await?; + fn build_state(&self) -> Result { let mut names = vec![]; for group in &self.groups { names.push(group.name()); } Ok(SpeakerState { - is_playing, - current_volume, + is_playing: self.cached_is_playing, + current_volume: self.cached_volume, group_names: names, selected_group: self.selected_group, - now_playing: current_track, - queue, + now_playing: self.cached_now_playing.clone(), + queue: self.cached_queue.clone(), + current_view: self.current_view, + favorites: self.favorites.clone(), + selected_favorite: self.selected_favorite, }) } } @@ -244,3 +433,90 @@ impl SpeakerGroup { names.join(" + ") } } + +async fn fetch_favorite_playlists(speaker: &Speaker) -> Result> { + let service = URN::service("schemas-upnp-org", "ContentDirectory", 1); + + let payload = r#"FV:2 +BrowseDirectChildren +* +0 +100 +"#; + + let response = speaker + .action(&service, "Browse", payload) + .await + .context("Failed to browse favorites")?; + + let xml = response + .get("Result") + .context("No Result in browse response")?; + + Ok(parse_favorite_playlists(xml)) +} + +fn parse_favorite_playlists(xml: &str) -> Vec { + let mut playlists = Vec::new(); + + // Split XML into individual items + let items: Vec<&str> = xml.split(" tag + let uri = extract_tag_content(item, "") + .and_then(|res_block| { + if let Some(start) = res_block.find('>') { + Some(&res_block[start + 1..]) + } else { + None + } + }) + .unwrap_or(""); + + // Filter for playlists only (check URI patterns and upnp:class) + let is_playlist = uri.contains("playlist") + || uri.starts_with("x-rincon-cpcontainer:") + || item.contains("playlistContainer"); + + if !is_playlist { + continue; + } + + let title = extract_tag_content(item, "", "") + .unwrap_or("Unknown") + .to_string(); + + let description = extract_tag_content(item, "", "") + .unwrap_or("") + .to_string(); + + let metadata = extract_tag_content(item, "", "") + .unwrap_or("") + .to_string(); + + playlists.push(FavoritePlaylist { + title, + description, + uri: uri.to_string(), + metadata, + }); + } + + playlists +} + +fn extract_tag_content<'a>(text: &'a str, start_tag: &str, end_tag: &str) -> Option<&'a str> { + let start = text.find(start_tag)?; + let content_start = start + start_tag.len(); + let end = text[content_start..].find(end_tag)?; + Some(&text[content_start..content_start + end]) +} + +fn html_unescape(s: &str) -> String { + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") +} diff --git a/src/view.rs b/src/view.rs index 848697e..3052aed 100644 --- a/src/view.rs +++ b/src/view.rs @@ -12,13 +12,14 @@ use ratatui::{ Frame, }; -use crate::{sonos::SpeakerState, Action}; +use crate::{sonos::SpeakerState, Action, Direction, ViewMode}; pub fn render_ui(frame: &mut Frame, state: &SpeakerState) { - let [title, tabs, playbar, queue] = Layout::vertical([ + let [title, tabs, playbar, view_tabs, content] = Layout::vertical([ Constraint::Length(1), Constraint::Length(3), Constraint::Length(3), + Constraint::Length(1), Constraint::Min(1), ]) .areas(frame.area()); @@ -32,12 +33,34 @@ pub fn render_ui(frame: &mut Frame, state: &SpeakerState) { // playbar render_playbar(state, frame, playbar); - // queue - render_queue(state, frame, queue); + // View tabs + render_view_tabs(state, frame, view_tabs); + + // Main content area (switches based on current view) + match state.current_view { + ViewMode::Queue => render_queue(state, frame, content), + ViewMode::Favorites => render_favorites(state, frame, content), + } } pub fn handle_input(input: &KeyEvent, state: &SpeakerState) -> Action { match input.code { + // View switching + KeyCode::Char('1') => Action::SwitchView(ViewMode::Queue), + KeyCode::Char('2') => Action::SwitchView(ViewMode::Favorites), + + // Favorites navigation (only when in Favorites view) + KeyCode::Up | KeyCode::Char('k') if matches!(state.current_view, ViewMode::Favorites) => { + Action::NavigateFavorites(Direction::Up) + } + KeyCode::Down | KeyCode::Char('j') if matches!(state.current_view, ViewMode::Favorites) => { + Action::NavigateFavorites(Direction::Down) + } + KeyCode::Enter if matches!(state.current_view, ViewMode::Favorites) => { + Action::PlayFavorite(state.selected_favorite) + } + + // Playback controls (work in any view) KeyCode::Char(' ') => { if state.is_playing { Action::Pause @@ -49,6 +72,8 @@ pub fn handle_input(input: &KeyEvent, state: &SpeakerState) -> Action { KeyCode::Char('p') => Action::Prev, KeyCode::Char('[') => Action::VolAdjust(-2), KeyCode::Char(']') => Action::VolAdjust(2), + + // Group switching KeyCode::Tab => { if input.modifiers.contains(KeyModifiers::SHIFT) { Action::PrevSpeaker @@ -56,6 +81,7 @@ pub fn handle_input(input: &KeyEvent, state: &SpeakerState) -> Action { Action::NextSpeaker } } + _ => Action::Nop, } } @@ -93,6 +119,21 @@ fn render_tabs(state: &SpeakerState, frame: &mut Frame, area: Rect) { frame.render_widget(tabs, area); } +fn render_view_tabs(state: &SpeakerState, frame: &mut Frame, area: Rect) { + let view_names = vec!["1 Queue", "2 Favorites"]; + let selected = match state.current_view { + ViewMode::Queue => 0, + ViewMode::Favorites => 1, + }; + + let tabs = Tabs::new(view_names) + .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .select(selected) + .divider(VERTICAL); + + frame.render_widget(tabs, area); +} + fn render_queue(state: &SpeakerState, frame: &mut Frame, area: Rect) { // Select the currently playing track in the queue (if any) let mut list_state = ListState::default(); @@ -120,6 +161,11 @@ fn render_queue(state: &SpeakerState, frame: &mut Frame, area: Rect) { .block(Block::bordered().title(" Queue ").border_type(Rounded)); frame.render_stateful_widget(list, area, &mut list_state); + + // Show help text at the bottom if there are tracks in the queue + if !state.queue.is_empty() { + render_help_in_border(frame, area, " SPACE play/pause • n next • p prev • [ ] volume "); + } } fn render_playbar(state: &SpeakerState, frame: &mut Frame, area: Rect) { @@ -182,6 +228,66 @@ fn render_playbar(state: &SpeakerState, frame: &mut Frame, area: Rect) { frame.render_widget(playbar, bar_area); } +fn render_favorites(state: &SpeakerState, frame: &mut Frame, area: Rect) { + let mut list_state = ListState::default(); + list_state.select(Some(state.selected_favorite)); + + let items = state.favorites.iter().map(|fav| { + let s = format!( + "{} - {}", + fav.title, + fav.description + ); + ListItem::new(s) + }); + + let list = List::new(items) + .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .highlight_symbol("⏵ ") + .block( + Block::bordered() + .title(" Favorite Playlists ") + .border_type(Rounded) + ); + + frame.render_stateful_widget(list, area, &mut list_state); + + // Show help text at the bottom if there are favorites + if !state.favorites.is_empty() { + render_help_in_border(frame, area, " ↑↓ Navigate • ENTER to play "); + } +} + +fn render_help_in_border(frame: &mut Frame, area: Rect, help_text: &str) { + // Calculate how much space the help text takes + let text_width = help_text.len(); + // Available width is total width minus the 2 border characters (left and right) + let available_width = area.width.saturating_sub(2) as usize; + + // Calculate padding for centering + let padding = (available_width.saturating_sub(text_width)) / 2; + + // Create the bottom border content (without corner characters - they're drawn by the Block) + let left_border = "─".repeat(padding); + let right_border = "─".repeat(available_width.saturating_sub(padding + text_width)); + + let border_line = Line::from(vec![ + Span::raw(left_border), + Span::styled(help_text, Style::default().fg(Color::DarkGray)), + Span::raw(right_border), + ]); + + let help = Paragraph::new(border_line); + // Position the help text just after the left border corner character + let help_area = Rect { + x: area.x + 1, // Skip the corner character + y: area.y + area.height.saturating_sub(1), + width: area.width.saturating_sub(2), // Exclude both corner characters + height: 1, + }; + frame.render_widget(help, help_area); +} + fn format_duration(secs: u32) -> String { let minutes = secs / 60; let seconds = secs % 60;