From bd1f65dcb901a909ad7a10618bd81c920cecba94 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Fri, 3 Nov 2023 23:35:13 +0000 Subject: [PATCH] Add speaker state update support --- Cargo.lock | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ shalom/Cargo.toml | 3 ++- shalom/src/hass_client.rs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ shalom/src/main.rs | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ shalom/src/oracle.rs | 376 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------- shalom/src/pages/room.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- shalom/src/widgets/media_player.rs | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------- 7 files changed, 843 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da419e3..fe776a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -299,6 +305,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" [[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.48.5", +] + +[[package]] name = "clipboard-win" version = "4.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -530,6 +549,41 @@ dependencies = [ ] [[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.38", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.38", +] + +[[package]] name = "data-encoding" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -548,6 +602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" dependencies = [ "powerfmt", + "serde", ] [[package]] @@ -1069,6 +1124,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] name = "hexf-parse" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1147,6 +1208,29 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] name = "iced" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1320,6 +1404,12 @@ dependencies = [ ] [[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] name = "idna" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1362,6 +1452,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1372,6 +1463,7 @@ checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897" dependencies = [ "equivalent", "hashbrown 0.14.2", + "serde", ] [[package]] @@ -2761,6 +2853,35 @@ dependencies = [ ] [[package]] +name = "serde_with" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.0.2", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.38", +] + +[[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2786,6 +2907,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_with", "strum", "time", "tokio", @@ -2965,6 +3087,12 @@ dependencies = [ ] [[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3123,9 +3251,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" dependencies = [ "deranged", + "itoa", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -3135,6 +3265,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] +name = "time-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +dependencies = [ + "time-core", +] + +[[package]] name = "tiny-skia" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3986,6 +4125,15 @@ dependencies = [ ] [[package]] +name = "windows-core" +version = "0.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] name = "windows-sys" version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml index 61ee44a..83eace2 100644 --- a/shalom/Cargo.toml +++ b/shalom/Cargo.toml @@ -16,12 +16,13 @@ lru = "0.12" palette = "0.7" reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots"] } serde = { version = "1.0", features = ["derive"] } +serde_with = { version = "3.4", features = ["macros"] } serde_json = { version = "1.0", features = ["raw_value"] } strum = { version = "0.25", features = ["derive"] } tokio = { version = "1.33", features = ["net", "sync", "rt", "macros", "time", "fs"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = { version = "0.20", features = ["rustls-tls-native-roots"] } toml = "0.8" -time = { version = "0.3", features = ["std"] } +time = { version = "0.3", features = ["std", "serde", "parsing"] } url = "2.4.1" yoke = { version = "0.7", features = ["derive"] } diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index 9881634..ea9997d 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -5,6 +5,7 @@ use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; use iced::futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; +use serde_with::serde_as; use time::OffsetDateTime; use tokio::sync::{broadcast, mpsc, oneshot}; use tokio_tungstenite::tungstenite::Message; @@ -266,6 +267,7 @@ pub struct CallServiceRequestTarget { #[serde(rename_all = "snake_case", tag = "domain")] pub enum CallServiceRequestData { Light(CallServiceRequestLight), + MediaPlayer(CallServiceRequestMediaPlayer), } #[derive(Serialize)] @@ -283,6 +285,65 @@ pub struct CallServiceRequestLightTurnOn { pub hs_color: Option<(f32, f32)>, } +#[derive(Serialize)] +#[serde(rename_all = "snake_case", tag = "service", content = "service_data")] +pub enum CallServiceRequestMediaPlayer { + VolumeMute(CallServiceRequestMediaPlayerVolumeMute), + VolumeSet(CallServiceRequestMediaPlayerVolumeSet), + MediaSeek(CallServiceRequestMediaPlayerMediaSeek), + ShuffleSet(CallServiceRequestMediaPlayerShuffleSet), + RepeatSet(CallServiceRequestMediaPlayerRepeatSet), + MediaPlay, + MediaPause, + MediaNextTrack, + MediaPreviousTrack, +} + +#[derive(Serialize)] +pub struct CallServiceRequestMediaPlayerVolumeMute { + pub is_volume_muted: bool, +} + +#[derive(Serialize)] +pub struct CallServiceRequestMediaPlayerVolumeSet { + pub volume_level: f32, +} + +#[serde_as] +#[derive(Serialize)] +pub struct CallServiceRequestMediaPlayerMediaSeek { + #[serde_as(as = "serde_with::DurationSeconds")] + pub seek_position: Duration, +} + +#[derive(Serialize)] +pub struct CallServiceRequestMediaPlayerShuffleSet { + pub shuffle: bool, +} + +#[derive(Serialize)] +pub struct CallServiceRequestMediaPlayerRepeatSet { + pub repeat: MediaPlayerRepeat, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MediaPlayerRepeat { + Off, + One, + All, +} + +impl MediaPlayerRepeat { + pub fn next(self) -> Self { + match self { + MediaPlayerRepeat::Off => MediaPlayerRepeat::One, + MediaPlayerRepeat::One => MediaPlayerRepeat::All, + MediaPlayerRepeat::All => MediaPlayerRepeat::Off, + } + } +} + pub mod events { use std::borrow::Cow; @@ -533,6 +594,8 @@ pub mod responses { pub media_content_type: Option>, pub media_duration: Option, pub media_position: Option, + #[serde(with = "time::serde::iso8601::option", default)] + pub media_position_updated_at: Option, pub media_title: Option>, pub media_artist: Option>, pub media_album_name: Option>, diff --git a/shalom/src/main.rs b/shalom/src/main.rs index 1677f96..6980114 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -29,6 +29,37 @@ pub struct Shalom { page: ActivePage, context_menu: Option, oracle: Option>, + home_room: Option<&'static str>, +} + +impl Shalom { + fn is_on_home_page(&self) -> bool { + match (&self.page, self.home_room) { + (ActivePage::Omni(_), None) => true, + (ActivePage::Room(r), Some(id)) if r.room_id() == id => true, + _ => false, + } + } + + fn build_home_route(&self) -> ActivePage { + self.home_room.map_or_else( + || self.build_omni_route(), + |room| self.build_room_route(room), + ) + } + + fn build_room_route(&self, room: &'static str) -> ActivePage { + ActivePage::Room(pages::room::Room::new( + room, + self.oracle.as_ref().unwrap().clone(), + )) + } + + fn build_omni_route(&self) -> ActivePage { + ActivePage::Omni(pages::omni::Omni::new( + self.oracle.as_ref().unwrap().clone(), + )) + } } impl Application for Shalom { @@ -42,6 +73,7 @@ impl Application for Shalom { page: ActivePage::Loading, context_menu: None, oracle: None, + home_room: Some("living_room"), }; // this is only best-effort to try and prevent blocking when loading @@ -64,15 +96,13 @@ impl Application for Shalom { String::from("Shalom") } + #[allow(clippy::too_many_lines)] fn update(&mut self, message: Self::Message) -> Command { #[allow(clippy::single_match)] match (message, &mut self.page, &mut self.context_menu) { (Message::Loaded(oracle), _, _) => { self.oracle = Some(oracle); - self.page = ActivePage::Room(pages::room::Room::new( - "living_room", - self.oracle.clone().unwrap(), - )); + self.page = self.build_home_route(); Command::none() } (Message::CloseContextMenu, _, _) => { @@ -80,15 +110,16 @@ impl Application for Shalom { Command::none() } (Message::OpenOmniPage, _, _) => { - self.page = ActivePage::Omni(pages::omni::Omni::new(self.oracle.clone().unwrap())); + self.page = self.build_omni_route(); + Command::none() + } + (Message::OpenHomePage, _, _) => { + self.page = self.build_home_route(); Command::none() } (Message::OmniEvent(e), ActivePage::Omni(r), _) => match r.update(e) { Some(pages::omni::Event::OpenRoom(room)) => { - self.page = ActivePage::Room(pages::room::Room::new( - room, - self.oracle.clone().unwrap(), - )); + self.page = self.build_room_route(room); Command::none() } None => Command::none(), @@ -111,6 +142,69 @@ impl Application for Shalom { Message::UpdateLightResult, ) } + Some(pages::room::Event::SetSpeakerVolume(id, new)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).set_volume(new).await }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SetSpeakerPosition(id, new)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).seek(new).await }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SetSpeakerPlaying(id, new)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { + let speaker = oracle.speaker(id); + if new { + speaker.play().await; + } else { + speaker.pause().await; + } + }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SetSpeakerMuted(id, new)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).set_mute(new).await }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SetSpeakerRepeat(id, new)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).set_repeat(new).await }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SpeakerNextTrack(id)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).next().await }, + Message::UpdateLightResult, + ) + } + Some(pages::room::Event::SpeakerPreviousTrack(id)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.speaker(id).previous().await }, + Message::UpdateLightResult, + ) + } None => Command::none(), }, (Message::LightControlMenu(e), _, Some(ActiveContextMenu::LightControl(menu))) => { @@ -145,7 +239,7 @@ impl Application for Shalom { let mut content = Column::new().push(scrollable(page_content)); let (show_back, show_home) = match &self.page { - // _ if self.page == self.homepage => (true, false), + _ if self.is_on_home_page() => (true, false), ActivePage::Loading => (false, false), ActivePage::Omni(_) => (false, true), ActivePage::Room(_) => (true, true), @@ -163,8 +257,8 @@ impl Application for Shalom { .height(32) .width(32) .content_fit(ContentFit::None), - ); - // .on_press(Message::ChangePage(self.homepage.clone())); + ) + .on_press(Message::OpenHomePage); let navigation = match (show_back, show_home) { (true, true) => Some(Element::from( @@ -228,6 +322,7 @@ pub enum Message { Loaded(Arc), CloseContextMenu, OpenOmniPage, + OpenHomePage, OmniEvent(pages::omni::Message), RoomEvent(pages::room::Message), LightControlMenu(context_menus::light_control::Message), diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index f0fbf99..9013b0e 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -1,6 +1,6 @@ use std::{ borrow::Cow, - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, str::FromStr, sync::{Arc, Mutex}, time::Duration, @@ -9,9 +9,14 @@ use std::{ use iced::futures::{future, Stream, StreamExt}; use internment::Intern; use itertools::Itertools; -use tokio::sync::{broadcast, broadcast::error::RecvError}; +use time::OffsetDateTime; +use tokio::{ + sync::{broadcast, broadcast::error::RecvError}, + time::MissedTickBehavior, +}; use tokio_stream::wrappers::BroadcastStream; use url::Url; +use yoke::Yoke; use crate::{ hass_client::{ @@ -20,8 +25,11 @@ use crate::{ StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, StateWeatherAttributes, StatesList, WeatherCondition, }, - CallServiceRequestData, CallServiceRequestLight, CallServiceRequestLightTurnOn, Event, - HassRequestKind, + CallServiceRequestData, CallServiceRequestLight, CallServiceRequestLightTurnOn, + CallServiceRequestMediaPlayer, CallServiceRequestMediaPlayerMediaSeek, + CallServiceRequestMediaPlayerRepeatSet, CallServiceRequestMediaPlayerShuffleSet, + CallServiceRequestMediaPlayerVolumeMute, CallServiceRequestMediaPlayerVolumeSet, Event, + HassRequestKind, MediaPlayerRepeat, }, widgets::colour_picker::clamp_to_u8, }; @@ -76,7 +84,7 @@ impl Oracle { StateAttributes::MediaPlayer(attr) => { media_players.insert( Intern::::from(state.entity_id.as_ref()).as_ref(), - MediaPlayer::new(attr, &hass_client.base), + MediaPlayer::new(attr, &state.state, &hass_client.base), ); } StateAttributes::Light(attr) => { @@ -135,6 +143,13 @@ impl Oracle { self.lights.lock().unwrap().get(entity_id).cloned() } + pub fn speaker(&self, speaker_id: &'static str) -> EloquentSpeaker<'_> { + EloquentSpeaker { + speaker_id, + oracle: self, + } + } + pub async fn set_light_state(&self, entity_id: &'static str, on: bool) { let _res = self .client @@ -176,51 +191,251 @@ impl Oracle { pub fn spawn_worker(self: Arc) { tokio::spawn(async move { let mut recv = self.client.subscribe(); + let mut second_tick = tokio::time::interval(Duration::from_secs(1)); + second_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let mut active_media_players = self + .media_players + .lock() + .unwrap() + .iter() + .filter(|(_k, v)| v.is_playing()) + .map(|(k, _v)| *k) + .collect::>(); loop { - let msg = match recv.recv().await { - Ok(msg) => msg, - Err(RecvError::Lagged(_)) => continue, - Err(RecvError::Closed) => break, - }; - - match msg.get() { - Event::StateChanged(state_changed) => { - match &state_changed.new_state.attributes { - StateAttributes::MediaPlayer(attrs) => { - self.media_players.lock().unwrap().insert( - Intern::::from(state_changed.entity_id.as_ref()).as_ref(), - MediaPlayer::new(attrs, &self.client.base), - ); - } - StateAttributes::Weather(attrs) => { - *self.weather.lock().unwrap() = - Weather::parse_from_state_and_attributes( - state_changed.new_state.state.as_ref(), - attrs, - ); - } - StateAttributes::Light(attrs) => { - self.lights.lock().unwrap().insert( - Intern::::from(state_changed.entity_id.as_ref()).as_ref(), - Light::from(( - attrs.clone(), - state_changed.new_state.state.as_ref(), - )), - ); - } - _ => { - // TODO - } + tokio::select! { + msg = recv.recv() => match msg { + Ok(msg) => self.handle_state_update_event(&msg, &mut active_media_players), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + }, + _ = second_tick.tick(), if !active_media_players.is_empty() => { + self.update_media_player_positions(&active_media_players); + }, + } + } + }); + } + + fn update_media_player_positions(&self, active_media_players: &HashSet<&'static str>) { + let mut media_players = self.media_players.lock().unwrap(); + + for entity_id in active_media_players { + let Some(MediaPlayer::Speaker(speaker)) = media_players.get_mut(entity_id) else { + continue; + }; + + speaker.actual_media_position = speaker + .media_position + .zip(speaker.media_position_updated_at) + .map(calculate_actual_media_position); + + let _res = self.entity_updates.send(Arc::from(*entity_id)); + } + } + + fn handle_state_update_event( + &self, + msg: &Yoke, String>, + active_media_players: &mut HashSet<&'static str>, + ) { + match msg.get() { + Event::StateChanged(state_changed) => { + match &state_changed.new_state.attributes { + StateAttributes::MediaPlayer(attrs) => { + let entity_id = + Intern::::from(state_changed.entity_id.as_ref()).as_ref(); + let new_state = MediaPlayer::new( + attrs, + &state_changed.new_state.state, + &self.client.base, + ); + + if new_state.is_playing() { + active_media_players.insert(entity_id); + } else { + active_media_players.remove(entity_id); } - let _res = self - .entity_updates - .send(Arc::from(state_changed.entity_id.as_ref())); + self.media_players + .lock() + .unwrap() + .insert(entity_id, new_state); + } + StateAttributes::Weather(attrs) => { + *self.weather.lock().unwrap() = Weather::parse_from_state_and_attributes( + state_changed.new_state.state.as_ref(), + attrs, + ); + } + StateAttributes::Light(attrs) => { + self.lights.lock().unwrap().insert( + Intern::::from(state_changed.entity_id.as_ref()).as_ref(), + Light::from((attrs.clone(), state_changed.new_state.state.as_ref())), + ); + } + _ => { + // TODO } } + + let _res = self + .entity_updates + .send(Arc::from(state_changed.entity_id.as_ref())); } - }); + } + } +} + +/// Eloquent interface for interacting with a speaker. Does not hold any state +/// of its own. +pub struct EloquentSpeaker<'a> { + oracle: &'a Oracle, + speaker_id: &'static str, +} + +impl EloquentSpeaker<'_> { + async fn call(&self, msg: CallServiceRequestMediaPlayer) { + let _res = self + .oracle + .client + .call_service(self.speaker_id, CallServiceRequestData::MediaPlayer(msg)) + .await; + } + + pub async fn set_mute(&self, is_volume_muted: bool) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.muted = true; + } + + self.call(CallServiceRequestMediaPlayer::VolumeMute( + CallServiceRequestMediaPlayerVolumeMute { is_volume_muted }, + )) + .await; + } + + pub async fn set_volume(&self, volume_level: f32) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.volume = volume_level; + } + + self.call(CallServiceRequestMediaPlayer::VolumeSet( + CallServiceRequestMediaPlayerVolumeSet { volume_level }, + )) + .await; + } + + pub async fn seek(&self, position: Duration) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.media_position = Some(position); + speaker.actual_media_position = Some(position); + speaker.media_position_updated_at = Some(OffsetDateTime::now_utc()); + } + + self.call(CallServiceRequestMediaPlayer::MediaSeek( + CallServiceRequestMediaPlayerMediaSeek { + seek_position: position, + }, + )) + .await; + } + + pub async fn set_shuffle(&self, shuffle: bool) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.shuffle = shuffle; + } + + self.call(CallServiceRequestMediaPlayer::ShuffleSet( + CallServiceRequestMediaPlayerShuffleSet { shuffle }, + )) + .await; + } + + pub async fn set_repeat(&self, repeat: MediaPlayerRepeat) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.repeat = repeat; + } + + self.call(CallServiceRequestMediaPlayer::RepeatSet( + CallServiceRequestMediaPlayerRepeatSet { repeat }, + )) + .await; + } + + pub async fn play(&self) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.state = MediaPlayerSpeakerState::Playing; + } + + self.call(CallServiceRequestMediaPlayer::MediaPlay).await; + } + + pub async fn pause(&self) { + if let MediaPlayer::Speaker(speaker) = self + .oracle + .media_players + .lock() + .unwrap() + .get_mut(self.speaker_id) + .unwrap() + { + speaker.state = MediaPlayerSpeakerState::Paused; + } + + self.call(CallServiceRequestMediaPlayer::MediaPause).await; + } + + pub async fn next(&self) { + self.call(CallServiceRequestMediaPlayer::MediaNextTrack) + .await; + } + + pub async fn previous(&self) { + self.call(CallServiceRequestMediaPlayer::MediaPreviousTrack) + .await; } } @@ -270,19 +485,54 @@ pub enum MediaPlayer { } impl MediaPlayer { - fn new(attr: &StateMediaPlayerAttributes, base: &Url) -> Self { + pub fn is_playing(&self) -> bool { + if let MediaPlayer::Speaker(speaker) = self { + speaker.state == MediaPlayerSpeakerState::Playing + } else { + false + } + } +} + +impl MediaPlayer { + fn new(attr: &StateMediaPlayerAttributes, state: &str, base: &Url) -> Self { + let state = match state { + "playing" => MediaPlayerSpeakerState::Playing, + "paused" => MediaPlayerSpeakerState::Paused, + "idle" => MediaPlayerSpeakerState::Idle, + "unavailable" => MediaPlayerSpeakerState::Unavailable, + "off" => MediaPlayerSpeakerState::Off, + v => panic!("unknown speaker state: {v}"), + }; + + let repeat = match attr.repeat.as_deref() { + None | Some("off") => MediaPlayerRepeat::Off, + Some("all") => MediaPlayerRepeat::All, + Some("one") => MediaPlayerRepeat::One, + v => panic!("unknown speaker repeat: {v:?}"), + }; + if attr.volume_level.is_some() { + let actual_media_position = attr + .media_position + .map(Duration::from_secs) + .zip(attr.media_position_updated_at) + .map(calculate_actual_media_position); + MediaPlayer::Speaker(MediaPlayerSpeaker { + state, volume: attr.volume_level.unwrap(), muted: attr.is_volume_muted.unwrap(), source: Box::from(attr.source.as_deref().unwrap_or("")), + actual_media_position, media_duration: attr.media_duration.map(Duration::from_secs), media_position: attr.media_position.map(Duration::from_secs), + media_position_updated_at: attr.media_position_updated_at, media_title: attr.media_title.as_deref().map(Box::from), media_artist: attr.media_artist.as_deref().map(Box::from), media_album_name: attr.media_album_name.as_deref().map(Box::from), shuffle: attr.shuffle.unwrap_or(false), - repeat: Box::from(attr.repeat.as_deref().unwrap_or("")), + repeat, entity_picture: attr .entity_picture .as_deref() @@ -342,19 +592,46 @@ impl From<(StateLightAttributes<'_>, &str)> for Light { #[derive(Debug, Clone)] pub struct MediaPlayerSpeaker { + pub state: MediaPlayerSpeakerState, pub volume: f32, pub muted: bool, pub source: Box, pub media_duration: Option, pub media_position: Option, + pub media_position_updated_at: Option, + pub actual_media_position: Option, pub media_title: Option>, pub media_artist: Option>, pub media_album_name: Option>, pub shuffle: bool, - pub repeat: Box, + pub repeat: MediaPlayerRepeat, pub entity_picture: Option, } +fn calculate_actual_media_position( + (position, updated_at): (Duration, time::OffsetDateTime), +) -> Duration { + let now = time::OffsetDateTime::now_utc(); + let since_update = now - updated_at; + + (position + since_update).unsigned_abs() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaPlayerSpeakerState { + Playing, + Unavailable, + Off, + Idle, + Paused, +} + +impl MediaPlayerSpeakerState { + pub fn is_playing(self) -> bool { + matches!(self, MediaPlayerSpeakerState::Playing) + } +} + #[derive(Debug, Clone)] pub struct MediaPlayerTv {} @@ -367,7 +644,7 @@ pub struct Room { } impl Room { - pub fn speaker(&self, oracle: &Oracle) -> Option { + pub fn speaker(&self, oracle: &Oracle) -> Option<(&'static str, MediaPlayerSpeaker)> { match self.speaker_id.and_then(|v| { oracle .media_players @@ -375,9 +652,10 @@ impl Room { .unwrap() .get(v.as_ref()) .cloned() + .zip(Some(v)) })? { - MediaPlayer::Speaker(v) => Some(v), - MediaPlayer::Tv(_) => None, + (MediaPlayer::Speaker(v), id) => Some((id.as_ref(), v)), + (MediaPlayer::Tv(_), _) => None, } } diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs index 98bbee4..eba6a2f 100644 --- a/shalom/src/pages/room.rs +++ b/shalom/src/pages/room.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use iced::{ advanced::graphics::core::Element, @@ -8,10 +8,10 @@ use iced::{ widget::{container, image::Handle, text, Column, Row}, Font, Renderer, Subscription, }; -use internment::Intern; use url::Url; use crate::{ + hass_client::MediaPlayerRepeat, oracle::{Light, MediaPlayerSpeaker, Oracle}, subscriptions::download_image, theme::Icon, @@ -21,9 +21,10 @@ use crate::{ #[derive(Debug)] pub struct Room { + id: &'static str, oracle: Arc, room: crate::oracle::Room, - speaker: Option, + speaker: Option<(&'static str, MediaPlayerSpeaker)>, now_playing_image: Option, lights: BTreeMap<&'static str, Light>, } @@ -36,6 +37,7 @@ impl Room { let lights = room.lights(&oracle); Self { + id, oracle, room, speaker, @@ -44,6 +46,10 @@ impl Room { } } + pub fn room_id(&self) -> &'static str { + self.id + } + pub fn update(&mut self, event: Message) -> Option { match event { Message::SetLightState(id, state) => { @@ -59,7 +65,7 @@ impl Room { if self .speaker .as_ref() - .and_then(|v| v.entity_picture.as_ref()) + .and_then(|(_, v)| v.entity_picture.as_ref()) == Some(&url) { self.now_playing_image = Some(handle); @@ -73,11 +79,11 @@ impl Room { if self .speaker .as_ref() - .and_then(|v| v.entity_picture.as_ref()) + .and_then(|(_, v)| v.entity_picture.as_ref()) != new .as_ref() .as_ref() - .and_then(|v| v.entity_picture.as_ref()) + .and_then(|(_, v)| v.entity_picture.as_ref()) { self.now_playing_image = None; } @@ -93,6 +99,25 @@ impl Room { None } + Message::OnSpeakerVolumeChange(new) => { + Some(Event::SetSpeakerVolume(self.speaker.as_ref()?.0, new)) + } + Message::OnSpeakerPositionChange(new) => { + Some(Event::SetSpeakerPosition(self.speaker.as_ref()?.0, new)) + } + Message::OnSpeakerStateChange(new) => { + Some(Event::SetSpeakerPlaying(self.speaker.as_ref()?.0, new)) + } + Message::OnSpeakerMuteChange(new) => { + Some(Event::SetSpeakerMuted(self.speaker.as_ref()?.0, new)) + } + Message::OnSpeakerRepeatChange(new) => { + Some(Event::SetSpeakerRepeat(self.speaker.as_ref()?.0, new)) + } + Message::OnSpeakerNextTrack => Some(Event::SpeakerNextTrack(self.speaker.as_ref()?.0)), + Message::OnSpeakerPreviousTrack => { + Some(Event::SpeakerPreviousTrack(self.speaker.as_ref()?.0)) + } } } @@ -128,12 +153,18 @@ impl Room { let mut col = Column::new().spacing(20).padding(40).push(header); - if let Some(speaker) = self.speaker.clone() { + if let Some((_, speaker)) = self.speaker.clone() { col = col.push( - container(widgets::media_player::media_player( - speaker, - self.now_playing_image.clone(), - )) + container( + widgets::media_player::media_player(speaker, self.now_playing_image.clone()) + .on_volume_change(Message::OnSpeakerVolumeChange) + .on_mute_change(Message::OnSpeakerMuteChange) + .on_repeat_change(Message::OnSpeakerRepeatChange) + .on_state_change(Message::OnSpeakerStateChange) + .on_position_change(Message::OnSpeakerPositionChange) + .on_next_track(Message::OnSpeakerNextTrack) + .on_previous_track(Message::OnSpeakerPreviousTrack), + ) .padding([12, 0, 24, 0]), ); } @@ -155,7 +186,7 @@ impl Room { let image_subscription = if let (Some(uri), None) = ( self.speaker .as_ref() - .and_then(|v| v.entity_picture.as_ref()), + .and_then(|(_, v)| v.entity_picture.as_ref()), &self.now_playing_image, ) { download_image(uri.clone(), uri.clone(), Message::NowPlayingImageLoaded) @@ -163,17 +194,17 @@ impl Room { Subscription::none() }; - let speaker_subscription = - if let Some(speaker_id) = self.room.speaker_id.map(Intern::as_ref) { - subscription::run_with_id( - speaker_id, - self.oracle - .subscribe_id(speaker_id) - .map(|()| Message::UpdateSpeaker), - ) - } else { - Subscription::none() - }; + let speaker_subscription = if let Some(speaker_id) = self.speaker.as_ref().map(|(k, _)| *k) + { + subscription::run_with_id( + speaker_id, + self.oracle + .subscribe_id(speaker_id) + .map(|()| Message::UpdateSpeaker), + ) + } else { + Subscription::none() + }; let light_subscriptions = Subscription::batch(self.lights.keys().copied().map(|key| { subscription::run_with_id( @@ -195,6 +226,13 @@ impl Room { pub enum Event { OpenLightContextMenu(&'static str), SetLightState(&'static str, bool), + SetSpeakerVolume(&'static str, f32), + SetSpeakerPosition(&'static str, Duration), + SetSpeakerPlaying(&'static str, bool), + SetSpeakerMuted(&'static str, bool), + SetSpeakerRepeat(&'static str, MediaPlayerRepeat), + SpeakerNextTrack(&'static str), + SpeakerPreviousTrack(&'static str), } #[derive(Clone, Debug)] @@ -204,4 +242,11 @@ pub enum Message { OpenLightOptions(&'static str), UpdateSpeaker, UpdateLight(&'static str), + OnSpeakerVolumeChange(f32), + OnSpeakerPositionChange(Duration), + OnSpeakerStateChange(bool), + OnSpeakerMuteChange(bool), + OnSpeakerRepeatChange(MediaPlayerRepeat), + OnSpeakerNextTrack, + OnSpeakerPreviousTrack, } diff --git a/shalom/src/widgets/media_player.rs b/shalom/src/widgets/media_player.rs index b9af5aa..ea16907 100644 --- a/shalom/src/widgets/media_player.rs +++ b/shalom/src/widgets/media_player.rs @@ -1,4 +1,7 @@ -use std::{fmt::Display, time::Duration}; +use std::{ + fmt::Display, + time::{Duration, Instant}, +}; use iced::{ advanced::graphics::core::Element, @@ -10,7 +13,8 @@ use iced::{ }; use crate::{ - oracle::MediaPlayerSpeaker, + hass_client::MediaPlayerRepeat, + oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState}, theme::{ colours::{SKY_500, SLATE_400, SLATE_600}, Icon, @@ -24,7 +28,13 @@ pub fn media_player(device: MediaPlayerSpeaker, image: Option) -> Med width: Length::Fill, device, image, - _on_something: None, + on_volume_change: None, + on_position_change: None, + on_state_change: None, + on_mute_change: None, + on_repeat_change: None, + on_next_track: None, + on_previous_track: None, } } @@ -34,41 +44,107 @@ pub struct MediaPlayer { width: Length, device: MediaPlayerSpeaker, image: Option, - _on_something: Option, + on_volume_change: Option M>, + on_position_change: Option M>, + on_state_change: Option M>, + on_mute_change: Option M>, + on_repeat_change: Option M>, + on_next_track: Option, + on_previous_track: Option, } -impl Component for MediaPlayer { +impl MediaPlayer { + pub fn on_volume_change(mut self, f: fn(f32) -> M) -> Self { + self.on_volume_change = Some(f); + self + } + + pub fn on_position_change(mut self, f: fn(Duration) -> M) -> Self { + self.on_position_change = Some(f); + self + } + + pub fn on_state_change(mut self, f: fn(bool) -> M) -> Self { + self.on_state_change = Some(f); + self + } + + pub fn on_mute_change(mut self, f: fn(bool) -> M) -> Self { + self.on_mute_change = Some(f); + self + } + + pub fn on_repeat_change(mut self, f: fn(MediaPlayerRepeat) -> M) -> Self { + self.on_repeat_change = Some(f); + self + } + + pub fn on_next_track(mut self, msg: M) -> Self { + self.on_next_track = Some(msg); + self + } + + pub fn on_previous_track(mut self, msg: M) -> Self { + self.on_previous_track = Some(msg); + self + } +} + +impl Component for MediaPlayer { type State = State; type Event = Event; fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option { match event { Event::VolumeChange(new) => { - state.volume = new; + state.overridden_volume = Some(new); None } Event::PositionChange(new) => { - state.track_position = Duration::from_secs_f64(new); - None - } - Event::TogglePlaying => { - state.playing = !state.playing; + state.overridden_position = Some(Duration::from_secs_f64(new)); None } - Event::ToggleMute => { - state.muted = !state.muted; - None - } - Event::ToggleRepeat => { - state.repeat = !state.repeat; - None + Event::TogglePlaying => self + .on_state_change + .map(|f| f(!self.device.state.is_playing())), + Event::ToggleMute => self.on_mute_change.map(|f| f(!self.device.muted)), + Event::ToggleRepeat => self.on_repeat_change.map(|f| f(self.device.repeat.next())), + Event::OnVolumeRelease => self + .on_volume_change + .zip(state.overridden_volume.take()) + .map(|(f, vol)| f(vol)), + Event::OnPositionRelease => self + .on_position_change + .zip(state.overridden_position.take()) + .map(|(f, pos)| f(pos)), + Event::PreviousTrack => { + let last_press = state + .last_previous_click + .as_ref() + .map_or(Duration::MAX, Instant::elapsed); + state.last_previous_click = Some(Instant::now()); + + if last_press > Duration::from_secs(2) { + self.on_position_change.map(|f| f(Duration::ZERO)) + } else { + self.on_previous_track.clone() + } } + Event::NextTrack => self.on_next_track.clone(), } } + #[allow(clippy::too_many_lines)] fn view(&self, state: &Self::State) -> Element<'_, Self::Event, Renderer> { let icon_style = |v| Svg::Custom(Box::new(if v { Style::Active } else { Style::Inactive })); + let position = state + .overridden_position + .or(self.device.actual_media_position) + .unwrap_or_default(); + + let volume = state.overridden_volume.unwrap_or(self.device.volume); + container( row![ container(crate::widgets::track_card::track_card( @@ -95,12 +171,15 @@ impl Component for MediaPlayer { // .align_y(Vertical::Center) // .width(Length::Fill), row![ - svg(Icon::Backward) - .height(24) - .width(24) - .style(icon_style(false)), mouse_area( - svg(if state.playing { + svg(Icon::Backward) + .height(24) + .width(24) + .style(icon_style(false)) + ) + .on_press(Event::PreviousTrack), + mouse_area( + svg(if self.device.state == MediaPlayerSpeakerState::Playing { Icon::Pause } else { Icon::Play @@ -110,28 +189,32 @@ impl Component for MediaPlayer { .style(icon_style(false)) ) .on_press(Event::TogglePlaying), - svg(Icon::Forward) - .height(24) - .width(24) - .style(icon_style(false)), + mouse_area( + svg(Icon::Forward) + .height(24) + .width(24) + .style(icon_style(false)) + ) + .on_press(Event::NextTrack), mouse_area( svg(Icon::Repeat) .height(24) .width(24) - .style(icon_style(state.repeat)), + .style(icon_style(self.device.repeat != MediaPlayerRepeat::Off)), ) .on_press(Event::ToggleRepeat), ] .spacing(14), row![ - text(format_time(state.track_position)) + text(format_time(position)) .style(Text::Color(SLATE_400)) .size(12), slider( 0.0..=self.device.media_duration.unwrap_or_default().as_secs_f64(), - state.track_position.as_secs_f64(), + position.as_secs_f64(), Event::PositionChange - ), + ) + .on_release(Event::OnPositionRelease), text(format_time(self.device.media_duration.unwrap_or_default())) .style(Text::Color(SLATE_400)) .size(12), @@ -144,7 +227,7 @@ impl Component for MediaPlayer { .width(Length::FillPortion(12)), row![ mouse_area( - svg(if state.muted { + svg(if self.device.muted { Icon::SpeakerMuted } else { Icon::Speaker @@ -154,7 +237,10 @@ impl Component for MediaPlayer { .style(icon_style(false)), ) .on_press(Event::ToggleMute), - slider(0..=100, state.volume, Event::VolumeChange).width(128), + slider(0.0..=1.0, volume, Event::VolumeChange) + .width(128) + .step(0.01) + .on_release(Event::OnVolumeRelease), ] .align_items(Alignment::Center) .width(Length::FillPortion(4)) @@ -172,13 +258,11 @@ impl Component for MediaPlayer { } } -#[derive(Default)] +#[derive(Copy, Clone, Debug, Default)] pub struct State { - muted: bool, - volume: u8, - track_position: Duration, - playing: bool, - repeat: bool, + overridden_position: Option, + overridden_volume: Option, + last_previous_click: Option, } #[derive(Clone)] @@ -186,8 +270,12 @@ pub enum Event { TogglePlaying, ToggleMute, ToggleRepeat, - VolumeChange(u8), + VolumeChange(f32), PositionChange(f64), + OnVolumeRelease, + OnPositionRelease, + PreviousTrack, + NextTrack, } impl<'a, M> From> for Element<'a, M, Renderer> -- libgit2 1.7.2