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
5 changes: 3 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ reqwest = { version = "0.13.5", default-features = false, features = ["rustls"]
rustls = { version = "0.23.45", default-features = false, features = ["aws_lc_rs", "std"] }
serde = { version = "1.0.229", features = ["derive"] }
sha2 = "0.11.1"
sinuous-client = "0.2.0"
sinuous-client = "0.2.2"
tokio = { version = "1.18.2", features = ["full"] }
toml = "1.1.8"
toml_edit = "0.25.17"
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ local network.
## Features

- See what each group of speakers is playing, and switch between them
- Play, pause and skip tracks, and toggle shuffle, repeat and crossfade
- Play, pause and skip tracks, toggle shuffle, repeat and crossfade, and set a
sleep timer
- Adjust the volume of a group, or of each of its speakers
- Group and ungroup speakers
- Play your Sonos favorites
Expand Down Expand Up @@ -61,7 +62,7 @@ sinuous --device "Living Room"
| <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>.</kbd> | Playback menu: toggle crossfade, and set the sleep timer |
| <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
23 changes: 22 additions & 1 deletion src/sonos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ pub struct GroupState {
pub now_playing: Option<TrackInfo>,
pub next_track: Option<TrackInfo>,
pub source: Option<Source>,
/// When the sleep timer runs out, and playback fades out and pauses, if it is set.
pub sleep_timer: Option<Instant>,
}

impl GroupState {
Expand All @@ -135,6 +137,7 @@ impl GroupState {
now_playing: None,
next_track: None,
source: None,
sleep_timer: None,
}
}
}
Expand Down Expand Up @@ -304,6 +307,8 @@ pub enum GroupAction {
/// Make these players the members of the group. The coordinator must come first.
SetMembers(Vec<PlayerId>),
SetPlayModes(PlayModesUpdate),
/// Pause after this long, or never.
SetSleepTimer(Option<Duration>),
}

#[derive(Debug)]
Expand Down Expand Up @@ -513,6 +518,11 @@ impl Service {
group.source = Source::new(&metadata);
}
EventPayload::GroupVolume(volume) => group.volume = Some(volume.volume),
EventPayload::SleepTimerStatus(timer) => {
group.sleep_timer = timer.active.then(|| {
Instant::now() + timer.remaining.unwrap_or(Duration::ZERO)
});
}
_ => return false,
}
true
Expand Down Expand Up @@ -572,6 +582,7 @@ impl Service {
Subscription::Playback(group.id.clone()),
Subscription::PlaybackMetadata(group.id.clone()),
Subscription::GroupVolume(group.id.clone()),
Subscription::SleepTimer(group.id.clone()),
]
});
let players = state
Expand Down Expand Up @@ -645,7 +656,8 @@ impl Service {
let name = match subscription {
Subscription::Playback(id)
| Subscription::PlaybackMetadata(id)
| Subscription::GroupVolume(id) => state.group(id).map(|g| g.name.clone()),
| Subscription::GroupVolume(id)
| Subscription::SleepTimer(id) => state.group(id).map(|g| g.name.clone()),
Subscription::PlayerVolume(id) => state
.players
.iter()
Expand Down Expand Up @@ -775,6 +787,14 @@ async fn run_group_command(
.set_play_modes(modes)
.await
.context("Failed to change the play modes")?,
GroupAction::SetSleepTimer(Some(duration)) => group
.set_sleep_timer(*duration)
.await
.context("Failed to set the sleep timer")?,
GroupAction::SetSleepTimer(None) => group
.cancel_sleep_timer()
.await
.context("Failed to cancel the sleep timer")?,
}
Ok(())
}
Expand Down Expand Up @@ -904,6 +924,7 @@ fn keep_group_states(groups: &mut [GroupState], old_groups: &[GroupState]) {
group.now_playing.clone_from(&old.now_playing);
group.next_track.clone_from(&old.next_track);
group.source.clone_from(&old.source);
group.sleep_timer = old.sleep_timer;
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/symbols.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub struct Symbols {
pub checked: &'static str,
pub unchecked: &'static str,
pub theme: &'static str,

// In the playback menu
pub sleep_timer: &'static str,
}

impl Symbols {
Expand Down Expand Up @@ -85,6 +88,7 @@ impl Symbols {
checked: "[x]",
unchecked: "[ ]",
theme: "🎨",
sleep_timer: "🌙",
};

/// Material Design icons, see https://www.nerdfonts.com/cheat-sheet.
Expand Down Expand Up @@ -129,6 +133,7 @@ impl Symbols {
checked: "\u{f0132}", // nf-md-checkbox_marked
unchecked: "\u{f0131}", // nf-md-checkbox_blank_outline
theme: "\u{f03d8}", // nf-md-palette
sleep_timer: "\u{f04b2}", // nf-md-sleep
};

/// The volume level `volume` is shown as.
Expand Down
47 changes: 37 additions & 10 deletions src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ 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_playback_menu,
render_theme_picker, render_volume_mixer,
render_sleep_timer_picker, render_theme_picker, render_volume_mixer,
};
use system_info::render_system_info;

Expand Down Expand Up @@ -66,8 +66,14 @@ 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 playback menu, and the sleep timer picker on top of it, if open.
PlaybackMenu {
/// The item highlighted, as an index in [`PlaybackItem::ALL`].
highlighted: usize,
/// The choice highlighted in the sleep timer picker, as an index in
/// [`SLEEP_TIMER_MINUTES`].
sleep_timer_picker: Option<usize>,
},
/// The keyboard shortcuts.
Help,
/// The details of each speaker, in place of everything but the title bar.
Expand All @@ -85,12 +91,24 @@ enum Mode {
#[derive(Debug, Clone, Copy)]
enum PlaybackItem {
Crossfade,
SleepTimer,
}

impl PlaybackItem {
const ALL: [Self; 1] = [Self::Crossfade];
const ALL: [Self; 2] = [Self::Crossfade, Self::SleepTimer];
}

/// What the sleep timer can be set to, in minutes, or off.
const SLEEP_TIMER_MINUTES: [Option<u64>; 7] = [
None,
Some(15),
Some(30),
Some(45),
Some(60),
Some(90),
Some(120),
];

/// What a key press asks for, besides changing what is shown.
#[derive(Debug)]
pub enum Request {
Expand Down Expand Up @@ -208,7 +226,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::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 @@ -274,11 +292,14 @@ pub fn artwork_url<'a>(state: &'a HouseholdState, ui: &UiState) -> Option<&'a Ur
/// change.
pub fn is_animated(state: &HouseholdState, ui: &UiState) -> bool {
let showing_error = ui.shown_error(state).is_some();
let advancing = ui
.current_group(state)
.and_then(|(_, group)| group.playback)
let group = ui.current_group(state).map(|(_, group)| group);
let advancing = group
.and_then(|group| group.playback)
.is_some_and(|playback| playback.is_advancing());
showing_error || advancing
// The playback menu shows the time left on the sleep timer.
let counting_down = matches!(ui.mode, Mode::PlaybackMenu { .. })
&& group.is_some_and(|group| group.sleep_timer.is_some());
showing_error || advancing || counting_down
}

pub fn render_ui(
Expand Down Expand Up @@ -349,8 +370,14 @@ pub fn render_ui(
Mode::VolumeMixer(highlighted) => {
render_volume_mixer(state, group, theme, highlighted, frame);
}
Mode::PlaybackMenu(highlighted) => {
Mode::PlaybackMenu {
highlighted,
sleep_timer_picker,
} => {
render_playback_menu(group, symbols, theme, highlighted, frame);
if let Some(highlighted) = sleep_timer_picker {
render_sleep_timer_picker(theme, highlighted, frame);
}
}
Mode::Help => render_help(theme, frame),
Mode::Config {
Expand Down
Loading
Loading