Load cameras on omni view
Diff
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/subscriptions.rs | 8 ++++----
shalom/src/theme.rs | 2 ++
shalom/src/pages/omni.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
shalom/src/pages/room.rs | 4 +++-
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(-)
@@ -1,4 +1,5 @@
# Resources
- https://heroicons.com/
- https://github.com/basmilius/weather-icons
- https://fonts.google.com/icons
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953l7.108-4.062A1.125 1.125 0 0121 8.688v8.123zM11.25 16.811c0 .864-.933 1.405-1.683.977l-7.108-4.062a1.125 1.125 0 010-1.953L9.567 7.71a1.125 1.125 0 011.683.977v8.123z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062A1.125 1.125 0 013 16.81V8.688zM12.75 8.688c0-.864.933-1.405 1.683-.977l7.108 4.062a1.125 1.125 0 010 1.953l-7.108 4.062a1.125 1.125 0 01-1.683-.977V8.688z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M11,16H9V8h2V16z M15,16h-2V8h2V16z"/></g></g></svg>
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M12,2C6.48,2,2,6.48,2,12s4.48,10,10,10s10-4.48,10-10S17.52,2,12,2z M9.5,16.5v-9l7,4.5L9.5,16.5z"/></g></svg>
@@ -1,0 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z"/></svg>
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>
@@ -1,3 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/></svg>
@@ -624,19 +624,19 @@
#[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<Cow<'a, str>>,
pub stream_source: Option<Cow<'a, str>>,
#[serde(borrow)]
still_image_url: Option<Cow<'a, str>>,
pub still_image_url: Option<Cow<'a, str>>,
#[serde(borrow)]
name: Option<Cow<'a, str>>,
pub name: Option<Cow<'a, str>>,
#[serde(borrow)]
id: Option<Cow<'a, str>>,
pub id: Option<Cow<'a, str>>,
#[serde(borrow)]
entity_picture: Cow<'a, str>,
pub entity_picture: Cow<'a, str>,
}
#[derive(Default, Deserialize, Debug, EnumString, Copy, Clone, FromRepr)]
@@ -25,8 +25,8 @@
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 @@
weather: Atomic<Weather>,
media_players: Mutex<BTreeMap<&'static str, MediaPlayer>>,
lights: Mutex<BTreeMap<&'static str, Light>>,
cameras: Mutex<BTreeMap<&'static str, Camera>>,
entity_updates: broadcast::Sender<Arc<str>>,
}
@@ -81,6 +82,7 @@
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 @@
Light::from((attr.clone(), state.state.as_ref())),
);
}
StateAttributes::Camera(attr) => {
cameras.insert(
Intern::<str>::from(state.entity_id.as_ref()).as_ref(),
Camera::new(attr, &hass_client.base),
);
}
_ => {}
}
}
@@ -109,6 +117,7 @@
media_players: Mutex::new(media_players),
lights: Mutex::new(lights),
entity_updates: entity_updates.clone(),
cameras: Mutex::new(cameras),
});
this.clone().spawn_worker();
@@ -132,6 +141,17 @@
BroadcastStream::new(self.entity_updates.subscribe())
.filter_map(|v| future::ready(v.ok()))
.filter(|v| future::ready(v.starts_with("weather.")))
.map(|_| ())
}
pub fn cameras(&self) -> BTreeMap<&'static str, Camera> {
(*self.cameras.lock()).clone()
}
pub fn subscribe_all_cameras(&self) -> impl Stream<Item = ()> {
BroadcastStream::new(self.entity_updates.subscribe())
.filter_map(|v| future::ready(v.ok()))
.filter(|v| future::ready(v.starts_with("camera.")))
.map(|_| ())
}
@@ -275,6 +295,12 @@
self.lights.lock().insert(
Intern::<str>::from(state_changed.entity_id.as_ref()).as_ref(),
Light::from((attrs.clone(), state_changed.new_state.state.as_ref())),
);
}
StateAttributes::Camera(attrs) => {
self.cameras.lock().insert(
Intern::<str>::from(state_changed.entity_id.as_ref()).as_ref(),
Camera::new(attrs, &self.client.base),
);
}
_ => {
@@ -470,6 +496,21 @@
};
(area, room)
}
#[derive(Clone, Debug)]
pub struct Camera {
pub name: Box<str>,
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)]
@@ -6,10 +6,10 @@
use parking_lot::Mutex;
use url::Url;
pub fn download_image<I: Hash + 'static, M: 'static>(
pub fn download_image<I: Hash + Copy + Send + 'static, M: 'static>(
id: I,
url: Url,
resp: fn(Url, image::Handle) -> M,
resp: fn(I, Url, image::Handle) -> M,
) -> Subscription<M> {
static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap())));
@@ -18,7 +18,7 @@
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 @@
CACHE.lock().push(url.clone(), handle.clone());
(resp)(url, handle)
(resp)(id, url, handle)
}),
)
}
@@ -43,6 +43,7 @@
Play,
Pause,
Repeat,
Repeat1,
Cloud,
ClearNight,
Fog,
@@ -95,6 +96,7 @@
Self::ClearDay => image!("clear-day"),
Self::Wind => image!("wind"),
Self::Shuffle => image!("shuffle"),
Self::Repeat1 => image!("repeat-1"),
}
}
}
@@ -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 @@
pub struct Omni {
oracle: Arc<Oracle>,
weather: Weather,
cameras: BTreeMap<&'static str, CameraImage>,
}
#[derive(Debug)]
pub enum CameraImage {
Unresolved(Url, Option<iced::widget::image::Handle>),
Resolved(Url, iced::widget::image::Handle),
}
impl Omni {
pub fn new(oracle: Arc<Oracle>) -> Self {
Self {
weather: oracle.current_weather(),
cameras: oracle
.cameras()
.into_iter()
.map(|(k, v)| (k, CameraImage::Unresolved(v.entity_picture, None)))
.collect(),
oracle,
}
}
@@ -43,6 +57,35 @@
Message::OpenRoom(room) => Some(Event::OpenRoom(room)),
Message::UpdateWeather => {
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
}
}
@@ -67,6 +110,22 @@
};
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
@@ -82,6 +141,7 @@
greeting,
crate::widgets::cards::weather::WeatherCard::new(self.weather),
rooms,
cameras,
]
.spacing(20)
.padding(40),
@@ -91,13 +151,38 @@
pub fn subscription(&self) -> Subscription<Message> {
pub struct WeatherSubscription;
pub struct CameraSubscription;
subscription::run_with_id(
let weather_subscription = subscription::run_with_id(
TypeId::of::<WeatherSubscription>(),
self.oracle
.subscribe_weather()
.map(|()| Message::UpdateWeather),
)
);
let camera_subscription = subscription::run_with_id(
TypeId::of::<CameraSubscription>(),
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 Message {
OpenRoom(&'static str),
UpdateWeather,
UpdateCameras,
CameraImageDownloaded(&'static str, Url, iced::widget::image::Handle),
}
@@ -209,7 +209,9 @@
.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()
};
@@ -67,8 +67,7 @@
Message::MouseUp,
))
.height(192)
.width(192)
.into(),
.width(192),
);
let hue_slider = forced_rounded(
@@ -78,8 +77,7 @@
Message::MouseUp,
))
.height(192)
.width(32)
.into(),
.width(32),
);
Row::new()
@@ -15,8 +15,12 @@
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<iced::Element<'a, M, R>>,
) -> ForcedRounded<'a, M, R> {
ForcedRounded {
element: element.into(),
}
}
pub struct ForcedRounded<'a, M, R> {
@@ -5,11 +5,11 @@
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 @@
.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 @@
} 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 @@
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 @@
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))
@@ -309,6 +315,59 @@
let seconds = secs % 60;
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)]