Add room sidebar & blurred image background
Diff
assets/icons/hvac.svg | 1 +
assets/icons/speaker-full.svg | 1 +
assets/images/sunset-blur.jpg | 3 +++
shalom/src/hass_client.rs | 2 +-
shalom/src/main.rs | 79 +++++++++----------------------------------------------------------------------
shalom/src/oracle.rs | 2 +-
shalom/src/theme.rs | 6 ++++++
shalom/src/pages/room.rs | 48 +++++++++++++++++++++++++++++++++++++++++-------
shalom/src/widgets/image_background.rs | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
shalom/src/widgets/media_player.rs | 40 +++++++++++++++++++++++-----------------
shalom/src/widgets/mod.rs | 2 ++
shalom/src/widgets/room_navigation.rs | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
shalom/src/widgets/track_card.rs | 8 ++++----
13 files changed, 503 insertions(+), 105 deletions(-)
@@ -1,0 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M480.157-253.334q94.51 0 160.509-66.156 66-66.157 66-160.667T640.51-640.666q-66.157-66-160.667-66T319.334-640.51q-66 66.157-66 160.667t66.156 160.509q66.157 66 160.667 66ZM480-320q-29 0-56-10.5T375-360h210q-22 19-49 29.5T480-320Zm-138-80q-8-14-13-29t-7-31h316q-2 16-7 31t-13 29H342Zm-20-100q2-16 7-31t13-29h276q8 14 13 29t7 31H322Zm53-100q22-19 49-29.5t56-10.5q29 0 56 10.5t49 29.5H375ZM186.666-120q-27.5 0-47.083-19.583T120-186.666v-586.668q0-27.5 19.583-47.083T186.666-840h586.668q27.5 0 47.083 19.583T840-773.334v586.668q0 27.5-19.583 47.083T773.334-120H186.666Z"/></svg>
@@ -1,0 +1,1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="40" viewBox="0 -960 960 960" width="40"><path d="M693.334-80H266.666q-27.5 0-47.083-19.583T200-146.666v-666.668q0-27.5 19.583-47.083T266.666-880h426.668q27.5 0 47.083 19.583T760-813.334v666.668q0 27.5-19.583 47.083T693.334-80ZM480.667-602.667q32.333 0 54.833-22.5T558-680q0-32.333-22.5-54.833t-54.833-22.5q-32.334 0-54.834 22.5T403.333-680q0 32.333 22.5 54.833t54.834 22.5ZM480-197.333q68 0 115.333-47.334Q642.667-292 642.667-360t-47.334-115.333Q548-522.667 480-522.667t-115.333 47.334Q317.333-428 317.333-360t47.334 115.333Q412-197.333 480-197.333ZM479.953-264q-39.62 0-67.787-28.214Q384-320.428 384-360.047q0-39.62 28.214-67.787Q440.428-456 480.047-456q39.62 0 67.787 28.214Q576-399.572 576-359.953q0 39.62-28.214 67.787Q519.572-264 479.953-264Z"/></svg>
@@ -1,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bdebb781c73a42e84e0884ceb1c1ee7bd460490786e7dd50658fdaf425a0f099
size 33479
@@ -760,7 +760,7 @@
#[serde(borrow)]
pub dynamics: Option<Cow<'a, str>>,
#[serde(borrow)]
pub friendly_name: Cow<'a, str>,
pub friendly_name: Option<Cow<'a, str>>,
pub color_mode: Option<ColorMode>,
pub brightness: Option<f32>,
pub color_temp_kelvin: Option<u16>,
@@ -12,18 +12,11 @@
use std::sync::Arc;
use iced::{
alignment::{Horizontal, Vertical},
widget::{column, container, row, scrollable, svg, Column},
window, Application, Command, ContentFit, Element, Length, Renderer, Settings, Subscription,
Theme,
widget::{column, Column},
window, Application, Command, Element, Renderer, Settings, Subscription, Theme,
};
use crate::{
config::Config,
oracle::Oracle,
theme::{Icon, Image},
widgets::{context_menu::ContextMenu, mouse_area::mouse_area},
};
use crate::{config::Config, oracle::Oracle, theme::Image, widgets::context_menu::ContextMenu};
pub struct Shalom {
page: ActivePage,
@@ -33,14 +26,6 @@
}
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(),
@@ -212,6 +197,10 @@
async move { oracle.speaker(id).set_shuffle(new).await },
Message::UpdateLightResult,
)
}
Some(pages::room::Event::Exit) => {
self.page = self.build_omni_route();
Command::none()
}
None => Command::none(),
},
@@ -242,61 +231,9 @@
ActivePage::Loading => Element::from(column!["Loading...",].spacing(20)),
ActivePage::Room(room) => room.view().map(Message::RoomEvent),
ActivePage::Omni(omni) => omni.view().map(Message::OmniEvent),
};
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),
};
let back = mouse_area(
svg(Icon::Back)
.height(32)
.width(32)
.content_fit(ContentFit::None),
)
.on_press(Message::OpenOmniPage);
let home = mouse_area(
svg(Icon::Home)
.height(32)
.width(32)
.content_fit(ContentFit::None),
)
.on_press(Message::OpenHomePage);
let navigation = match (show_back, show_home) {
(true, true) => Some(Element::from(
row![
back,
container(home)
.width(Length::Fill)
.align_x(Horizontal::Right),
]
.height(32),
)),
(false, true) => Some(Element::from(
row![container(home)
.width(Length::Fill)
.align_x(Horizontal::Right),]
.height(32),
)),
(true, false) => Some(Element::from(back)),
(false, false) => None,
};
if let Some(navigation) = navigation {
content = content.push(
container(navigation)
.height(Length::Fill)
.width(Length::Fill)
.align_y(Vertical::Bottom)
.padding(40),
);
}
let content = Column::new().push(page_content);
if let Some(context_menu) = &self.context_menu {
let context_menu = match context_menu {
@@ -616,7 +616,7 @@
supported_color_modes: value.supported_color_modes.clone(),
mode: value.mode.map(Cow::into_owned).map(Box::from),
dynamics: value.dynamics.map(Cow::into_owned).map(Box::from),
friendly_name: Box::from(value.friendly_name.as_ref()),
friendly_name: Box::from(value.friendly_name.as_deref().unwrap_or("unknown")),
color_mode: value.color_mode,
brightness: value.brightness,
color_temp_kelvin: value.color_temp_kelvin,
@@ -57,7 +57,9 @@
Snow,
ClearDay,
Wind,
Hvac,
Shuffle,
SpeakerFull,
}
impl Icon {
@@ -77,6 +79,7 @@
Self::Hamburger => image!("hamburger"),
Self::Speaker => image!("speaker"),
Self::SpeakerMuted => image!("speaker-muted"),
Self::SpeakerFull => image!("speaker-full"),
Self::Backward => image!("backward"),
Self::Forward => image!("forward"),
Self::Play => image!("play"),
@@ -94,6 +97,7 @@
Self::Rain => image!("rain"),
Self::Snow => image!("snow"),
Self::ClearDay => image!("clear-day"),
Self::Hvac => image!("hvac"),
Self::Wind => image!("wind"),
Self::Shuffle => image!("shuffle"),
Self::Repeat1 => image!("repeat-1"),
@@ -114,6 +118,7 @@
Bathroom,
Bedroom,
DiningRoom,
Sunset,
}
impl Image {
@@ -137,6 +142,7 @@
Image::Bathroom => image!("../../assets/images/bathroom.jpg"),
Image::Bedroom => image!("../../assets/images/bedroom.jpg"),
Image::DiningRoom => image!("../../assets/images/dining_room.jpg"),
Image::Sunset => image!("../../assets/images/sunset-blur.jpg"),
}
}
@@ -1,12 +1,12 @@
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use iced::{
advanced::graphics::core::Element,
font::{Stretch, Weight},
futures::StreamExt,
subscription,
widget::{container, image::Handle, text, Column, Row},
Font, Renderer, Subscription,
subscription, theme,
widget::{container, image::Handle, row, text, Column, Row},
Color, Font, Length, Renderer, Subscription,
};
use url::Url;
@@ -16,7 +16,11 @@
subscriptions::download_image,
theme::Icon,
widgets,
widgets::colour_picker::colour_from_hsb,
widgets::{
colour_picker::colour_from_hsb,
image_background::image_background,
room_navigation::{Page, RoomNavigation},
},
};
#[derive(Debug)]
@@ -27,6 +31,7 @@
speaker: Option<(&'static str, MediaPlayerSpeaker)>,
now_playing_image: Option<Handle>,
lights: BTreeMap<&'static str, Light>,
current_page: Page,
}
impl Room {
@@ -43,6 +48,7 @@
speaker,
now_playing_image: None,
lights,
current_page: Page::Listen,
}
}
@@ -137,15 +143,23 @@
speaker.shuffle = new;
Some(Event::SetSpeakerShuffle(id, new))
}
Message::ChangePage(page) => {
self.current_page = page;
None
}
Message::Exit => Some(Event::Exit),
}
}
pub fn view(&self) -> Element<'_, Message, Renderer> {
let header = text(self.room.name.as_ref()).size(60).font(Font {
weight: Weight::Bold,
stretch: Stretch::Condensed,
..Font::with_name("Helvetica Neue")
});
let header = text(self.room.name.as_ref())
.size(60)
.font(Font {
weight: Weight::Bold,
stretch: Stretch::Condensed,
..Font::with_name("Helvetica Neue")
})
.style(theme::Text::Color(Color::WHITE));
let light = |id, light: &Light| {
let mut toggle_card = widgets::toggle_card::toggle_card(
@@ -199,7 +213,18 @@
.spacing(10);
col = col.push(lights);
col.into()
row![
RoomNavigation::new(self.current_page)
.width(Length::FillPortion(2))
.on_change(Message::ChangePage)
.on_exit(Message::Exit),
image_background(crate::theme::Image::Sunset, col.width(Length::Fill).into())
.width(Length::FillPortion(15))
.height(Length::Fill),
]
.height(Length::Fill)
.width(Length::Fill)
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
@@ -256,6 +281,7 @@
SetSpeakerRepeat(&'static str, MediaPlayerRepeat),
SpeakerNextTrack(&'static str),
SpeakerPreviousTrack(&'static str),
Exit,
}
#[derive(Clone, Debug)]
@@ -273,4 +299,6 @@
OnSpeakerRepeatChange(MediaPlayerRepeat),
OnSpeakerNextTrack,
OnSpeakerPreviousTrack,
ChangePage(Page),
Exit,
}
@@ -1,0 +1,202 @@
use iced::{
advanced::{
image::Data,
layout::{Limits, Node},
overlay,
renderer::Style,
widget::Tree,
Clipboard, Layout, Shell, Widget,
},
event::Status,
mouse::Cursor,
widget::image,
Alignment, ContentFit, Element, Event, Length, Point, Rectangle, Size, Vector,
};
pub fn image_background<'a, M: 'a, R>(
handle: impl Into<image::Handle>,
el: Element<'a, M, R>,
) -> ImageBackground<'a, M, R> {
let image_handle = handle.into();
ImageBackground {
image_handle: image_handle.clone(),
el,
width: Length::FillPortion(1),
height: Length::Fixed(128.0),
}
}
pub struct ImageBackground<'a, M, R> {
image_handle: image::Handle,
el: Element<'a, M, R>,
width: Length,
height: Length,
}
impl<'a, M, R> ImageBackground<'a, M, R> {
pub fn height(mut self, height: Length) -> Self {
self.height = height;
self
}
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
}
impl<
'a,
M: Clone,
R: iced::advanced::Renderer
+ iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
> Widget<M, R> for ImageBackground<'a, M, R>
{
fn width(&self) -> Length {
self.width
}
fn height(&self) -> Length {
self.height
}
fn layout(&self, _renderer: &R, limits: &Limits) -> Node {
let limits = limits.width(self.width).height(self.height);
let size = limits.resolve(Size::ZERO);
Node::new(size)
}
fn draw(
&self,
_state: &Tree,
renderer: &mut R,
_theme: &<R as iced::advanced::Renderer>::Theme,
_style: &Style,
layout: Layout<'_>,
_cursor: Cursor,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
#[allow(clippy::cast_precision_loss)]
let image_size = match self.image_handle.data() {
Data::Rgba { width, height, .. } => Size::new(*width as f32, *height as f32),
Data::Path(_) | Data::Bytes(_) => panic!("only parsed images are supported"),
};
let adjusted_fit = ContentFit::Cover.fit(image_size, bounds.size());
renderer.with_layer(bounds, |renderer| {
let offset = Vector::new(
(bounds.width - adjusted_fit.width).min(0.0) / 1.5,
(bounds.height - adjusted_fit.height).min(0.0) / 1.5,
);
let drawing_bounds = Rectangle {
width: adjusted_fit.width,
height: adjusted_fit.height,
..bounds
};
renderer.draw(self.image_handle.clone(), drawing_bounds + offset);
});
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.el)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(&[&self.el]);
}
fn overlay<'b>(
&'b mut self,
state: &'b mut Tree,
layout: Layout<'_>,
_renderer: &R,
) -> Option<iced::advanced::overlay::Element<'b, M, R>> {
Some(
overlay::Group::with_children(vec![overlay::Element::new(
layout.position(),
Box::new(Overlay {
el: &mut self.el,
tree: &mut state.children[0],
size: layout.bounds().size(),
}),
)])
.overlay(),
)
}
}
struct Overlay<'a, 'b, M, R> {
el: &'b mut Element<'a, M, R>,
tree: &'b mut Tree,
size: Size,
}
impl<'a, 'b, M: Clone, R: iced::advanced::Renderer> overlay::Overlay<M, R>
for Overlay<'a, 'b, M, R>
{
fn layout(&self, renderer: &R, _bounds: Size, position: Point) -> Node {
let limits = Limits::new(Size::ZERO, self.size).pad([0, 0, 10, 0].into());
let mut child = self.el.as_widget().layout(renderer, &limits);
child.align(Alignment::Start, Alignment::Start, limits.max());
let mut node = Node::with_children(self.size, vec![child]);
node.move_to(position);
node
}
fn draw(
&self,
renderer: &mut R,
theme: &<R as iced::advanced::Renderer>::Theme,
style: &Style,
layout: Layout<'_>,
cursor: Cursor,
) {
self.el.as_widget().draw(
self.tree,
renderer,
theme,
style,
layout.children().next().unwrap(),
cursor,
&layout.bounds(),
);
}
fn on_event(
&mut self,
_event: Event,
_layout: Layout<'_>,
_cursor: Cursor,
_renderer: &R,
_clipboard: &mut dyn Clipboard,
_shell: &mut Shell<'_, M>,
) -> Status {
Status::Ignored
}
}
impl<'a, M, R> From<ImageBackground<'a, M, R>> for Element<'a, M, R>
where
M: 'a + Clone,
R: iced::advanced::Renderer
+ iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>
+ 'a,
{
fn from(modal: ImageBackground<'a, M, R>) -> Self {
Element::new(modal)
}
}
@@ -5,18 +5,18 @@
use iced::{
advanced::graphics::core::Element,
theme::{Slider, Svg, Text},
theme::{Container, Slider, Svg, Text},
widget::{
column as icolumn, component, container, image::Handle, row, slider, svg, text, Component,
},
Alignment, Color, Length, Renderer, Theme,
Alignment, Background, Color, Length, Renderer, Theme,
};
use crate::{
hass_client::MediaPlayerRepeat,
oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState},
theme::{
colours::{SKY_500, SLATE_400, SLATE_600},
colours::{SKY_500, SLATE_400},
Icon,
},
widgets::mouse_area::mouse_area,
@@ -274,7 +274,8 @@
.width(self.width)
.center_x()
.center_y()
.style(Container::Custom(Box::new(Style::Inactive)))
.padding(20)
.into()
}
}
@@ -376,19 +377,22 @@
Inactive,
}
impl container::StyleSheet for Style {
type Style = Theme;
fn appearance(&self, _style: &Self::Style) -> container::Appearance {
container::Appearance {
text_color: None,
background: Some(Background::Color(Color {
a: 0.8,
..Color::BLACK
})),
border_radius: 10.0.into(),
border_width: 0.,
border_color: Color::default(),
}
}
}
impl svg::StyleSheet for Style {
type Style = Theme;
@@ -396,7 +400,7 @@
fn appearance(&self, _style: &Self::Style) -> svg::Appearance {
let color = match self {
Self::Active => SKY_500,
Self::Inactive => SLATE_600,
Self::Inactive => Color::WHITE,
};
svg::Appearance { color: Some(color) }
@@ -1,9 +1,11 @@
pub mod cards;
pub mod colour_picker;
pub mod context_menu;
pub mod forced_rounded;
pub mod image_background;
pub mod image_card;
pub mod media_player;
pub mod mouse_area;
pub mod room_navigation;
pub mod toggle_card;
pub mod track_card;
@@ -1,0 +1,214 @@
use iced::{
advanced::graphics::core::Element,
alignment::Vertical,
font::{Stretch, Weight},
theme,
widget::{column, component, container, horizontal_rule, rule, svg, text, Component},
Alignment, Background, Color, ContentFit, Font, Length, Renderer, Theme,
};
use super::mouse_area::mouse_area;
use crate::theme::{
colours::{SKY_500, SLATE_200},
Icon,
};
pub struct RoomNavigation<M> {
_phantom: std::marker::PhantomData<M>,
width: Length,
current: Page,
on_change: Option<fn(Page) -> M>,
on_exit: Option<M>,
}
impl<M> RoomNavigation<M> {
pub fn new(current: Page) -> Self {
Self {
_phantom: std::marker::PhantomData,
width: Length::Fill,
current,
on_change: None,
on_exit: None,
}
}
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
pub fn on_change(mut self, on_change: fn(Page) -> M) -> Self {
self.on_change = Some(on_change);
self
}
pub fn on_exit(mut self, event: M) -> Self {
self.on_exit = Some(event);
self
}
}
impl<M: Clone> Component<M, Renderer> for RoomNavigation<M> {
type State = ();
type Event = Event;
fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<M> {
match event {
Event::Change(page) => self.on_change.map(|v| v(page)),
Event::Exit => self.on_exit.clone(),
}
}
fn view(&self, _state: &Self::State) -> Element<'_, Self::Event, Renderer> {
let section = |icon: Icon, t: &'static str, state: Style, page| {
mouse_area(
container(
column![
svg(icon)
.height(Length::Fixed(64.))
.width(Length::Fixed(64.))
.style(theme::Svg::Custom(Box::new(state))),
text(t).size(18.).font(Font {
weight: Weight::Bold,
stretch: Stretch::Condensed,
..Font::with_name("Helvetica Neue")
}),
]
.width(Length::Fill)
.align_items(Alignment::Center)
.padding(12.),
)
.style(theme::Container::Custom(Box::new(state)))
.width(Length::Fill),
)
.on_press(Event::Change(page))
};
let s = |p: &[Page]| {
if p.contains(&self.current) {
Style::Active
} else {
Style::Inactive
}
};
let exit = container(
mouse_area(
svg(Icon::Back)
.height(32)
.width(32)
.content_fit(ContentFit::None),
)
.on_press(Event::Exit),
)
.height(Length::Fill)
.width(Length::Fill)
.align_y(Vertical::Bottom)
.padding(40);
column![
section(Icon::Speaker, "Listen", s(&[Page::Listen]), Page::Listen),
horizontal_rule(1).style(theme::Rule::Custom(Box::new(s(&[
Page::Listen,
Page::Climate
])))),
section(Icon::Hvac, "Climate", s(&[Page::Climate]), Page::Climate),
horizontal_rule(1).style(theme::Rule::Custom(Box::new(s(&[
Page::Climate,
Page::Lights
])))),
section(Icon::Bulb, "Lights", s(&[Page::Lights]), Page::Lights),
exit,
]
.width(self.width)
.height(Length::Fill)
.into()
}
}
#[derive(Copy, Clone)]
pub enum Event {
Change(Page),
Exit,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Page {
Listen,
Climate,
Lights,
}
impl<'a, M> From<RoomNavigation<M>> for Element<'a, M, Renderer>
where
M: 'a + Clone,
{
fn from(card: RoomNavigation<M>) -> Self {
component(card)
}
}
#[derive(Copy, Clone)]
pub enum Style {
Active,
Inactive,
}
impl container::StyleSheet for Style {
type Style = Theme;
fn appearance(&self, _style: &Self::Style) -> container::Appearance {
match self {
Self::Active => container::Appearance {
text_color: Some(Color::WHITE),
background: Some(Background::Color(SKY_500)),
border_radius: 0.0.into(),
border_width: 0.0,
border_color: Color::default(),
},
Self::Inactive => container::Appearance {
text_color: Some(Color::BLACK),
background: Some(Background::Color(Color::WHITE)),
border_radius: 0.0.into(),
border_width: 0.0,
border_color: Color::default(),
},
}
}
}
impl svg::StyleSheet for Style {
type Style = Theme;
fn appearance(&self, _style: &Self::Style) -> svg::Appearance {
match self {
Self::Active => svg::Appearance {
color: Some(Color::WHITE),
},
Self::Inactive => svg::Appearance {
color: Some(Color::BLACK),
},
}
}
}
impl rule::StyleSheet for Style {
type Style = Theme;
fn appearance(&self, _style: &Self::Style) -> rule::Appearance {
match self {
Self::Active => rule::Appearance {
color: Color::WHITE,
width: 1,
radius: 0.0.into(),
fill_mode: rule::FillMode::Full,
},
Self::Inactive => rule::Appearance {
color: SLATE_200,
width: 1,
radius: 0.0.into(),
fill_mode: rule::FillMode::Full,
},
}
}
}
@@ -6,10 +6,10 @@
image::{self, Image},
row, text, vertical_space, Component,
},
Alignment, Background, Renderer, Theme,
Alignment, Background, Color, Renderer, Theme,
};
use crate::theme::colours::SLATE_400;
use crate::theme::colours::{SLATE_200, SLATE_400};
pub fn track_card(artist: String, song: String, image: Option<image::Handle>) -> TrackCard {
TrackCard {
@@ -40,7 +40,7 @@
} else {
Element::from(container(vertical_space(0)).width(64).height(64).style(
|_t: &Theme| container::Appearance {
background: Some(Background::Color(SLATE_400)),
background: Some(Background::Color(SLATE_200)),
..container::Appearance::default()
},
))
@@ -49,7 +49,7 @@
row![
image,
icolumn![
text(&self.song).size(14),
text(&self.song).size(14).style(Text::Color(Color::WHITE)),
text(&self.artist).style(Text::Color(SLATE_400)).size(14)
]
]