Add search bar to media player page
Diff
Cargo.lock | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
shalom/Cargo.toml | 1 +
assets/icons/close.svg | 3 +++
assets/icons/search.svg | 3 +++
shalom/src/hass_client.rs | 7 +++++--
shalom/src/main.rs | 1 +
shalom/src/oracle.rs | 8 ++++----
shalom/src/theme.rs | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
shalom/src/magic/header_search.rs | 437 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
shalom/src/magic/mod.rs | 1 +
shalom/src/pages/room.rs | 27 ++++++++++++++++-----------
shalom/src/widgets/media_player.rs | 11 ++++++++++-
shalom/src/pages/room/listen.rs | 28 +++++++++++++++++++++++++++-
13 files changed, 879 insertions(+), 40 deletions(-)
@@ -472,7 +472,7 @@
checksum = "b0b68966c2543609f8d92f9d33ac3b719b2a67529b0c6c0b3e025637b477eef9"
dependencies = [
"aliasable",
"fontdb",
"fontdb 0.14.1",
"libm",
"log",
"rangemap",
@@ -611,6 +611,12 @@
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 @@
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "674e258f4b5d2dcd63888c01c68413c51f565e8af99d2f7701c7b81d79ef41c4"
dependencies = [
"roxmltree",
"roxmltree 0.18.1",
]
[[package]]
@@ -806,6 +812,20 @@
"slotmap",
"tinyvec",
"ttf-parser 0.19.2",
]
[[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]]
@@ -1781,6 +1801,15 @@
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d28bba84adfe6646737845bc5ebbfa2c08424eb1c37e94a1fd2a82adb56a872"
dependencies = [
"libc",
]
[[package]]
name = "memmap2"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45fd3a57831bf88bc63f8cebc0cf956116276e97fef3966103e96416209f7c92"
dependencies = [
"libc",
]
@@ -2626,9 +2655,9 @@
"pico-args",
"png",
"rgb",
"svgtypes",
"svgtypes 0.11.0",
"tiny-skia 0.10.0",
"usvg",
"usvg 0.35.0",
]
[[package]]
@@ -2662,6 +2691,12 @@
dependencies = [
"xmlparser",
]
[[package]]
name = "roxmltree"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
[[package]]
name = "rustc-demangle"
@@ -2754,6 +2789,22 @@
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-general-category",
"unicode-script",
]
[[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",
]
@@ -2950,6 +3001,7 @@
"tokio-tungstenite",
"toml",
"url",
"usvg 0.37.0",
"yoke",
]
@@ -3173,6 +3225,16 @@
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4b0611e7f3277f68c0fa18e385d9e2d26923691379690039548f867cef02a7"
dependencies = [
"kurbo",
"siphasher",
]
[[package]]
name = "svgtypes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70"
dependencies = [
"kurbo",
"siphasher",
@@ -3366,6 +3428,17 @@
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f60aa35c89ac2687ace1a2556eaaea68e8c0d47408a2e3e7f5c98a489e7281c"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5de35e8a90052baaaf61f171680ac2f8e925a1e43ea9d2e3a00514772250e541"
dependencies = [
"arrayref",
"bytemuck",
@@ -3553,6 +3626,12 @@
version = "0.19.2"
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"
@@ -3635,6 +3714,12 @@
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f91c8b21fbbaa18853c3d0801c78f4fc94cdb976699bb03e832e75f7fd22f0"
[[package]]
name = "unicode-script"
@@ -3698,28 +3783,61 @@
"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",
]
[[package]]
name = "usvg-parser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19bf93d230813599927d88557014e0908ecc3531666d47c634c6838bc8db408"
dependencies = [
"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",
"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 @@
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 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",
"usvg-tree 0.37.0",
]
[[package]]
@@ -3746,8 +3880,20 @@
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]]
@@ -30,4 +30,5 @@
toml = "0.8"
time = { version = "0.3", features = ["std", "serde", "parsing"] }
url = "2.4.1"
usvg = "0.37"
yoke = { version = "0.7", features = ["derive"] }
@@ -1,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>
@@ -1,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>
@@ -91,6 +91,8 @@
eprintln!("rtt: {}", OffsetDateTime::now_utc() - ts);
}
Message::Text(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 @@
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 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>>,
@@ -1,9 +1,10 @@
#![deny(clippy::pedantic)]
#![allow(clippy::struct_field_names)]
mod config;
mod context_menus;
mod hass_client;
mod magic;
mod oracle;
mod pages;
mod subscriptions;
@@ -553,7 +553,7 @@
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 @@
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),
@@ -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 @@
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 @@
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,
})
}
}
@@ -251,4 +356,103 @@
}
imageops::crop(&mut image, left, top, right - left, bottom - top).to_image()
}
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]
}
}
@@ -1,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)
}
}
@@ -1,0 +1,1 @@
pub mod header_search;
@@ -59,17 +59,22 @@
}
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);
@@ -38,6 +38,7 @@
on_next_track: None,
on_previous_track: None,
on_shuffle_change: None,
on_search: None,
}
}
@@ -56,6 +57,7 @@
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 @@
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 @@
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),
@@ -1,15 +1,16 @@
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 @@
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 @@
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) => {
@@ -135,6 +151,14 @@
}
Message::ArtistLogoDownloaded(handle) => {
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 @@
OnSpeakerRepeatChange(MediaPlayerRepeat),
OnSpeakerNextTrack,
OnSpeakerPreviousTrack,
OnSearchTerm(String),
OnSearchVisibleChange(bool),
}