From 065ac908775cf488d4a85306d1788669fcbc0ee5 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Wed, 10 Jan 2024 01:33:40 +0000 Subject: [PATCH] Add search bar to media player page --- Cargo.lock | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- assets/icons/close.svg | 3 +++ assets/icons/search.svg | 3 +++ shalom/Cargo.toml | 1 + shalom/src/hass_client.rs | 7 +++++-- shalom/src/magic/header_search.rs | 437 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ shalom/src/magic/mod.rs | 1 + shalom/src/main.rs | 1 + shalom/src/oracle.rs | 8 ++++---- shalom/src/pages/room.rs | 27 ++++++++++++++++----------- shalom/src/pages/room/listen.rs | 28 +++++++++++++++++++++++++++- shalom/src/theme.rs | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- shalom/src/widgets/media_player.rs | 11 ++++++++++- 13 files changed, 879 insertions(+), 40 deletions(-) create mode 100644 assets/icons/close.svg create mode 100644 assets/icons/search.svg create mode 100644 shalom/src/magic/header_search.rs create mode 100644 shalom/src/magic/mod.rs diff --git a/Cargo.lock b/Cargo.lock index df3aa62..fc3cb17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -472,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0b68966c2543609f8d92f9d33ac3b719b2a67529b0c6c0b3e025637b477eef9" dependencies = [ "aliasable", - "fontdb", + "fontdb 0.14.1", "libm", "log", "rangemap", @@ -611,6 +611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d7439c3735f405729d52c3fbbe4de140eaf938a1fe47d227c27f8254d4302a5" [[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + +[[package]] name = "deranged" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -791,7 +797,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "674e258f4b5d2dcd63888c01c68413c51f565e8af99d2f7701c7b81d79ef41c4" dependencies = [ - "roxmltree", + "roxmltree 0.18.1", ] [[package]] @@ -809,6 +815,20 @@ dependencies = [ ] [[package]] +name = "fontdb" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b88c54a38407f7352dd2c4238830115a6377741098ffd1f997c813d0e088a6" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.3", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1786,6 +1806,15 @@ dependencies = [ ] [[package]] +name = "memmap2" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45fd3a57831bf88bc63f8cebc0cf956116276e97fef3966103e96416209f7c92" +dependencies = [ + "libc", +] + +[[package]] name = "memoffset" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2626,9 +2655,9 @@ dependencies = [ "pico-args", "png", "rgb", - "svgtypes", + "svgtypes 0.11.0", "tiny-skia 0.10.0", - "usvg", + "usvg 0.35.0", ] [[package]] @@ -2664,6 +2693,12 @@ dependencies = [ ] [[package]] +name = "roxmltree" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" + +[[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2758,6 +2793,22 @@ dependencies = [ ] [[package]] +name = "rustybuzz" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0ae5692c5beaad6a9e22830deeed7874eae8a4e3ba4076fb48e12c56856222c" +dependencies = [ + "bitflags 2.4.1", + "bytemuck", + "smallvec", + "ttf-parser 0.20.0", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] name = "ryu" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2950,6 +3001,7 @@ dependencies = [ "tokio-tungstenite", "toml", "url", + "usvg 0.37.0", "yoke", ] @@ -3179,6 +3231,16 @@ dependencies = [ ] [[package]] +name = "svgtypes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] name = "swash" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3373,6 +3435,17 @@ dependencies = [ ] [[package]] +name = "tiny-skia-path" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de35e8a90052baaaf61f171680ac2f8e925a1e43ea9d2e3a00514772250e541" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] name = "tinyvec" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3555,6 +3628,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" [[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] name = "tungstenite" version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3637,6 +3716,12 @@ dependencies = [ ] [[package]] +name = "unicode-properties" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f91c8b21fbbaa18853c3d0801c78f4fc94cdb976699bb03e832e75f7fd22f0" + +[[package]] name = "unicode-script" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3698,9 +3783,24 @@ dependencies = [ "base64", "log", "pico-args", - "usvg-parser", - "usvg-text-layout", - "usvg-tree", + "usvg-parser 0.35.0", + "usvg-text-layout 0.35.0", + "usvg-tree 0.35.0", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756" +dependencies = [ + "base64", + "log", + "pico-args", + "usvg-parser 0.37.0", + "usvg-text-layout 0.37.0", + "usvg-tree 0.37.0", "xmlwriter", ] @@ -3710,16 +3810,34 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d19bf93d230813599927d88557014e0908ecc3531666d47c634c6838bc8db408" dependencies = [ - "data-url", + "data-url 0.2.0", + "flate2", + "imagesize", + "kurbo", + "log", + "roxmltree 0.18.1", + "simplecss", + "siphasher", + "svgtypes 0.11.0", + "usvg-tree 0.35.0", +] + +[[package]] +name = "usvg-parser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc" +dependencies = [ + "data-url 0.3.1", "flate2", "imagesize", "kurbo", "log", - "roxmltree", + "roxmltree 0.19.0", "simplecss", "siphasher", - "svgtypes", - "usvg-tree", + "svgtypes 0.13.0", + "usvg-tree 0.37.0", ] [[package]] @@ -3728,14 +3846,30 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "035044604e89652c0a2959b8b356946997a52649ba6cade45928c2842376feb4" dependencies = [ - "fontdb", + "fontdb 0.14.1", "kurbo", "log", "rustybuzz 0.7.0", "unicode-bidi", "unicode-script", "unicode-vo", - "usvg-tree", + "usvg-tree 0.35.0", +] + +[[package]] +name = "usvg-text-layout" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d383a3965de199d7f96d4e11a44dd859f46e86de7f3dca9a39bf82605da0a37c" +dependencies = [ + "fontdb 0.16.0", + "kurbo", + "log", + "rustybuzz 0.12.1", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "usvg-tree 0.37.0", ] [[package]] @@ -3746,11 +3880,23 @@ checksum = "7939a7e4ed21cadb5d311d6339730681c3e24c3e81d60065be80e485d3fc8b92" dependencies = [ "rctree", "strict-num", - "svgtypes", + "svgtypes 0.11.0", "tiny-skia-path 0.10.0", ] [[package]] +name = "usvg-tree" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee3d202ebdb97a6215604b8f5b4d6ef9024efd623cf2e373a6416ba976ec7d3" +dependencies = [ + "rctree", + "strict-num", + "svgtypes 0.13.0", + "tiny-skia-path 0.11.3", +] + +[[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/assets/icons/close.svg b/assets/icons/close.svg new file mode 100644 index 0000000..8967ba0 --- /dev/null +++ b/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/search.svg b/assets/icons/search.svg new file mode 100644 index 0000000..9045eae --- /dev/null +++ b/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml index 82f5de1..c808a0b 100644 --- a/shalom/Cargo.toml +++ b/shalom/Cargo.toml @@ -30,4 +30,5 @@ tokio-tungstenite = { version = "0.20", features = ["rustls-tls-native-roots"] } toml = "0.8" time = { version = "0.3", features = ["std", "serde", "parsing"] } url = "2.4.1" +usvg = "0.37" yoke = { version = "0.7", features = ["derive"] } diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index 2304128..c12a01f 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -91,6 +91,8 @@ pub async fn create(config: HomeAssistantConfig) -> Client { eprintln!("rtt: {}", OffsetDateTime::now_utc() - ts); } Message::Text(payload) => { + // eprintln!("{payload}"); + let yoked_payload: Yoke = Yoke::attach_to_cart(payload, |s| serde_json::from_str(s).unwrap()); let payload: &HassResponse = yoked_payload.get(); @@ -536,6 +538,7 @@ pub mod responses { let attributes = match kind { "sun" => StateAttributes::Sun(serde_json::from_str(attributes.get()).unwrap()), "media_player" => { + eprintln!("{}", attributes.get()); StateAttributes::MediaPlayer(serde_json::from_str(attributes.get()).unwrap()) } "camera" => { @@ -592,8 +595,8 @@ pub mod responses { pub media_content_id: Option>, #[serde(borrow)] pub media_content_type: Option>, - pub media_duration: Option, - pub media_position: Option, + pub media_duration: Option, + pub media_position: Option, #[serde(with = "time::serde::iso8601::option", default)] pub media_position_updated_at: Option, pub media_title: Option>, diff --git a/shalom/src/magic/header_search.rs b/shalom/src/magic/header_search.rs new file mode 100644 index 0000000..3c6879c --- /dev/null +++ b/shalom/src/magic/header_search.rs @@ -0,0 +1,437 @@ +use std::time::Instant; + +use iced::{ + advanced::{ + graphics::core::Element, + layout::{Limits, Node}, + mouse, + renderer::{Quad, Style}, + widget::Tree, + Clipboard, Layout, Renderer as IRenderer, Shell, Widget, + }, + event::Status, + mouse::Cursor, + widget::{ + text_input::{Appearance, Id}, + Text, + }, + window::RedrawRequest, + Alignment, Background, Color, Length, Rectangle, Renderer, Size, Theme, Vector, +}; +use keyframe::{functions::EaseOutQuint, keyframes, AnimationSequence}; + +use crate::theme::Icon; + +const INITIAL_SEARCH_BOX_SIZE: Size = Size::new(54., 54.); + +pub fn header_search<'a, M>( + on_input: fn(String) -> M, + on_state_change: fn(bool) -> M, + open: bool, + search_query: &str, + mut header: Text<'a, Renderer>, +) -> HeaderSearch<'a, M> +where + M: Clone + 'a, +{ + if open { + header = header.style(iced::theme::Text::Color(Color { + a: 0.0, + ..Color::WHITE + })); + } + + let current_search_box_size = if open { BoxSize::Fill } else { BoxSize::Min }; + + HeaderSearch { + header, + current_search_box_size, + input: iced::widget::text_input("Search...", search_query) + .id(Id::unique()) + .on_input(on_input) + .style(iced::theme::TextInput::Custom(Box::new(InputStyle))) + .into(), + on_state_change, + search_icon: Element::from(Icon::Search.canvas(Color::BLACK)), + close_icon: Element::from(Icon::Close.canvas(Color::BLACK)), + } +} + +pub enum BoxSize { + Fill, + Min, + Fixed(Size), +} + +pub struct HeaderSearch<'a, M> { + header: Text<'a, Renderer>, + current_search_box_size: BoxSize, + on_state_change: fn(bool) -> M, + input: Element<'a, M, Renderer>, + search_icon: Element<'a, M, Renderer>, + close_icon: Element<'a, M, Renderer>, +} + +impl<'a, M> Widget for HeaderSearch<'a, M> +where + M: Clone + 'a, +{ + fn width(&self) -> Length { + Length::Fill + } + + fn height(&self) -> Length { + Length::Shrink + } + + fn layout(&self, renderer: &Renderer, limits: &Limits) -> Node { + let text_node = as Widget>::layout( + &self.header, + renderer, + limits, + ); + + let size = limits.height(Length::Fixed(text_node.size().height)).max(); + + let current_search_box_size = match self.current_search_box_size { + BoxSize::Fixed(size) => size, + BoxSize::Min => INITIAL_SEARCH_BOX_SIZE, + BoxSize::Fill => Size { + width: limits.max().width, + ..INITIAL_SEARCH_BOX_SIZE + }, + }; + + let search_icon_size = Size::new(36., 36.); + let mut search_icon_node = Node::new(search_icon_size).translate(Vector { + x: -(INITIAL_SEARCH_BOX_SIZE.width - search_icon_size.width) / 2.0, + y: 0.0, + }); + search_icon_node.align(Alignment::End, Alignment::Center, current_search_box_size); + + let mut search_input = self + .input + .as_widget() + .layout( + renderer, + &limits + .width(current_search_box_size.width) + .pad([0, 20, 0, 60].into()), + ) + .translate(Vector { x: 20.0, y: 0.0 }); + search_input.align(Alignment::Start, Alignment::Center, current_search_box_size); + + let mut search_box = Node::with_children( + current_search_box_size, + vec![search_icon_node, search_input], + ); + search_box.align(Alignment::End, Alignment::Center, size); + + Node::with_children(size, vec![text_node, search_box]) + } + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &Style, + layout: Layout<'_>, + cursor: Cursor, + viewport: &Rectangle, + ) { + let local_state = state.state.downcast_ref::(); + let mut layout_children = layout.children(); + let text_layout = layout_children.next().unwrap(); + let search_layout = layout_children.next().unwrap(); + let mut search_children = search_layout.children(); + + as Widget>::draw( + &self.header, + state, + renderer, + theme, + style, + text_layout, + cursor, + viewport, + ); + + renderer.fill_quad( + Quad { + bounds: search_layout.bounds(), + border_radius: 1000.0.into(), + border_width: 0.0, + border_color: Color::default(), + }, + Background::Color(Color::WHITE), + ); + + let icon_bounds = search_children.next().unwrap(); + + if !matches!(local_state, State::Open) { + self.search_icon.as_widget().draw( + &state.children[1], + renderer, + theme, + style, + icon_bounds, + cursor, + viewport, + ); + } + + if !matches!(local_state, State::Closed) { + self.close_icon.as_widget().draw( + &state.children[2], + renderer, + theme, + style, + icon_bounds, + cursor, + viewport, + ); + } + + if !matches!(local_state, State::Closed) { + self.input.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + search_children.next().unwrap(), + cursor, + viewport, + ); + } + } + + fn on_event( + &mut self, + state: &mut Tree, + event: iced::Event, + layout: Layout<'_>, + cursor: Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, M>, + viewport: &Rectangle, + ) -> Status { + let status = match event { + iced::Event::Mouse(iced::mouse::Event::ButtonPressed(mouse::Button::Left)) + | iced::Event::Touch(iced::touch::Event::FingerPressed { .. }) + if cursor.is_over( + layout + .children() + .nth(1) + .unwrap() + .children() + .next() + .unwrap() + .bounds(), + ) => + { + let state = state.state.downcast_mut::(); + *state = state.clone().flip(); + shell.request_redraw(RedrawRequest::NextFrame); + Status::Captured + } + iced::Event::Window(iced::window::Event::RedrawRequested(_)) => { + let state = state.state.downcast_mut::(); + let State::Animate { + last_draw, + next_state, + text_opacity, + search_box_size, + search_icon, + close_icon, + } = state + else { + return Status::Ignored; + }; + + let elapsed = last_draw.elapsed().as_secs_f64(); + *last_draw = Instant::now(); + + text_opacity.advance_by(elapsed); + self.header = self.header.clone().style(iced::theme::Text::Color(Color { + a: text_opacity.now(), + ..Color::WHITE + })); + + search_box_size.advance_by(elapsed); + self.current_search_box_size = BoxSize::Fixed(Size { + width: INITIAL_SEARCH_BOX_SIZE.width + + ((layout.bounds().width - INITIAL_SEARCH_BOX_SIZE.width) + * search_box_size.now()), + ..INITIAL_SEARCH_BOX_SIZE + }); + + search_icon.advance_by(elapsed); + self.search_icon = Element::from(Icon::Search.canvas(Color { + a: search_icon.now(), + ..Color::BLACK + })); + + close_icon.advance_by(elapsed); + self.close_icon = Element::from(Icon::Close.canvas(Color { + a: close_icon.now(), + ..Color::BLACK + })); + + if text_opacity.finished() && search_box_size.finished() { + *state = std::mem::take(next_state); + + match &state { + State::Open => shell.publish((self.on_state_change)(true)), + State::Closed => shell.publish((self.on_state_change)(false)), + State::Animate { .. } => {} + } + + self.current_search_box_size = BoxSize::Fill; + } + + shell.request_redraw(RedrawRequest::NextFrame); + + Status::Captured + } + _ => Status::Ignored, + }; + + if status == Status::Ignored { + self.input.as_widget_mut().on_event( + &mut state.children[0], + event, + layout.children().nth(1).unwrap().children().nth(1).unwrap(), + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } else { + status + } + } + + fn state(&self) -> iced::advanced::widget::tree::State { + iced::advanced::widget::tree::State::Some(Box::new( + if matches!(self.current_search_box_size, BoxSize::Fill) { + State::Open + } else { + State::Closed + }, + )) + } + + fn children(&self) -> Vec { + vec![ + Tree::new(&self.input), + Tree::new(&self.search_icon), + Tree::new(&self.close_icon), + ] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&[&self.input, &self.search_icon, &self.close_icon]); + } +} + +#[derive(Clone, Default)] +#[allow(clippy::large_enum_variant)] +pub enum State { + #[default] + Closed, + Animate { + last_draw: Instant, + next_state: Box, + text_opacity: AnimationSequence, + search_box_size: AnimationSequence, + search_icon: AnimationSequence, + close_icon: AnimationSequence, + }, + Open, +} + +impl State { + fn flip(self) -> Self { + match self { + State::Closed => Self::Animate { + last_draw: Instant::now(), + next_state: Box::new(State::Open), + text_opacity: keyframes![(1.0, 0.0, EaseOutQuint), (0.0, 0.5)], + search_box_size: keyframes![(0.0, 0.0, EaseOutQuint), (0.0, 0.1), (1.0, 0.5)], + search_icon: keyframes![(1.0, 0.0, EaseOutQuint), (0.0, 0.5)], + close_icon: keyframes![(0.0, 0.0, EaseOutQuint), (0.0, 0.1), (1.0, 0.5)], + }, + State::Open => Self::Animate { + last_draw: Instant::now(), + next_state: Box::new(State::Closed), + text_opacity: keyframes![(0.0, 0.0, EaseOutQuint), (0.0, 0.1), (1.0, 0.5)], + search_box_size: keyframes![(1.0, 0.0, EaseOutQuint), (0.0, 0.5)], + search_icon: keyframes![(0.0, 0.0, EaseOutQuint), (0.0, 0.1), (1.0, 0.5)], + close_icon: keyframes![(1.0, 0.0, EaseOutQuint), (0.0, 0.5)], + }, + v @ State::Animate { .. } => v, + } + } +} + +impl<'a, M> From> for iced::Element<'a, M, Renderer> +where + M: 'a + Clone, +{ + fn from(modal: HeaderSearch<'a, M>) -> Self { + iced::Element::new(modal) + } +} + +pub struct InputStyle; + +impl iced::widget::text_input::StyleSheet for InputStyle { + type Style = iced::Theme; + + fn active(&self, _style: &Self::Style) -> Appearance { + Appearance { + background: Background::Color(Color::WHITE), + border_radius: 0.0.into(), + border_width: 0.0, + border_color: Color::default(), + icon_color: Color::default(), + } + } + + fn focused(&self, style: &Self::Style) -> Appearance { + self.active(style) + } + + fn placeholder_color(&self, style: &Self::Style) -> Color { + let palette = style.extended_palette(); + + palette.background.strong.color + } + + fn value_color(&self, style: &Self::Style) -> Color { + let palette = style.extended_palette(); + + palette.background.base.text + } + + fn disabled_color(&self, style: &Self::Style) -> Color { + self.placeholder_color(style) + } + + fn selection_color(&self, style: &Self::Style) -> Color { + let palette = style.extended_palette(); + + palette.primary.weak.color + } + + fn hovered(&self, style: &Self::Style) -> Appearance { + self.active(style) + } + + fn disabled(&self, style: &Self::Style) -> Appearance { + self.active(style) + } +} diff --git a/shalom/src/magic/mod.rs b/shalom/src/magic/mod.rs new file mode 100644 index 0000000..310d59d --- /dev/null +++ b/shalom/src/magic/mod.rs @@ -0,0 +1 @@ +pub mod header_search; diff --git a/shalom/src/main.rs b/shalom/src/main.rs index ca52e54..973c15b 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -4,6 +4,7 @@ mod config; mod context_menus; mod hass_client; +mod magic; mod oracle; mod pages; mod subscriptions; diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index 8cb4f80..3ee40ab 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -553,7 +553,7 @@ impl MediaPlayer { if attr.volume_level.is_some() { let actual_media_position = attr .media_position - .map(Duration::from_secs) + .map(Duration::from_secs_f64) .zip(attr.media_position_updated_at) .zip(Some(state)) .map(calculate_actual_media_position); @@ -561,11 +561,11 @@ impl MediaPlayer { MediaPlayer::Speaker(MediaPlayerSpeaker { state, volume: attr.volume_level.unwrap(), - muted: attr.is_volume_muted.unwrap(), + muted: attr.is_volume_muted.unwrap_or_default(), 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_duration: attr.media_duration.map(Duration::from_secs_f64), + media_position: attr.media_position.map(Duration::from_secs_f64), 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), diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs index 85366a9..0ad0cb3 100644 --- a/shalom/src/pages/room.rs +++ b/shalom/src/pages/room.rs @@ -59,17 +59,22 @@ impl Room { } pub fn view(&self) -> Element<'_, Message, Renderer> { - let header = container( - 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)), - ) - .padding([40, 40, 0, 40]); + 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 header = if let Page::Listen = self.current_page { + self.listen.header_magic(header).map(Message::Listen) + } else { + Element::from(header) + }; + + let header = container(header).padding([40, 40, 0, 40]); let mut col = Column::new().spacing(20).push(header); diff --git a/shalom/src/pages/room/listen.rs b/shalom/src/pages/room/listen.rs index 4169f83..50afff7 100644 --- a/shalom/src/pages/room/listen.rs +++ b/shalom/src/pages/room/listen.rs @@ -3,13 +3,14 @@ use std::{convert::identity, sync::Arc, time::Duration}; use iced::{ futures::StreamExt, subscription, - widget::{container, image::Handle, Column}, + widget::{container, image::Handle, Column, Text}, Element, Renderer, Subscription, }; use url::Url; use crate::{ hass_client::MediaPlayerRepeat, + magic::header_search::header_search, oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState, Oracle, Room}, subscriptions::{download_image, find_fanart_urls, find_musicbrainz_artist, MaybePendingImage}, theme::{darken_image, trim_transparent_padding}, @@ -25,6 +26,8 @@ pub struct Listen { musicbrainz_artist_id: Option, pub background: Option, artist_logo: Option, + search_query: String, + search_open: bool, } impl Listen { @@ -39,9 +42,22 @@ impl Listen { musicbrainz_artist_id: None, background: None, artist_logo: None, + search_query: String::new(), + search_open: false, } } + pub fn header_magic<'a>(&'a self, text: Text<'a>) -> Element<'a, Message> { + header_search( + Message::OnSearchTerm, + Message::OnSearchVisibleChange, + self.search_open, + &self.search_query, + text, + ) + .into() + } + pub fn update(&mut self, event: Message) -> Option { match event { Message::AlbumArtImageLoaded(handle) => { @@ -137,6 +153,14 @@ impl Listen { self.artist_logo = Some(MaybePendingImage::Downloaded(handle)); None } + Message::OnSearchTerm(v) => { + self.search_query = v; + None + } + Message::OnSearchVisibleChange(v) => { + self.search_open = v; + None + } } } @@ -269,4 +293,6 @@ pub enum Message { OnSpeakerRepeatChange(MediaPlayerRepeat), OnSpeakerNextTrack, OnSpeakerPreviousTrack, + OnSearchTerm(String), + OnSearchVisibleChange(bool), } diff --git a/shalom/src/theme.rs b/shalom/src/theme.rs index c5a3390..a977117 100644 --- a/shalom/src/theme.rs +++ b/shalom/src/theme.rs @@ -1,10 +1,17 @@ use ::image::{imageops, GenericImageView, Pixel, Rgba, RgbaImage}; use iced::{ advanced::svg::Handle, - widget::{image, svg}, + mouse::Cursor, + widget::{ + canvas, + canvas::{Cache, Geometry, LineDash, Path, Stroke, Style}, + image, svg, Canvas, + }, + Color, Point, Rectangle, Renderer, Theme, }; use once_cell::sync::Lazy; use stackblur_iter::imgref::Img; +use usvg::{tiny_skia_path::PathSegment, NodeKind, Transform, TreeParsing}; pub mod colours { use iced::Color; @@ -60,16 +67,16 @@ pub enum Icon { Shuffle, SpeakerFull, Dead, + Search, + Close, } impl Icon { - pub fn handle(self) -> svg::Handle { + pub fn data(self) -> &'static [u8] { macro_rules! image { - ($path:expr) => {{ - static FILE: &[u8] = include_bytes!(concat!("../../assets/icons/", $path, ".svg")); - static HANDLE: Lazy = Lazy::new(|| svg::Handle::from_memory(FILE)); - (*HANDLE).clone() - }}; + ($path:expr) => { + include_bytes!(concat!("../../assets/icons/", $path, ".svg")) + }; } match self { @@ -102,7 +109,105 @@ impl Icon { Self::Shuffle => image!("shuffle"), Self::Repeat1 => image!("repeat-1"), Self::Dead => image!("dead"), + Self::Search => image!("search"), + Self::Close => image!("close"), + } + } + + pub fn handle(self) -> svg::Handle { + macro_rules! image { + ($v:expr) => {{ + static HANDLE: Lazy = + Lazy::new(|| svg::Handle::from_memory($v.data())); + (*HANDLE).clone() + }}; + } + + match self { + Self::Home => image!(Icon::Home), + Self::Back => image!(Icon::Back), + Self::Bulb => image!(Icon::Bulb), + Self::Hamburger => image!(Icon::Hamburger), + Self::Speaker => image!(Icon::Speaker), + Self::SpeakerMuted => image!(Icon::SpeakerMuted), + Self::SpeakerFull => image!(Icon::SpeakerFull), + Self::Backward => image!(Icon::Backward), + Self::Forward => image!(Icon::Forward), + Self::Play => image!(Icon::Play), + Self::Pause => image!(Icon::Pause), + Self::Repeat => image!(Icon::Repeat), + Self::Cloud => image!(Icon::Cloud), + Self::ClearNight => image!(Icon::ClearNight), + Self::Fog => image!(Icon::Fog), + Self::Hail => image!(Icon::Hail), + Self::Thunderstorms => image!(Icon::Thunderstorms), + Self::ThunderstormsRain => image!(Icon::ThunderstormsRain), + Self::PartlyCloudyDay => image!(Icon::PartlyCloudyDay), + Self::PartlyCloudyNight => image!(Icon::PartlyCloudyNight), + Self::ExtremeRain => image!(Icon::ExtremeRain), + Self::Rain => image!(Icon::Rain), + Self::Snow => image!(Icon::Snow), + Self::ClearDay => image!(Icon::ClearDay), + Self::Hvac => image!(Icon::Hvac), + Self::Wind => image!(Icon::Wind), + Self::Shuffle => image!(Icon::Shuffle), + Self::Repeat1 => image!(Icon::Repeat1), + Self::Dead => image!(Icon::Dead), + Self::Search => image!(Icon::Search), + Self::Close => image!(Icon::Close), + } + } + + pub fn canvas(self, color: Color) -> Canvas { + macro_rules! image { + ($v:expr) => {{ + thread_local! { + static HANDLE: once_cell::unsync::Lazy = once_cell::unsync::Lazy::new(|| usvg::Tree::from_data($v.data(), &usvg::Options::default()).unwrap()); + } + + HANDLE.with(|v| (*v).clone()) + }}; } + + let svg = match self { + Self::Home => image!(Icon::Home), + Self::Back => image!(Icon::Back), + Self::Bulb => image!(Icon::Bulb), + Self::Hamburger => image!(Icon::Hamburger), + Self::Speaker => image!(Icon::Speaker), + Self::SpeakerMuted => image!(Icon::SpeakerMuted), + Self::SpeakerFull => image!(Icon::SpeakerFull), + Self::Backward => image!(Icon::Backward), + Self::Forward => image!(Icon::Forward), + Self::Play => image!(Icon::Play), + Self::Pause => image!(Icon::Pause), + Self::Repeat => image!(Icon::Repeat), + Self::Cloud => image!(Icon::Cloud), + Self::ClearNight => image!(Icon::ClearNight), + Self::Fog => image!(Icon::Fog), + Self::Hail => image!(Icon::Hail), + Self::Thunderstorms => image!(Icon::Thunderstorms), + Self::ThunderstormsRain => image!(Icon::ThunderstormsRain), + Self::PartlyCloudyDay => image!(Icon::PartlyCloudyDay), + Self::PartlyCloudyNight => image!(Icon::PartlyCloudyNight), + Self::ExtremeRain => image!(Icon::ExtremeRain), + Self::Rain => image!(Icon::Rain), + Self::Snow => image!(Icon::Snow), + Self::ClearDay => image!(Icon::ClearDay), + Self::Hvac => image!(Icon::Hvac), + Self::Wind => image!(Icon::Wind), + Self::Shuffle => image!(Icon::Shuffle), + Self::Repeat1 => image!(Icon::Repeat1), + Self::Dead => image!(Icon::Dead), + Self::Search => image!(Icon::Search), + Self::Close => image!(Icon::Close), + }; + + canvas(IconCanvas { + cache: Cache::new(), + svg, + color, + }) } } @@ -252,3 +357,102 @@ pub fn trim_transparent_padding(mut image: RgbaImage) -> RgbaImage { imageops::crop(&mut image, left, top, right - left, bottom - top).to_image() } + +/// Opacity, rotation and other transforms aren't available on iced's svg +/// primitive, so we'll draw the svg onto a canvas we can apply transforms +/// to instead. +pub struct IconCanvas { + cache: Cache, + svg: usvg::Tree, + color: Color, +} + +impl canvas::Program for IconCanvas { + type State = (); + + fn draw( + &self, + _state: &Self::State, + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: Cursor, + ) -> Vec { + let frame = self.cache.draw(renderer, bounds.size(), |frame| { + let scale = bounds.width / self.svg.size.width(); + let translate_x = (bounds.width - self.svg.size.width() * scale) / 2.0; + let translate_y = (bounds.height - self.svg.size.height() * scale) / 2.0; + + let transform = + Transform::from_translate(translate_x, translate_y).post_scale(scale, scale); + + for node in self.svg.root.children() { + if let NodeKind::Path(ref path) = *node.borrow() { + let builder = Path::new(|builder| { + for segment in path.data.segments() { + match segment { + PathSegment::MoveTo(mut p) => { + transform.map_point(&mut p); + let usvg::tiny_skia_path::Point { x, y } = p; + builder.move_to(Point::new(x, y)); + } + PathSegment::LineTo(mut p) => { + transform.map_point(&mut p); + let usvg::tiny_skia_path::Point { x, y } = p; + builder.line_to(Point::new(x, y)); + } + PathSegment::Close => { + builder.close(); + } + PathSegment::QuadTo(mut p1, mut p2) => { + transform.map_point(&mut p1); + transform.map_point(&mut p2); + builder.quadratic_curve_to( + Point::new(p1.x, p1.y), + Point::new(p2.x, p2.y), + ); + } + PathSegment::CubicTo(mut p1, mut p2, mut p3) => { + transform.map_point(&mut p1); + transform.map_point(&mut p2); + transform.map_point(&mut p3); + builder.bezier_curve_to( + Point::new(p1.x, p1.y), + Point::new(p2.x, p2.y), + Point::new(p3.x, p3.y), + ); + } + } + } + }); + + let stroke = if let Some(stroke) = &path.stroke { + Stroke { + style: Style::Solid(self.color), + width: stroke.width.get(), + line_cap: match stroke.linecap { + usvg::LineCap::Butt => canvas::LineCap::Butt, + usvg::LineCap::Round => canvas::LineCap::Round, + usvg::LineCap::Square => canvas::LineCap::Square, + }, + line_join: match stroke.linejoin { + usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => { + canvas::LineJoin::Miter + } + usvg::LineJoin::Round => canvas::LineJoin::Round, + usvg::LineJoin::Bevel => canvas::LineJoin::Bevel, + }, + line_dash: LineDash::default(), + } + } else { + Stroke::default() + }; + + frame.stroke(&builder, stroke); + } + } + }); + + vec![frame] + } +} diff --git a/shalom/src/widgets/media_player.rs b/shalom/src/widgets/media_player.rs index 0b43774..06c374e 100644 --- a/shalom/src/widgets/media_player.rs +++ b/shalom/src/widgets/media_player.rs @@ -38,6 +38,7 @@ pub fn media_player(device: MediaPlayerSpeaker, album_art: Option) -> on_next_track: None, on_previous_track: None, on_shuffle_change: None, + on_search: None, } } @@ -56,6 +57,7 @@ pub struct MediaPlayer { on_next_track: Option, on_previous_track: Option, on_shuffle_change: Option M>, + on_search: Option, } impl MediaPlayer { @@ -69,6 +71,11 @@ impl MediaPlayer { self } + pub fn on_search(mut self, m: M) -> Self { + self.on_search = Some(m); + self + } + pub fn on_position_change(mut self, f: fn(Duration) -> M) -> Self { self.on_position_change = Some(f); self @@ -267,7 +274,9 @@ impl Component for MediaPlayer { container( icolumn![ row![ - row![].width(Length::FillPortion(8)), + container(row![]) + .width(Length::FillPortion(8)) + .align_x(Horizontal::Left), container(playback_controls) .width(Length::FillPortion(20)) .align_x(Horizontal::Center), -- libgit2 1.7.2