From 5b77fe19a512bdc7d01c3ffad8d01db9c2735064 Mon Sep 17 00:00:00 2001 From: Samuel Oldham Date: Thu, 3 Oct 2024 13:03:09 +0100 Subject: [PATCH 01/15] Start to build request --- psst-gui/src/data/artist.rs | 1 + psst-gui/src/data/mod.rs | 1 + psst-gui/src/ui/artist.rs | 35 ++++++++-- psst-gui/src/webapi/client.rs | 122 ++++++++++++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/psst-gui/src/data/artist.rs b/psst-gui/src/data/artist.rs index da98640d..ba43e89b 100644 --- a/psst-gui/src/data/artist.rs +++ b/psst-gui/src/data/artist.rs @@ -11,6 +11,7 @@ pub struct ArtistDetail { pub albums: Promise, pub top_tracks: Promise, pub related_artists: Promise>, ArtistLink>, + pub artist_links: Promise, ArtistLink> } #[derive(Clone, Data, Lens, Deserialize)] diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 685b4e81..53e4bbd6 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -151,6 +151,7 @@ impl AppState { albums: Promise::Empty, top_tracks: Promise::Empty, related_artists: Promise::Empty, + artist_links: Promise::Empty, }, playlist_detail: PlaylistDetail { playlist: Promise::Empty, diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 7a92aae4..88f09fe8 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -1,8 +1,5 @@ use druid::{ - im::Vector, - kurbo::Circle, - widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, - Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, UnitPoint, Widget, WidgetExt, + im::Vector, kurbo::Circle, piet::d3d::Error, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, UnitPoint, Widget, WidgetExt }; use crate::{ @@ -15,12 +12,13 @@ use crate::{ widget::{Async, MyWidgetExt, RemoteImage}, }; -use super::{album, playable, theme, track, utils}; +use super::{album, playable, theme, track, utils::{self, error_widget}}; pub const LOAD_DETAIL: Selector = Selector::new("app.artist.load-detail"); pub fn detail_widget() -> impl Widget { Flex::column() + .with_child(async_artist_links()) .with_child(async_top_tracks_widget()) .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0))) .with_child(async_related_widget().padding((theme::grid(1.0), 0.0))) @@ -71,6 +69,33 @@ fn async_albums_widget() -> impl Widget { ) } +fn async_artist_links() -> impl Widget { + Async::new( + || utils::spinner_widget(), + || { + List::new(|| { + Label::new(|item: &String, _env: &_| item.to_string()) + .with_line_break_mode(LineBreaking::WordWrap) + }) + .lens(Ctx::data()) + }, + || utils::error_widget(), + ) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::artist_detail.then(ArtistDetail::artist_links), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_DETAIL, + |d| WebApi::global().get_artist_links(&d.id), + |_, data, d| data.artist_detail.artist_links.defer(d), + |_, data, r| data.artist_detail.artist_links.update(r), + ) +} + fn async_related_widget() -> impl Widget { Async::new(utils::spinner_widget, related_widget, utils::error_widget) .lens(AppState::artist_detail.then(ArtistDetail::related_artists)) diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index 61c65276..adfacb28 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -1,10 +1,5 @@ use std::{ - fmt::Display, - io::{self, Read}, - path::PathBuf, - sync::Arc, - thread, - time::Duration, + env::var, fmt::Display, io::{self, Read}, path::PathBuf, sync::Arc, thread, time::Duration }; use druid::{ @@ -19,6 +14,7 @@ use sanitize_html::rules::predefined::DEFAULT; use sanitize_html::sanitize_str; use serde::{de::DeserializeOwned, Deserialize}; use serde_json::json; +use serde_json::Value; use ureq::{Agent, Request, Response}; use psst_core::{ @@ -757,6 +753,120 @@ impl WebApi { let result: Cached = self.load_cached(request, "related-artists", id)?; Ok(result.map(|result| result.artists)) } + + pub fn get_artist_links(&self, id: &str) -> Result, Error> { + #[derive(Deserialize)] + pub struct Welcome { + data: Data, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Data { + artist_union: ArtistUnion, + } + + #[derive(Deserialize)] + pub struct ArtistUnion { + goods: Goods, + id: String, + profile: Profile, + stats: Stats, + } + + #[derive(Deserialize)] + pub struct Goods { + events: Events, + } + + #[derive(Deserialize)] + pub struct Events { + concerts: Merch, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Merch { + items: Vec>, + total_count: i64, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Profile { + biography: Biography, + external_links: ExternalLinks, + name: String, + } + + #[derive(Deserialize)] + pub struct Biography { + text: String, + } + + #[derive(Deserialize)] + pub struct ExternalLinks { + items: Vec, + } + + #[derive(Deserialize)] + pub struct ExternalLinksItem { + name: String, + url: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Stats { + followers: i64, + monthly_listeners: i64, + top_cities: TopCities, + world_rank: i64, + } + + #[derive(Deserialize)] + pub struct TopCities { + items: Vec, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct TopCitiesItem { + city: String, + country: String, + number_of_listeners: i64, + region: String, + } + + let extensions = json!({ + "persistedQuery": { + "version": 1, + // From https://github.com/spicetify/cli/blob/bb767a9059143fe183c1c577acff335dc6a462b7/Extensions/shuffle%2B.js#L373 keep and eye on this and change accordingly + "sha256Hash": "35648a112beb1794e39ab931365f6ae4a8d45e65396d641eeda94e4003d41497" + } + }); + let extensions_json = serde_json::to_string(&extensions); + + let variables = json!( { + "uri": format!("spotify:artist:{}", id), + "locale": "", + "includePrerelease": true, // Assuming this returns a Result + }); + let variables_json = serde_json::to_string(&variables); + + let request = self.get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "queryArtistOverview") + .query("variables", &variables_json.unwrap().to_string()) + .query("extensions", &extensions_json.unwrap().to_string()); + + let result: Welcome = self.load(request)?; + + let hrefs: Vector = result.data.artist_union.profile.external_links.items + .into_iter() + .map(|link| link.url) + .collect(); + Ok(hrefs) + } } /// Album endpoints. From 6b480199257721ba601ea76ee2b22f8dd48b30ad Mon Sep 17 00:00:00 2001 From: Samuel Oldham Date: Thu, 3 Oct 2024 15:48:18 +0100 Subject: [PATCH 02/15] Everything is here, make it neat --- psst-gui/src/data/artist.rs | 16 +++++++- psst-gui/src/data/mod.rs | 4 +- psst-gui/src/ui/artist.rs | 66 +++++++++++++++++++++++-------- psst-gui/src/webapi/client.rs | 73 +++++++++++++++-------------------- 4 files changed, 97 insertions(+), 62 deletions(-) diff --git a/psst-gui/src/data/artist.rs b/psst-gui/src/data/artist.rs index ba43e89b..2bef4ced 100644 --- a/psst-gui/src/data/artist.rs +++ b/psst-gui/src/data/artist.rs @@ -11,7 +11,7 @@ pub struct ArtistDetail { pub albums: Promise, pub top_tracks: Promise, pub related_artists: Promise>, ArtistLink>, - pub artist_links: Promise, ArtistLink> + pub artist_info: Promise, } #[derive(Clone, Data, Lens, Deserialize)] @@ -41,6 +41,20 @@ pub struct ArtistAlbums { pub compilations: Vector>, pub appears_on: Vector>, } +#[derive(Clone, Data, Lens)] +pub struct ArtistInfo { + pub main_image: Arc, + pub stats: ArtistStats, + pub bio: String, + pub artist_links: Vector, +} + +#[derive(Clone, Data, Lens)] +pub struct ArtistStats { + pub followers: String, + pub monthly_listeners: String, + pub world_rank: String, +} #[derive(Clone, Data, Lens)] pub struct ArtistTracks { diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 53e4bbd6..31ca260f 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -34,7 +34,7 @@ use psst_core::{item_id::ItemId, session::SessionService}; pub use crate::data::{ album::{Album, AlbumDetail, AlbumLink, AlbumType}, - artist::{Artist, ArtistAlbums, ArtistDetail, ArtistLink, ArtistTracks}, + artist::{Artist, ArtistAlbums, ArtistInfo, ArtistStats, ArtistDetail, ArtistLink, ArtistTracks}, config::{AudioQuality, Authentication, Config, Preferences, PreferencesTab, Theme}, ctx::Ctx, find::{FindQuery, Finder, MatchFindQuery}, @@ -151,7 +151,7 @@ impl AppState { albums: Promise::Empty, top_tracks: Promise::Empty, related_artists: Promise::Empty, - artist_links: Promise::Empty, + artist_info: Promise::Empty, }, playlist_detail: PlaylistDetail { playlist: Promise::Empty, diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 88f09fe8..a8fbb2a2 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -1,24 +1,25 @@ use druid::{ - im::Vector, kurbo::Circle, piet::d3d::Error, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, UnitPoint, Widget, WidgetExt + im::Vector, kurbo::Circle, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt }; use crate::{ cmd, data::{ - AppState, Artist, ArtistAlbums, ArtistDetail, ArtistLink, ArtistTracks, Cached, Ctx, Nav, - WithCtx, + AppState, Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistStats, ArtistTracks, Cached, Ctx, Nav, WithCtx }, webapi::WebApi, widget::{Async, MyWidgetExt, RemoteImage}, }; -use super::{album, playable, theme, track, utils::{self, error_widget}}; +use super::{album, playable, theme, track, utils::{self}}; pub const LOAD_DETAIL: Selector = Selector::new("app.artist.load-detail"); pub fn detail_widget() -> impl Widget { Flex::column() - .with_child(async_artist_links()) + .with_child(Flex::row() + .with_child(async_artist_info()) + ) .with_child(async_top_tracks_widget()) .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0))) .with_child(async_related_widget().padding((theme::grid(1.0), 0.0))) @@ -69,30 +70,24 @@ fn async_albums_widget() -> impl Widget { ) } -fn async_artist_links() -> impl Widget { +fn async_artist_info() -> impl Widget { Async::new( || utils::spinner_widget(), - || { - List::new(|| { - Label::new(|item: &String, _env: &_| item.to_string()) - .with_line_break_mode(LineBreaking::WordWrap) - }) - .lens(Ctx::data()) - }, + artist_info_widget, || utils::error_widget(), ) .lens( Ctx::make( AppState::common_ctx, - AppState::artist_detail.then(ArtistDetail::artist_links), + AppState::artist_detail.then(ArtistDetail::artist_info), ) .then(Ctx::in_promise()), ) .on_command_async( LOAD_DETAIL, - |d| WebApi::global().get_artist_links(&d.id), - |_, data, d| data.artist_detail.artist_links.defer(d), - |_, data, r| data.artist_detail.artist_links.update(r), + |d| WebApi::global().get_artist_info(&d.id), + |_, data, d| data.artist_detail.artist_info.defer(d), + |_, data, r| data.artist_detail.artist_info.update(r), ) } @@ -169,6 +164,43 @@ pub fn cover_widget(size: f64) -> impl Widget { .clip(Circle::new((radius, radius), radius)) } +fn artist_info_widget() -> impl Widget> { + let size = theme::grid(10.0); + Flex::row() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_spacer(theme::grid(1.0)) + .with_child(RemoteImage::new(utils::placeholder_widget(), move |artist: &ArtistInfo, _| { + Some(artist.main_image.clone()) + }) + .fix_size(size, size) + .clip(Size::new(size, size).to_rounded_rect(4.0)) + .lens(Ctx::data()) + ) + .with_child(List::new(|| { + Label::new(|item: &String, _env: &_| item.to_string()) + .with_line_break_mode(LineBreaking::WordWrap) + }) + .lens(Ctx::data().then(ArtistInfo::artist_links))) + .with_child(Label::raw() + .with_line_break_mode(LineBreaking::WordWrap) + .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::followers)))) + .with_child(Label::raw() + .with_line_break_mode(LineBreaking::WordWrap) + .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::monthly_listeners)))) + .with_child(Label::raw() + .with_line_break_mode(LineBreaking::WordWrap) + .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::world_rank)))) + .with_child( + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(header_widget("Biography")) + .with_child( + Label::raw() + .with_line_break_mode(LineBreaking::WordWrap) + .lens(Ctx::data().then(ArtistInfo::bio)) + ) + ) +} fn top_tracks_widget() -> impl Widget> { playable::list_widget(playable::Display { track: track::Display { diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index adfacb28..88340487 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -1,5 +1,5 @@ use std::{ - env::var, fmt::Display, io::{self, Read}, path::PathBuf, sync::Arc, thread, time::Duration + fmt::Display, io::{self, Read}, path::PathBuf, sync::Arc, thread, time::Duration }; use druid::{ @@ -14,7 +14,6 @@ use sanitize_html::rules::predefined::DEFAULT; use sanitize_html::sanitize_str; use serde::{de::DeserializeOwned, Deserialize}; use serde_json::json; -use serde_json::Value; use ureq::{Agent, Request, Response}; use psst_core::{ @@ -24,9 +23,7 @@ use psst_core::{ use crate::{ data::{ - self, Album, AlbumType, Artist, ArtistAlbums, ArtistLink, AudioAnalysis, Cached, Episode, - EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, - RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, UserProfile, + self, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo, ArtistLink, ArtistStats, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, UserProfile }, error::Error, }; @@ -754,7 +751,7 @@ impl WebApi { Ok(result.map(|result| result.artists)) } - pub fn get_artist_links(&self, id: &str) -> Result, Error> { + pub fn get_artist_info(&self, id: &str) -> Result { #[derive(Deserialize)] pub struct Welcome { data: Data, @@ -768,50 +765,47 @@ impl WebApi { #[derive(Deserialize)] pub struct ArtistUnion { - goods: Goods, - id: String, profile: Profile, stats: Stats, + visuals: Visuals, } #[derive(Deserialize)] - pub struct Goods { - events: Events, + #[serde(rename_all = "camelCase")] + pub struct Profile { + biography: Biography, + external_links: ExternalLinks, } #[derive(Deserialize)] - pub struct Events { - concerts: Merch, + pub struct Biography { + text: String, } #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Merch { - items: Vec>, - total_count: i64, + pub struct ExternalLinks { + items: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] - pub struct Profile { - biography: Biography, - external_links: ExternalLinks, - name: String, + pub struct Visuals { + avatar_image: AvatarImage, } - #[derive(Deserialize)] - pub struct Biography { - text: String, + pub struct AvatarImage { + sources: Vec, } #[derive(Deserialize)] - pub struct ExternalLinks { - items: Vec, + pub struct Source { + height: i64, + url: String, + width: i64, } #[derive(Deserialize)] pub struct ExternalLinksItem { - name: String, url: String, } @@ -820,24 +814,9 @@ impl WebApi { pub struct Stats { followers: i64, monthly_listeners: i64, - top_cities: TopCities, world_rank: i64, } - #[derive(Deserialize)] - pub struct TopCities { - items: Vec, - } - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct TopCitiesItem { - city: String, - country: String, - number_of_listeners: i64, - region: String, - } - let extensions = json!({ "persistedQuery": { "version": 1, @@ -865,7 +844,17 @@ impl WebApi { .into_iter() .map(|link| link.url) .collect(); - Ok(hrefs) + + Ok(ArtistInfo { + main_image: Arc::from(result.data.artist_union.visuals.avatar_image.sources[0].url.to_string()), + stats: ArtistStats{ + followers: result.data.artist_union.stats.followers.to_string(), + monthly_listeners: result.data.artist_union.stats.monthly_listeners.to_string(), + world_rank: result.data.artist_union.stats.world_rank.to_string() + }, + bio: result.data.artist_union.profile.biography.text, + artist_links: hrefs.into() + }) } } From 11320f622fa78dac0123dc6e217abb2a086f043f Mon Sep 17 00:00:00 2001 From: so9010 Date: Mon, 7 Oct 2024 22:22:18 +0100 Subject: [PATCH 03/15] Right click to go to social --- psst-gui/src/cmd.rs | 1 + psst-gui/src/delegate.rs | 3 ++ psst-gui/src/ui/artist.rs | 31 +++++++++++++--- psst-gui/src/webapi/client.rs | 68 +++++++++++++++++++---------------- 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 65957e13..f567903f 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -18,6 +18,7 @@ pub const CLOSE_ALL_WINDOWS: Selector = Selector::new("app.close-all-windows"); pub const QUIT_APP_WITH_SAVE: Selector = Selector::new("app.quit-with-save"); pub const SET_FOCUS: Selector = Selector::new("app.set-focus"); pub const COPY: Selector = Selector::new("app.copy-to-clipboard"); +pub const GO_TO_URL: Selector = Selector::new("app.go-to-url"); // Find pub const TOGGLE_FINDER: Selector = Selector::new("app.show-finder"); diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 617071b2..96fe24ed 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -129,6 +129,9 @@ impl AppDelegate for Delegate { } else if let Some(text) = cmd.get(cmd::COPY) { Application::global().clipboard().put_string(text); Handled::Yes + } else if let Some(text) = cmd.get(cmd::GO_TO_URL) { + let _ = webbrowser::open(text); + Handled::Yes } else if let Handled::Yes = self.command_image(ctx, target, cmd, data) { Handled::Yes } else if let Some(link) = cmd.get(UNFOLLOW_PLAYLIST_CONFIRM) { diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index a8fbb2a2..22e2c026 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -176,11 +176,6 @@ fn artist_info_widget() -> impl Widget> { .clip(Size::new(size, size).to_rounded_rect(4.0)) .lens(Ctx::data()) ) - .with_child(List::new(|| { - Label::new(|item: &String, _env: &_| item.to_string()) - .with_line_break_mode(LineBreaking::WordWrap) - }) - .lens(Ctx::data().then(ArtistInfo::artist_links))) .with_child(Label::raw() .with_line_break_mode(LineBreaking::WordWrap) .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::followers)))) @@ -200,6 +195,7 @@ fn artist_info_widget() -> impl Widget> { .lens(Ctx::data().then(ArtistInfo::bio)) ) ) + .context_menu(|artist| artist_info_menu(&artist.data)) } fn top_tracks_widget() -> impl Widget> { playable::list_widget(playable::Display { @@ -246,6 +242,31 @@ fn header_widget(text: impl Into>) -> impl Widget { .padding(Insets::new(0.0, theme::grid(2.0), 0.0, theme::grid(1.0))) } +fn artist_info_menu(artist: &ArtistInfo) -> Menu { + let mut menu = Menu::empty(); + + for artist_links in &artist.artist_links { + let more_than_one_artist = artist.artist_links.len() > 1; + let title = if more_than_one_artist { + LocalizedString::new("menu-item-go-to-social") + .with_placeholder(format!("Go to their“{:?}”", artist_links + .strip_prefix("https://") + .unwrap_or(artist_links) + .split(".com") + .next() + .unwrap_or("No socials"))) + } else { + LocalizedString::new("").with_placeholder("") + }; + menu = menu.entry( + MenuItem::new(title) + .command(cmd::GO_TO_URL.with(artist_links.to_owned())) + ); + } + + menu +} + fn artist_menu(artist: &ArtistLink) -> Menu { let mut menu = Menu::empty(); diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index 88340487..da4b3218 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -23,7 +23,7 @@ use psst_core::{ use crate::{ data::{ - self, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo, ArtistLink, ArtistStats, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, UserProfile + self, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo, ArtistLink, ArtistStats, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, Image, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, UserProfile }, error::Error, }; @@ -752,64 +752,56 @@ impl WebApi { } pub fn get_artist_info(&self, id: &str) -> Result { - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct Welcome { - data: Data, + data: Data1, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] #[serde(rename_all = "camelCase")] - pub struct Data { + pub struct Data1 { artist_union: ArtistUnion, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct ArtistUnion { profile: Profile, stats: Stats, visuals: Visuals, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Profile { biography: Biography, external_links: ExternalLinks, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct Biography { text: String, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct ExternalLinks { - items: Vec, + items: Vector, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Visuals { avatar_image: AvatarImage, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct AvatarImage { - sources: Vec, - } - - #[derive(Deserialize)] - pub struct Source { - height: i64, - url: String, - width: i64, + sources: Vector, } - - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] pub struct ExternalLinksItem { url: String, } - #[derive(Deserialize)] + #[derive(Clone, Data, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Stats { followers: i64, @@ -838,21 +830,37 @@ impl WebApi { .query("variables", &variables_json.unwrap().to_string()) .query("extensions", &extensions_json.unwrap().to_string()); - let result: Welcome = self.load(request)?; + let result: Cached = self.load_cached(request, "artist-info", id)?; - let hrefs: Vector = result.data.artist_union.profile.external_links.items + let hrefs: Vector = result.data.data.artist_union.profile.external_links.items .into_iter() .map(|link| link.url) .collect(); Ok(ArtistInfo { - main_image: Arc::from(result.data.artist_union.visuals.avatar_image.sources[0].url.to_string()), + main_image: Arc::from(result.data.data.artist_union.visuals.avatar_image.sources[0].url.to_string()), stats: ArtistStats{ - followers: result.data.artist_union.stats.followers.to_string(), - monthly_listeners: result.data.artist_union.stats.monthly_listeners.to_string(), - world_rank: result.data.artist_union.stats.world_rank.to_string() + followers: result.data.data.artist_union.stats.followers.to_string(), + monthly_listeners: result.data.data.artist_union.stats.monthly_listeners.to_string(), + world_rank: result.data.data.artist_union.stats.world_rank.to_string() + }, + bio: { + let desc = sanitize_str( + &DEFAULT, + &result.data + .data + .artist_union.profile.biography.text, + ) + .unwrap_or_default(); + // This is roughly 3 lines of description, truncated if too long + if desc.chars().count() > 255 { + desc.chars().take(254).collect::() + "..." + } else { + desc + } + .into() }, - bio: result.data.artist_union.profile.biography.text, + artist_links: hrefs.into() }) } From afd2739e670fe2c156018a4100e25c2d0bb5771d Mon Sep 17 00:00:00 2001 From: so9010 Date: Mon, 7 Oct 2024 22:36:14 +0100 Subject: [PATCH 04/15] test --- psst-gui/src/webapi/client.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index da4b3218..5d813f05 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -23,7 +23,10 @@ use psst_core::{ use crate::{ data::{ - self, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo, ArtistLink, ArtistStats, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, Image, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, UserProfile + self, Album, AlbumType, Artist, ArtistAlbums, ArtistLink, AudioAnalysis, Cached, Episode, + EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, + RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, + UserProfile, ArtistInfo, Image, ArtistStats, }, error::Error, }; From 528df0f79d861e233f05d99d35a1da16735fc675 Mon Sep 17 00:00:00 2001 From: so9010 Date: Mon, 7 Oct 2024 22:39:27 +0100 Subject: [PATCH 05/15] fix --- psst-gui/src/webapi/client.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index de5697c3..0283a787 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -25,9 +25,8 @@ use crate::{ data::{ self, Album, AlbumType, Artist, ArtistAlbums, ArtistLink, AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, - Artist-view, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, - UserProfile, ArtistInfo, Image, ArtistStats, RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, TrackLines, - UserProfile,main + RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, + UserProfile, ArtistInfo, Image, ArtistStats, TrackLines, }, error::Error, }; From 66d15c902fb9037499ff337145589e2d3f294ac7 Mon Sep 17 00:00:00 2001 From: Jackson Goode Date: Mon, 7 Oct 2024 18:05:40 -0700 Subject: [PATCH 06/15] Clean up view --- psst-gui/src/ui/artist.rs | 141 +++++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 46 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 22e2c026..4bdb318e 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -1,25 +1,31 @@ use druid::{ - im::Vector, kurbo::Circle, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt + im::Vector, + kurbo::Circle, + widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, + Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, + WidgetExt, }; use crate::{ cmd, data::{ - AppState, Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistStats, ArtistTracks, Cached, Ctx, Nav, WithCtx + AppState, Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistTracks, Cached, + Ctx, Nav, WithCtx, }, webapi::WebApi, widget::{Async, MyWidgetExt, RemoteImage}, }; -use super::{album, playable, theme, track, utils::{self}}; +use super::{ + album, playable, theme, track, + utils::{self}, +}; pub const LOAD_DETAIL: Selector = Selector::new("app.artist.load-detail"); pub fn detail_widget() -> impl Widget { Flex::column() - .with_child(Flex::row() - .with_child(async_artist_info()) - ) + .with_child(Flex::row().with_child(async_artist_info())) .with_child(async_top_tracks_widget()) .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0))) .with_child(async_related_widget().padding((theme::grid(1.0), 0.0))) @@ -166,37 +172,55 @@ pub fn cover_widget(size: f64) -> impl Widget { fn artist_info_widget() -> impl Widget> { let size = theme::grid(10.0); - Flex::row() + + let artist_image = RemoteImage::new( + utils::placeholder_widget(), + move |artist: &ArtistInfo, _| Some(artist.main_image.clone()), + ) + .fix_size(size, size) + .clip(Size::new(size, size).to_rounded_rect(4.0)) + .lens(Ctx::data()); + + let biography = Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) - .with_spacer(theme::grid(1.0)) - .with_child(RemoteImage::new(utils::placeholder_widget(), move |artist: &ArtistInfo, _| { - Some(artist.main_image.clone()) - }) - .fix_size(size, size) - .clip(Size::new(size, size).to_rounded_rect(4.0)) - .lens(Ctx::data()) - ) - .with_child(Label::raw() - .with_line_break_mode(LineBreaking::WordWrap) - .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::followers)))) - .with_child(Label::raw() - .with_line_break_mode(LineBreaking::WordWrap) - .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::monthly_listeners)))) - .with_child(Label::raw() - .with_line_break_mode(LineBreaking::WordWrap) - .lens(Ctx::data().then(ArtistInfo::stats.then(ArtistStats::world_rank)))) + .with_child(header_widget("Biography")) .with_child( - Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header_widget("Biography")) - .with_child( - Label::raw() - .with_line_break_mode(LineBreaking::WordWrap) - .lens(Ctx::data().then(ArtistInfo::bio)) - ) + Label::raw() + .with_line_break_mode(LineBreaking::WordWrap) + .with_text_size(theme::TEXT_SIZE_SMALL) + .lens(Ctx::data().then(ArtistInfo::bio)) + .expand_width(), ) + .fix_width(theme::grid(40.0)); + + let artist_stats = Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(header_widget("Artist Stats")) + .with_child(stat_row("Followers:", |info: &ArtistInfo| { + format!("{} followers", info.stats.followers) + })) + .with_child(stat_row("Monthly Listeners:", |info: &ArtistInfo| { + format!("{} monthly listeners", info.stats.monthly_listeners) + })) + .with_child(stat_row("Ranking:", |info: &ArtistInfo| { + format!("#{} in the world", info.stats.world_rank) + })) + .fix_width(theme::grid(20.0)); + + Flex::row() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(artist_image) + .with_spacer(theme::grid(1.0)) + .with_flex_child(biography, 1.0) + .with_spacer(theme::grid(1.0)) + .with_child(artist_stats) + .expand_width() + .padding(theme::grid(1.5)) + .background(theme::BACKGROUND_DARK) + .rounded(theme::BUTTON_BORDER_RADIUS) .context_menu(|artist| artist_info_menu(&artist.data)) } + fn top_tracks_widget() -> impl Widget> { playable::list_widget(playable::Display { track: track::Display { @@ -245,23 +269,31 @@ fn header_widget(text: impl Into>) -> impl Widget { fn artist_info_menu(artist: &ArtistInfo) -> Menu { let mut menu = Menu::empty(); - for artist_links in &artist.artist_links { - let more_than_one_artist = artist.artist_links.len() > 1; - let title = if more_than_one_artist { - LocalizedString::new("menu-item-go-to-social") - .with_placeholder(format!("Go to their“{:?}”", artist_links + for artist_link in &artist.artist_links { + let platform = if artist_link.contains("wikipedia.org") { + "Wikipedia" + } else { + artist_link .strip_prefix("https://") - .unwrap_or(artist_links) - .split(".com") + .unwrap_or(artist_link) + .split('.') .next() - .unwrap_or("No socials"))) - } else { - LocalizedString::new("").with_placeholder("") + .unwrap_or("Unknown") }; - menu = menu.entry( - MenuItem::new(title) - .command(cmd::GO_TO_URL.with(artist_links.to_owned())) - ); + + let title = LocalizedString::new("menu-item-go-to-social").with_placeholder(format!( + "Go to their {}", + platform + .chars() + .next() + .unwrap() + .to_uppercase() + .collect::() + + &platform[1..] + )); + + menu = + menu.entry(MenuItem::new(title).command(cmd::GO_TO_URL.with(artist_link.to_owned()))); } menu @@ -279,3 +311,20 @@ fn artist_menu(artist: &ArtistLink) -> Menu { menu } + +fn stat_row( + label: &'static str, + value_func: impl Fn(&ArtistInfo) -> String + 'static, +) -> impl Widget> { + Flex::row() + .with_child( + Label::new(label) + .with_text_size(theme::TEXT_SIZE_SMALL) + .with_text_color(theme::PLACEHOLDER_COLOR), + ) + .with_spacer(theme::grid(0.5)) + .with_child( + Label::new(move |ctx: &WithCtx, _env: &_| value_func(&ctx.data)) + .with_text_size(theme::TEXT_SIZE_SMALL), + ) +} From 5c4cdfa8118dbd05d57a38c745fe445602407cae Mon Sep 17 00:00:00 2001 From: so9010 Date: Thu, 10 Oct 2024 11:02:47 +0100 Subject: [PATCH 07/15] Add wrapping for bio and move stats --- psst-gui/src/ui/artist.rs | 50 ++++++++++++++++------------------- psst-gui/src/webapi/client.rs | 7 +---- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 4bdb318e..2ce40e5d 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -1,7 +1,7 @@ use druid::{ im::Vector, kurbo::Circle, - widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, + widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List, Scroll}, Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt, }; @@ -25,10 +25,10 @@ pub const LOAD_DETAIL: Selector = Selector::new("app.artist.load-det pub fn detail_widget() -> impl Widget { Flex::column() - .with_child(Flex::row().with_child(async_artist_info())) + .with_child(async_artist_info().expand_width().padding((theme::grid(1.0), 0.0))) .with_child(async_top_tracks_widget()) - .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0))) - .with_child(async_related_widget().padding((theme::grid(1.0), 0.0))) + .with_child(async_albums_widget().expand_width().padding((theme::grid(1.0), 0.0))) + .with_child(async_related_widget().expand_width().padding((theme::grid(1.0), 0.0))) } fn async_top_tracks_widget() -> impl Widget { @@ -171,7 +171,7 @@ pub fn cover_widget(size: f64) -> impl Widget { } fn artist_info_widget() -> impl Widget> { - let size = theme::grid(10.0); + let size = theme::grid(13.0); let artist_image = RemoteImage::new( utils::placeholder_widget(), @@ -182,20 +182,18 @@ fn artist_info_widget() -> impl Widget> { .lens(Ctx::data()); let biography = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header_widget("Biography")) - .with_child( - Label::raw() - .with_line_break_mode(LineBreaking::WordWrap) - .with_text_size(theme::TEXT_SIZE_SMALL) - .lens(Ctx::data().then(ArtistInfo::bio)) - .expand_width(), - ) - .fix_width(theme::grid(40.0)); + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(header_widget("Biography")) + .with_child(Scroll::new( + Label::new(|data: &ArtistInfo, _env: &_| data.bio.clone()) // Use a closure to fetch the biography + .with_line_break_mode(LineBreaking::WordWrap) + .with_text_size(theme::TEXT_SIZE_SMALL) + .lens(Ctx::data()),) + .vertical() + .fix_height(size-theme::grid(1.5)) + ); - let artist_stats = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header_widget("Artist Stats")) + let artist_stats = Flex::row() .with_child(stat_row("Followers:", |info: &ArtistInfo| { format!("{} followers", info.stats.followers) })) @@ -205,19 +203,17 @@ fn artist_info_widget() -> impl Widget> { .with_child(stat_row("Ranking:", |info: &ArtistInfo| { format!("#{} in the world", info.stats.world_rank) })) - .fix_width(theme::grid(20.0)); + .align_left(); Flex::row() - .cross_axis_alignment(CrossAxisAlignment::Start) .with_child(artist_image) .with_spacer(theme::grid(1.0)) - .with_flex_child(biography, 1.0) - .with_spacer(theme::grid(1.0)) - .with_child(artist_stats) - .expand_width() - .padding(theme::grid(1.5)) - .background(theme::BACKGROUND_DARK) - .rounded(theme::BUTTON_BORDER_RADIUS) + .with_flex_child(Flex::column() + .with_child(biography) + .with_spacer(theme::grid(1.0)) + .with_child(artist_stats), + 1.0 + ) .context_menu(|artist| artist_info_menu(&artist.data)) } diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index 0283a787..d65b3466 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -855,12 +855,7 @@ impl WebApi { .artist_union.profile.biography.text, ) .unwrap_or_default(); - // This is roughly 3 lines of description, truncated if too long - if desc.chars().count() > 255 { - desc.chars().take(254).collect::() + "..." - } else { - desc - } + desc .into() }, From bd5e4651d5e28313842e1cfddced844d9f6e9b45 Mon Sep 17 00:00:00 2001 From: so9010 Date: Thu, 10 Oct 2024 11:07:34 +0100 Subject: [PATCH 08/15] Add case for when word ranking isnt recorded --- psst-gui/src/ui/artist.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 2ce40e5d..3ab436bf 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -201,7 +201,11 @@ fn artist_info_widget() -> impl Widget> { format!("{} monthly listeners", info.stats.monthly_listeners) })) .with_child(stat_row("Ranking:", |info: &ArtistInfo| { - format!("#{} in the world", info.stats.world_rank) + if !info.stats.world_rank.starts_with("0") { + format!("#{} in the world", info.stats.world_rank) + } else { + "N/A".to_string() + } })) .align_left(); From 50347de5e3fcbb31f3d6e7c67e388927bab910a5 Mon Sep 17 00:00:00 2001 From: so9010 Date: Thu, 10 Oct 2024 11:13:52 +0100 Subject: [PATCH 09/15] Lint! --- psst-gui/src/ui/artist.rs | 10 +++++----- psst-gui/src/webapi/client.rs | 8 +++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 3ab436bf..39e7a868 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -25,10 +25,10 @@ pub const LOAD_DETAIL: Selector = Selector::new("app.artist.load-det pub fn detail_widget() -> impl Widget { Flex::column() - .with_child(async_artist_info().expand_width().padding((theme::grid(1.0), 0.0))) + .with_child(async_artist_info().padding((theme::grid(1.0), 0.0))) .with_child(async_top_tracks_widget()) - .with_child(async_albums_widget().expand_width().padding((theme::grid(1.0), 0.0))) - .with_child(async_related_widget().expand_width().padding((theme::grid(1.0), 0.0))) + .with_child(async_albums_widget().padding((theme::grid(1.0), 0.0))) + .with_child(async_related_widget().padding((theme::grid(1.0), 0.0))) } fn async_top_tracks_widget() -> impl Widget { @@ -78,9 +78,9 @@ fn async_albums_widget() -> impl Widget { fn async_artist_info() -> impl Widget { Async::new( - || utils::spinner_widget(), + utils::spinner_widget, artist_info_widget, - || utils::error_widget(), + utils::error_widget, ) .lens( Ctx::make( diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index d65b3466..a59be431 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -848,18 +848,16 @@ impl WebApi { world_rank: result.data.data.artist_union.stats.world_rank.to_string() }, bio: { - let desc = sanitize_str( + sanitize_str( &DEFAULT, &result.data .data .artist_union.profile.biography.text, ) - .unwrap_or_default(); - desc - .into() + .unwrap_or_default() }, - artist_links: hrefs.into() + artist_links: hrefs }) } } From 44ee1896ab59d07fb8f596b3a8db654a37925cbf Mon Sep 17 00:00:00 2001 From: so9010 Date: Thu, 10 Oct 2024 20:20:26 +0100 Subject: [PATCH 10/15] De-clutter and make text bigger --- psst-gui/src/ui/artist.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 39e7a868..28da4a57 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -183,11 +183,10 @@ fn artist_info_widget() -> impl Widget> { let biography = Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(header_widget("Biography")) .with_child(Scroll::new( Label::new(|data: &ArtistInfo, _env: &_| data.bio.clone()) // Use a closure to fetch the biography .with_line_break_mode(LineBreaking::WordWrap) - .with_text_size(theme::TEXT_SIZE_SMALL) + .with_text_size(theme::TEXT_SIZE_NORMAL) .lens(Ctx::data()),) .vertical() .fix_height(size-theme::grid(1.5)) From f37e70dcbdce189bbfe1be846d99083a2d46fb24 Mon Sep 17 00:00:00 2001 From: Jackson Goode Date: Tue, 15 Oct 2024 00:34:56 -0700 Subject: [PATCH 11/15] Break point change --- psst-gui/src/ui/artist.rs | 121 ++++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 19 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 28da4a57..94155625 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -2,8 +2,9 @@ use druid::{ im::Vector, kurbo::Circle, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List, Scroll}, - Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, - WidgetExt, + BoxConstraints, Data, Env, Event, EventCtx, Insets, LayoutCtx, LensExt, LifeCycle, + LifeCycleCtx, LocalizedString, Menu, MenuItem, PaintCtx, Point, Selector, Size, UnitPoint, + UpdateCtx, Widget, WidgetExt, WidgetPod, }; use crate::{ @@ -171,7 +172,7 @@ pub fn cover_widget(size: f64) -> impl Widget { } fn artist_info_widget() -> impl Widget> { - let size = theme::grid(13.0); + let size = theme::grid(15.0); let artist_image = RemoteImage::new( utils::placeholder_widget(), @@ -182,17 +183,19 @@ fn artist_info_widget() -> impl Widget> { .lens(Ctx::data()); let biography = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(Scroll::new( - Label::new(|data: &ArtistInfo, _env: &_| data.bio.clone()) // Use a closure to fetch the biography - .with_line_break_mode(LineBreaking::WordWrap) - .with_text_size(theme::TEXT_SIZE_NORMAL) - .lens(Ctx::data()),) + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Scroll::new( + Label::new(|data: &ArtistInfo, _env: &_| data.bio.clone()) + .with_line_break_mode(LineBreaking::WordWrap) + .with_text_size(theme::TEXT_SIZE_NORMAL) + .lens(Ctx::data()), + ) .vertical() - .fix_height(size-theme::grid(1.5)) - ); + .fix_height(size - theme::grid(1.5)), + ); - let artist_stats = Flex::row() + let artist_stats = Flex::column() .with_child(stat_row("Followers:", |info: &ArtistInfo| { format!("{} followers", info.stats.followers) })) @@ -205,19 +208,98 @@ fn artist_info_widget() -> impl Widget> { } else { "N/A".to_string() } - })) - .align_left(); + })); Flex::row() .with_child(artist_image) .with_spacer(theme::grid(1.0)) - .with_flex_child(Flex::column() - .with_child(biography) - .with_spacer(theme::grid(1.0)) - .with_child(artist_stats), - 1.0 + .with_flex_child( + Flex::row().with_flex_child(ArtistInfoLayout::new(biography, artist_stats), 1.0), + 1.0, ) .context_menu(|artist| artist_info_menu(&artist.data)) + .padding((0.0, theme::grid(1.0))) // Keep overall vertical padding +} + +struct ArtistInfoLayout { + biography: WidgetPod, + stats: WidgetPod, +} + +impl ArtistInfoLayout +where + T: Data, + B: Widget, + S: Widget, +{ + fn new(biography: B, stats: S) -> Self { + Self { + biography: WidgetPod::new(biography), + stats: WidgetPod::new(stats), + } + } +} + +impl, S: Widget> Widget for ArtistInfoLayout { + fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { + self.biography.event(ctx, event, data, env); + self.stats.event(ctx, event, data, env); + } + + fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { + self.biography.lifecycle(ctx, event, data, env); + self.stats.lifecycle(ctx, event, data, env); + } + + fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { + self.biography.update(ctx, data, env); + self.stats.update(ctx, data, env); + } + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { + let max = bc.max(); + let wide_layout = max.width > theme::grid(60.0); + let padding = theme::grid(1.0); // Padding between bio and stats + + if wide_layout { + let biography_width = max.width * 0.75 - padding / 2.0; + let stats_width = max.width * 0.25 - padding / 2.0; + + let biography_bc = + BoxConstraints::new(Size::ZERO, Size::new(biography_width, max.height)); + let stats_bc = BoxConstraints::new(Size::ZERO, Size::new(stats_width, max.height)); + + let biography_size = self.biography.layout(ctx, &biography_bc, data, env); + let stats_size = self.stats.layout(ctx, &stats_bc, data, env); + + self.biography.set_origin(ctx, Point::ORIGIN); + self.stats + .set_origin(ctx, Point::new(biography_width + padding, 0.0)); + + Size::new(max.width, biography_size.height.max(stats_size.height)) + } else { + let biography_size = self.biography.layout(ctx, bc, data, env); + let stats_bc = BoxConstraints::new( + Size::ZERO, + Size::new(max.width, max.height - biography_size.height - padding), + ); + let stats_size = self.stats.layout(ctx, &stats_bc, data, env); + + self.biography.set_origin(ctx, Point::ORIGIN); + self.stats + .set_origin(ctx, Point::new(0.0, biography_size.height + padding)); + + Size::new( + max.width, + biography_size.height + padding + stats_size.height, + ) + } + } + + fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { + self.biography.paint(ctx, data, env); + self.stats.paint(ctx, data, env); + } } fn top_tracks_widget() -> impl Widget> { @@ -326,4 +408,5 @@ fn stat_row( Label::new(move |ctx: &WithCtx, _env: &_| value_func(&ctx.data)) .with_text_size(theme::TEXT_SIZE_SMALL), ) + .align_left() } From df9ae6a1f62a9a0369968f78d2d57fa720ffc8c1 Mon Sep 17 00:00:00 2001 From: so9010 Date: Wed, 16 Oct 2024 15:35:47 +0100 Subject: [PATCH 12/15] Change at the same point as player bar --- psst-gui/src/ui/artist.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 94155625..fa83306d 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -14,7 +14,7 @@ use crate::{ Ctx, Nav, WithCtx, }, webapi::WebApi, - widget::{Async, MyWidgetExt, RemoteImage}, + widget::{Async, Empty, MyWidgetExt, RemoteImage}, }; use super::{ @@ -81,7 +81,7 @@ fn async_artist_info() -> impl Widget { Async::new( utils::spinner_widget, artist_info_widget, - utils::error_widget, + || Empty, ) .lens( Ctx::make( @@ -258,7 +258,7 @@ impl, S: Widget> Widget for ArtistInfoLayout Size { let max = bc.max(); - let wide_layout = max.width > theme::grid(60.0); + let wide_layout = max.width > theme::grid(60.0) + theme::GRID * 2.0; let padding = theme::grid(1.0); // Padding between bio and stats if wide_layout { From 9e5634ddf96f9d635f52e6f40e10d929d4e07e65 Mon Sep 17 00:00:00 2001 From: so9010 Date: Wed, 16 Oct 2024 17:50:04 +0100 Subject: [PATCH 13/15] Match --- psst-gui/src/ui/artist.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index fa83306d..09db024e 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -258,7 +258,8 @@ impl, S: Widget> Widget for ArtistInfoLayout Size { let max = bc.max(); - let wide_layout = max.width > theme::grid(60.0) + theme::GRID * 2.0; + // The smaller the nuber the futhre in you have to go + let wide_layout = max.width > theme::grid(60.0) + theme::GRID * 3.45; let padding = theme::grid(1.0); // Padding between bio and stats if wide_layout { From 51fb962a6f66f88310f5247cc1567637a5c3df37 Mon Sep 17 00:00:00 2001 From: Jackson Goode Date: Wed, 16 Oct 2024 13:48:31 -0700 Subject: [PATCH 14/15] Give a bit more space, redundant monthly text and add vertical space to stats --- psst-gui/src/ui/artist.rs | 43 ++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 09db024e..45be0e10 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -78,24 +78,20 @@ fn async_albums_widget() -> impl Widget { } fn async_artist_info() -> impl Widget { - Async::new( - utils::spinner_widget, - artist_info_widget, - || Empty, - ) - .lens( - Ctx::make( - AppState::common_ctx, - AppState::artist_detail.then(ArtistDetail::artist_info), + Async::new(utils::spinner_widget, artist_info_widget, || Empty) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::artist_detail.then(ArtistDetail::artist_info), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_DETAIL, + |d| WebApi::global().get_artist_info(&d.id), + |_, data, d| data.artist_detail.artist_info.defer(d), + |_, data, r| data.artist_detail.artist_info.update(r), ) - .then(Ctx::in_promise()), - ) - .on_command_async( - LOAD_DETAIL, - |d| WebApi::global().get_artist_info(&d.id), - |_, data, d| data.artist_detail.artist_info.defer(d), - |_, data, r| data.artist_detail.artist_info.update(r), - ) } fn async_related_widget() -> impl Widget { @@ -172,7 +168,7 @@ pub fn cover_widget(size: f64) -> impl Widget { } fn artist_info_widget() -> impl Widget> { - let size = theme::grid(15.0); + let size = theme::grid(16.0); let artist_image = RemoteImage::new( utils::placeholder_widget(), @@ -199,9 +195,11 @@ fn artist_info_widget() -> impl Widget> { .with_child(stat_row("Followers:", |info: &ArtistInfo| { format!("{} followers", info.stats.followers) })) + .with_default_spacer() .with_child(stat_row("Monthly Listeners:", |info: &ArtistInfo| { - format!("{} monthly listeners", info.stats.monthly_listeners) + format!("{} listeners", info.stats.monthly_listeners) })) + .with_default_spacer() .with_child(stat_row("Ranking:", |info: &ArtistInfo| { if !info.stats.world_rank.starts_with("0") { format!("#{} in the world", info.stats.world_rank) @@ -258,13 +256,12 @@ impl, S: Widget> Widget for ArtistInfoLayout Size { let max = bc.max(); - // The smaller the nuber the futhre in you have to go let wide_layout = max.width > theme::grid(60.0) + theme::GRID * 3.45; - let padding = theme::grid(1.0); // Padding between bio and stats + let padding = theme::grid(1.0); if wide_layout { - let biography_width = max.width * 0.75 - padding / 2.0; - let stats_width = max.width * 0.25 - padding / 2.0; + let biography_width = max.width * 0.67 - padding / 2.0; + let stats_width = max.width * 0.33 - padding / 2.0; let biography_bc = BoxConstraints::new(Size::ZERO, Size::new(biography_width, max.height)); From 84420d4ed68478efecbfe020e3e61e141a9aa52e Mon Sep 17 00:00:00 2001 From: Jackson Goode Date: Wed, 16 Oct 2024 13:56:00 -0700 Subject: [PATCH 15/15] Replace & with &, lint --- psst-gui/src/webapi/client.rs | 65 +++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index a59be431..ae141038 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -1,5 +1,10 @@ use std::{ - fmt::Display, io::{self, Read}, path::PathBuf, sync::Arc, thread, time::Duration + fmt::Display, + io::{self, Read}, + path::PathBuf, + sync::Arc, + thread, + time::Duration, }; use druid::{ @@ -23,10 +28,10 @@ use psst_core::{ use crate::{ data::{ - self, Album, AlbumType, Artist, ArtistAlbums, ArtistLink, AudioAnalysis, Cached, Episode, - EpisodeId, EpisodeLink, MixedView, Nav, Page, Playlist, PublicUser, Range, Recommendations, - RecommendationsRequest, SearchResults, SearchTopic, Show, SpotifyUrl, Track, - UserProfile, ArtistInfo, Image, ArtistStats, TrackLines, + self, Album, AlbumType, Artist, ArtistAlbums, ArtistInfo, ArtistLink, ArtistStats, + AudioAnalysis, Cached, Episode, EpisodeId, EpisodeLink, Image, MixedView, Nav, Page, + Playlist, PublicUser, Range, Recommendations, RecommendationsRequest, SearchResults, + SearchTopic, Show, SpotifyUrl, Track, TrackLines, UserProfile, }, error::Error, }; @@ -820,7 +825,7 @@ impl WebApi { } }); let extensions_json = serde_json::to_string(&extensions); - + let variables = json!( { "uri": format!("spotify:artist:{}", id), "locale": "", @@ -828,36 +833,52 @@ impl WebApi { }); let variables_json = serde_json::to_string(&variables); - let request = self.get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? .query("operationName", "queryArtistOverview") .query("variables", &variables_json.unwrap().to_string()) .query("extensions", &extensions_json.unwrap().to_string()); let result: Cached = self.load_cached(request, "artist-info", id)?; - let hrefs: Vector = result.data.data.artist_union.profile.external_links.items - .into_iter() - .map(|link| link.url) - .collect(); + let hrefs: Vector = result + .data + .data + .artist_union + .profile + .external_links + .items + .into_iter() + .map(|link| link.url) + .collect(); Ok(ArtistInfo { - main_image: Arc::from(result.data.data.artist_union.visuals.avatar_image.sources[0].url.to_string()), - stats: ArtistStats{ + main_image: Arc::from( + result.data.data.artist_union.visuals.avatar_image.sources[0] + .url + .to_string(), + ), + stats: ArtistStats { followers: result.data.data.artist_union.stats.followers.to_string(), - monthly_listeners: result.data.data.artist_union.stats.monthly_listeners.to_string(), - world_rank: result.data.data.artist_union.stats.world_rank.to_string() + monthly_listeners: result + .data + .data + .artist_union + .stats + .monthly_listeners + .to_string(), + world_rank: result.data.data.artist_union.stats.world_rank.to_string(), }, bio: { - sanitize_str( + let sanitized_bio = sanitize_str( &DEFAULT, - &result.data - .data - .artist_union.profile.biography.text, + &result.data.data.artist_union.profile.biography.text, ) - .unwrap_or_default() + .unwrap_or_default(); + sanitized_bio.replace("&", "&") }, - - artist_links: hrefs + + artist_links: hrefs, }) } }