Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ sinuous --device "Living Room"
| --- | --- |
| <kbd>Space</kbd> | Play / pause |
| <kbd>p</kbd> / <kbd>n</kbd> | Previous / next track |
| <kbd>s</kbd> / <kbd>r</kbd> / <kbd>c</kbd> | Toggle shuffle / cycle repeat (all, one, off) / toggle crossfade |
| <kbd>s</kbd> / <kbd>r</kbd> | Toggle shuffle / cycle repeat (all, one, off) |
| <kbd>[</kbd> / <kbd>]</kbd> | Volume of the group down / up |
| <kbd>v</kbd> | Volume of the group and of each of its speakers |
| <kbd>.</kbd> | Playback menu, e.g. to toggle crossfade |
| <kbd>g</kbd> | Switch to another group, from a panel showing what each group is playing |
| <kbd>G</kbd> | Add speakers to or remove speakers from the group |
| <kbd>f</kbd> | Play one of your Sonos favorites |
Expand Down
10 changes: 10 additions & 0 deletions src/sonos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
3 changes: 0 additions & 3 deletions src/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,7 +65,6 @@ impl Symbols {
shuffle: "shuffle",
repeat: "repeat",
repeat_one: "repeat one",
crossfade: "crossfade",
album: "Album",
artist: "Artist",
playlist: "Playlist",
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 87 additions & 11 deletions src/view/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Command> {
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(
Expand Down Expand Up @@ -314,7 +355,7 @@ fn handle_favorites_picker_input(
fn group_command(input: &KeyEvent, state: &HouseholdState, ui: &UiState) -> Option<Command> {
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))
}
Expand All @@ -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<Action>),
) -> Option<Command> {
if required.is_some_and(|required| !supports(group, required)) {
return None;
}
Expand Down Expand Up @@ -405,6 +447,7 @@ mod tests {
use sinuous_client::GroupId;

use super::*;
use crate::sonos::Playback;
use crate::view::tests::{household, press};

#[test]
Expand Down Expand Up @@ -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));
}
Expand All @@ -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());
Expand All @@ -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();
Expand Down
22 changes: 8 additions & 14 deletions src/view/now_playing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,38 +271,32 @@ 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 {
symbols.playing
} 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) {
Expand Down
51 changes: 50 additions & 1 deletion src/view/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand All @@ -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"),
],
),
(
Expand Down
Loading