🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-11-03 23:35:13.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-11-03 23:35:13.0 +00:00:00
commit
bd1f65dcb901a909ad7a10618bd81c920cecba94 [patch]
tree
966cfc65b9606a4eace81b0143026404e4c5c61f
parent
554be8ff4cc5aa76770c94716ff1e654fd7d4444
download
bd1f65dcb901a909ad7a10618bd81c920cecba94.tar.gz

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(-)

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<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>>,
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<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 {
@@ -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<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 @@ 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<Oracle>),
    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::<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) => {
@@ -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<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(),
                                    )),
                                );
                            }
                            _ => {
                                // 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<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())),
                        );
                    }
                    _ => {
                        // 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<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)]
pub struct MediaPlayerTv {}

@@ -367,7 +644,7 @@ pub struct Room {
}

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 @@ 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<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 @@ 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<Event> {
        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<M>(device: MediaPlayerSpeaker, image: Option<Handle>) -> 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<M> {
    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> Component<M, Renderer> for MediaPlayer<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: 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 @@ impl<M> Component<M, Renderer> for MediaPlayer<M> {
                    // .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<M> Component<M, Renderer> for MediaPlayer<M> {
                            .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<M> Component<M, Renderer> for MediaPlayer<M> {
                .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<M> Component<M, Renderer> for MediaPlayer<M> {
                        .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<M> Component<M, Renderer> for MediaPlayer<M> {
    }
}

#[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 @@ pub enum Event {
    TogglePlaying,
    ToggleMute,
    ToggleRepeat,
    VolumeChange(u8),
    VolumeChange(f32),
    PositionChange(f64),
    OnVolumeRelease,
    OnPositionRelease,
    PreviousTrack,
    NextTrack,
}

impl<'a, M> From<MediaPlayer<M>> for Element<'a, M, Renderer>