🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2024-01-03 17:04:21.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2024-01-03 17:04:21.0 +00:00:00
commit
7ff01f1471c2da126638a0e0051dfd2725564762 [patch]
tree
2229b1d77999c16b474d630b9fbfd24d41c6b451
parent
5c71c384510c4a8570fb9ac0cc58950656c7aed6
download
7ff01f1471c2da126638a0e0051dfd2725564762.tar.gz

Redesign listen view



Diff

 .github/screenshots/listen.webp    |   4 +-
 Cargo.lock                         |   1 +-
 shalom/Cargo.toml                  |   3 +-
 shalom/src/config.rs               |   6 +-
 shalom/src/oracle.rs               |   1 +-
 shalom/src/pages/omni.rs           |   9 +-
 shalom/src/pages/room.rs           |  42 ++++--
 shalom/src/pages/room/listen.rs    | 141 +++++++++++++++-----
 shalom/src/subscriptions.rs        | 141 ++++++++++++++++++--
 shalom/src/theme.rs                |  13 +-
 shalom/src/widgets/media_player.rs | 262 +++++++++++++++++++-------------------
 shalom/src/widgets/track_card.rs   |  75 +++++++----
 12 files changed, 489 insertions(+), 209 deletions(-)

diff --git a/.github/screenshots/listen.webp b/.github/screenshots/listen.webp
index 9ddd047..1830207 100644
--- a/.github/screenshots/listen.webp
+++ b/.github/screenshots/listen.webp
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:202171412da17ceb5126dde885259b93301fa22a652ad9c1dfb72dbd77fa5d64
size 56054
oid sha256:f7a117d25df6a310f93cd836f85622043164d6a24d5204723ae3ed490e492c32
size 57180
diff --git a/Cargo.lock b/Cargo.lock
index 5361944..1738165 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2907,6 +2907,7 @@ version = "0.1.0"
dependencies = [
 "atomic",
 "bytemuck",
 "bytes",
 "iced",
 "image",
 "internment",
diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml
index 694d855..21b9f54 100644
--- a/shalom/Cargo.toml
+++ b/shalom/Cargo.toml
@@ -8,6 +8,7 @@ edition = "2021"
[dependencies]
atomic = "0.6"
bytemuck = "1.14"
bytes = "1"
iced = { version = "0.10", features = ["tokio", "svg", "lazy", "advanced", "image", "canvas"] }
image = "0.24"
once_cell = "1.18"
@@ -17,7 +18,7 @@ itertools = "0.11"
keyframe = "1.1"
lru = "0.12"
palette = "0.7"
reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots"] }
reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_with = { version = "3.4", features = ["macros"] }
serde_json = { version = "1.0", features = ["raw_value"] }
diff --git a/shalom/src/config.rs b/shalom/src/config.rs
index 520a98a..3fae16e 100644
--- a/shalom/src/config.rs
+++ b/shalom/src/config.rs
@@ -2,6 +2,12 @@

use serde::Deserialize;

#[allow(dead_code)]
pub const LAST_FM_API_KEY: &str = "732433605ea7893c761d340a05752695";
#[allow(dead_code)]
pub const LAST_FM_SHARED_SECRET: &str = "420fdb301e6b4a62a888bf51def71670";
pub const FANART_PROJECT_KEY: &str = "df5eb171c6e0e49122ad59830cdf789f";

#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs
index 64290df..8cb4f80 100644
--- a/shalom/src/oracle.rs
+++ b/shalom/src/oracle.rs
@@ -269,6 +269,7 @@ impl Oracle {
                    StateAttributes::MediaPlayer(attrs) => {
                        let entity_id =
                            Intern::<str>::from(state_changed.entity_id.as_ref()).as_ref();
                        eprintln!("{entity_id} updated");
                        let new_state = MediaPlayer::new(
                            attrs,
                            &state_changed.new_state.state,
diff --git a/shalom/src/pages/omni.rs b/shalom/src/pages/omni.rs
index e3fa596..90ac268 100644
--- a/shalom/src/pages/omni.rs
+++ b/shalom/src/pages/omni.rs
@@ -1,4 +1,4 @@
use std::{any::TypeId, collections::BTreeMap, sync::Arc};
use std::{any::TypeId, collections::BTreeMap, convert::identity, sync::Arc};

use iced::{
    advanced::graphics::core::Element,
@@ -170,8 +170,11 @@ impl Omni {
        let camera_image_downloads =
            Subscription::batch(self.cameras.iter().filter_map(|(k, v)| {
                if let CameraImage::Unresolved(url, _) = v {
                    Some(download_image(*k, url.clone(), |id, url, handle| {
                        Message::CameraImageDownloaded(id, url, handle)
                    let k = *k;
                    let url = url.clone();

                    Some(download_image(url.clone(), identity, move |handle| {
                        Message::CameraImageDownloaded(k, url, handle)
                    }))
                } else {
                    None
diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs
index 233dd18..e7d04c7 100644
--- a/shalom/src/pages/room.rs
+++ b/shalom/src/pages/room.rs
@@ -7,12 +7,13 @@ use iced::{
    advanced::graphics::core::Element,
    font::{Stretch, Weight},
    theme,
    widget::{row, text, Column},
    widget::{container, row, text, Column},
    Color, Font, Length, Renderer, Subscription,
};

use crate::{
    oracle::Oracle,
    subscriptions::MaybePendingImage,
    widgets::{
        image_background::image_background,
        room_navigation::{Page, RoomNavigation},
@@ -58,16 +59,19 @@ impl Room {
    }

    pub fn view(&self) -> Element<'_, Message, Renderer> {
        let header = text(self.room.name.as_ref())
            .size(60)
            .font(Font {
                weight: Weight::Bold,
                stretch: Stretch::Condensed,
                ..Font::with_name("Helvetica Neue")
            })
            .style(theme::Text::Color(Color::WHITE));
        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 mut col = Column::new().spacing(20).padding(40).push(header);
        let mut col = Column::new().spacing(20).push(header);

        col = col.push(match self.current_page {
            Page::Climate => Element::from(row![]),
@@ -75,14 +79,26 @@ impl Room {
            Page::Lights => self.lights.view().map(Message::Lights),
        });

        let background = match self.current_page {
            Page::Listen => self
                .listen
                .background
                .as_ref()
                .and_then(MaybePendingImage::handle),
            _ => None,
        };

        row![
            RoomNavigation::new(self.current_page)
                .width(Length::FillPortion(2))
                .on_change(Message::ChangePage)
                .on_exit(Message::Exit),
            image_background(crate::theme::Image::Sunset, col.width(Length::Fill).into())
                .width(Length::FillPortion(15))
                .height(Length::Fill),
            image_background(
                background.unwrap_or_else(|| crate::theme::Image::Sunset.into()),
                col.width(Length::Fill).into()
            )
            .width(Length::FillPortion(15))
            .height(Length::Fill),
        ]
        .height(Length::Fill)
        .width(Length::Fill)
diff --git a/shalom/src/pages/room/listen.rs b/shalom/src/pages/room/listen.rs
index 981cca9..0186a9b 100644
--- a/shalom/src/pages/room/listen.rs
+++ b/shalom/src/pages/room/listen.rs
@@ -1,4 +1,4 @@
use std::{sync::Arc, time::Duration};
use std::{convert::identity, sync::Arc, time::Duration};

use iced::{
    futures::StreamExt,
@@ -6,12 +6,14 @@ use iced::{
    widget::{container, image::Handle, Column},
    Element, Renderer, Subscription,
};
use image::imageops::blur;
use url::Url;

use crate::{
    hass_client::MediaPlayerRepeat,
    oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState, Oracle, Room},
    subscriptions::download_image,
    subscriptions::{download_image, find_fanart_urls, find_musicbrainz_artist, MaybePendingImage},
    theme::darken_image,
    widgets,
};

@@ -20,7 +22,10 @@ pub struct Listen {
    room: Room,
    oracle: Arc<Oracle>,
    speaker: Option<(&'static str, MediaPlayerSpeaker)>,
    now_playing_image: Option<Handle>,
    album_art_image: Option<Handle>,
    musicbrainz_artist_id: Option<String>,
    pub background: Option<MaybePendingImage>,
    artist_logo: Option<MaybePendingImage>,
}

impl Listen {
@@ -31,22 +36,27 @@ impl Listen {
            room: room.clone(),
            speaker,
            oracle,
            now_playing_image: None,
            album_art_image: None,
            musicbrainz_artist_id: None,
            background: None,
            artist_logo: None,
        }
    }

    pub fn update(&mut self, event: Message) -> Option<Event> {
        match event {
            Message::NowPlayingImageLoaded(url, handle) => {
                if self
                    .speaker
                    .as_ref()
                    .and_then(|(_, v)| v.entity_picture.as_ref())
                    == Some(&url)
                {
                    self.now_playing_image = Some(handle);
                }

            Message::AlbumArtImageLoaded(handle) => {
                self.album_art_image = Some(handle);
                None
            }
            Message::FanArtLoaded(logo, background) => {
                self.background = background.map(MaybePendingImage::Loading);
                self.artist_logo = logo.map(MaybePendingImage::Loading);
                None
            }
            Message::MusicbrainzArtistLoaded(v) => {
                eprintln!("musicbrainz artist {v}");
                self.musicbrainz_artist_id = Some(v);
                None
            }
            Message::UpdateSpeaker => {
@@ -61,7 +71,21 @@ impl Listen {
                        .as_ref()
                        .and_then(|(_, v)| v.entity_picture.as_ref())
                {
                    self.now_playing_image = None;
                    self.album_art_image = None;
                    self.artist_logo = None;
                    self.background = None;
                }

                if self
                    .speaker
                    .as_ref()
                    .and_then(|(_, v)| v.media_artist.as_ref())
                    != new
                        .as_ref()
                        .as_ref()
                        .and_then(|(_, v)| v.media_artist.as_ref())
                {
                    self.musicbrainz_artist_id = None;
                }

                self.speaker = new;
@@ -106,15 +130,26 @@ impl Listen {
                speaker.shuffle = new;
                Some(Event::SetSpeakerShuffle(id, new))
            }
            Message::BackgroundDownloaded(handle) => {
                self.background = Some(MaybePendingImage::Downloaded(handle));
                None
            }
            Message::ArtistLogoDownloaded(handle) => {
                self.artist_logo = Some(MaybePendingImage::Downloaded(handle));
                None
            }
        }
    }

    pub fn view(&self) -> Element<'_, Message, Renderer> {
        let mut col = Column::new();

        if let Some((_, speaker)) = self.speaker.clone() {
            col = col.push(container(
                widgets::media_player::media_player(speaker, self.now_playing_image.clone())
            container(
                widgets::media_player::media_player(speaker, self.album_art_image.clone())
                    .with_artist_logo(
                        self.artist_logo
                            .as_ref()
                            .and_then(MaybePendingImage::handle),
                    )
                    .on_volume_change(Message::OnSpeakerVolumeChange)
                    .on_mute_change(Message::OnSpeakerMuteChange)
                    .on_repeat_change(Message::OnSpeakerRepeatChange)
@@ -123,22 +158,59 @@ impl Listen {
                    .on_next_track(Message::OnSpeakerNextTrack)
                    .on_previous_track(Message::OnSpeakerPreviousTrack)
                    .on_shuffle_change(Message::OnSpeakerShuffleChange),
            ));
            )
            .into()
        } else {
            Column::new().into()
        }

        col.into()
    }

    pub fn subscription(&self) -> Subscription<Message> {
        let image_subscription = if let (Some(uri), None) = (
        let album_art_subscription = if let (Some(uri), None) = (
            self.speaker
                .as_ref()
                .and_then(|(_, v)| v.entity_picture.as_ref()),
            &self.now_playing_image,
            &self.album_art_image,
        ) {
            download_image(uri.clone(), identity, Message::AlbumArtImageLoaded)
        } else {
            Subscription::none()
        };

        let musicbrainz_artist_id_subscription = if let (Some(artist), None) = (
            self.speaker
                .as_ref()
                .and_then(|(_, v)| v.media_artist.as_ref()),
            &self.musicbrainz_artist_id,
        ) {
            find_musicbrainz_artist(artist.to_string(), Message::MusicbrainzArtistLoaded)
        } else {
            Subscription::none()
        };

        let fanart_subscription = if let (None, None, Some(musicbrainz_id)) = (
            &self.background,
            &self.artist_logo,
            &self.musicbrainz_artist_id,
        ) {
            download_image("now-playing", uri.clone(), |_, url, handle| {
                Message::NowPlayingImageLoaded(url, handle)
            })
            find_fanart_urls(musicbrainz_id.clone(), Message::FanArtLoaded)
        } else {
            Subscription::none()
        };

        let background_subscription =
            if let Some(MaybePendingImage::Loading(url)) = &self.background {
                download_image(
                    url.clone(),
                    |image| blur(&darken_image(image, 0.3), 5.0),
                    Message::BackgroundDownloaded,
                )
            } else {
                Subscription::none()
            };

        let logo_subscription = if let Some(MaybePendingImage::Loading(url)) = &self.artist_logo {
            download_image(url.clone(), identity, Message::ArtistLogoDownloaded)
        } else {
            Subscription::none()
        };
@@ -155,7 +227,14 @@ impl Listen {
            Subscription::none()
        };

        Subscription::batch([image_subscription, speaker_subscription])
        Subscription::batch([
            album_art_subscription,
            speaker_subscription,
            musicbrainz_artist_id_subscription,
            background_subscription,
            logo_subscription,
            fanart_subscription,
        ])
    }
}

@@ -173,7 +252,11 @@ pub enum Event {

#[derive(Clone, Debug)]
pub enum Message {
    NowPlayingImageLoaded(Url, Handle),
    AlbumArtImageLoaded(Handle),
    BackgroundDownloaded(Handle),
    ArtistLogoDownloaded(Handle),
    MusicbrainzArtistLoaded(String),
    FanArtLoaded(Option<Url>, Option<Url>),
    UpdateSpeaker,
    OnSpeakerVolumeChange(f32),
    OnSpeakerPositionChange(Duration),
diff --git a/shalom/src/subscriptions.rs b/shalom/src/subscriptions.rs
index 36aef78..3a6c05a 100644
--- a/shalom/src/subscriptions.rs
+++ b/shalom/src/subscriptions.rs
@@ -1,24 +1,44 @@
use std::{hash::Hash, num::NonZeroUsize};
use std::num::NonZeroUsize;

use ::image::GenericImageView;
use iced::{futures::stream, subscription, widget::image, Subscription};
use lru::LruCache;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use url::Url;

pub fn download_image<I: Hash + Copy + Send + 'static, M: 'static>(
    id: I,
use crate::config::FANART_PROJECT_KEY;

#[derive(Debug)]
pub enum MaybePendingImage {
    Downloaded(image::Handle),
    Loading(Url),
}

impl MaybePendingImage {
    pub fn handle(&self) -> Option<image::Handle> {
        match self {
            Self::Downloaded(h) => Some(h.clone()),
            Self::Loading(_) => None,
        }
    }
}

pub fn download_image<M: 'static>(
    url: Url,
    resp: fn(I, Url, image::Handle) -> M,
    post_process: fn(::image::RgbaImage) -> ::image::RgbaImage,
    resp: impl FnOnce(image::Handle) -> M + Send + 'static,
) -> Subscription<M> {
    static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
        Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap())));

    subscription::run_with_id(
        id,
        url.to_string(),
        stream::once(async move {
            eprintln!("{url} dl");

            if let Some(handle) = CACHE.lock().get(&url) {
                return (resp)(id, url, handle.clone());
                return (resp)(handle.clone());
            }

            let bytes = reqwest::get(url.clone())
@@ -27,11 +47,116 @@ pub fn download_image<I: Hash + Copy + Send + 'static, M: 'static>(
                .bytes()
                .await
                .unwrap();
            let handle = image::Handle::from_memory(bytes);

            let handle = tokio::task::spawn_blocking(move || {
                eprintln!("parsing image");
                let img = ::image::load_from_memory(&bytes).unwrap();
                let (h, w) = img.dimensions();
                eprintln!("post processing");
                let data = post_process(img.into_rgba8()).into_raw();
                image::Handle::from_pixels(h, w, data)
            })
            .await
            .unwrap();

            CACHE.lock().push(url.clone(), handle.clone());

            (resp)(id, url, handle)
            (resp)(handle)
        }),
    )
}

pub fn find_musicbrainz_artist<M: 'static>(
    artist: String,
    to_msg: fn(String) -> M,
) -> Subscription<M> {
    static CACHE: Lazy<Mutex<LruCache<String, String>>> =
        Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap())));

    subscription::run_with_id(
        format!("musicbrainz-{artist}"),
        stream::once(async move {
            eprintln!("musicbrainz req");

            if let Some(handle) = CACHE.lock().get(&artist) {
                return (to_msg)(handle.to_string());
            }

            // TODO
            let client = reqwest::Client::builder()
                .user_agent(format!(
                    "{}/{}",
                    env!("CARGO_PKG_NAME"),
                    env!("CARGO_PKG_VERSION")
                ))
                .build()
                .unwrap();

            let resp: serde_json::Value = client
                .get(format!(
                    "https://musicbrainz.org/ws/2/artist/?query={artist}&fmt=json",
                ))
                .send()
                .await
                .unwrap()
                .json()
                .await
                .unwrap();

            let id = resp
                .get("artists")
                .unwrap()
                .get(0)
                .unwrap()
                .get("id")
                .unwrap()
                .as_str()
                .unwrap()
                .to_string();

            CACHE.lock().push(artist, id.clone());

            // TODO: typing
            (to_msg)(id)
        }),
    )
}

pub fn find_fanart_urls<M: 'static>(
    musicbrainz_id: String,
    to_msg: fn(Option<Url>, Option<Url>) -> M,
) -> Subscription<M> {
    subscription::run_with_id(
        format!("fanart-{musicbrainz_id}"),
        stream::once(async move {
            eprintln!("fanart req");

            let resp: serde_json::Value = reqwest::get(format!("http://webservice.fanart.tv/v3/music/{musicbrainz_id}?api_key={FANART_PROJECT_KEY}"))
                .await
                .unwrap()
                .json()
                .await
                .unwrap();

            // TODO: typing
            let logo = resp
                .get("hdmusiclogo")
                .and_then(|v| v.get(0))
                .and_then(|v| v.get("url"))
                .and_then(|v| v.as_str())
                .map(Url::parse)
                .transpose()
                .unwrap();
            let background = resp
                .get("artistbackground")
                .and_then(|v| v.get(0))
                .and_then(|v| v.get("url"))
                .and_then(|v| v.as_str())
                .map(Url::parse)
                .transpose()
                .unwrap();

            (to_msg)(logo, background)
        }),
    )
}
diff --git a/shalom/src/theme.rs b/shalom/src/theme.rs
index e2d00f1..06640be 100644
--- a/shalom/src/theme.rs
+++ b/shalom/src/theme.rs
@@ -1,4 +1,4 @@
use ::image::GenericImageView;
use ::image::{GenericImageView, Pixel, RgbaImage};
use iced::{
    advanced::svg::Handle,
    widget::{image, svg},
@@ -160,3 +160,14 @@ impl From<Image> for image::Handle {
        value.handle()
    }
}

pub fn darken_image(mut img: RgbaImage, factor: f32) -> RgbaImage {
    for px in img.pixels_mut() {
        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        px.apply_without_alpha(|v| (f32::from(v) * (1.0 - factor)).min(255.0) as u8);
    }

    eprintln!("darkened");

    img
}
diff --git a/shalom/src/widgets/media_player.rs b/shalom/src/widgets/media_player.rs
index f11189f..0b43774 100644
--- a/shalom/src/widgets/media_player.rs
+++ b/shalom/src/widgets/media_player.rs
@@ -5,6 +5,7 @@ use std::{

use iced::{
    advanced::graphics::core::Element,
    alignment::Horizontal,
    theme::{Container, Slider, Svg, Text},
    widget::{
        column as icolumn, component, container, image::Handle, row, slider, svg, text, Component,
@@ -22,12 +23,13 @@ use crate::{
    widgets::mouse_area::mouse_area,
};

pub fn media_player<M>(device: MediaPlayerSpeaker, image: Option<Handle>) -> MediaPlayer<M> {
pub fn media_player<M>(device: MediaPlayerSpeaker, album_art: Option<Handle>) -> MediaPlayer<M> {
    MediaPlayer {
        height: Length::Shrink,
        width: Length::Fill,
        device,
        image,
        album_art,
        artist_logo: None,
        on_volume_change: None,
        on_position_change: None,
        on_state_change: None,
@@ -44,7 +46,8 @@ pub struct MediaPlayer<M> {
    height: Length,
    width: Length,
    device: MediaPlayerSpeaker,
    image: Option<Handle>,
    album_art: Option<Handle>,
    artist_logo: Option<Handle>,
    on_volume_change: Option<fn(f32) -> M>,
    on_position_change: Option<fn(Duration) -> M>,
    on_state_change: Option<fn(bool) -> M>,
@@ -56,6 +59,11 @@ pub struct MediaPlayer<M> {
}

impl<M> MediaPlayer<M> {
    pub fn with_artist_logo(mut self, logo: Option<Handle>) -> Self {
        self.artist_logo = logo;
        self
    }

    pub fn on_volume_change(mut self, f: fn(f32) -> M) -> Self {
        self.on_volume_change = Some(f);
        self
@@ -153,132 +161,136 @@ impl<M: Clone> Component<M, Renderer> for MediaPlayer<M> {

        let volume = state.overridden_volume.unwrap_or(self.device.volume);

        container(
            row![
                container(crate::widgets::track_card::track_card(
                    self.device
                        .media_artist
                        .as_ref()
                        .map(ToString::to_string)
                        .unwrap_or_default(),
                    self.device
                        .media_title
                        .as_ref()
                        .map(ToString::to_string)
                        .unwrap_or_default(),
                    self.image.clone(),
                ),)
                .width(Length::FillPortion(8)),
        let track_card = crate::widgets::track_card::track_card(
            self.device.media_artist.as_deref().unwrap_or_default(),
            self.device.media_title.as_deref().unwrap_or_default(),
            self.album_art.clone(),
            self.artist_logo.clone(),
        );

        let playback_controls = row![
            mouse_area(
                svg(Icon::Shuffle)
                    .height(24)
                    .width(24)
                    .style(icon_style(self.device.shuffle)),
            )
            .on_press(Event::ToggleShuffle),
            mouse_area(
                svg(Icon::Backward)
                    .height(28)
                    .width(28)
                    .style(icon_style(false))
            )
            .on_press(Event::PreviousTrack),
            mouse_area(
                svg(if self.device.state == MediaPlayerSpeakerState::Playing {
                    Icon::Pause
                } else {
                    Icon::Play
                })
                .height(42)
                .width(42)
                .style(icon_style(false))
            )
            .on_press(Event::TogglePlaying),
            mouse_area(
                svg(Icon::Forward)
                    .height(28)
                    .width(28)
                    .style(icon_style(false))
            )
            .on_press(Event::NextTrack),
            mouse_area(
                svg(match self.device.repeat {
                    MediaPlayerRepeat::Off | MediaPlayerRepeat::All => Icon::Repeat,
                    MediaPlayerRepeat::One => Icon::Repeat1,
                })
                .height(28)
                .width(28)
                .style(icon_style(self.device.repeat != MediaPlayerRepeat::Off)),
            )
            .on_press(Event::ToggleRepeat),
        ]
        .spacing(14)
        .align_items(Alignment::Center);

        let volume_controls = row![
            mouse_area(
                svg(if self.device.muted {
                    Icon::SpeakerMuted
                } else {
                    Icon::Speaker
                })
                .height(16)
                .width(16)
                .style(icon_style(false)),
            )
            .on_press(Event::ToggleMute),
            slider(0.0..=1.0, volume, Event::VolumeChange)
                .width(128)
                .step(0.01)
                .on_release(Event::OnVolumeRelease)
                .style(Slider::Custom(Box::new(SliderStyle))),
        ]
        .spacing(12)
        .align_items(Alignment::Center);

        let scrubber = row![
            text(format_time(position))
                .style(Text::Color(SLATE_400))
                .size(12)
                .width(Length::FillPortion(10)),
            slider(
                0.0..=self.device.media_duration.unwrap_or_default().as_secs_f64(),
                position.as_secs_f64(),
                Event::PositionChange
            )
            .on_release(Event::OnPositionRelease)
            .style(Slider::Custom(Box::new(SliderStyle)))
            .width(Length::FillPortion(80)),
            text(format_time(self.device.media_duration.unwrap_or_default()))
                .style(Text::Color(SLATE_400))
                .size(12)
                .width(Length::FillPortion(10))
                .horizontal_alignment(iced::alignment::Horizontal::Right),
        ]
        .spacing(14)
        .align_items(Alignment::Center);

        icolumn![
            container(track_card)
                .width(Length::Fill)
                .height(Length::Fill)
                .padding([0, 40, 0, 40])
                .center_y(),
            container(
                icolumn![
                    // container(
                    //     svg(Icon::Hamburger)
                    //         .height(30)
                    //         .width(30),
                    // )
                    // .align_x(Horizontal::Right)
                    // .align_y(Vertical::Center)
                    // .width(Length::Fill),
                    row![
                        mouse_area(
                            svg(Icon::Shuffle)
                                .height(24)
                                .width(24)
                                .style(icon_style(self.device.shuffle)),
                        )
                        .on_press(Event::ToggleShuffle),
                        mouse_area(
                            svg(Icon::Backward)
                                .height(28)
                                .width(28)
                                .style(icon_style(false))
                        )
                        .on_press(Event::PreviousTrack),
                        mouse_area(
                            svg(if self.device.state == MediaPlayerSpeakerState::Playing {
                                Icon::Pause
                            } else {
                                Icon::Play
                            })
                            .height(42)
                            .width(42)
                            .style(icon_style(false))
                        )
                        .on_press(Event::TogglePlaying),
                        mouse_area(
                            svg(Icon::Forward)
                                .height(28)
                                .width(28)
                                .style(icon_style(false))
                        )
                        .on_press(Event::NextTrack),
                        mouse_area(
                            svg(match self.device.repeat {
                                MediaPlayerRepeat::Off | MediaPlayerRepeat::All => Icon::Repeat,
                                MediaPlayerRepeat::One => Icon::Repeat1,
                            })
                            .height(28)
                            .width(28)
                            .style(icon_style(self.device.repeat != MediaPlayerRepeat::Off)),
                        )
                        .on_press(Event::ToggleRepeat),
                        row![].width(Length::FillPortion(8)),
                        container(playback_controls)
                            .width(Length::FillPortion(20))
                            .align_x(Horizontal::Center),
                        container(volume_controls)
                            .width(Length::FillPortion(8))
                            .align_x(Horizontal::Right),
                    ]
                    .spacing(14)
                    .align_items(Alignment::Center),
                    row![
                        text(format_time(position))
                            .style(Text::Color(SLATE_400))
                            .size(12)
                            .width(Length::FillPortion(10)),
                        slider(
                            0.0..=self.device.media_duration.unwrap_or_default().as_secs_f64(),
                            position.as_secs_f64(),
                            Event::PositionChange
                        )
                        .on_release(Event::OnPositionRelease)
                        .style(Slider::Custom(Box::new(SliderStyle)))
                        .width(Length::FillPortion(80)),
                        text(format_time(self.device.media_duration.unwrap_or_default()))
                            .style(Text::Color(SLATE_400))
                            .size(12)
                            .width(Length::FillPortion(10)),
                    ]
                    .spacing(14)
                    .align_items(Alignment::Center),
                ]
                .spacing(8)
                .align_items(Alignment::Center)
                .width(Length::FillPortion(12)),
                row![
                    mouse_area(
                        svg(if self.device.muted {
                            Icon::SpeakerMuted
                        } else {
                            Icon::Speaker
                        })
                        .height(16)
                        .width(16)
                        .style(icon_style(false)),
                    )
                    .on_press(Event::ToggleMute),
                    slider(0.0..=1.0, volume, Event::VolumeChange)
                        .width(128)
                        .step(0.01)
                        .on_release(Event::OnVolumeRelease)
                        .style(Slider::Custom(Box::new(SliderStyle))),
                    .spacing(8)
                    .align_items(Alignment::Center)
                    .width(Length::Fill),
                    scrubber,
                ]
                .align_items(Alignment::Center)
                .width(Length::FillPortion(4))
                .spacing(12),
            ]
            .align_items(Alignment::Center)
            .spacing(48),
        )
        .height(self.height)
        .width(self.width)
        .center_x()
        .center_y()
        .style(Container::Custom(Box::new(Style::Inactive)))
        .padding(20)
                .spacing(24),
            )
            .height(self.height)
            .width(self.width)
            .center_x()
            .center_y()
            .style(Container::Custom(Box::new(Style::Inactive)))
            .padding([20, 40, 20, 40])
        ]
        .spacing(30)
        .into()
    }
}
@@ -390,7 +402,7 @@ impl container::StyleSheet for Style {
                a: 0.8,
                ..Color::BLACK
            })),
            border_radius: 10.0.into(),
            border_radius: 0.0.into(),
            border_width: 0.,
            border_color: Color::default(),
        }
diff --git a/shalom/src/widgets/track_card.rs b/shalom/src/widgets/track_card.rs
index d186b97..746b2d4 100644
--- a/shalom/src/widgets/track_card.rs
+++ b/shalom/src/widgets/track_card.rs
@@ -1,21 +1,28 @@
use iced::{
    advanced::graphics::core::Element,
    font::{Stretch, Weight},
    theme::Text,
    widget::{
        column as icolumn, component, container,
        image::{self, Image},
        row, text, vertical_space, Component,
        text, vertical_space, Component,
    },
    Alignment, Background, Color, Renderer, Theme,
    Background, Color, Font, Renderer, Theme,
};

use crate::theme::colours::{SLATE_200, SLATE_400};
use crate::theme::colours::SLATE_200;

pub fn track_card(artist: String, song: String, image: Option<image::Handle>) -> TrackCard {
pub fn track_card(
    artist: &str,
    song: &str,
    image: Option<image::Handle>,
    artist_logo: Option<image::Handle>,
) -> TrackCard {
    TrackCard {
        artist,
        song,
        artist: artist.to_uppercase(),
        song: format!("\"{}\"", song.to_uppercase()),
        image,
        artist_logo,
    }
}

@@ -23,6 +30,7 @@ pub struct TrackCard {
    artist: String,
    song: String,
    image: Option<image::Handle>,
    artist_logo: Option<image::Handle>,
}

impl<M> Component<M, Renderer> for TrackCard {
@@ -34,28 +42,41 @@ impl<M> Component<M, Renderer> for TrackCard {
    }

    fn view(&self, _state: &Self::State) -> Element<'_, Self::Event, Renderer> {
        let image =
            if let Some(handle) = self.image.clone() {
                Element::from(Image::new(handle).width(64).height(64))
            } else {
                Element::from(container(vertical_space(0)).width(64).height(64).style(
                    |_t: &Theme| container::Appearance {
                        background: Some(Background::Color(SLATE_200)),
                        ..container::Appearance::default()
                    },
                ))
            };
        let image = if let Some(handle) = self.image.clone() {
            Element::from(Image::new(handle).width(192).height(192))
        } else {
            Element::from(container(vertical_space(0)).width(192).height(192).style(
                |_t: &Theme| container::Appearance {
                    background: Some(Background::Color(SLATE_200)),
                    ..container::Appearance::default()
                },
            ))
        };

        let artist = if let Some(handle) = self.artist_logo.clone() {
            Element::from(Image::new(handle).height(64))
        } else {
            Element::from(
                text(&self.artist)
                    .size(49)
                    .style(Text::Color(Color::WHITE))
                    .font(Font {
                        weight: Weight::Bold,
                        stretch: Stretch::Condensed,
                        ..Font::with_name("Helvetica Neue")
                    }),
            )
        };

        let song = text(&self.song)
            .size(24)
            .style(Text::Color(Color::WHITE))
            .font(Font {
                weight: Weight::Medium,
                ..Font::with_name("Helvetica Neue")
            });

        row![
            image,
            icolumn![
                text(&self.song).size(14).style(Text::Color(Color::WHITE)),
                text(&self.artist).style(Text::Color(SLATE_400)).size(14)
            ]
        ]
        .align_items(Alignment::Center)
        .spacing(10)
        .into()
        icolumn![icolumn![image, artist,].spacing(5), song,].into()
    }
}