diff --git a/Cargo.lock b/Cargo.lock
index 0f81f81..6bf1c30 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2737,10 +2737,11 @@ dependencies = [
[[package]]
name = "sinuous-client"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84554ceab433edf7cd873c63c8528c177acf63be3548cd8bc9f1d58725f8eae1"
+checksum = "632e7b341fdc87921e9f6ae72e90d42d8afbab7b5450b2a25134a3e6c39dd2ce"
dependencies = [
+ "base64 0.23.1",
"futures-util",
"iddqd",
"rustls",
diff --git a/Cargo.toml b/Cargo.toml
index 5fc266c..21d2756 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index 12bab72..5c7d2a6 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -61,7 +62,7 @@ sinuous --device "Living Room"
| 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 |
+| . | Playback menu: toggle crossfade, and set the sleep timer |
| 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 851f252..b4f14d6 100644
--- a/src/sonos.rs
+++ b/src/sonos.rs
@@ -116,6 +116,8 @@ pub struct GroupState {
pub now_playing: Option,
pub next_track: Option,
pub source: Option,
+ /// When the sleep timer runs out, and playback fades out and pauses, if it is set.
+ pub sleep_timer: Option,
}
impl GroupState {
@@ -135,6 +137,7 @@ impl GroupState {
now_playing: None,
next_track: None,
source: None,
+ sleep_timer: None,
}
}
}
@@ -304,6 +307,8 @@ pub enum GroupAction {
/// Make these players the members of the group. The coordinator must come first.
SetMembers(Vec),
SetPlayModes(PlayModesUpdate),
+ /// Pause after this long, or never.
+ SetSleepTimer(Option),
}
#[derive(Debug)]
@@ -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
@@ -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
@@ -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()
@@ -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(())
}
@@ -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;
}
}
}
diff --git a/src/symbols.rs b/src/symbols.rs
index 74c7b1a..84d6182 100644
--- a/src/symbols.rs
+++ b/src/symbols.rs
@@ -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 {
@@ -85,6 +88,7 @@ impl Symbols {
checked: "[x]",
unchecked: "[ ]",
theme: "🎨",
+ sleep_timer: "🌙",
};
/// Material Design icons, see https://www.nerdfonts.com/cheat-sheet.
@@ -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.
diff --git a/src/view.rs b/src/view.rs
index 7de51b0..5c047eb 100644
--- a/src/view.rs
+++ b/src/view.rs
@@ -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;
@@ -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,
+ },
/// The keyboard shortcuts.
Help,
/// The details of each speaker, in place of everything but the title bar.
@@ -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; 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 {
@@ -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,
@@ -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(
@@ -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 {
diff --git a/src/view/input.rs b/src/view/input.rs
index 599cc10..9cb0aa3 100644
--- a/src/view/input.rs
+++ b/src/view/input.rs
@@ -2,6 +2,8 @@
use std::collections::HashSet;
+use std::time::Duration;
+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use sinuous_client::{
PlayerId,
@@ -12,7 +14,10 @@ use crate::config::{UiConfig, UiSetting};
use crate::sonos::{Command, GroupAction, GroupState, HouseholdState, PlayerAction};
use crate::theme::THEMES;
-use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Scroll, Selection, UiState};
+use super::{
+ GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, SLEEP_TIMER_MINUTES, Scroll,
+ Selection, UiState,
+};
/// How much the volume changes with each key press.
const VOLUME_STEP: i8 = 1;
@@ -52,9 +57,14 @@ 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::PlaybackMenu {
+ highlighted,
+ sleep_timer_picker: None,
+ } => handle_playback_menu_input(input, state, ui, highlighted),
+ Mode::PlaybackMenu {
+ highlighted,
+ sleep_timer_picker: Some(choice),
+ } => handle_sleep_timer_picker_input(input, state, ui, highlighted, choice),
Mode::SystemInfo(scroll) => {
handle_system_info_input(input, ui, scroll);
None
@@ -94,7 +104,10 @@ fn handle_now_playing_input(
}
KeyCode::Char('v') => Mode::VolumeMixer(0),
- KeyCode::Char('.') => Mode::PlaybackMenu(0),
+ KeyCode::Char('.') => Mode::PlaybackMenu {
+ highlighted: 0,
+ sleep_timer_picker: None,
+ },
KeyCode::Char('?') => Mode::Help,
KeyCode::Char('i') => Mode::SystemInfo(Scroll::default()),
@@ -320,7 +333,10 @@ fn handle_playback_menu_input(
highlighted: usize,
) -> Option {
if let Some(highlighted) = navigate(input.code, highlighted, PlaybackItem::ALL.len()) {
- ui.mode = Mode::PlaybackMenu(highlighted);
+ ui.mode = Mode::PlaybackMenu {
+ highlighted,
+ sleep_timer_picker: None,
+ };
return None;
}
match input.code {
@@ -338,6 +354,13 @@ fn handle_playback_menu_input(
(GroupAction::SetPlayModes(update), Some(Action::Crossfade)),
)
}
+ PlaybackItem::SleepTimer => {
+ ui.mode = Mode::PlaybackMenu {
+ highlighted,
+ sleep_timer_picker: Some(0),
+ };
+ None
+ }
}
}
KeyCode::Esc | KeyCode::Char('.') => {
@@ -348,6 +371,44 @@ fn handle_playback_menu_input(
}
}
+/// Update `ui` according to the key pressed while the sleep timer picker is open, over the
+/// playback menu with the item at `item` highlighted, with the choice at `highlighted`
+/// highlighted, and return the command setting the sleep timer, if any.
+fn handle_sleep_timer_picker_input(
+ input: &KeyEvent,
+ state: &HouseholdState,
+ ui: &mut UiState,
+ item: usize,
+ highlighted: usize,
+) -> Option {
+ let menu = |sleep_timer_picker| Mode::PlaybackMenu {
+ highlighted: item,
+ sleep_timer_picker,
+ };
+ if let Some(highlighted) = navigate(input.code, highlighted, SLEEP_TIMER_MINUTES.len()) {
+ ui.mode = menu(Some(highlighted));
+ return None;
+ }
+ match input.code {
+ KeyCode::Enter => {
+ ui.mode = menu(None);
+ let minutes = *SLEEP_TIMER_MINUTES.get(highlighted)?;
+ let (_, group) = ui.current_group(state)?;
+ Some(Command::Group {
+ group: group.id.clone(),
+ action: GroupAction::SetSleepTimer(
+ minutes.map(|minutes| Duration::from_secs(minutes * 60)),
+ ),
+ })
+ }
+ KeyCode::Esc => {
+ ui.mode = menu(None);
+ 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(
@@ -692,7 +753,13 @@ mod tests {
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)));
+ assert!(matches!(
+ ui.mode,
+ Mode::PlaybackMenu {
+ highlighted: 0,
+ sleep_timer_picker: None
+ }
+ ));
state.groups[0].playback = Some(Playback::paused(
PlayModes::default(),
@@ -703,6 +770,57 @@ mod tests {
assert!(matches!(ui.mode, Mode::NowPlaying));
}
+ #[test]
+ fn sleep_timer_picker_sets_and_cancels_the_timer() {
+ let state = household();
+ let mut ui = UiState::default();
+ ui.sync(&state);
+ let sleep_timer = |request| match request {
+ Some(Request::Speakers(Command::Group {
+ group,
+ action: GroupAction::SetSleepTimer(duration),
+ })) => {
+ assert_eq!(group, GroupId::from("bedroom"));
+ duration
+ }
+ request => panic!("Unexpected request: {request:?}"),
+ };
+
+ // Off, then 15 and 30 minutes
+ let keys = [
+ KeyCode::Char('.'),
+ KeyCode::Char('j'),
+ KeyCode::Enter,
+ KeyCode::Char('j'),
+ KeyCode::Char('j'),
+ KeyCode::Enter,
+ ];
+ let duration = sleep_timer(press(&keys, &state, &mut ui));
+ assert_eq!(duration, Some(Duration::from_secs(30 * 60)));
+ // Back to the menu, on the sleep timer
+ assert!(matches!(
+ ui.mode,
+ Mode::PlaybackMenu {
+ highlighted: 1,
+ sleep_timer_picker: None
+ }
+ ));
+
+ let duration = sleep_timer(press(&[KeyCode::Enter, KeyCode::Enter], &state, &mut ui));
+ assert_eq!(duration, None);
+
+ // Cancelling the picker leaves the timer alone.
+ let keys = [KeyCode::Enter, KeyCode::Char('j'), KeyCode::Char('q')];
+ assert!(press(&keys, &state, &mut ui).is_none());
+ assert!(matches!(
+ ui.mode,
+ Mode::PlaybackMenu {
+ sleep_timer_picker: None,
+ ..
+ }
+ ));
+ }
+
#[test]
fn help_closes_with_the_key_opening_it() {
let state = household();
diff --git a/src/view/popup.rs b/src/view/popup.rs
index b03d060..b2536e8 100644
--- a/src/view/popup.rs
+++ b/src/view/popup.rs
@@ -1,5 +1,7 @@
//! The popups shown in the middle of the screen.
+use std::time::{Duration, Instant};
+
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@@ -13,10 +15,10 @@ 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;
+use super::{PlaybackItem, SLEEP_TIMER_MINUTES};
/// A popup listing the favorites, with the one at `highlighted` highlighted.
pub(super) fn render_favorites_picker(
@@ -126,6 +128,7 @@ pub(super) fn render_playback_menu(
.map(|item| {
let (on, action, description) = match item {
PlaybackItem::Crossfade => (modes.crossfade, Action::Crossfade, "Crossfade"),
+ PlaybackItem::SleepTimer => return sleep_timer_line(group, symbols, theme),
};
let checkbox = if on {
symbols.checked
@@ -158,6 +161,64 @@ pub(super) fn render_playback_menu(
);
}
+/// The time left on the sleep timer of `group`, if it is set, lined up with the checkboxes above
+/// it.
+fn sleep_timer_line(group: &GroupState, symbols: &Symbols, theme: &Theme) -> Line<'static> {
+ let padding = Span::raw(symbols.checked)
+ .width()
+ .saturating_sub(Span::raw(symbols.sleep_timer).width());
+ let value = match group.sleep_timer {
+ Some(end) => Span::styled(
+ format!(
+ "{} left",
+ time_left(end.saturating_duration_since(Instant::now()))
+ ),
+ Style::default().fg(theme.name),
+ ),
+ None => Span::styled("off", Style::default().fg(theme.secondary)),
+ };
+ Line::from(vec![
+ Span::styled(symbols.sleep_timer, Style::default().fg(theme.secondary)),
+ Span::raw(" ".repeat(padding + 1)),
+ Span::raw("Sleep timer: "),
+ value,
+ ])
+}
+
+/// `time` as hours, minutes and seconds, or minutes and seconds if under an hour.
+fn time_left(time: Duration) -> String {
+ let secs = time.as_secs();
+ let (hours, minutes, seconds) = (secs / 3600, secs / 60 % 60, secs % 60);
+ if hours > 0 {
+ format!("{hours}:{minutes:02}:{seconds:02}")
+ } else {
+ format!("{minutes}:{seconds:02}")
+ }
+}
+
+/// A popup listing what the sleep timer can be set to, with the choice at `highlighted`
+/// highlighted.
+pub(super) fn render_sleep_timer_picker(theme: &Theme, highlighted: usize, frame: &mut Frame) {
+ let lines = SLEEP_TIMER_MINUTES
+ .into_iter()
+ .map(|minutes| match minutes {
+ None => Line::from("Off"),
+ Some(60) => Line::from("1 hour"),
+ Some(minutes) if minutes % 60 == 0 => Line::from(format!("{} hours", minutes / 60)),
+ Some(minutes) => Line::from(format!("{minutes} minutes")),
+ })
+ .collect();
+
+ render_popup(
+ frame,
+ theme,
+ " Sleep timer ",
+ lines,
+ Some(highlighted),
+ " ↑↓ Navigate • ENTER choose • ESC cancel ",
+ );
+}
+
/// 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.
@@ -170,7 +231,7 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) {
("s", "Toggle shuffle"),
("r", "Cycle repeat: all, one, off"),
("[ / ]", "Volume down / up"),
- (".", "Playback menu"),
+ (".", "Playback menu: crossfade, sleep timer"),
],
),
(