From ec6b0878aa22a7f139bcfa49c56e0b324aa34806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Fri, 16 Oct 2026 15:43:22 +1100 Subject: [PATCH 1/2] feat: Add a playback menu, with the crossfade toggle Press . to open it. Crossfade isn't toggled often enough for its own key, nor to show on the main screen: c no longer toggles it, and its icon is gone. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- src/sonos.rs | 10 +++++ src/symbols.rs | 3 -- src/view.rs | 20 ++++++++- src/view/input.rs | 98 ++++++++++++++++++++++++++++++++++++----- src/view/now_playing.rs | 1 - src/view/popup.rs | 51 ++++++++++++++++++++- 7 files changed, 167 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1735c5d..53cc7a7 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,10 @@ sinuous --device "Living Room" | --- | --- | | Space | Play / pause | | p / n | Previous / next track | -| s / r / c | Toggle shuffle / cycle repeat (all, one, off) / toggle crossfade | +| s / r | Toggle shuffle / cycle repeat (all, one, off) | | [ / ] | Volume of the group down / up | | v | Volume of the group and of each of its speakers | +| . | Playback menu, e.g. to toggle crossfade | | g | Switch to another group, from a panel showing what each group is playing | | G | Add speakers to or remove speakers from the group | | f | Play one of your Sonos favorites | diff --git a/src/sonos.rs b/src/sonos.rs index 5050b89..cac4766 100644 --- a/src/sonos.rs +++ b/src/sonos.rs @@ -196,6 +196,16 @@ impl Playback { pub fn is_advancing(&self) -> bool { self.advancing_since.is_some() } + + /// Paused playback, in these play modes, of a source supporting `actions`. + #[cfg(test)] + pub fn paused(play_modes: PlayModes, actions: PlaybackActions) -> Self { + Self { + play_modes, + actions, + ..Self::from_parts(PlaybackState::Paused, Duration::ZERO, Instant::now()) + } + } } #[derive(Debug, Clone)] diff --git a/src/symbols.rs b/src/symbols.rs index 264da86..74c7b1a 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -18,7 +18,6 @@ pub struct Symbols { pub shuffle: &'static str, pub repeat: &'static str, pub repeat_one: &'static str, - pub crossfade: &'static str, // Kinds of sources, e.g. a group playing an album pub album: &'static str, @@ -66,7 +65,6 @@ impl Symbols { shuffle: "shuffle", repeat: "repeat", repeat_one: "repeat one", - crossfade: "crossfade", album: "Album", artist: "Artist", playlist: "Playlist", @@ -100,7 +98,6 @@ impl Symbols { shuffle: "\u{f049f}", // nf-md-shuffle_variant repeat: "\u{f0456}", // nf-md-repeat repeat_one: "\u{f0458}", // nf-md-repeat_once - crossfade: "\u{f0915}", // nf-md-transition album: "\u{f0025}", // nf-md-album artist: "\u{f0803}", // nf-md-account_music playlist: "\u{f0cb8}", // nf-md-playlist_music diff --git a/src/view.rs b/src/view.rs index 9a6945c..ef72ec5 100644 --- a/src/view.rs +++ b/src/view.rs @@ -26,8 +26,8 @@ pub use input::handle_input; use now_playing::{AlbumArt, now_playing_width, render_now_playing}; use panel::{render_group_editor, render_group_panel}; use popup::{ - render_config_dialog, render_favorites_picker, render_help, render_theme_picker, - render_volume_mixer, + render_config_dialog, render_favorites_picker, render_help, render_playback_menu, + render_theme_picker, render_volume_mixer, }; /// How long errors are displayed for. @@ -64,6 +64,8 @@ enum Mode { FavoritesPicker(usize), /// The volume mixer, with this row highlighted: 0 for the selected group, then its members. VolumeMixer(usize), + /// The playback menu, with the item at this index in [`PlaybackItem::ALL`] highlighted. + PlaybackMenu(usize), /// The keyboard shortcuts. Help, /// The configuration dialog, and the theme picker on top of it, if open. @@ -75,6 +77,16 @@ enum Mode { }, } +/// What the playback menu lists. +#[derive(Debug, Clone, Copy)] +enum PlaybackItem { + Crossfade, +} + +impl PlaybackItem { + const ALL: [Self; 1] = [Self::Crossfade]; +} + /// What a key press asks for, besides changing what is shown. #[derive(Debug)] pub enum Request { @@ -183,6 +195,7 @@ impl UiState { } Mode::GroupEditor(editor) => clamp(&mut editor.highlight.item, state.players.len()), Mode::FavoritesPicker(highlighted) => clamp(highlighted, state.favorites.len()), + Mode::PlaybackMenu(highlighted) => clamp(highlighted, PlaybackItem::ALL.len()), Mode::VolumeMixer(highlighted) => match mixer_rows { Some(rows) => clamp(highlighted, rows), None => self.mode = Mode::NowPlaying, @@ -320,6 +333,9 @@ pub fn render_ui( Mode::VolumeMixer(highlighted) => { render_volume_mixer(state, group, theme, highlighted, frame); } + Mode::PlaybackMenu(highlighted) => { + render_playback_menu(group, symbols, theme, highlighted, frame); + } Mode::Help => render_help(theme, frame), Mode::Config { highlighted, diff --git a/src/view/input.rs b/src/view/input.rs index dcd0390..20b050d 100644 --- a/src/view/input.rs +++ b/src/view/input.rs @@ -12,7 +12,7 @@ use crate::config::{UiConfig, UiSetting}; use crate::sonos::{Command, GroupAction, GroupState, HouseholdState, PlayerAction}; use crate::theme::THEMES; -use super::{GroupEditor, Mode, PanelHighlight, Request, Selection, UiState}; +use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Selection, UiState}; /// How much the volume changes with each key press. const VOLUME_STEP: i8 = 1; @@ -52,6 +52,9 @@ pub fn handle_input( handle_favorites_picker_input(input, state, ui, highlighted) } Mode::VolumeMixer(highlighted) => handle_volume_mixer_input(input, state, ui, highlighted), + Mode::PlaybackMenu(highlighted) => { + handle_playback_menu_input(input, state, ui, highlighted) + } Mode::Help => { if matches!(input.code, KeyCode::Esc | KeyCode::Char('?')) { ui.mode = Mode::NowPlaying; @@ -87,6 +90,7 @@ fn handle_now_playing_input( } KeyCode::Char('v') => Mode::VolumeMixer(0), + KeyCode::Char('.') => Mode::PlaybackMenu(0), KeyCode::Char('?') => Mode::Help, KeyCode::Char(',') => Mode::Config { @@ -282,6 +286,43 @@ fn handle_volume_mixer_input( } } +/// Update `ui` according to the key pressed while the playback menu is open, with the item at +/// `highlighted` highlighted, and return the command it asks for, if any. +fn handle_playback_menu_input( + input: &KeyEvent, + state: &HouseholdState, + ui: &mut UiState, + highlighted: usize, +) -> Option { + if let Some(highlighted) = navigate(input.code, highlighted, PlaybackItem::ALL.len()) { + ui.mode = Mode::PlaybackMenu(highlighted); + return None; + } + match input.code { + // It stays open, to see the change. + KeyCode::Char(' ') | KeyCode::Enter => { + let (_, group) = ui.current_group(state)?; + match PlaybackItem::ALL.get(highlighted)? { + PlaybackItem::Crossfade => { + let update = PlayModesUpdate { + crossfade: Some(!group.playback?.play_modes.crossfade), + ..Default::default() + }; + command_for( + group, + (GroupAction::SetPlayModes(update), Some(Action::Crossfade)), + ) + } + } + } + KeyCode::Esc | KeyCode::Char('.') => { + ui.mode = Mode::NowPlaying; + None + } + _ => None, + } +} + /// Update `ui` according to the key pressed while the favorites picker is open, with the favorite /// at index `highlighted` highlighted, and return the command playing the favorite, if any. fn handle_favorites_picker_input( @@ -314,7 +355,7 @@ fn handle_favorites_picker_input( fn group_command(input: &KeyEvent, state: &HouseholdState, ui: &UiState) -> Option { let (_, group) = ui.current_group(state)?; // The action, and what the current source needs to support for it, if anything. - let (action, required) = match input.code { + let action = match input.code { KeyCode::Char(' ') if group.playback.is_some_and(|p| p.is_playing) => { (GroupAction::Pause, Some(Action::PlayPause)) } @@ -337,16 +378,17 @@ fn group_command(input: &KeyEvent, state: &HouseholdState, ui: &UiState) -> Opti let update = next_repeat(playback.play_modes, playback.actions); (GroupAction::SetPlayModes(update), Some(Action::Repeat)) } - KeyCode::Char('c') => { - let update = PlayModesUpdate { - crossfade: Some(!group.playback?.play_modes.crossfade), - ..Default::default() - }; - (GroupAction::SetPlayModes(update), Some(Action::Crossfade)) - } _ => return None, }; + command_for(group, action) +} + +/// The command doing `action` for `group`, unless its source doesn't support what it requires. +fn command_for( + group: &GroupState, + (action, required): (GroupAction, Option), +) -> Option { if required.is_some_and(|required| !supports(group, required)) { return None; } @@ -405,6 +447,7 @@ mod tests { use sinuous_client::GroupId; use super::*; + use crate::sonos::Playback; use crate::view::tests::{household, press}; #[test] @@ -564,7 +607,7 @@ mod tests { Some(Request::Toggle(UiSetting::NerdFont)) )); // Keys don't reach the main screen while it is open. - assert!(press(&[KeyCode::Char('c')], &state, &mut ui).is_none()); + assert!(press(&[KeyCode::Char('s')], &state, &mut ui).is_none()); assert!(press(&[KeyCode::Esc], &state, &mut ui).is_none()); assert!(matches!(ui.mode, Mode::NowPlaying)); } @@ -575,7 +618,7 @@ mod tests { let mut ui = UiState::default(); ui.sync(&state); - for opening in [',', 'v', 'f', 'g', 'G', '?'] { + for opening in [',', 'v', '.', 'f', 'g', 'G', '?'] { assert!(press(&[KeyCode::Char(opening)], &state, &mut ui).is_none()); assert!(!matches!(ui.mode, Mode::NowPlaying), "{opening}"); assert!(press(&[KeyCode::Char('q')], &state, &mut ui).is_none()); @@ -602,6 +645,39 @@ mod tests { )); } + #[test] + fn playback_menu_toggles_crossfade_when_supported() { + let mut state = household(); + let actions = PlaybackActions { + can_crossfade: true, + ..Default::default() + }; + state.groups[0].playback = Some(Playback::paused(PlayModes::default(), actions)); + let mut ui = UiState::default(); + ui.sync(&state); + + let request = press(&[KeyCode::Char('.'), KeyCode::Char(' ')], &state, &mut ui); + let Some(Request::Speakers(Command::Group { + group, + action: GroupAction::SetPlayModes(update), + })) = request + else { + panic!("Unexpected request: {request:?}"); + }; + assert_eq!(group, GroupId::from("bedroom")); + assert_eq!(update.crossfade, Some(true)); + // It stays open, to see the change. + assert!(matches!(ui.mode, Mode::PlaybackMenu(0))); + + state.groups[0].playback = Some(Playback::paused( + PlayModes::default(), + PlaybackActions::default(), + )); + assert!(press(&[KeyCode::Enter], &state, &mut ui).is_none()); + assert!(press(&[KeyCode::Char('.')], &state, &mut ui).is_none()); + assert!(matches!(ui.mode, Mode::NowPlaying)); + } + #[test] fn help_closes_with_the_key_opening_it() { let state = household(); diff --git a/src/view/now_playing.rs b/src/view/now_playing.rs index 4272aee..a4d4166 100644 --- a/src/view/now_playing.rs +++ b/src/view/now_playing.rs @@ -295,7 +295,6 @@ fn controls_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<' let modes = [ (symbols.shuffle, modes.shuffle, Action::Shuffle), (repeat, modes.repeat || modes.repeat_one, Action::Repeat), - (symbols.crossfade, modes.crossfade, Action::Crossfade), ]; let mut spans = Vec::new(); diff --git a/src/view/popup.rs b/src/view/popup.rs index beccf7c..5223dca 100644 --- a/src/view/popup.rs +++ b/src/view/popup.rs @@ -13,6 +13,8 @@ use crate::sonos::{GroupState, HouseholdState}; use crate::symbols::Symbols; use crate::theme::{self, THEMES, Theme}; +use super::PlaybackItem; +use super::input::{Action, supports}; use super::now_playing::{volume_slider, volume_text}; use super::panel::speaker_name; @@ -109,6 +111,53 @@ pub(super) fn render_volume_mixer( } } +/// A popup listing what can be changed about the playback of `group`, with the item at +/// `highlighted` highlighted. What its source doesn't support is dimmed. +pub(super) fn render_playback_menu( + group: &GroupState, + symbols: &Symbols, + theme: &Theme, + highlighted: usize, + frame: &mut Frame, +) { + let modes = group.playback.map(|p| p.play_modes).unwrap_or_default(); + let lines = PlaybackItem::ALL + .into_iter() + .map(|item| { + let (on, action, description) = match item { + PlaybackItem::Crossfade => (modes.crossfade, Action::Crossfade, "Crossfade"), + }; + let checkbox = if on { + symbols.checked + } else { + symbols.unchecked + }; + let (checkbox_style, style) = match (supports(group, action), on) { + (false, _) => ( + Style::default().fg(theme.muted), + Style::default().fg(theme.muted), + ), + (true, true) => (Style::default().fg(theme.on), Style::default()), + (true, false) => (Style::default().fg(theme.secondary), Style::default()), + }; + Line::from(vec![ + Span::styled(checkbox, checkbox_style), + Span::raw(" "), + Span::styled(description, style), + ]) + }) + .collect(); + + render_popup( + frame, + theme, + " Playback ", + lines, + Some(highlighted), + " ↑↓ Navigate • SPACE change • ESC close ", + ); +} + /// A popup listing all the keyboard shortcuts. pub(super) fn render_help(theme: &Theme, frame: &mut Frame) { // The keys to use in the other popups are shown at their bottom. @@ -120,8 +169,8 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) { ("p / n", "Previous / next track"), ("s", "Toggle shuffle"), ("r", "Cycle repeat: all, one, off"), - ("c", "Toggle crossfade"), ("[ / ]", "Volume down / up"), + (".", "Playback menu"), ], ), ( From 8fc71fcdd1c2f317dab75daa81416498d057f0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoine=20B=C3=BCsch?= Date: Fri, 16 Oct 2026 16:34:57 +1100 Subject: [PATCH 2/2] feat: Show shuffle and repeat on either side of the playback controls With crossfade gone, the separator between the controls and the play modes isn't needed. Co-Authored-By: Claude Opus 5.5 --- src/view/now_playing.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/view/now_playing.rs b/src/view/now_playing.rs index a4d4166..5ba4878 100644 --- a/src/view/now_playing.rs +++ b/src/view/now_playing.rs @@ -271,8 +271,8 @@ fn title_line<'a>(track: &'a TrackInfo, theme: &Theme) -> Line<'a> { Line::from(spans) } -/// The playback controls and play modes, highlighting the playback status and the play modes that -/// are on. +/// The playback controls between the play modes, shuffle on the left and repeat on the right, +/// highlighting the playback status and the play modes that are on. fn controls_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<'static> { let is_playing = group.playback.is_some_and(|p| p.is_playing); let status = if is_playing { @@ -280,28 +280,23 @@ fn controls_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<' } else { symbols.paused }; - let controls = [ - (symbols.previous, false, Action::Previous), - (status, is_playing, Action::PlayPause), - (symbols.next, false, Action::Next), - ]; - let modes = group.playback.map(|p| p.play_modes).unwrap_or_default(); let repeat = if modes.repeat_one { symbols.repeat_one } else { symbols.repeat }; - let modes = [ + let items = [ (symbols.shuffle, modes.shuffle, Action::Shuffle), + (symbols.previous, false, Action::Previous), + (status, is_playing, Action::PlayPause), + (symbols.next, false, Action::Next), (repeat, modes.repeat || modes.repeat_one, Action::Repeat), ]; let mut spans = Vec::new(); - for (i, (label, on, action)) in controls.into_iter().chain(modes).enumerate() { - if i == controls.len() { - spans.push(Span::styled(" │ ", Style::default().fg(theme.muted))); - } else if i > 0 { + for (i, (label, on, action)) in items.into_iter().enumerate() { + if i > 0 { spans.push(Span::raw(" ")); } let style = match (supports(group, action), on) {