diff --git a/README.md b/README.md index cabd5f17..22118b27 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ cargo bundle --release - [x] Audio loudness normalization - [x] Genre playlists and "For You" content - [x] Dark theme +- [x] Credits support - [ ] Resilience to network errors (automatically retry timed-out requests) - [ ] Managing playlists - Follow/unfollow diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 86dd3a90..c0fb1700 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -1,7 +1,8 @@ -use std::time::Duration; - +use crate::data::Track; use druid::{Selector, WidgetId}; use psst_core::{item_id::ItemId, player::item::PlaybackItem}; +use std::sync::Arc; +use std::time::Duration; use crate::{ data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry}, @@ -66,3 +67,7 @@ pub const SORT_BY_DURATION: Selector = Selector::new("app.sort-by-duration"); // Sort direction control pub const TOGGLE_SORT_ORDER: Selector = Selector::new("app.toggle-sort-order"); + +// Track credits +pub const SHOW_CREDITS_WINDOW: Selector> = Selector::new("app.credits-show-window"); +pub const LOAD_TRACK_CREDITS: Selector> = Selector::new("app.credits-load"); diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 45da6743..18c7e707 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -61,6 +61,7 @@ pub use crate::data::{ user::{PublicUser, UserProfile}, utils::{Cached, Float64, Image, Page}, }; +use crate::ui::credits::TrackCredits; pub const ALERT_DURATION: Duration = Duration::from_secs(5); @@ -86,6 +87,7 @@ pub struct AppState { pub finder: Finder, pub added_queue: Vector, pub lyrics: Promise>, + pub credits: Option, } impl AppState { @@ -169,6 +171,7 @@ impl AppState { alerts: Vector::new(), finder: Finder::new(), lyrics: Promise::Empty, + credits: None, } } } diff --git a/psst-gui/src/delegate.rs b/psst-gui/src/delegate.rs index 743ecd23..89d2018c 100644 --- a/psst-gui/src/delegate.rs +++ b/psst-gui/src/delegate.rs @@ -1,11 +1,13 @@ use druid::{ - commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target, WindowId, + commands, AppDelegate, Application, Command, DelegateCtx, Env, Event, Handled, Target, + WindowDesc, WindowId, }; use threadpool::ThreadPool; use crate::ui::playlist::{ RENAME_PLAYLIST, RENAME_PLAYLIST_CONFIRM, UNFOLLOW_PLAYLIST, UNFOLLOW_PLAYLIST_CONFIRM, }; +use crate::ui::theme; use crate::{ cmd, data::{AppState, Config}, @@ -17,6 +19,7 @@ use crate::{ pub struct Delegate { main_window: Option, preferences_window: Option, + credits_window: Option, image_pool: ThreadPool, size_updated: bool, } @@ -28,6 +31,7 @@ impl Delegate { Self { main_window: None, preferences_window: None, + credits_window: None, image_pool: ThreadPool::with_name("image_loading".into(), MAX_IMAGE_THREADS), size_updated: false, } @@ -88,6 +92,7 @@ impl Delegate { ctx.submit_command(commands::CLOSE_ALL_WINDOWS); self.main_window = None; self.preferences_window = None; + self.credits_window = None; } fn close_preferences(&mut self, ctx: &mut DelegateCtx) { @@ -95,6 +100,31 @@ impl Delegate { ctx.submit_command(commands::CLOSE_WINDOW.to(id)); } } + + fn close_credits(&mut self, ctx: &mut DelegateCtx) { + if let Some(id) = self.credits_window.take() { + ctx.submit_command(commands::CLOSE_WINDOW.to(id)); + } + } + + fn show_credits(&mut self, ctx: &mut DelegateCtx) -> WindowId { + match self.credits_window { + Some(id) => { + ctx.submit_command(commands::SHOW_WINDOW.to(id)); + id + } + None => { + let window = WindowDesc::new(ui::credits::credits_widget()) + .title("Track Credits") + .window_size((theme::grid(50.0), theme::grid(55.0))) + .resizable(false); + let window_id = window.id; + self.credits_window = Some(window_id); + ctx.new_window(window); + window_id + } + } + } } impl AppDelegate for Delegate { @@ -106,7 +136,17 @@ impl AppDelegate for Delegate { data: &mut AppState, _env: &Env, ) -> Handled { - if cmd.is(cmd::SHOW_MAIN) { + if cmd.is(cmd::SHOW_CREDITS_WINDOW) { + let _window_id = self.show_credits(ctx); + if let Some(track) = cmd.get(cmd::SHOW_CREDITS_WINDOW) { + ctx.submit_command( + cmd::LOAD_TRACK_CREDITS + .with(track.clone()) + .to(Target::Global), + ); + } + Handled::Yes + } else if cmd.is(cmd::SHOW_MAIN) { self.show_main(&data.config, ctx); Handled::Yes } else if cmd.is(cmd::SHOW_ACCOUNT_SETUP) { @@ -124,6 +164,11 @@ impl AppDelegate for Delegate { self.close_preferences(ctx); return Handled::Yes; } + } else if let Some(window_id) = self.credits_window { + if target == Target::Window(window_id) { + self.close_credits(ctx); + return Handled::Yes; + } } Handled::No } else if let Some(text) = cmd.get(cmd::COPY) { @@ -159,6 +204,10 @@ impl AppDelegate for Delegate { _env: &Env, ctx: &mut DelegateCtx, ) { + if self.credits_window == Some(id) { + self.credits_window = None; + data.credits = None; + } if self.preferences_window == Some(id) { self.preferences_window.take(); data.preferences.reset(); diff --git a/psst-gui/src/ui/credits.rs b/psst-gui/src/ui/credits.rs new file mode 100644 index 00000000..28378d5b --- /dev/null +++ b/psst-gui/src/ui/credits.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; + +use crate::widget::Empty; +use crate::{ + cmd, + data::{AppState, ArtistLink, Nav}, + ui::theme, + ui::utils, +}; +use druid::{ + widget::{Controller, CrossAxisAlignment, Either, Flex, Label, List, Maybe, Painter, Scroll}, + Cursor, Data, Env, Event, EventCtx, Lens, RenderContext, Target, UpdateCtx, Widget, WidgetExt, +}; +use serde::Deserialize; + +#[derive(Debug, Clone, Data, Lens, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackCredits { + pub track_uri: String, + pub track_title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub role_credits: Arc>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub extended_credits: Arc>, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub source_names: Arc>, +} + +#[derive(Debug, Clone, Data, Lens, Deserialize)] +pub struct RoleCredit { + #[serde(rename = "roleTitle")] + pub role_title: String, + pub artists: Arc>, +} + +#[derive(Debug, Clone, Data, Lens, Deserialize)] +pub struct CreditArtist { + pub uri: Option, + pub name: String, + #[serde(rename = "imageUri")] + pub image_uri: Option, + #[serde(rename = "externalUrl")] + pub external_url: Option, + #[serde(rename = "creatorUri")] + pub creator_uri: Option, + #[serde(default)] + pub subroles: Arc>, + #[serde(default)] + pub weight: f64, +} + +pub fn credits_widget() -> impl Widget { + Scroll::new( + Maybe::new( + || { + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Label::new(|data: &TrackCredits, _: &_| data.track_title.clone()) + .with_font(theme::UI_FONT_MEDIUM) + .with_text_size(theme::TEXT_SIZE_LARGE) + .padding(theme::grid(2.0)) + .expand_width(), + ) + .with_child(Either::new( + |data: &TrackCredits, _| data.role_credits.is_empty(), + Empty, + List::new(role_credit_widget).lens(TrackCredits::role_credits), + )) + .with_child(Either::new( + |data: &TrackCredits, _| data.source_names.is_empty(), + Empty, + Label::new(|data: &TrackCredits, _: &_| { + format!("Source: {}", data.source_names.join(", ")) + }) + .with_text_size(theme::TEXT_SIZE_SMALL) + .with_text_color(theme::PLACEHOLDER_COLOR) + .padding(theme::grid(2.0)), + )) + .padding(theme::grid(2.0)) + }, + utils::spinner_widget, + ) + .lens(AppState::credits) + .controller(CreditsController), + ) + .vertical() + .expand() +} + +fn role_credit_widget() -> impl Widget { + Either::new( + |role: &RoleCredit, _| role.artists.is_empty(), + Empty, + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Label::new(|role: &RoleCredit, _: &_| capitalize_first(&role.role_title)) + .with_text_size(theme::TEXT_SIZE_NORMAL) + .padding((theme::grid(2.0), theme::grid(1.0))), + ) + .with_child( + List::new(credit_artist_widget) + .lens(RoleCredit::artists) + .padding((theme::grid(2.0), 0.0, theme::grid(2.0), 0.0)), + ), + ) +} + +fn credit_artist_widget() -> impl Widget { + let painter = Painter::new(|ctx, data: &CreditArtist, env| { + let bounds = ctx.size().to_rect(); + + if ctx.is_hot() && data.uri.is_some() { + ctx.fill(bounds, &env.get(theme::LINK_HOT_COLOR)); + } else if data.uri.is_some() { + ctx.fill(bounds, &env.get(theme::LINK_COLD_COLOR)); + } + + if ctx.is_active() && data.uri.is_some() { + ctx.fill(bounds, &env.get(theme::LINK_ACTIVE_COLOR)); + } + }); + + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child( + Label::new(|artist: &CreditArtist, _: &_| proper_case(&artist.name)) + .with_font(theme::UI_FONT_MEDIUM), + ) + .with_child( + Label::new(|artist: &CreditArtist, _: &_| { + capitalize_first(&artist.subroles.join(", ")) + }) + .with_text_size(theme::TEXT_SIZE_SMALL) + .with_text_color(theme::PLACEHOLDER_COLOR), + ) + .padding(theme::grid(1.0)) + .expand_width() + .background(painter) + .rounded(theme::BUTTON_BORDER_RADIUS) + .on_click(|ctx: &mut EventCtx, data: &mut CreditArtist, _: &Env| { + if let Some(uri) = &data.uri { + let artist_id = uri.split(':').last().unwrap_or("").to_string(); + let artist_link = ArtistLink { + id: artist_id.into(), + name: data.name.clone().into(), + }; + ctx.submit_command( + cmd::NAVIGATE + .with(Nav::ArtistDetail(artist_link)) + .to(Target::Global), + ); + } + }) + .disabled_if(|artist: &CreditArtist, _| artist.uri.is_none()) + .controller(CursorController), + ) + .padding((0.0, theme::grid(0.5))) +} + +struct CursorController; + +impl> Controller for CursorController { + fn event( + &mut self, + child: &mut W, + ctx: &mut EventCtx, + event: &Event, + data: &mut CreditArtist, + env: &Env, + ) { + if let Event::MouseMove(_) = event { + if data.uri.is_some() { + ctx.set_cursor(&Cursor::Pointer); + } else { + ctx.clear_cursor(); + } + } + child.event(ctx, event, data, env) + } +} + +fn proper_case(s: &str) -> String { + s.split_whitespace() + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(f) => { + f.to_uppercase().collect::() + chars.as_str().to_lowercase().as_str() + } + } + }) + .collect::>() + .join(" ") +} + +fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } +} + +/// Controller that handles updating the credits view when data changes +pub struct CreditsController; + +impl> Controller for CreditsController { + fn update( + &mut self, + child: &mut W, + ctx: &mut UpdateCtx, + old_data: &AppState, + data: &AppState, + env: &Env, + ) { + if !old_data.credits.same(&data.credits) { + ctx.request_layout(); + ctx.request_paint(); + } + child.update(ctx, old_data, data, env) + } +} diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index fcf9612d..49ca5259 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -1,13 +1,6 @@ -use std::time::Duration; - -use druid::{ - im::Vector, - widget::{CrossAxisAlignment, Either, Flex, Label, List, Scroll, Slider, Split, ViewSwitcher}, - Color, Env, Insets, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, WindowDesc, -}; -use druid_shell::Cursor; - use crate::data::config::SortCriteria; +use crate::data::Track; +use crate::error::Error; use crate::{ cmd, controller::{ @@ -17,13 +10,24 @@ use crate::{ config::SortOrder, Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route, ALERT_DURATION, }, + webapi::WebApi, widget::{ icons, icons::SvgIcon, Border, Empty, MyWidgetExt, Overlay, ThemeScope, ViewDispatcher, }, }; +use credits::TrackCredits; +use druid::{ + im::Vector, + widget::{CrossAxisAlignment, Either, Flex, Label, List, Scroll, Slider, Split, ViewSwitcher}, + Color, Env, Insets, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, WindowDesc, +}; +use druid_shell::Cursor; +use std::sync::Arc; +use std::time::Duration; pub mod album; pub mod artist; +pub mod credits; pub mod episode; pub mod find; pub mod home; @@ -166,6 +170,25 @@ fn root_widget() -> impl Widget { .controller(SessionController) .controller(NavController) .controller(SortController) + .on_command_async( + cmd::LOAD_TRACK_CREDITS, + |track: Arc| { + log::debug!("Fetching credits for track: {}", track.name); + WebApi::global().get_track_credits(&track.id.0.to_base62()) + }, + |_, data: &mut AppState, _| { + data.credits = None; + }, + |_ctx, data, (_track, result): (Arc, Result)| match result { + Ok(credits) => { + data.credits = Some(credits); + } + Err(err) => { + log::error!("Failed to fetch credits for {}: {:?}", _track.name, err); + data.error_alert(format!("Failed to fetch track credits: {}", err)); + } + }, + ) // .debug_invalidation() // .debug_widget_id() // .debug_paint_layout() diff --git a/psst-gui/src/ui/track.rs b/psst-gui/src/ui/track.rs index cd56ee7f..9a76617b 100644 --- a/psst-gui/src/ui/track.rs +++ b/psst-gui/src/ui/track.rs @@ -261,13 +261,20 @@ pub fn track_menu( menu = menu.entry( MenuItem::new( - LocalizedString::new("menu-item-copy-link").with_placeholder("Copy Link to Track"), + LocalizedString::new("menu-item-show-credits").with_placeholder("Show Track Credits"), ) - .command(cmd::COPY.with(track.url())), + .command(cmd::SHOW_CREDITS_WINDOW.with(track.clone())), ); menu = menu.separator(); + menu = menu.entry( + MenuItem::new( + LocalizedString::new("menu-item-copy-link").with_placeholder("Copy Link to Track"), + ) + .command(cmd::COPY.with(track.url())), + ); + if library.contains_track(track) { menu = menu.entry( MenuItem::new( diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index ae141038..a59cf631 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -12,6 +12,7 @@ use druid::{ image::{self, ImageFormat}, Data, ImageBuf, }; + use itertools::Itertools; use once_cell::sync::OnceCell; use parking_lot::Mutex; @@ -34,10 +35,10 @@ use crate::{ SearchTopic, Show, SpotifyUrl, Track, TrackLines, UserProfile, }, error::Error, + ui::credits::TrackCredits, }; use super::{cache::WebApiCache, local::LocalTrackManager}; - pub struct WebApi { session: SessionService, agent: Agent, @@ -626,6 +627,7 @@ impl WebApi { }) } } + static GLOBAL_WEBAPI: OnceCell> = OnceCell::new(); /// Global instance. @@ -946,6 +948,15 @@ impl WebApi { Ok(result) } + pub fn get_track_credits(&self, track_id: &str) -> Result { + let request = self.get( + format!("track-credits-view/v0/experimental/{}/credits", track_id), + Some("spclient.wg.spotify.com"), + )?; + let result: TrackCredits = self.load(request)?; + Ok(result) + } + pub fn get_lyrics(&self, track_id: String) -> Result, Error> { #[derive(Default, Debug, Clone, PartialEq, Deserialize, Data)] #[serde(rename_all = "camelCase")]