diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index ce6bd0cb..86dd3a90 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/data/artist.rs b/psst-gui/src/data/artist.rs index da98640d..2bef4ced 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_info: Promise, } #[derive(Clone, Data, Lens, Deserialize)] @@ -40,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 21fa81d0..16c3e7ea 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}, @@ -152,6 +152,7 @@ impl AppState { albums: Promise::Empty, top_tracks: Promise::Empty, related_artists: Promise::Empty, + artist_info: Promise::Empty, }, playlist_detail: PlaylistDetail { playlist: Promise::Empty, 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 7a92aae4..45be0e10 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -1,26 +1,32 @@ use druid::{ im::Vector, kurbo::Circle, - widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, - Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, UnitPoint, Widget, WidgetExt, + widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List, Scroll}, + BoxConstraints, Data, Env, Event, EventCtx, Insets, LayoutCtx, LensExt, LifeCycle, + LifeCycleCtx, LocalizedString, Menu, MenuItem, PaintCtx, Point, Selector, Size, UnitPoint, + UpdateCtx, Widget, WidgetExt, WidgetPod, }; use crate::{ cmd, data::{ - AppState, Artist, ArtistAlbums, ArtistDetail, ArtistLink, ArtistTracks, Cached, Ctx, Nav, - WithCtx, + AppState, Artist, ArtistAlbums, ArtistDetail, ArtistInfo, ArtistLink, ArtistTracks, Cached, + Ctx, Nav, WithCtx, }, webapi::WebApi, - widget::{Async, MyWidgetExt, RemoteImage}, + widget::{Async, Empty, MyWidgetExt, RemoteImage}, }; -use super::{album, playable, theme, track, utils}; +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_info().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))) @@ -71,6 +77,23 @@ 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), + ) + .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 { Async::new(utils::spinner_widget, related_widget, utils::error_widget) .lens(AppState::artist_detail.then(ArtistDetail::related_artists)) @@ -144,6 +167,139 @@ pub fn cover_widget(size: f64) -> impl Widget { .clip(Circle::new((radius, radius), radius)) } +fn artist_info_widget() -> impl Widget> { + let size = theme::grid(16.0); + + 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_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)), + ); + + let artist_stats = Flex::column() + .with_child(stat_row("Followers:", |info: &ArtistInfo| { + format!("{} followers", info.stats.followers) + })) + .with_default_spacer() + .with_child(stat_row("Monthly Listeners:", |info: &ArtistInfo| { + 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) + } else { + "N/A".to_string() + } + })); + + Flex::row() + .with_child(artist_image) + .with_spacer(theme::grid(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) + theme::GRID * 3.45; + let padding = theme::grid(1.0); + + if wide_layout { + 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)); + 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> { playable::list_widget(playable::Display { track: track::Display { @@ -189,6 +345,39 @@ 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_link in &artist.artist_links { + let platform = if artist_link.contains("wikipedia.org") { + "Wikipedia" + } else { + artist_link + .strip_prefix("https://") + .unwrap_or(artist_link) + .split('.') + .next() + .unwrap_or("Unknown") + }; + + 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 +} + fn artist_menu(artist: &ArtistLink) -> Menu { let mut menu = Menu::empty(); @@ -201,3 +390,21 @@ 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), + ) + .align_left() +} diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index 9e75e1d6..ae141038 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -28,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, TrackLines, - 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, TrackLines, UserProfile, }, error::Error, }; @@ -758,6 +758,129 @@ impl WebApi { let result: Cached = self.load_cached(request, "related-artists", id)?; Ok(result.map(|result| result.artists)) } + + pub fn get_artist_info(&self, id: &str) -> Result { + #[derive(Clone, Data, Deserialize)] + pub struct Welcome { + data: Data1, + } + + #[derive(Clone, Data, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Data1 { + artist_union: ArtistUnion, + } + + #[derive(Clone, Data, Deserialize)] + pub struct ArtistUnion { + profile: Profile, + stats: Stats, + visuals: Visuals, + } + + #[derive(Clone, Data, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Profile { + biography: Biography, + external_links: ExternalLinks, + } + + #[derive(Clone, Data, Deserialize)] + pub struct Biography { + text: String, + } + + #[derive(Clone, Data, Deserialize)] + pub struct ExternalLinks { + items: Vector, + } + + #[derive(Clone, Data, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Visuals { + avatar_image: AvatarImage, + } + #[derive(Clone, Data, Deserialize)] + pub struct AvatarImage { + sources: Vector, + } + #[derive(Clone, Data, Deserialize)] + pub struct ExternalLinksItem { + url: String, + } + + #[derive(Clone, Data, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Stats { + followers: i64, + monthly_listeners: i64, + world_rank: i64, + } + + 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: 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(); + + Ok(ArtistInfo { + 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(), + }, + bio: { + let sanitized_bio = sanitize_str( + &DEFAULT, + &result.data.data.artist_union.profile.biography.text, + ) + .unwrap_or_default(); + sanitized_bio.replace("&", "&") + }, + + artist_links: hrefs, + }) + } } /// Album endpoints.