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..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,29 +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),
- (symbols.crossfade, modes.crossfade, Action::Crossfade),
];
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) {
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"),
],
),
(