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
1 change: 1 addition & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ url = "2"

[profile.release]
strip = "symbols"

[dev-dependencies]
serde_json = "1.0.154"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ sinuous --device "Living Room"
| <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 |
| <kbd>i</kbd> | System info: the details of each speaker, for troubleshooting |
| <kbd>,</kbd> | Settings |
| <kbd>?</kbd> | Keyboard shortcuts |
| <kbd>q</kbd> | Close what is open, or quit |
Expand Down
19 changes: 15 additions & 4 deletions src/sonos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use futures::StreamExt;
use sinuous_client::{
Event, EventPayload, FavoriteId, GroupId, Household, PlayerId, Subscription, Topology,
Event, EventPayload, FavoriteId, GroupId, Household, HouseholdId, PlayerId, Subscription,
Topology,
favorites::Favorite,
playback::{
LoadAction, LoadOptions, PlayModes, PlayModesUpdate, PlaybackActions, PlaybackState,
Expand Down Expand Up @@ -63,6 +64,9 @@ impl ProvidedDevices {
/// What the service knows about the household.
#[derive(Debug, Clone, Default)]
pub struct HouseholdState {
pub household_id: Option<HouseholdId>,
/// The groups and players as the household last reported them, with all their details.
pub topology: Arc<Topology>,
/// Sorted by name.
pub groups: Vec<GroupState>,
/// Sorted by name.
Expand Down Expand Up @@ -147,6 +151,7 @@ pub struct PlayerInfo {

#[derive(Debug, Clone, Copy)]
pub struct Playback {
pub state: PlaybackState,
pub is_playing: bool,
pub play_modes: PlayModes,
/// What the current source supports.
Expand All @@ -173,6 +178,7 @@ impl Playback {

fn from_parts(state: PlaybackState, position: Duration, now: Instant) -> Self {
Self {
state,
is_playing: matches!(state, PlaybackState::Playing | PlaybackState::Buffering),
play_modes: PlayModes::default(),
actions: PlaybackActions::default(),
Expand Down Expand Up @@ -368,7 +374,10 @@ impl SonosHandle {
///
/// The groups and speakers of the household are in the state right away.
pub fn spawn(household: Household) -> SonosHandle {
let (state_tx, state_rx) = watch::channel(HouseholdState::default());
let (state_tx, state_rx) = watch::channel(HouseholdState {
household_id: Some(household.id().clone()),
..HouseholdState::default()
});
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_QUEUE_SIZE);

tokio::spawn(run_commands(household.clone(), cmd_rx, state_tx.clone()));
Expand Down Expand Up @@ -536,11 +545,13 @@ impl Service {
/// Update the groups and players from the household's topology, keeping what we know about
/// them.
fn update_groups(&mut self) {
let (groups, players) = {
let (topology, groups, players) = {
let topology = self.topology_rx.borrow_and_update();
(group_states(&topology), player_infos(&topology))
let (groups, players) = (group_states(&topology), player_infos(&topology));
(Arc::new(topology.clone()), groups, players)
};
self.state_tx.send_modify(|state| {
state.topology = topology;
let old_groups = std::mem::replace(&mut state.groups, groups);
keep_group_states(&mut state.groups, &old_groups);
let old_players = std::mem::replace(&mut state.players, players);
Expand Down
4 changes: 4 additions & 0 deletions src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub struct Theme {
pub text: Color,
/// Less important text, e.g. labels.
pub secondary: Color,
/// Labels standing out from their values, where both matter, e.g. in the system info.
pub label: Color,
/// Unimportant text, e.g. separators, and actions that aren't supported.
pub muted: Color,
/// The name of the app, and titles.
Expand Down Expand Up @@ -88,6 +90,7 @@ impl Theme {
selection: None,
text: Color::Reset,
secondary: Color::Gray,
label: Color::Cyan,
muted: Color::DarkGray,
title: Color::Yellow,
title_background: Color::DarkGray,
Expand Down Expand Up @@ -116,6 +119,7 @@ impl Theme {
selection: Some(color(tokens::BG_SELECTION)),
text: color(tokens::TEXT_PRIMARY),
secondary: color(tokens::TEXT_SECONDARY),
label: color(tokens::ACCENT_SECONDARY),
muted: color(tokens::TEXT_DIM),
title: color(tokens::ACCENT_PRIMARY),
title_background: color(tokens::BG_HIGHLIGHT),
Expand Down
24 changes: 20 additions & 4 deletions src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod input;
mod now_playing;
mod panel;
mod popup;
mod system_info;

pub use input::handle_input;
use now_playing::{AlbumArt, now_playing_width, render_now_playing};
Expand All @@ -29,6 +30,7 @@ use popup::{
render_config_dialog, render_favorites_picker, render_help, render_playback_menu,
render_theme_picker, render_volume_mixer,
};
use system_info::render_system_info;

/// How long errors are displayed for.
const ERROR_DISPLAY_TIME: Duration = Duration::from_secs(5);
Expand Down Expand Up @@ -68,6 +70,8 @@ enum Mode {
PlaybackMenu(usize),
/// The keyboard shortcuts.
Help,
/// The details of each speaker, in place of everything but the title bar.
SystemInfo(Scroll),
/// The configuration dialog, and the theme picker on top of it, if open.
Config {
/// The setting highlighted: one of [`UiSetting::ALL`], or the theme after them.
Expand Down Expand Up @@ -145,6 +149,15 @@ impl GroupEditor {
}
}

/// How far a fullscreen view is scrolled.
#[derive(Debug, Clone, Copy, Default)]
struct Scroll {
/// The first line shown.
offset: usize,
/// How many lines were shown last time, to scroll by pages.
page: usize,
}

/// The highlighted item of a list in the panel on the left, and the first one shown.
#[derive(Debug, Clone, Copy)]
struct PanelHighlight {
Expand Down Expand Up @@ -200,16 +213,16 @@ impl UiState {
Some(rows) => clamp(highlighted, rows),
None => self.mode = Mode::NowPlaying,
},
Mode::NowPlaying | Mode::Help | Mode::Config { .. } => {}
Mode::NowPlaying | Mode::Help | Mode::SystemInfo(_) | Mode::Config { .. } => {}
}
}

/// Whether a popup is open in the middle of the screen. The group panel and the group editor
/// are beside what is playing, not on top of it.
/// are beside what is playing, not on top of it, and the system info takes its place.
fn popup_open(&self) -> bool {
!matches!(
self.mode,
Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_)
Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_)
)
}

Expand Down Expand Up @@ -285,6 +298,9 @@ pub fn render_ui(
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(frame.area());

render_title_bar(state, ui, group, symbols, theme, frame, title);
if let Mode::SystemInfo(scroll) = &mut ui.mode {
return render_system_info(state, theme, scroll, frame, body);
}
let panel_help = match ui.mode {
Mode::GroupPicker(_) => Some(GROUP_PANEL_HELP),
Mode::GroupEditor(_) => Some(GROUP_EDITOR_HELP),
Expand Down Expand Up @@ -346,7 +362,7 @@ pub fn render_ui(
render_theme_picker(&settings.theme, theme, highlighted, frame);
}
}
Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) => {}
Mode::NowPlaying | Mode::GroupPicker(_) | Mode::GroupEditor(_) | Mode::SystemInfo(_) => {}
}
}

Expand Down
29 changes: 27 additions & 2 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, PlaybackItem, Request, Selection, UiState};
use super::{GroupEditor, Mode, PanelHighlight, PlaybackItem, Request, Scroll, Selection, UiState};

/// How much the volume changes with each key press.
const VOLUME_STEP: i8 = 1;
Expand Down Expand Up @@ -55,6 +55,10 @@ pub fn handle_input(
Mode::PlaybackMenu(highlighted) => {
handle_playback_menu_input(input, state, ui, highlighted)
}
Mode::SystemInfo(scroll) => {
handle_system_info_input(input, ui, scroll);
None
}
Mode::Help => {
if matches!(input.code, KeyCode::Esc | KeyCode::Char('?')) {
ui.mode = Mode::NowPlaying;
Expand Down Expand Up @@ -93,6 +97,7 @@ fn handle_now_playing_input(
KeyCode::Char('.') => Mode::PlaybackMenu(0),

KeyCode::Char('?') => Mode::Help,
KeyCode::Char('i') => Mode::SystemInfo(Scroll::default()),
KeyCode::Char(',') => Mode::Config {
highlighted: 0,
theme_picker: None,
Expand Down Expand Up @@ -169,6 +174,26 @@ fn handle_theme_picker_input(
None
}

/// Update `ui` according to the key pressed while the system info is open, scrolled to `scroll`.
fn handle_system_info_input(input: &KeyEvent, ui: &mut UiState, scroll: Scroll) {
// Scrolling past the end is undone when drawing it, which is when its length is known.
let page = scroll.page.max(1);
let offset = match input.code {
KeyCode::Up | KeyCode::Char('k') => scroll.offset.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => scroll.offset.saturating_add(1),
KeyCode::PageUp => scroll.offset.saturating_sub(page),
KeyCode::PageDown => scroll.offset.saturating_add(page),
KeyCode::Home => 0,
KeyCode::End => usize::MAX,
KeyCode::Esc | KeyCode::Char('i') => {
ui.mode = Mode::NowPlaying;
return;
}
_ => return,
};
ui.mode = Mode::SystemInfo(Scroll { offset, ..scroll });
}

/// Update `ui` according to the key pressed while the group picker is open, with the group at
/// `highlight` highlighted.
fn handle_group_picker_input(
Expand Down Expand Up @@ -618,7 +643,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', 'i', '?'] {
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 Down
1 change: 1 addition & 0 deletions src/view/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ pub(super) fn render_help(theme: &Theme, frame: &mut Frame) {
("f", "Play a favorite"),
("g", "Switch to another group"),
("G", "Add or remove speakers from the group"),
("i", "System info, for troubleshooting"),
(",", "Configuration"),
("?", "This help"),
("q", "Close what is open, or quit"),
Expand Down
Loading
Loading