Add speaker state update support
Diff
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(-)
@@ -92,6 +92,12 @@
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"
@@ -297,6 +303,19 @@
version = "0.1.1"
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"
@@ -527,6 +546,41 @@
"bitflags 1.3.2",
"libloading 0.7.4",
"winapi",
]
[[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]]
@@ -548,6 +602,7 @@
checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
@@ -1067,6 +1122,12 @@
version = "0.3.3"
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"
@@ -1144,6 +1205,29 @@
"rustls",
"tokio",
"tokio-rustls",
]
[[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]]
@@ -1320,6 +1404,12 @@
]
[[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 @@
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
@@ -1372,6 +1463,7 @@
dependencies = [
"equivalent",
"hashbrown 0.14.2",
"serde",
]
[[package]]
@@ -2758,6 +2850,35 @@
"itoa",
"ryu",
"serde",
]
[[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]]
@@ -2786,6 +2907,7 @@
"reqwest",
"serde",
"serde_json",
"serde_with",
"strum",
"time",
"tokio",
@@ -2963,6 +3085,12 @@
dependencies = [
"float-cmp",
]
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "strum"
@@ -3123,9 +3251,11 @@
checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5"
dependencies = [
"deranged",
"itoa",
"powerfmt",
"serde",
"time-core",
"time-macros",
]
[[package]]
@@ -3135,6 +3265,15 @@
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"
@@ -3983,6 +4122,15 @@
checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
dependencies = [
"windows-targets 0.42.2",
]
[[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]]
@@ -16,12 +16,13 @@
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"] }
@@ -5,6 +5,7 @@
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 @@
#[serde(rename_all = "snake_case", tag = "domain")]
pub enum CallServiceRequestData {
Light(CallServiceRequestLight),
MediaPlayer(CallServiceRequestMediaPlayer),
}
#[derive(Serialize)]
@@ -283,6 +285,65 @@
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 media_content_type: Option<Cow<'a, str>>,
pub media_duration: Option<u64>,
pub media_position: Option<u64>,
#[serde(with = "time::serde::iso8601::option", default)]
pub media_position_updated_at: Option<time::OffsetDateTime>,
pub media_title: Option<Cow<'a, str>>,
pub media_artist: Option<Cow<'a, str>>,
pub media_album_name: Option<Cow<'a, str>>,
@@ -29,8 +29,39 @@
page: ActivePage,
context_menu: Option<ActiveContextMenu>,
oracle: Option<Arc<Oracle>>,
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 {
type Executor = iced::executor::Default;
type Message = Message;
@@ -42,6 +73,7 @@
page: ActivePage::Loading,
context_menu: None,
oracle: None,
home_room: Some("living_room"),
};
@@ -64,15 +96,13 @@
String::from("Shalom")
}
#[allow(clippy::too_many_lines)]
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
#[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 @@
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(),
@@ -108,9 +139,72 @@
Command::perform(
async move { oracle.set_light_state(id, state).await },
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 @@
let mut content = Column::new().push(scrollable(page_content));
let (show_back, show_home) = match &self.page {
_ if self.is_on_home_page() => (true, false),
ActivePage::Loading => (false, false),
ActivePage::Omni(_) => (false, true),
ActivePage::Room(_) => (true, true),
@@ -163,8 +257,8 @@
.height(32)
.width(32)
.content_fit(ContentFit::None),
);
)
.on_press(Message::OpenHomePage);
let navigation = match (show_back, show_home) {
(true, true) => Some(Element::from(
@@ -228,6 +322,7 @@
Loaded(Arc<Oracle>),
CloseContextMenu,
OpenOmniPage,
OpenHomePage,
OmniEvent(pages::omni::Message),
RoomEvent(pages::room::Message),
LightControlMenu(context_menus::light_control::Message),
@@ -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 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 @@
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 @@
StateAttributes::MediaPlayer(attr) => {
media_players.insert(
Intern::<str>::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) => {
@@ -133,6 +141,13 @@
pub fn fetch_light(&self, entity_id: &'static str) -> Option<Light> {
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) {
@@ -176,52 +191,252 @@
pub fn spawn_worker(self: Arc<Self>) {
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::<HashSet<_>>();
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::<str>::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::<str>::from(state_changed.entity_id.as_ref()).as_ref(),
Light::from((
attrs.clone(),
state_changed.new_state.state.as_ref(),
)),
);
}
_ => {
}
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<Event<'static>, 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::<str>::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::<str>::from(state_changed.entity_id.as_ref()).as_ref(),
Light::from((attrs.clone(), state_changed.new_state.state.as_ref())),
);
}
_ => {
}
}
let _res = self
.entity_updates
.send(Arc::from(state_changed.entity_id.as_ref()));
}
});
}
}
}
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;
}
}
fn build_room(
@@ -267,22 +482,57 @@
pub enum MediaPlayer {
Speaker(MediaPlayerSpeaker),
Tv(MediaPlayerTv),
}
impl MediaPlayer {
pub fn is_playing(&self) -> bool {
if let MediaPlayer::Speaker(speaker) = self {
speaker.state == MediaPlayerSpeakerState::Playing
} else {
false
}
}
}
impl MediaPlayer {
fn new(attr: &StateMediaPlayerAttributes, base: &Url) -> Self {
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,17 +592,44 @@
#[derive(Debug, Clone)]
pub struct MediaPlayerSpeaker {
pub state: MediaPlayerSpeakerState,
pub volume: f32,
pub muted: bool,
pub source: Box<str>,
pub media_duration: Option<Duration>,
pub media_position: Option<Duration>,
pub media_position_updated_at: Option<time::OffsetDateTime>,
pub actual_media_position: Option<Duration>,
pub media_title: Option<Box<str>>,
pub media_artist: Option<Box<str>>,
pub media_album_name: Option<Box<str>>,
pub shuffle: bool,
pub repeat: Box<str>,
pub repeat: MediaPlayerRepeat,
pub entity_picture: Option<Url>,
}
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)]
@@ -367,7 +644,7 @@
}
impl Room {
pub fn speaker(&self, oracle: &Oracle) -> Option<MediaPlayerSpeaker> {
pub fn speaker(&self, oracle: &Oracle) -> Option<(&'static str, MediaPlayerSpeaker)> {
match self.speaker_id.and_then(|v| {
oracle
.media_players
@@ -375,9 +652,10 @@
.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,
}
}
@@ -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 @@
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 @@
#[derive(Debug)]
pub struct Room {
id: &'static str,
oracle: Arc<Oracle>,
room: crate::oracle::Room,
speaker: Option<MediaPlayerSpeaker>,
speaker: Option<(&'static str, MediaPlayerSpeaker)>,
now_playing_image: Option<Handle>,
lights: BTreeMap<&'static str, Light>,
}
@@ -36,6 +37,7 @@
let lights = room.lights(&oracle);
Self {
id,
oracle,
room,
speaker,
@@ -44,6 +46,10 @@
}
}
pub fn room_id(&self) -> &'static str {
self.id
}
pub fn update(&mut self, event: Message) -> Option<Event> {
match event {
Message::SetLightState(id, state) => {
@@ -59,7 +65,7 @@
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 @@
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;
}
@@ -92,6 +98,25 @@
}
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 @@
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 @@
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 @@
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 @@
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 @@
OpenLightOptions(&'static str),
UpdateSpeaker,
UpdateLight(&'static str),
OnSpeakerVolumeChange(f32),
OnSpeakerPositionChange(Duration),
OnSpeakerStateChange(bool),
OnSpeakerMuteChange(bool),
OnSpeakerRepeatChange(MediaPlayerRepeat),
OnSpeakerNextTrack,
OnSpeakerPreviousTrack,
}
@@ -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 crate::{
oracle::MediaPlayerSpeaker,
hass_client::MediaPlayerRepeat,
oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState},
theme::{
colours::{SKY_500, SLATE_400, SLATE_600},
Icon,
@@ -24,7 +28,13 @@
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 @@
width: Length,
device: MediaPlayerSpeaker,
image: Option<Handle>,
_on_something: Option<M>,
on_volume_change: Option<fn(f32) -> M>,
on_position_change: Option<fn(Duration) -> M>,
on_state_change: Option<fn(bool) -> M>,
on_mute_change: Option<fn(bool) -> M>,
on_repeat_change: Option<fn(MediaPlayerRepeat) -> M>,
on_next_track: Option<M>,
on_previous_track: Option<M>,
}
impl<M> MediaPlayer<M> {
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<M> Component<M, Renderer> for MediaPlayer<M> {
impl<M: Clone> Component<M, Renderer> for MediaPlayer<M> {
type State = State;
type Event = Event;
fn update(&mut self, state: &mut Self::State, event: Self::Event) -> Option<M> {
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 @@
row![
svg(Icon::Backward)
.height(24)
.width(24)
.style(icon_style(false)),
mouse_area(
svg(Icon::Backward)
.height(24)
.width(24)
.style(icon_style(false))
)
.on_press(Event::PreviousTrack),
mouse_area(
svg(if state.playing {
svg(if self.device.state == MediaPlayerSpeakerState::Playing {
Icon::Pause
} else {
Icon::Play
@@ -110,28 +189,32 @@
.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 @@
.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 @@
.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 @@
}
}
#[derive(Default)]
#[derive(Copy, Clone, Debug, Default)]
pub struct State {
muted: bool,
volume: u8,
track_position: Duration,
playing: bool,
repeat: bool,
overridden_position: Option<Duration>,
overridden_volume: Option<f32>,
last_previous_click: Option<Instant>,
}
#[derive(Clone)]
@@ -186,8 +270,12 @@
TogglePlaying,
ToggleMute,
ToggleRepeat,
VolumeChange(u8),
VolumeChange(f32),
PositionChange(f64),
OnVolumeRelease,
OnPositionRelease,
PreviousTrack,
NextTrack,
}
impl<'a, M> From<MediaPlayer<M>> for Element<'a, M, Renderer>