From 05593d27b747151b0b5f11971fe4d990e3ce7055 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Tue, 7 Nov 2023 18:58:59 +0000 Subject: [PATCH] Load cameras on omni view --- assets/icons/README.md | 1 + assets/icons/backward.svg | 4 +--- assets/icons/forward.svg | 4 +--- assets/icons/pause.svg | 4 +--- assets/icons/play.svg | 4 +--- assets/icons/repeat-1.svg | 1 + assets/icons/repeat.svg | 4 +--- assets/icons/shuffle.svg | 4 +--- shalom/src/hass_client.rs | 14 +++++++------- shalom/src/oracle.rs | 45 +++++++++++++++++++++++++++++++++++++++++++-- shalom/src/pages/omni.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- shalom/src/pages/room.rs | 4 +++- shalom/src/subscriptions.rs | 8 ++++---- shalom/src/theme.rs | 2 ++ shalom/src/widgets/colour_picker.rs | 6 ++---- shalom/src/widgets/forced_rounded.rs | 8 ++++++-- shalom/src/widgets/media_player.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- 17 files changed, 240 insertions(+), 57 deletions(-) create mode 100644 assets/icons/repeat-1.svg diff --git a/assets/icons/README.md b/assets/icons/README.md index ad6e55f..f40eabf 100644 --- a/assets/icons/README.md +++ b/assets/icons/README.md @@ -2,3 +2,4 @@ - https://heroicons.com/ - https://github.com/basmilius/weather-icons +- https://fonts.google.com/icons diff --git a/assets/icons/backward.svg b/assets/icons/backward.svg index a358c55..7347272 100644 --- a/assets/icons/backward.svg +++ b/assets/icons/backward.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/icons/forward.svg b/assets/icons/forward.svg index 7366fe5..48b0ea9 100644 --- a/assets/icons/forward.svg +++ b/assets/icons/forward.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/icons/pause.svg b/assets/icons/pause.svg index 68225b1..914f25a 100644 --- a/assets/icons/pause.svg +++ b/assets/icons/pause.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/icons/play.svg b/assets/icons/play.svg index cecf998..1bb457c 100644 --- a/assets/icons/play.svg +++ b/assets/icons/play.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/icons/repeat-1.svg b/assets/icons/repeat-1.svg new file mode 100644 index 0000000..d90acfa --- /dev/null +++ b/assets/icons/repeat-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/repeat.svg b/assets/icons/repeat.svg index e63b652..cd41e84 100644 --- a/assets/icons/repeat.svg +++ b/assets/icons/repeat.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/assets/icons/shuffle.svg b/assets/icons/shuffle.svg index a5d2841..5bff963 100644 --- a/assets/icons/shuffle.svg +++ b/assets/icons/shuffle.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index 1d837f5..9f8df6d 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -624,19 +624,19 @@ pub mod responses { #[derive(Deserialize, Debug, Clone)] pub struct StateCameraAttributes<'a> { #[serde(borrow)] - access_token: Cow<'a, str>, + pub access_token: Cow<'a, str>, #[serde(borrow)] - friendly_name: Cow<'a, str>, + pub friendly_name: Cow<'a, str>, #[serde(borrow)] - stream_source: Option>, + pub stream_source: Option>, #[serde(borrow)] - still_image_url: Option>, + pub still_image_url: Option>, #[serde(borrow)] - name: Option>, + pub name: Option>, #[serde(borrow)] - id: Option>, + pub id: Option>, #[serde(borrow)] - entity_picture: Cow<'a, str>, + pub entity_picture: Cow<'a, str>, } #[derive(Default, Deserialize, Debug, EnumString, Copy, Clone, FromRepr)] diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index a847601..409e7d5 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -25,8 +25,8 @@ use crate::{ hass_client::{ responses::{ Area, AreaRegistryList, ColorMode, DeviceRegistryList, Entity, EntityRegistryList, - StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, - StateWeatherAttributes, StatesList, WeatherCondition, + StateAttributes, StateCameraAttributes, StateLightAttributes, + StateMediaPlayerAttributes, StateWeatherAttributes, StatesList, WeatherCondition, }, CallServiceRequestData, CallServiceRequestLight, CallServiceRequestLightTurnOn, CallServiceRequestMediaPlayer, CallServiceRequestMediaPlayerMediaSeek, @@ -45,6 +45,7 @@ pub struct Oracle { weather: Atomic, media_players: Mutex>, lights: Mutex>, + cameras: Mutex>, entity_updates: broadcast::Sender>, } @@ -81,6 +82,7 @@ impl Oracle { let mut media_players = BTreeMap::new(); let mut lights = BTreeMap::new(); + let mut cameras = BTreeMap::new(); for state in &states.0 { match &state.attributes { @@ -96,6 +98,12 @@ impl Oracle { Light::from((attr.clone(), state.state.as_ref())), ); } + StateAttributes::Camera(attr) => { + cameras.insert( + Intern::::from(state.entity_id.as_ref()).as_ref(), + Camera::new(attr, &hass_client.base), + ); + } _ => {} } } @@ -109,6 +117,7 @@ impl Oracle { media_players: Mutex::new(media_players), lights: Mutex::new(lights), entity_updates: entity_updates.clone(), + cameras: Mutex::new(cameras), }); this.clone().spawn_worker(); @@ -135,6 +144,17 @@ impl Oracle { .map(|_| ()) } + pub fn cameras(&self) -> BTreeMap<&'static str, Camera> { + (*self.cameras.lock()).clone() + } + + pub fn subscribe_all_cameras(&self) -> impl Stream { + BroadcastStream::new(self.entity_updates.subscribe()) + .filter_map(|v| future::ready(v.ok())) + .filter(|v| future::ready(v.starts_with("camera."))) + .map(|_| ()) + } + pub fn subscribe_id(&self, id: &'static str) -> impl Stream { BroadcastStream::new(self.entity_updates.subscribe()) .filter_map(|v| future::ready(v.ok())) @@ -277,6 +297,12 @@ impl Oracle { Light::from((attrs.clone(), state_changed.new_state.state.as_ref())), ); } + StateAttributes::Camera(attrs) => { + self.cameras.lock().insert( + Intern::::from(state_changed.entity_id.as_ref()).as_ref(), + Camera::new(attrs, &self.client.base), + ); + } _ => { // TODO } @@ -472,6 +498,21 @@ fn build_room( (area, room) } +#[derive(Clone, Debug)] +pub struct Camera { + pub name: Box, + pub entity_picture: Url, +} + +impl Camera { + pub fn new(value: &StateCameraAttributes, base: &Url) -> Self { + Self { + name: value.friendly_name.to_string().into_boxed_str(), + entity_picture: base.join(&value.entity_picture).unwrap(), + } + } +} + #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum MediaPlayer { diff --git a/shalom/src/pages/omni.rs b/shalom/src/pages/omni.rs index 24e9308..e3fa596 100644 --- a/shalom/src/pages/omni.rs +++ b/shalom/src/pages/omni.rs @@ -1,18 +1,20 @@ -use std::{any::TypeId, sync::Arc}; +use std::{any::TypeId, collections::BTreeMap, sync::Arc}; use iced::{ advanced::graphics::core::Element, font::{Stretch, Weight}, futures::StreamExt, subscription, - widget::{column, scrollable, text, Column, Row}, + widget::{column, container, image, scrollable, text, vertical_space, Column, Row}, Font, Renderer, Subscription, }; use itertools::Itertools; use time::OffsetDateTime; +use url::Url; use crate::{ oracle::{Oracle, Weather}, + subscriptions::download_image, theme::Image, widgets::image_card, }; @@ -21,12 +23,24 @@ use crate::{ pub struct Omni { oracle: Arc, weather: Weather, + cameras: BTreeMap<&'static str, CameraImage>, +} + +#[derive(Debug)] +pub enum CameraImage { + Unresolved(Url, Option), + Resolved(Url, iced::widget::image::Handle), } impl Omni { pub fn new(oracle: Arc) -> Self { Self { weather: oracle.current_weather(), + cameras: oracle + .cameras() + .into_iter() + .map(|(k, v)| (k, CameraImage::Unresolved(v.entity_picture, None))) + .collect(), oracle, } } @@ -45,6 +59,35 @@ impl Omni { self.weather = self.oracle.current_weather(); None } + Message::UpdateCameras => { + self.cameras = self + .oracle + .cameras() + .into_iter() + .map(|(k, v)| match self.cameras.remove(k) { + Some(CameraImage::Resolved(old_url, old_handle)) + if old_url != v.entity_picture => + { + ( + k, + CameraImage::Unresolved(v.entity_picture, Some(old_handle)), + ) + } + Some(CameraImage::Unresolved(old_url, old_handle)) + if old_url != v.entity_picture => + { + (k, CameraImage::Unresolved(v.entity_picture, old_handle)) + } + Some(v) => (k, v), + None => (k, CameraImage::Unresolved(v.entity_picture, None)), + }) + .collect(); + None + } + Message::CameraImageDownloaded(id, url, handle) => { + self.cameras.insert(id, CameraImage::Resolved(url, handle)); + None + } } } @@ -68,6 +111,22 @@ impl Omni { // .width(Length::FillPortion(1)) }; + let cameras = self + .cameras + .values() + .map(|v| match v { + CameraImage::Unresolved(_, Some(handle)) | CameraImage::Resolved(_, handle) => { + Element::from(image(handle.clone()).width(512.).height(288.)) + } + CameraImage::Unresolved(..) => { + Element::from(container(vertical_space(0)).width(512.).height(288.)) + } + }) + .chunks(2) + .into_iter() + .map(|children| children.into_iter().fold(Row::new(), Row::push)) + .fold(Column::new(), Column::push); + let rooms = self .oracle .rooms() @@ -82,6 +141,7 @@ impl Omni { greeting, crate::widgets::cards::weather::WeatherCard::new(self.weather), rooms, + cameras, ] .spacing(20) .padding(40), @@ -91,13 +151,38 @@ impl Omni { pub fn subscription(&self) -> Subscription { pub struct WeatherSubscription; + pub struct CameraSubscription; - subscription::run_with_id( + let weather_subscription = subscription::run_with_id( TypeId::of::(), self.oracle .subscribe_weather() .map(|()| Message::UpdateWeather), - ) + ); + + let camera_subscription = subscription::run_with_id( + TypeId::of::(), + self.oracle + .subscribe_all_cameras() + .map(|()| Message::UpdateCameras), + ); + + let camera_image_downloads = + Subscription::batch(self.cameras.iter().filter_map(|(k, v)| { + if let CameraImage::Unresolved(url, _) = v { + Some(download_image(*k, url.clone(), |id, url, handle| { + Message::CameraImageDownloaded(id, url, handle) + })) + } else { + None + } + })); + + Subscription::batch([ + weather_subscription, + camera_subscription, + camera_image_downloads, + ]) } } @@ -123,4 +208,6 @@ pub enum Event { pub enum Message { OpenRoom(&'static str), UpdateWeather, + UpdateCameras, + CameraImageDownloaded(&'static str, Url, iced::widget::image::Handle), } diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs index 8d05124..0f3f245 100644 --- a/shalom/src/pages/room.rs +++ b/shalom/src/pages/room.rs @@ -209,7 +209,9 @@ impl Room { .and_then(|(_, v)| v.entity_picture.as_ref()), &self.now_playing_image, ) { - download_image(uri.clone(), uri.clone(), Message::NowPlayingImageLoaded) + download_image("now-playing", uri.clone(), |_, url, handle| { + Message::NowPlayingImageLoaded(url, handle) + }) } else { Subscription::none() }; diff --git a/shalom/src/subscriptions.rs b/shalom/src/subscriptions.rs index c6867de..36aef78 100644 --- a/shalom/src/subscriptions.rs +++ b/shalom/src/subscriptions.rs @@ -6,10 +6,10 @@ use once_cell::sync::Lazy; use parking_lot::Mutex; use url::Url; -pub fn download_image( +pub fn download_image( id: I, url: Url, - resp: fn(Url, image::Handle) -> M, + resp: fn(I, Url, image::Handle) -> M, ) -> Subscription { static CACHE: Lazy>> = Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap()))); @@ -18,7 +18,7 @@ pub fn download_image( id, stream::once(async move { if let Some(handle) = CACHE.lock().get(&url) { - return (resp)(url, handle.clone()); + return (resp)(id, url, handle.clone()); } let bytes = reqwest::get(url.clone()) @@ -31,7 +31,7 @@ pub fn download_image( CACHE.lock().push(url.clone(), handle.clone()); - (resp)(url, handle) + (resp)(id, url, handle) }), ) } diff --git a/shalom/src/theme.rs b/shalom/src/theme.rs index 6004df6..76b475b 100644 --- a/shalom/src/theme.rs +++ b/shalom/src/theme.rs @@ -43,6 +43,7 @@ pub enum Icon { Play, Pause, Repeat, + Repeat1, Cloud, ClearNight, Fog, @@ -95,6 +96,7 @@ impl Icon { Self::ClearDay => image!("clear-day"), Self::Wind => image!("wind"), Self::Shuffle => image!("shuffle"), + Self::Repeat1 => image!("repeat-1"), } } } diff --git a/shalom/src/widgets/colour_picker.rs b/shalom/src/widgets/colour_picker.rs index a9de4b9..39e99d9 100644 --- a/shalom/src/widgets/colour_picker.rs +++ b/shalom/src/widgets/colour_picker.rs @@ -67,8 +67,7 @@ impl Component for ColourPicker { Message::MouseUp, )) .height(192) - .width(192) - .into(), + .width(192), ); let hue_slider = forced_rounded( @@ -78,8 +77,7 @@ impl Component for ColourPicker { Message::MouseUp, )) .height(192) - .width(32) - .into(), + .width(32), ); Row::new() diff --git a/shalom/src/widgets/forced_rounded.rs b/shalom/src/widgets/forced_rounded.rs index dfb19c6..7e2790f 100644 --- a/shalom/src/widgets/forced_rounded.rs +++ b/shalom/src/widgets/forced_rounded.rs @@ -15,8 +15,12 @@ use iced::{ Background, Color, Event, Length, Point, Rectangle, Size, }; -pub fn forced_rounded<'a, M: 'a, R>(element: iced::Element<'a, M, R>) -> ForcedRounded<'a, M, R> { - ForcedRounded { element } +pub fn forced_rounded<'a, M: 'a, R>( + element: impl Into>, +) -> ForcedRounded<'a, M, R> { + ForcedRounded { + element: element.into(), + } } pub struct ForcedRounded<'a, M, R> { diff --git a/shalom/src/widgets/media_player.rs b/shalom/src/widgets/media_player.rs index ef10954..c8be440 100644 --- a/shalom/src/widgets/media_player.rs +++ b/shalom/src/widgets/media_player.rs @@ -5,11 +5,11 @@ use std::{ use iced::{ advanced::graphics::core::Element, - theme::{Svg, Text}, + theme::{Slider, Svg, Text}, widget::{ column as icolumn, component, container, image::Handle, row, slider, svg, text, Component, }, - Alignment, Length, Renderer, Theme, + Alignment, Color, Length, Renderer, Theme, }; use crate::{ @@ -188,8 +188,8 @@ impl Component for MediaPlayer { .on_press(Event::ToggleShuffle), mouse_area( svg(Icon::Backward) - .height(24) - .width(24) + .height(28) + .width(28) .style(icon_style(false)) ) .on_press(Event::PreviousTrack), @@ -199,27 +199,31 @@ impl Component for MediaPlayer { } else { Icon::Play }) - .height(24) - .width(24) + .height(42) + .width(42) .style(icon_style(false)) ) .on_press(Event::TogglePlaying), mouse_area( svg(Icon::Forward) - .height(24) - .width(24) + .height(28) + .width(28) .style(icon_style(false)) ) .on_press(Event::NextTrack), mouse_area( - svg(Icon::Repeat) - .height(24) - .width(24) - .style(icon_style(self.device.repeat != MediaPlayerRepeat::Off)), + svg(match self.device.repeat { + MediaPlayerRepeat::Off | MediaPlayerRepeat::All => Icon::Repeat, + MediaPlayerRepeat::One => Icon::Repeat1, + }) + .height(28) + .width(28) + .style(icon_style(self.device.repeat != MediaPlayerRepeat::Off)), ) .on_press(Event::ToggleRepeat), ] - .spacing(14), + .spacing(14) + .align_items(Alignment::Center), row![ text(format_time(position)) .style(Text::Color(SLATE_400)) @@ -229,7 +233,8 @@ impl Component for MediaPlayer { position.as_secs_f64(), Event::PositionChange ) - .on_release(Event::OnPositionRelease), + .on_release(Event::OnPositionRelease) + .style(Slider::Custom(Box::new(SliderStyle))), text(format_time(self.device.media_duration.unwrap_or_default())) .style(Text::Color(SLATE_400)) .size(12), @@ -255,7 +260,8 @@ impl Component for MediaPlayer { slider(0.0..=1.0, volume, Event::VolumeChange) .width(128) .step(0.01) - .on_release(Event::OnVolumeRelease), + .on_release(Event::OnVolumeRelease) + .style(Slider::Custom(Box::new(SliderStyle))), ] .align_items(Alignment::Center) .width(Length::FillPortion(4)) @@ -311,6 +317,59 @@ fn format_time(duration: Duration) -> impl Display { format!("{minutes:02}:{seconds:02}") } +struct SliderStyle; + +impl slider::StyleSheet for SliderStyle { + type Style = Theme; + + fn active(&self, style: &Self::Style) -> slider::Appearance { + let palette = style.extended_palette(); + + let handle = slider::Handle { + shape: slider::HandleShape::Rectangle { + width: 0, + border_radius: 0.0.into(), + }, + color: Color::TRANSPARENT, + border_color: Color::TRANSPARENT, + border_width: 0.0, + }; + + slider::Appearance { + rail: slider::Rail { + colors: (palette.primary.base.color, palette.secondary.base.color), + width: 4.0, + border_radius: 4.0.into(), + }, + handle, + } + } + + fn hovered(&self, style: &Self::Style) -> slider::Appearance { + let palette = style.extended_palette(); + + let handle = slider::Handle { + shape: slider::HandleShape::Circle { radius: 6.0 }, + color: palette.background.base.color, + border_color: palette.primary.base.color, + border_width: 1.0, + }; + + slider::Appearance { + rail: slider::Rail { + colors: (palette.primary.base.color, palette.secondary.base.color), + width: 4.0, + border_radius: 4.0.into(), + }, + handle, + } + } + + fn dragging(&self, style: &Self::Style) -> slider::Appearance { + self.hovered(style) + } +} + #[derive(Copy, Clone)] pub enum Style { Active, -- libgit2 1.7.2