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"), ], ), (