Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Artist view update #537

Merged
merged 16 commits into from
Oct 16, 2024
1 change: 1 addition & 0 deletions psst-gui/src/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Selector::new("app.copy-to-clipboard");
pub const GO_TO_URL: Selector<String> = Selector::new("app.go-to-url");

// Find
pub const TOGGLE_FINDER: Selector = Selector::new("app.show-finder");
Expand Down
15 changes: 15 additions & 0 deletions psst-gui/src/data/artist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub struct ArtistDetail {
pub albums: Promise<ArtistAlbums, ArtistLink>,
pub top_tracks: Promise<ArtistTracks, ArtistLink>,
pub related_artists: Promise<Cached<Vector<Artist>>, ArtistLink>,
pub artist_info: Promise<ArtistInfo, ArtistLink>,
}

#[derive(Clone, Data, Lens, Deserialize)]
Expand Down Expand Up @@ -40,6 +41,20 @@ pub struct ArtistAlbums {
pub compilations: Vector<Arc<Album>>,
pub appears_on: Vector<Arc<Album>>,
}
#[derive(Clone, Data, Lens)]
pub struct ArtistInfo {
pub main_image: Arc<str>,
pub stats: ArtistStats,
pub bio: String,
pub artist_links: Vector<String>,
}

#[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 {
Expand Down
3 changes: 2 additions & 1 deletion psst-gui/src/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions psst-gui/src/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ impl AppDelegate<AppState> 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) {
Expand Down
219 changes: 213 additions & 6 deletions psst-gui/src/ui/artist.rs
Original file line number Diff line number Diff line change
@@ -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<ArtistLink> = Selector::new("app.artist.load-detail");

pub fn detail_widget() -> impl Widget<AppState> {
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)))
Expand Down Expand Up @@ -71,6 +77,23 @@ fn async_albums_widget() -> impl Widget<AppState> {
)
}

fn async_artist_info() -> impl Widget<AppState> {
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<AppState> {
Async::new(utils::spinner_widget, related_widget, utils::error_widget)
.lens(AppState::artist_detail.then(ArtistDetail::related_artists))
Expand Down Expand Up @@ -144,6 +167,139 @@ pub fn cover_widget(size: f64) -> impl Widget<Artist> {
.clip(Circle::new((radius, radius), radius))
}

fn artist_info_widget() -> impl Widget<WithCtx<ArtistInfo>> {
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<T, B, S> {
biography: WidgetPod<T, B>,
stats: WidgetPod<T, S>,
}

impl<T, B, S> ArtistInfoLayout<T, B, S>
where
T: Data,
B: Widget<T>,
S: Widget<T>,
{
fn new(biography: B, stats: S) -> Self {
Self {
biography: WidgetPod::new(biography),
stats: WidgetPod::new(stats),
}
}
}

impl<T: Data, B: Widget<T>, S: Widget<T>> Widget<T> for ArtistInfoLayout<T, B, S> {
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<WithCtx<ArtistTracks>> {
playable::list_widget(playable::Display {
track: track::Display {
Expand Down Expand Up @@ -189,6 +345,39 @@ fn header_widget<T: Data>(text: impl Into<LabelText<T>>) -> impl Widget<T> {
.padding(Insets::new(0.0, theme::grid(2.0), 0.0, theme::grid(1.0)))
}

fn artist_info_menu(artist: &ArtistInfo) -> Menu<AppState> {
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::<String>()
+ &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<AppState> {
let mut menu = Menu::empty();

Expand All @@ -201,3 +390,21 @@ fn artist_menu(artist: &ArtistLink) -> Menu<AppState> {

menu
}

fn stat_row(
label: &'static str,
value_func: impl Fn(&ArtistInfo) -> String + 'static,
) -> impl Widget<WithCtx<ArtistInfo>> {
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<ArtistInfo>, _env: &_| value_func(&ctx.data))
.with_text_size(theme::TEXT_SIZE_SMALL),
)
.align_left()
}
Loading