diff --git a/psst-gui/src/controller/nav.rs b/psst-gui/src/controller/nav.rs index ec46a7ba..da4e0e9c 100644 --- a/psst-gui/src/controller/nav.rs +++ b/psst-gui/src/controller/nav.rs @@ -1,10 +1,12 @@ use crate::{ cmd, data::{AppState, Nav, SpotifyUrl}, - ui::{album, artist, library, lyrics, playlist, recommend, search, show}, + ui::{album, artist, library, lyrics, playlist, public_user, recommend, search, show}, +}; +use druid::{ + widget::{prelude::*, Controller}, + Code, }; -use druid::widget::{prelude::*, Controller}; -use druid::Code; pub struct NavController; @@ -52,6 +54,13 @@ impl NavController { ); } } + Nav::PublicUserDetail(public_user) => { + if !data.public_user_detail.info.contains(public_user) { + ctx.submit_command( + public_user::LOAD_DETAIL.with((public_user.to_owned(), data.to_owned())), + ); + } + } Nav::ShowDetail(link) => { if !data.show_detail.show.contains(link) { ctx.submit_command(show::LOAD_DETAIL.with(link.to_owned())); @@ -136,7 +145,8 @@ where env: &Env, ) { if let LifeCycle::WidgetAdded = event { - // Loads the library's saved tracks without the user needing to click on the tab. + // Loads the library's saved tracks without the user needing to click on the + // tab. ctx.submit_command(cmd::NAVIGATE.with(Nav::SavedTracks)); // Load the last route, or the default. ctx.submit_command( diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index 64426862..e2efb1c9 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -8,6 +8,7 @@ mod nav; mod playback; mod playlist; mod promise; +pub mod public_user; mod recommend; mod search; mod show; @@ -61,7 +62,7 @@ pub use crate::data::{ user::{PublicUser, UserProfile}, utils::{Cached, Float64, Image, Page}, }; -use crate::ui::credits::TrackCredits; +use crate::{data::public_user::PublicUserDetail, ui::credits::TrackCredits}; pub const ALERT_DURATION: Duration = Duration::from_secs(5); @@ -79,6 +80,7 @@ pub struct AppState { pub album_detail: AlbumDetail, pub artist_detail: ArtistDetail, pub playlist_detail: PlaylistDetail, + pub public_user_detail: PublicUserDetail, pub show_detail: ShowDetail, pub library: Arc, pub common_ctx: Arc, @@ -170,6 +172,9 @@ impl AppState { finder: Finder::new(), lyrics: Promise::Empty, credits: None, + public_user_detail: PublicUserDetail { + info: Promise::Empty, + }, } } } diff --git a/psst-gui/src/data/nav.rs b/psst-gui/src/data/nav.rs index 2d7f0db5..b72f4fcc 100644 --- a/psst-gui/src/data/nav.rs +++ b/psst-gui/src/data/nav.rs @@ -4,8 +4,7 @@ use druid::Data; use serde::{Deserialize, Serialize}; use url::Url; -use crate::data::track::TrackId; -use crate::data::{AlbumLink, ArtistLink, PlaylistLink, ShowLink}; +use crate::data::{track::TrackId, AlbumLink, ArtistLink, PlaylistLink, PublicUser, ShowLink}; use super::RecommendationsRequest; @@ -16,6 +15,7 @@ pub enum Route { SavedTracks, SavedAlbums, Shows, + PublicUser, SearchResults, ArtistDetail, AlbumDetail, @@ -36,6 +36,7 @@ pub enum Nav { AlbumDetail(AlbumLink, Option), ArtistDetail(ArtistLink), PlaylistDetail(PlaylistLink), + PublicUserDetail(PublicUser), ShowDetail(ShowLink), Recommendations(Arc), } @@ -49,6 +50,7 @@ impl Nav { Nav::SavedAlbums => Route::SavedAlbums, Nav::Shows => Route::Shows, Nav::SearchResults(_) => Route::SearchResults, + Nav::PublicUserDetail(_) => Route::PublicUser, Nav::AlbumDetail(_, _) => Route::AlbumDetail, Nav::ArtistDetail(_) => Route::ArtistDetail, Nav::PlaylistDetail(_) => Route::PlaylistDetail, @@ -65,6 +67,7 @@ impl Nav { Nav::SavedAlbums => "Saved Albums".to_string(), Nav::Shows => "Podcasts".to_string(), Nav::SearchResults(query) => query.to_string(), + Nav::PublicUserDetail(usr) => usr.display_name.to_string(), Nav::AlbumDetail(link, _) => link.name.to_string(), Nav::ArtistDetail(link) => link.name.to_string(), Nav::PlaylistDetail(link) => link.name.to_string(), @@ -80,6 +83,7 @@ impl Nav { Nav::SavedTracks => "Saved Tracks".to_string(), Nav::SavedAlbums => "Saved Albums".to_string(), Nav::Shows => "Saved Shows".to_string(), + Nav::PublicUserDetail(usr) => format!("User \"{}\"", usr.display_name), Nav::SearchResults(query) => format!("Search \"{query}\""), Nav::AlbumDetail(link, _) => format!("Album \"{}\"", link.name), Nav::ArtistDetail(link) => format!("Artist \"{}\"", link.name), diff --git a/psst-gui/src/data/public_user.rs b/psst-gui/src/data/public_user.rs new file mode 100644 index 00000000..098a5d9b --- /dev/null +++ b/psst-gui/src/data/public_user.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use druid::{im::Vector, Data, Lens}; + +use crate::data::{Cached, MixedView, Promise, PublicUser}; + +#[derive(Clone, Data, Lens)] +pub struct PublicUserDetail { + pub info: Promise>, PublicUser>, +} + +#[derive(Clone, Data, Lens)] +pub struct PublicUserInformation { + pub uri: String, + pub name: String, + pub image_url: Option, + pub followers_count: i64, + pub following_count: i64, + pub is_following: Option, + pub is_current_user: Option, + pub recently_played_artists: MixedView, + pub public_playlists: MixedView, + pub allow_follows: bool, + pub followers: Vector, + pub following: Vector, +} diff --git a/psst-gui/src/data/user.rs b/psst-gui/src/data/user.rs index e9dfc34b..2e553f74 100644 --- a/psst-gui/src/data/user.rs +++ b/psst-gui/src/data/user.rs @@ -1,17 +1,44 @@ use std::sync::Arc; use druid::{Data, Lens}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; #[derive(Clone, Data, Lens, Deserialize)] pub struct UserProfile { + #[serde(alias = "name")] pub display_name: Arc, pub email: Arc, pub id: Arc, } -#[derive(Clone, Data, Lens, Deserialize, Debug)] +#[derive(Clone, Data, Lens, Serialize, Deserialize, Debug, Eq, PartialEq, Default)] pub struct PublicUser { - pub display_name: Arc, + #[serde(default)] pub id: Arc, + #[serde(default, alias = "name")] + pub display_name: Arc, + // Extended profile fields (optional, for detailed responses) + #[serde(default)] + pub uri: Option>, + #[serde(default)] + pub image_url: Option>, + #[serde(default)] + pub followers_count: Option, + #[serde(default)] + pub is_following: Option, +} + +impl PublicUser { + /// Get the user ID, extracting from URI if `id` is empty. + /// URI format is "spotify:user:abc123", extracts "abc123". + pub fn get_id(&self) -> Arc { + if self.id.is_empty() { + self.uri + .as_ref() + .and_then(|uri| uri.split(':').nth(2).map(Arc::from)) + .unwrap_or_default() + } else { + self.id.clone() + } + } } diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index 3d5e3065..a576a8e9 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -1,15 +1,13 @@ -use crate::data::config::SortCriteria; -use crate::data::Track; -use crate::error::Error; use crate::{ cmd, controller::{ AfterDelay, AlertCleanupController, NavController, SessionController, SortController, }, data::{ - config::SortOrder, Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route, - ALERT_DURATION, + config::{SortCriteria, SortOrder}, + Alert, AlertStyle, AppState, Config, Nav, Playable, Playback, Route, Track, ALERT_DURATION, }, + error::Error, webapi::WebApi, widget::{ icons, icons::SvgIcon, Border, Empty, MyWidgetExt, Overlay, RemoteImage, ThemeScope, @@ -17,19 +15,17 @@ use crate::{ }, }; use credits::TrackCredits; -use druid::widget::Controller; -use druid::KbKey; use druid::{ im::Vector, widget::{ - CrossAxisAlignment, Either, Flex, Label, LineBreaking, List, Scroll, Slider, Split, - ViewSwitcher, + Controller, CrossAxisAlignment, Either, Flex, Label, LineBreaking, List, Scroll, Slider, + Split, ViewSwitcher, }, - Color, Env, Insets, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, WindowDesc, + Color, Env, Insets, KbKey, Key, LensExt, Menu, MenuItem, Selector, Widget, WidgetExt, + WindowDesc, }; use druid_shell::Cursor; -use std::sync::Arc; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; pub mod album; pub mod artist; @@ -44,6 +40,7 @@ pub mod playable; pub mod playback; pub mod playlist; pub mod preferences; +pub mod public_user; pub mod recommend; pub mod search; pub mod show; @@ -109,7 +106,8 @@ pub fn account_setup_window() -> WindowDesc { pub fn artwork_window() -> WindowDesc { let win_size = (theme::grid(50.0), theme::grid(50.0)); - // On Windows, the window size includes the titlebar, so we need to account for it + // On Windows, the window size includes the titlebar, so we need to account for + // it let win_size = if cfg!(target_os = "windows") { const WINDOWS_TITLEBAR_OFFSET: f64 = 24.0; // Standard Windows titlebar height (win_size.0, win_size.1 + WINDOWS_TITLEBAR_OFFSET) @@ -358,6 +356,11 @@ fn route_widget() -> impl Widget { .vertical() .boxed() } + Route::PublicUser => { + Scroll::new(public_user::detail_widget().padding(theme::grid(1.0))) + .vertical() + .boxed() + } Route::Shows => Scroll::new(library::saved_shows_widget().padding(theme::grid(1.0))) .vertical() .boxed(), @@ -624,6 +627,7 @@ fn route_icon_widget() -> impl Widget