🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2024-01-10 1:33:40.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-01-10 1:38:11.0 +00:00:00
commit
065ac908775cf488d4a85306d1788669fcbc0ee5 [patch]
tree
7797c096cf5ea9991bc534923f2ae3e4813aec17
parent
238c6c253678c3bc932bb3b0ae379162b3e6242b
download
065ac908775cf488d4a85306d1788669fcbc0ee5.tar.gz

Add search bar to media player page



Diff

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

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 @@
<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="M6 18 18 6M6 6l12 12" />
</svg>
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 @@
<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 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
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<HassResponse, String> = 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<MediaContentId<'a>>,
        #[serde(borrow)]
        pub media_content_type: Option<Cow<'a, str>>,
        pub media_duration: Option<u64>,
        pub media_position: Option<u64>,
        pub media_duration: Option<f64>,
        pub media_position: Option<f64>,
        #[serde(with = "time::serde::iso8601::option", default)]
        pub media_position_updated_at: Option<time::OffsetDateTime>,
        pub media_title: Option<Cow<'a, str>>,
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<M, Renderer> 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 = <iced::advanced::widget::Text<'_, Renderer> as Widget<M, Renderer>>::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::<State>();
        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();

        <iced::advanced::widget::Text<'_, Renderer> as Widget<M, Renderer>>::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 = state.clone().flip();
                shell.request_redraw(RedrawRequest::NextFrame);
                Status::Captured
            }
            iced::Event::Window(iced::window::Event::RedrawRequested(_)) => {
                let state = state.state.downcast_mut::<State>();
                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<Tree> {
        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<State>,
        text_opacity: AnimationSequence<f32>,
        search_box_size: AnimationSequence<f32>,
        search_icon: AnimationSequence<f32>,
        close_icon: AnimationSequence<f32>,
    },
    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<HeaderSearch<'a, M>> 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<String>,
    pub background: Option<MaybePendingImage>,
    artist_logo: Option<MaybePendingImage>,
    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<Event> {
        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<svg::Handle> = 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<svg::Handle> =
                    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<M>(self, color: Color) -> Canvas<IconCanvas, M, Renderer> {
        macro_rules! image {
            ($v:expr) => {{
                thread_local! {
                    static HANDLE: once_cell::unsync::Lazy<usvg::Tree> = 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<M> canvas::Program<M, Renderer> for IconCanvas {
    type State = ();

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: Cursor,
    ) -> Vec<Geometry> {
        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<M>(device: MediaPlayerSpeaker, album_art: Option<Handle>) ->
        on_next_track: None,
        on_previous_track: None,
        on_shuffle_change: None,
        on_search: None,
    }
}

@@ -56,6 +57,7 @@ pub struct MediaPlayer<M> {
    on_next_track: Option<M>,
    on_previous_track: Option<M>,
    on_shuffle_change: Option<fn(bool) -> M>,
    on_search: Option<M>,
}

impl<M> MediaPlayer<M> {
@@ -69,6 +71,11 @@ impl<M> MediaPlayer<M> {
        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<M: Clone> Component<M, Renderer> for MediaPlayer<M> {
            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),