From dc32ed3afc9671ff4409f669a27b1ced75593ed3 Mon Sep 17 00:00:00 2001 From: Libo Shen Date: Mon, 24 Nov 2025 16:36:42 +0000 Subject: [PATCH] feat: add Sonos favorites playlists support Add support for browsing and playing Sonos favorite playlists/albums directly from the TUI. Features: - Fetch and display favorite playlists from Sonos speakers - Two-view interface: Queue (1) and Favorites (2) - Navigate favorites with arrow keys (up/down or j/k) - Play favorites by pressing Enter - Support for various playlist types including YouTube Music - Optimized state updates with caching to prevent UI hangs - Batch command processing to improve responsiveness Implementation details: - Added FavoritePlaylist struct to represent Sonos favorites - Implemented ContentDirectory browsing via UPnP actions - Added ViewMode enum for switching between Queue and Favorites views - Enhanced SpeakerState with favorites list and navigation state - Improved command handling with batching and state caching - Added proper XML parsing for Sonos playlist metadata Technical notes: - Uses Arc and Arc> for efficient state sharing since sonor crate types don't implement Clone. Arc allows cheap cloning via reference counting instead of expensive data duplication on every state update (which happens every second). --- src/main.rs | 15 +++ src/sonos.rs | 330 ++++++++++++++++++++++++++++++++++++++++++++++----- src/view.rs | 114 +++++++++++++++++- 3 files changed, 428 insertions(+), 31 deletions(-) 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;