diff --git a/.gitignore b/.gitignore index 54354ea7..16d663e1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ cache .env *.iml rust-toolchain -*.ico +*.ico \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a5c3da16..6ef92630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1395,6 +1395,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -2080,6 +2090,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.77", +] + [[package]] name = "http" version = "0.2.12" @@ -2601,6 +2625,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mach" version = "0.3.2" @@ -2628,6 +2658,32 @@ dependencies = [ "libc", ] +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "matches" version = "0.1.10" @@ -3108,6 +3164,63 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "piet" version = "0.6.2" @@ -3298,6 +3411,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3445,6 +3564,7 @@ dependencies = [ "rand", "raw-window-handle", "regex", + "sanitize_html", "serde", "serde_json", "souvlaki", @@ -3863,6 +3983,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize_html" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4383365aa34a33238e8f6c7c1cd87be0ff89cebe6fce9476fc5f78fbf1c80bc0" +dependencies = [ + "html5ever", + "lazy_static", + "markup5ever_rcdom", + "regex", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4061,6 +4193,12 @@ dependencies = [ "quote", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "sized-chunks" version = "0.6.5" @@ -4150,6 +4288,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4321,6 +4485,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.63" @@ -4755,6 +4930,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_lit" version = "2.0.2" @@ -5355,6 +5536,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "zbus" version = "3.15.2" diff --git a/psst-gui/Cargo.toml b/psst-gui/Cargo.toml index ed1b752d..3a1f52d2 100644 --- a/psst-gui/Cargo.toml +++ b/psst-gui/Cargo.toml @@ -53,6 +53,7 @@ souvlaki = { version = "0.7.3", default-features = false, features = [ "use_zbus", ] } webbrowser = { version = "1.0.1" } +sanitize_html = "0.8.1" [target.'cfg(windows)'.build-dependencies] winres = { version = "0.1.12" } image = { version = "0.25.2" } @@ -68,4 +69,4 @@ category = "Music" short_description = "Fast Spotify client with native GUI" long_description = """ Small and efficient graphical music player for Spotify network. -""" \ No newline at end of file +""" diff --git a/psst-gui/src/data/album.rs b/psst-gui/src/data/album.rs index bdc81094..c04664a4 100644 --- a/psst-gui/src/data/album.rs +++ b/psst-gui/src/data/album.rs @@ -59,7 +59,7 @@ impl Album { self.release_date .as_ref() .map(|date| date.format(format).expect("invalid format")) - .unwrap_or_else(|| '-'.to_string()) + .unwrap_or_default() } pub fn image(&self, width: f64, height: f64) -> Option<&Image> { diff --git a/psst-gui/src/data/mod.rs b/psst-gui/src/data/mod.rs index a33d0f27..685b4e81 100644 --- a/psst-gui/src/data/mod.rs +++ b/psst-gui/src/data/mod.rs @@ -56,7 +56,7 @@ pub use crate::data::{ show::{Episode, EpisodeId, EpisodeLink, Show, ShowDetail, ShowEpisodes, ShowLink}, slider_scroll_scale::SliderScrollScale, track::{AudioAnalysis, Track, TrackId}, - user::UserProfile, + user::{PublicUser, UserProfile}, utils::{Cached, Float64, Image, Page}, }; @@ -79,7 +79,7 @@ pub struct AppState { pub show_detail: ShowDetail, pub library: Arc, pub common_ctx: Arc, - pub personalized: Personalized, + pub home_detail: HomeDetail, pub alerts: Vector, pub finder: Finder, pub added_queue: Vector, @@ -131,6 +131,18 @@ impl AppState { knobs: Default::default(), results: Promise::Empty, }, + home_detail: HomeDetail { + made_for_you: Promise::Empty, + user_top_mixes: Promise::Empty, + best_of_artists: Promise::Empty, + recommended_stations: Promise::Empty, + your_shows: Promise::Empty, + shows_that_you_might_like: Promise::Empty, + uniquely_yours: Promise::Empty, + jump_back_in: Promise::Empty, + user_top_tracks: Promise::Empty, + user_top_artists: Promise::Empty, + }, album_detail: AlbumDetail { album: Promise::Empty, }, @@ -150,9 +162,6 @@ impl AppState { }, library, common_ctx, - personalized: Personalized { - made_for_you: Promise::Empty, - }, alerts: Vector::new(), finder: Finder::new(), } @@ -512,8 +521,26 @@ impl CommonCtx { pub type WithCtx = Ctx, T>; #[derive(Clone, Data, Lens)] -pub struct Personalized { - pub made_for_you: Promise>, +pub struct HomeDetail { + pub made_for_you: Promise, + pub user_top_mixes: Promise, + pub best_of_artists: Promise, + pub recommended_stations: Promise, + pub uniquely_yours: Promise, + pub your_shows: Promise, + pub shows_that_you_might_like: Promise, + pub jump_back_in: Promise, + pub user_top_tracks: Promise>>, + pub user_top_artists: Promise>, +} + +#[derive(Clone, Data, Lens)] +pub struct MixedView { + pub title: Arc, + pub playlists: Vector, + pub artists: Vector, + pub albums: Vector>, + pub shows: Vector>, } static ALERT_ID: AtomicUsize = AtomicUsize::new(0); diff --git a/psst-gui/src/data/playback.rs b/psst-gui/src/data/playback.rs index 7a99de1f..e64ab586 100644 --- a/psst-gui/src/data/playback.rs +++ b/psst-gui/src/data/playback.rs @@ -107,6 +107,7 @@ impl NowPlaying { #[derive(Clone, Debug, Data)] pub enum PlaybackOrigin { + Home, Library, Album(AlbumLink), Artist(ArtistLink), @@ -119,6 +120,7 @@ pub enum PlaybackOrigin { impl PlaybackOrigin { pub fn to_nav(&self) -> Nav { match &self { + PlaybackOrigin::Home => Nav::Home, PlaybackOrigin::Library => Nav::SavedTracks, PlaybackOrigin::Album(link) => Nav::AlbumDetail(link.clone()), PlaybackOrigin::Artist(link) => Nav::ArtistDetail(link.clone()), @@ -133,6 +135,7 @@ impl PlaybackOrigin { impl fmt::Display for PlaybackOrigin { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { + PlaybackOrigin::Home => f.write_str("Home"), PlaybackOrigin::Library => f.write_str("Saved Tracks"), PlaybackOrigin::Album(link) => link.name.fmt(f), PlaybackOrigin::Artist(link) => link.name.fmt(f), diff --git a/psst-gui/src/ui/album.rs b/psst-gui/src/ui/album.rs index 515549f6..f53ad678 100644 --- a/psst-gui/src/ui/album.rs +++ b/psst-gui/src/ui/album.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use druid::{ widget::{CrossAxisAlignment, Flex, Label, LineBreaking, List, ViewSwitcher}, - LensExt, LocalizedString, Menu, MenuItem, Selector, Size, Widget, WidgetExt, + LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt, }; use crate::{ @@ -99,10 +99,15 @@ fn rounded_cover_widget(size: f64) -> impl Widget> { cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0)) } -pub fn album_widget() -> impl Widget>> { - let album_cover = rounded_cover_widget(theme::grid(6.0)); +pub fn album_widget(horizontal: bool) -> impl Widget>> { + let (album_cover_size, album_name_layout) = if horizontal { + (16.0, Flex::column()) + } else { + (6.0, Flex::row()) + }; + let album_cover = rounded_cover_widget(theme::grid(album_cover_size)); - let album_name = Flex::row() + let album_name = album_name_layout .with_child( Label::raw() .with_font(theme::UI_FONT_MEDIUM) @@ -121,7 +126,7 @@ pub fn album_widget() -> impl Widget>> { let album_artists = List::new(|| { Label::raw() .with_text_size(theme::TEXT_SIZE_SMALL) - .with_line_break_mode(LineBreaking::Clip) + .with_line_break_mode(LineBreaking::WordWrap) .lens(ArtistLink::name) }) .horizontal() @@ -129,25 +134,47 @@ pub fn album_widget() -> impl Widget>> { .lens(Album::artists.in_arc()); let album_date = Label::>::dynamic(|album, _| album.release_year()) + .with_line_break_mode(LineBreaking::WordWrap) .with_text_size(theme::TEXT_SIZE_SMALL) .with_text_color(theme::PLACEHOLDER_COLOR); - let album_info = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(album_name) - .with_spacer(1.0) - .with_child(album_artists) - .with_spacer(1.0) - .with_child(album_date); + let album_layout = if horizontal { + Flex::column() + .with_child(album_cover) + .with_default_spacer() + .with_child( + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(album_name) + .with_spacer(1.0) + .with_child(album_artists) + .with_spacer(1.0) + .with_child(album_date) + .align_horizontal(UnitPoint::CENTER) + .align_vertical(UnitPoint::TOP) + .fix_size(theme::grid(16.0), theme::grid(8.0)), + ) + .align_left() + } else { + Flex::row() + .with_child(album_cover) + .with_default_spacer() + .with_flex_child( + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(album_name) + .with_spacer(1.0) + .with_child(album_artists) + .with_spacer(1.0) + .with_child(album_date), + 1.0, + ) + .align_left() + }; - let album = Flex::row() - .with_child(album_cover) - .with_default_spacer() - .with_flex_child(album_info, 1.0) + album_layout .padding(theme::grid(1.0)) - .lens(Ctx::data()); - - album + .lens(Ctx::data()) .link() .rounded(theme::BUTTON_BORDER_RADIUS) .on_left_click(|ctx, _, album, _| { diff --git a/psst-gui/src/ui/artist.rs b/psst-gui/src/ui/artist.rs index 354bed4a..7a92aae4 100644 --- a/psst-gui/src/ui/artist.rs +++ b/psst-gui/src/ui/artist.rs @@ -2,7 +2,7 @@ use druid::{ im::Vector, kurbo::Circle, widget::{CrossAxisAlignment, Flex, Label, LabelText, LineBreaking, List}, - Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, Widget, WidgetExt, + Data, Insets, LensExt, LocalizedString, Menu, MenuItem, Selector, UnitPoint, Widget, WidgetExt, }; use crate::{ @@ -82,18 +82,41 @@ fn async_related_widget() -> impl Widget { ) } -pub fn artist_widget() -> impl Widget { - let artist_image = cover_widget(theme::grid(7.0)); - let artist_label = Label::raw() - .with_font(theme::UI_FONT_MEDIUM) - .lens(Artist::name); - let artist = Flex::row() - .with_child(artist_image) - .with_default_spacer() - .with_flex_child(artist_label, 1.0); +pub fn artist_widget(horizontal: bool) -> impl Widget { + let (mut artist, artist_image) = if horizontal { + (Flex::column(), cover_widget(theme::grid(16.0))) + } else { + (Flex::row(), cover_widget(theme::grid(6.0))) + }; + + artist = if horizontal { + artist + .with_child(artist_image) + .with_default_spacer() + .with_child( + Label::raw() + .with_font(theme::UI_FONT_MEDIUM) + .align_horizontal(UnitPoint::CENTER) + .align_vertical(UnitPoint::TOP) + .fix_size(theme::grid(16.0), theme::grid(8.0)) + .lens(Artist::name), + ) + } else { + artist + .with_child(artist_image) + .with_default_spacer() + .with_flex_child( + Label::raw() + .with_font(theme::UI_FONT_MEDIUM) + .lens(Artist::name), + 1.0, + ) + }; + artist - .padding(theme::grid(0.5)) + .padding(theme::grid(1.0)) .link() + .rounded(theme::BUTTON_BORDER_RADIUS) .on_left_click(|ctx, _, artist, _| { ctx.submit_command(cmd::NAVIGATE.with(Nav::ArtistDetail(artist.link()))); }) @@ -127,6 +150,7 @@ fn top_tracks_widget() -> impl Widget> { title: true, album: true, popularity: true, + cover: true, ..track::Display::empty() }, }) @@ -136,20 +160,24 @@ fn albums_widget() -> impl Widget> { Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) .with_child(header_widget("Albums")) - .with_child(List::new(album::album_widget).lens(Ctx::map(ArtistAlbums::albums))) + .with_child(List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::albums))) .with_child(header_widget("Singles")) - .with_child(List::new(album::album_widget).lens(Ctx::map(ArtistAlbums::singles))) + .with_child(List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::singles))) .with_child(header_widget("Compilations")) - .with_child(List::new(album::album_widget).lens(Ctx::map(ArtistAlbums::compilations))) + .with_child( + List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::compilations)), + ) .with_child(header_widget("Appears On")) - .with_child(List::new(album::album_widget).lens(Ctx::map(ArtistAlbums::appears_on))) + .with_child( + List::new(|| album::album_widget(false)).lens(Ctx::map(ArtistAlbums::appears_on)), + ) } fn related_widget() -> impl Widget>> { Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) .with_child(header_widget("Related Artists")) - .with_child(List::new(artist_widget)) + .with_child(List::new(|| artist_widget(false))) .lens(Cached::data) } diff --git a/psst-gui/src/ui/home.rs b/psst-gui/src/ui/home.rs index cfb498bb..91022290 100644 --- a/psst-gui/src/ui/home.rs +++ b/psst-gui/src/ui/home.rs @@ -1,12 +1,18 @@ +use std::sync::Arc; + +use druid::im::Vector; +use druid::widget::{Either, Flex, Label, Scroll}; use druid::{widget::List, LensExt, Selector, Widget, WidgetExt}; -use crate::data::Ctx; +use crate::data::{Artist, Ctx, HomeDetail, MixedView, Show, Track, WithCtx}; +use crate::widget::Empty; use crate::{ - data::{AppState, Personalized}, + data::AppState, webapi::WebApi, widget::{Async, MyWidgetExt}, }; +use super::{album, artist, playable, show, theme, track}; use super::{ playlist, utils::{error_widget, spinner_widget}, @@ -15,22 +21,297 @@ use super::{ pub const LOAD_MADE_FOR_YOU: Selector = Selector::new("app.home.load-made-for-your"); pub fn home_widget() -> impl Widget { + Flex::column() + .with_child(made_for_you()) + .with_child(jump_back_in()) + .with_child(user_top_mixes()) + .with_child(recommended_stations()) + .with_child(best_of_artists()) + .with_child(simple_title_label("Uniquely yours")) + .with_default_spacer() + .with_child(uniquely_yours()) + .with_child(your_shows()) + .with_child(shows_that_you_might_like()) + .with_child(simple_title_label("Your top artists")) + .with_default_spacer() + .with_child(user_top_artists_widget()) + .with_default_spacer() + .with_child(simple_title_label("Your top tracks")) + .with_default_spacer() + .with_child(user_top_tracks_widget()) +} + +fn simple_title_label(title: &str) -> impl Widget { + Label::new(title) + .with_text_size(theme::grid(2.5)) + .align_left() + .padding((theme::grid(1.5), 0.0)) +} + +pub fn made_for_you() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::made_for_you), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().get_made_for_you(), + |_, data, q| data.home_detail.made_for_you.defer(q), + |_, data, r| data.home_detail.made_for_you.update(r), + ) +} + +pub fn recommended_stations() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::recommended_stations), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().recommended_stations(), + |_, data, q| data.home_detail.recommended_stations.defer(q), + |_, data, r| data.home_detail.recommended_stations.update(r), + ) +} + +pub fn uniquely_yours() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::uniquely_yours), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().uniquely_yours(), + |_, data, q| data.home_detail.uniquely_yours.defer(q), + |_, data, r| data.home_detail.uniquely_yours.update(r), + ) +} + +pub fn user_top_mixes() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::user_top_mixes), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().get_top_mixes(), + |_, data, q| data.home_detail.user_top_mixes.defer(q), + |_, data, r| data.home_detail.user_top_mixes.update(r), + ) +} + +pub fn best_of_artists() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::best_of_artists), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().best_of_artists(), + |_, data, q| data.home_detail.best_of_artists.defer(q), + |_, data, r| data.home_detail.best_of_artists.update(r), + ) +} + +pub fn your_shows() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::your_shows), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().your_shows(), + |_, data, q| data.home_detail.your_shows.defer(q), + |_, data, r| data.home_detail.your_shows.update(r), + ) +} + +pub fn jump_back_in() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::jump_back_in), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().jump_back_in(), + |_, data, q| data.home_detail.jump_back_in.defer(q), + |_, data, r| data.home_detail.jump_back_in.update(r), + ) +} + +pub fn shows_that_you_might_like() -> impl Widget { + Async::new(spinner_widget, loaded_results_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::shows_that_you_might_like), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().shows_that_you_might_like(), + |_, data, q| data.home_detail.shows_that_you_might_like.defer(q), + |_, data, r| data.home_detail.shows_that_you_might_like.update(r), + ) +} + +fn loaded_results_widget() -> impl Widget> { + Either::new( + |results: &WithCtx, _| { + results.data.artists.is_empty() + && results.data.albums.is_empty() + && results.data.playlists.is_empty() + && results.data.shows.is_empty() + }, + Empty, + Flex::column().with_child(title_label()).with_child( + Scroll::new( + Flex::row() + .with_child(playlist_results_widget()) + .with_child(album_results_widget()) + .with_child(artist_results_widget()) + .with_child(show_results_widget()), + ) + .align_left(), + ), + ) +} + +fn title_label() -> impl Widget> { + Either::new( + |title_check: &Arc, _| title_check.is_empty(), + Empty, + Flex::column() + .with_default_spacer() + .with_child( + Label::raw() + .with_text_size(theme::grid(2.5)) + .align_left() + .padding((theme::grid(1.5), theme::grid(0.5))), + ) + .with_default_spacer() + .align_left(), + ) + .lens(Ctx::data().then(MixedView::title)) +} + +fn artist_results_widget() -> impl Widget> { + Either::new( + |artists: &Vector, _| artists.is_empty(), + Empty, + Scroll::new(List::new(|| artist::artist_widget(true)).horizontal()) + .horizontal() + .align_left(), + ) + .lens(Ctx::data().then(MixedView::artists)) +} + +fn album_results_widget() -> impl Widget> { + Either::new( + |playlists: &WithCtx, _| playlists.data.albums.is_empty(), + Empty, + Flex::column().with_child( + Scroll::new(List::new(|| album::album_widget(true)).horizontal()) + .horizontal() + .align_left() + .lens(Ctx::map(MixedView::albums)), + ), + ) +} + +fn playlist_results_widget() -> impl Widget> { + Either::new( + |playlists: &WithCtx, _| playlists.data.playlists.is_empty(), + Empty, + Flex::column().with_child( + Scroll::new(List::new(|| playlist::playlist_widget(true)).horizontal()) + .horizontal() + .align_left() + .lens(Ctx::map(MixedView::playlists)), + ), + ) +} + +fn show_results_widget() -> impl Widget> { + Either::new( + |shows: &WithCtx>>, _| shows.data.is_empty(), + Empty, + Flex::column().with_child( + Scroll::new(List::new(|| show::show_widget(true)).horizontal()).align_left(), + ), + ) + .lens(Ctx::map(MixedView::shows)) +} + +fn user_top_artists_widget() -> impl Widget { Async::new( spinner_widget, - || List::new(playlist::playlist_widget), + || Scroll::new(List::new(|| artist::artist_widget(true)).horizontal()).horizontal(), error_widget, ) - .lens( - Ctx::make( - AppState::common_ctx, - AppState::personalized.then(Personalized::made_for_you), - ) - .then(Ctx::in_promise()), - ) + .lens(AppState::home_detail.then(HomeDetail::user_top_artists)) .on_command_async( LOAD_MADE_FOR_YOU, - |_| WebApi::global().get_made_for_you(), - |_, data, d| data.personalized.made_for_you.defer(d), - |_, data, r| data.personalized.made_for_you.update(r), + |_| WebApi::global().get_user_top_artist(), + |_, data, d| data.home_detail.user_top_artists.defer(d), + |_, data, r| data.home_detail.user_top_artists.update(r), ) } + +fn top_tracks_widget() -> impl Widget>>> { + playable::list_widget(playable::Display { + track: track::Display { + title: true, + album: true, + popularity: true, + cover: true, + ..track::Display::empty() + }, + }) +} + +fn user_top_tracks_widget() -> impl Widget { + Async::new(spinner_widget, top_tracks_widget, error_widget) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::home_detail.then(HomeDetail::user_top_tracks), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_MADE_FOR_YOU, + |_| WebApi::global().get_user_top_tracks(), + |_, data, d| data.home_detail.user_top_tracks.defer(d), + |_, data, r| data.home_detail.user_top_tracks.update(r), + ) +} diff --git a/psst-gui/src/ui/library.rs b/psst-gui/src/ui/library.rs index b5891ded..67f92a20 100644 --- a/psst-gui/src/ui/library.rs +++ b/psst-gui/src/ui/library.rs @@ -104,7 +104,7 @@ pub fn saved_tracks_widget() -> impl Widget { pub fn saved_albums_widget() -> impl Widget { Async::new( utils::spinner_widget, - || List::new(album::album_widget).lens(Ctx::map(SavedAlbums::albums)), + || List::new(|| album::album_widget(false)).lens(Ctx::map(SavedAlbums::albums)), utils::error_widget, ) .lens( @@ -165,7 +165,7 @@ pub fn saved_albums_widget() -> impl Widget { pub fn saved_shows_widget() -> impl Widget { Async::new( utils::spinner_widget, - || List::new(show::show_widget).lens(Ctx::map(SavedShows::shows)), + || List::new(|| show::show_widget(false)).lens(Ctx::map(SavedShows::shows)), utils::error_widget, ) .lens( diff --git a/psst-gui/src/ui/playable.rs b/psst-gui/src/ui/playable.rs index c4b99179..caf11270 100644 --- a/psst-gui/src/ui/playable.rs +++ b/psst-gui/src/ui/playable.rs @@ -14,7 +14,7 @@ use crate::{ data::{ Album, ArtistTracks, CommonCtx, FindQuery, MatchFindQuery, Playable, PlaybackOrigin, PlaybackPayload, PlaylistTracks, Recommendations, SavedTracks, SearchResults, ShowEpisodes, - WithCtx, + Track, WithCtx, }, ui::theme, }; @@ -152,6 +152,24 @@ impl PlayableIter for Arc { } } +// This should change to a more specific name as it could be confusing for others +// As at the moment this is only used for the home page! +impl PlayableIter for Vector> { + fn origin(&self) -> PlaybackOrigin { + PlaybackOrigin::Home + } + + fn for_each(&self, mut cb: impl FnMut(Playable, usize)) { + for (position, track) in self.iter().enumerate() { + cb(Playable::Track(track.to_owned()), position); + } + } + + fn count(&self) -> usize { + self.len() + } +} + impl PlayableIter for PlaylistTracks { fn origin(&self) -> PlaybackOrigin { PlaybackOrigin::Playlist(self.link()) diff --git a/psst-gui/src/ui/playback.rs b/psst-gui/src/ui/playback.rs index 47dc7cdb..91d1ca41 100644 --- a/psst-gui/src/ui/playback.rs +++ b/psst-gui/src/ui/playback.rs @@ -172,6 +172,8 @@ fn cover_widget(size: f64) -> impl Widget { fn playback_origin_icon(origin: &PlaybackOrigin) -> &'static SvgIcon { match origin { + // TODO add home widget + PlaybackOrigin::Home => &icons::HOME, PlaybackOrigin::Library => &icons::HEART, PlaybackOrigin::Album { .. } => &icons::ALBUM, PlaybackOrigin::Artist { .. } => &icons::ARTIST, diff --git a/psst-gui/src/ui/playlist.rs b/psst-gui/src/ui/playlist.rs index 936159ab..31ee3639 100644 --- a/psst-gui/src/ui/playlist.rs +++ b/psst-gui/src/ui/playlist.rs @@ -3,9 +3,10 @@ use std::rc::Rc; use std::{cmp::Ordering, sync::Arc}; use druid::widget::{Button, LensWrap, TextBox}; +use druid::UnitPoint; use druid::{ im::Vector, - widget::{CrossAxisAlignment, Flex, Label, LineBreaking, List}, + widget::{Flex, Label, LineBreaking, List}, Insets, Lens, LensExt, LocalizedString, Menu, MenuItem, Selector, Size, Widget, WidgetExt, WindowDesc, }; @@ -318,8 +319,13 @@ fn information_section(title_msg: &str, description_msg: &str) -> impl Widget impl Widget> { - let playlist_image = rounded_cover_widget(theme::grid(6.0)).lens(Ctx::data()); +pub fn playlist_widget(horizontal: bool) -> impl Widget> { + let playlist_image_size = if horizontal { + theme::grid(16.0) + } else { + theme::grid(6.0) + }; + let playlist_image = rounded_cover_widget(playlist_image_size).lens(Ctx::data()); let playlist_name = Label::raw() .with_font(theme::UI_FONT_MEDIUM) @@ -332,17 +338,47 @@ pub fn playlist_widget() -> impl Widget> { .with_text_size(theme::TEXT_SIZE_SMALL) .lens(Ctx::data().then(Playlist::description)); - let playlist_info = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(playlist_name) - .with_spacer(2.0) - .with_child(playlist_description); - - let playlist = Flex::row() - .with_child(playlist_image) - .with_default_spacer() - .with_flex_child(playlist_info, 1.0) - .padding(theme::grid(1.0)); + let (playlist_name, playlist_description) = if horizontal { + ( + playlist_name.fix_width(playlist_image_size).align_left(), + playlist_description + .fix_width(playlist_image_size) + .align_left(), + ) + } else { + ( + playlist_name.align_left(), + playlist_description.align_left(), + ) + }; + + let playlist = if horizontal { + Flex::column() + .with_child(playlist_image) + .with_default_spacer() + .with_child( + Flex::column() + .with_child(playlist_name) + .with_spacer(2.0) + .with_child(playlist_description) + .align_horizontal(UnitPoint::CENTER) + .align_vertical(UnitPoint::TOP) + .fix_size(theme::grid(16.0), theme::grid(8.0)), + ) + .padding(theme::grid(1.0)) + } else { + Flex::row() + .with_child(playlist_image) + .with_default_spacer() + .with_flex_child( + Flex::column() + .with_child(playlist_name) + .with_spacer(2.0) + .with_child(playlist_description), + 1.0, + ) + .padding(theme::grid(1.0)) + }; playlist .link() diff --git a/psst-gui/src/ui/recommend.rs b/psst-gui/src/ui/recommend.rs index 98e91a80..9197cd31 100644 --- a/psst-gui/src/ui/recommend.rs +++ b/psst-gui/src/ui/recommend.rs @@ -131,6 +131,7 @@ fn track_results_widget() -> impl Widget> { title: true, artist: true, album: true, + cover: true, ..track::Display::empty() }, }) diff --git a/psst-gui/src/ui/search.rs b/psst-gui/src/ui/search.rs index 5d135a78..b5245db6 100644 --- a/psst-gui/src/ui/search.rs +++ b/psst-gui/src/ui/search.rs @@ -101,7 +101,7 @@ fn artist_results_widget() -> impl Widget> { Empty, Flex::column() .with_child(header_widget("Artists")) - .with_child(List::new(artist::artist_widget)), + .with_child(List::new(|| artist::artist_widget(false))), ) .lens(Ctx::data().then(SearchResults::artists)) } @@ -112,7 +112,7 @@ fn album_results_widget() -> impl Widget> { Empty, Flex::column() .with_child(header_widget("Albums")) - .with_child(List::new(album::album_widget)), + .with_child(List::new(|| album::album_widget(false))), ) .lens(Ctx::map(SearchResults::albums)) } @@ -142,7 +142,8 @@ fn playlist_results_widget() -> impl Widget> { Flex::column() .with_child(header_widget("Playlists")) .with_child( - List::new(playlist::playlist_widget).lens(Ctx::map(SearchResults::playlists)), + List::new(|| playlist::playlist_widget(false)) + .lens(Ctx::map(SearchResults::playlists)), ), ) } @@ -153,7 +154,7 @@ fn show_results_widget() -> impl Widget> { Empty, Flex::column() .with_child(header_widget("Podcasts")) - .with_child(List::new(show::show_widget)), + .with_child(List::new(|| show::show_widget(false))), ) .lens(Ctx::map(SearchResults::shows)) } diff --git a/psst-gui/src/ui/show.rs b/psst-gui/src/ui/show.rs index 653ed4a3..1643220f 100644 --- a/psst-gui/src/ui/show.rs +++ b/psst-gui/src/ui/show.rs @@ -1,157 +1,178 @@ -use std::sync::Arc; - -use druid::{ - widget::{CrossAxisAlignment, Flex, Label, LineBreaking}, - LensExt, LocalizedString, Menu, MenuItem, Selector, Size, Widget, WidgetExt, -}; - -use crate::{ - cmd, - data::{AppState, Ctx, Library, Nav, Show, ShowDetail, ShowEpisodes, ShowLink, WithCtx}, - webapi::WebApi, - widget::{Async, MyWidgetExt, RemoteImage}, -}; - -use super::{library, playable, theme, track, utils}; - -pub const LOAD_DETAIL: Selector = Selector::new("app.show.load-detail"); - -pub fn detail_widget() -> impl Widget { - Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - // .with_child(async_info_widget()) - // .with_default_spacer() - .with_child(async_episodes_widget()) -} - -// fn async_info_widget() -> impl Widget { -// Async::new(utils::spinner_widget, info_widget, utils::error_widget) -// .lens( -// Ctx::make( -// AppState::common_ctx, -// AppState::show_detail.then(ShowDetail::show), -// ) -// .then(Ctx::in_promise()), -// ) -// .on_command_async( -// LOAD_DETAIL, -// |d| WebApi::global().get_show(&d.id), -// |_, data, d| data.show_detail.show.defer(d), -// |_, data, (d, r)| data.show_detail.show.update((d, r)), -// ) -// } - -// fn info_widget() -> impl Widget>> { -// Label::raw().lens(Ctx::data().then(Show::description.in_arc())) -// } - -fn async_episodes_widget() -> impl Widget { - Async::new( - utils::spinner_widget, - || { - playable::list_widget(playable::Display { - track: track::Display::empty(), - }) - }, - utils::error_widget, - ) - .lens( - Ctx::make( - AppState::common_ctx, - AppState::show_detail.then(ShowDetail::episodes), - ) - .then(Ctx::in_promise()), - ) - .on_command_async( - LOAD_DETAIL, - |d| WebApi::global().get_show_episodes(&d.id), - |_, data, d| data.show_detail.episodes.defer(d), - |_, data, (d, r)| { - let r = r.map(|episodes| ShowEpisodes { - show: d.clone(), - episodes, - }); - data.show_detail.episodes.update((d, r)) - }, - ) -} - -pub fn show_widget() -> impl Widget>> { - let show_image = rounded_cover_widget(theme::grid(6.0)); - - let show_name = Label::raw() - .with_font(theme::UI_FONT_MEDIUM) - .with_line_break_mode(LineBreaking::Clip) - .lens(Show::name.in_arc()); - - let show_publisher = Label::raw() - .with_text_size(theme::TEXT_SIZE_SMALL) - .with_text_color(theme::PLACEHOLDER_COLOR) - .lens(Show::publisher.in_arc()); - - let show_info = Flex::column() - .cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(show_name) - .with_spacer(1.0) - .with_child(show_publisher); - - let show = Flex::row() - .with_child(show_image) - .with_default_spacer() - .with_flex_child(show_info, 1.0) - .lens(Ctx::data()); - - show.padding(theme::grid(1.0)) - .link() - .on_left_click(|ctx, _, show, _| { - ctx.submit_command(cmd::NAVIGATE.with(Nav::ShowDetail(show.data.link()))); - }) - .context_menu(show_ctx_menu) -} - -fn cover_widget(size: f64) -> impl Widget> { - RemoteImage::new(utils::placeholder_widget(), move |show: &Arc, _| { - show.image(size, size).map(|image| image.url.clone()) - }) - .fix_size(size, size) -} - -fn rounded_cover_widget(size: f64) -> impl Widget> { - // TODO: Take the radius from theme. - cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0)) -} - -fn show_ctx_menu(show: &WithCtx>) -> Menu { - show_menu(&show.data, &show.ctx.library) -} - -fn show_menu(show: &Arc, library: &Arc) -> Menu { - let mut menu = Menu::empty(); - - menu = menu.entry( - MenuItem::new( - LocalizedString::new("menu-item-copy-link").with_placeholder("Copy Link to Show"), - ) - .command(cmd::COPY.with(show.link().url())), - ); - - menu = menu.separator(); - - if library.contains_show(show) { - menu = menu.entry( - MenuItem::new( - LocalizedString::new("menu-item-remove-from-library").with_placeholder("Unfollow"), - ) - .command(library::UNSAVE_SHOW.with(show.link())), - ); - } else { - menu = menu.entry( - MenuItem::new( - LocalizedString::new("menu-item-save-to-library").with_placeholder("Follow"), - ) - .command(library::SAVE_SHOW.with(show.clone())), - ); - } - - menu -} +use std::sync::Arc; + +use druid::{ + widget::{CrossAxisAlignment, Flex, Label, LineBreaking}, + LensExt, LocalizedString, Menu, MenuItem, Selector, Size, UnitPoint, Widget, WidgetExt, +}; + +use crate::{ + cmd, + data::{AppState, Ctx, Library, Nav, Show, ShowDetail, ShowEpisodes, ShowLink, WithCtx}, + webapi::WebApi, + widget::{Async, MyWidgetExt, RemoteImage}, +}; + +use super::{library, playable, theme, track, utils}; + +pub const LOAD_DETAIL: Selector = Selector::new("app.show.load-detail"); + +pub fn detail_widget() -> impl Widget { + Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + // .with_child(async_info_widget()) + // .with_default_spacer() + .with_child(async_episodes_widget()) +} + +// fn async_info_widget() -> impl Widget { +// Async::new(utils::spinner_widget, info_widget, utils::error_widget) +// .lens( +// Ctx::make( +// AppState::common_ctx, +// AppState::show_detail.then(ShowDetail::show), +// ) +// .then(Ctx::in_promise()), +// ) +// .on_command_async( +// LOAD_DETAIL, +// |d| WebApi::global().get_show(&d.id), +// |_, data, d| data.show_detail.show.defer(d), +// |_, data, (d, r)| data.show_detail.show.update((d, r)), +// ) +// } + +// fn info_widget() -> impl Widget>> { +// Label::raw().lens(Ctx::data().then(Show::description.in_arc())) +// } + +fn async_episodes_widget() -> impl Widget { + Async::new( + utils::spinner_widget, + || { + playable::list_widget(playable::Display { + track: track::Display::empty(), + }) + }, + utils::error_widget, + ) + .lens( + Ctx::make( + AppState::common_ctx, + AppState::show_detail.then(ShowDetail::episodes), + ) + .then(Ctx::in_promise()), + ) + .on_command_async( + LOAD_DETAIL, + |d| WebApi::global().get_show_episodes(&d.id), + |_, data, d| data.show_detail.episodes.defer(d), + |_, data, (d, r)| { + let r = r.map(|episodes| ShowEpisodes { + show: d.clone(), + episodes, + }); + data.show_detail.episodes.update((d, r)) + }, + ) +} + +pub fn show_widget(horizontal: bool) -> impl Widget>> { + let image_size = theme::grid(if horizontal { 16.0 } else { 6.0 }); + let show_image = rounded_cover_widget(image_size); + + let show_name = Label::raw() + .with_font(theme::UI_FONT_MEDIUM) + .with_line_break_mode(LineBreaking::Clip) + .lens(Show::name.in_arc()) + .align_left(); + + let show_publisher = Label::raw() + .with_line_break_mode(LineBreaking::Clip) + .with_text_size(theme::TEXT_SIZE_SMALL) + .with_text_color(theme::PLACEHOLDER_COLOR) + .lens(Show::publisher.in_arc()) + .align_left(); + + let show = if horizontal { + Flex::column() + .with_child(show_image) + .with_default_spacer() + .with_child( + Flex::column() + .with_child(show_name) + .with_child(show_publisher) + .align_horizontal(UnitPoint::CENTER) + .align_vertical(UnitPoint::TOP) + .fix_size(theme::grid(16.0), theme::grid(8.0)), + ) + .padding(theme::grid(1.0)) + .lens(Ctx::data()) + } else { + Flex::row() + .with_child(show_image) + .with_default_spacer() + .with_flex_child( + Flex::column() + .with_child(show_name) + .with_child(show_publisher), + 1.0, + ) + .padding(theme::grid(1.0)) + .lens(Ctx::data()) + }; + + show.align_left() + .link() + .rounded(theme::BUTTON_BORDER_RADIUS) + .on_left_click(|ctx, _, show, _| { + ctx.submit_command(cmd::NAVIGATE.with(Nav::ShowDetail(show.data.link()))); + }) + .context_menu(show_ctx_menu) +} + +fn cover_widget(size: f64) -> impl Widget> { + RemoteImage::new(utils::placeholder_widget(), move |show: &Arc, _| { + show.image(size, size).map(|image| image.url.clone()) + }) + .fix_size(size, size) +} + +fn rounded_cover_widget(size: f64) -> impl Widget> { + // TODO: Take the radius from theme. + cover_widget(size).clip(Size::new(size, size).to_rounded_rect(4.0)) +} + +fn show_ctx_menu(show: &WithCtx>) -> Menu { + show_menu(&show.data, &show.ctx.library) +} + +fn show_menu(show: &Arc, library: &Arc) -> Menu { + let mut menu = Menu::empty(); + + menu = menu.entry( + MenuItem::new( + LocalizedString::new("menu-item-copy-link").with_placeholder("Copy Link to Show"), + ) + .command(cmd::COPY.with(show.link().url())), + ); + + menu = menu.separator(); + + if library.contains_show(show) { + menu = menu.entry( + MenuItem::new( + LocalizedString::new("menu-item-remove-from-library").with_placeholder("Unfollow"), + ) + .command(library::UNSAVE_SHOW.with(show.link())), + ); + } else { + menu = menu.entry( + MenuItem::new( + LocalizedString::new("menu-item-save-to-library").with_placeholder("Follow"), + ) + .command(library::SAVE_SHOW.with(show.clone())), + ); + } + + menu +} diff --git a/psst-gui/src/webapi/client.rs b/psst-gui/src/webapi/client.rs index de6bb444..56e8774f 100644 --- a/psst-gui/src/webapi/client.rs +++ b/psst-gui/src/webapi/client.rs @@ -15,6 +15,8 @@ use druid::{ use itertools::Itertools; use once_cell::sync::OnceCell; use parking_lot::Mutex; +use sanitize_html::rules::predefined::DEFAULT; +use sanitize_html::sanitize_str; use serde::{de::DeserializeOwned, Deserialize}; use serde_json::json; use ureq::{Agent, Request, Response}; @@ -26,9 +28,9 @@ use psst_core::{ use crate::{ data::{ - Album, AlbumType, Artist, ArtistAlbums, AudioAnalysis, Cached, Episode, EpisodeId, - EpisodeLink, Nav, Page, Playlist, 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, }, error::Error, }; @@ -70,29 +72,38 @@ impl WebApi { Ok(token.token) } - fn request(&self, method: &str, path: impl Display) -> Result { + fn build_request( + &self, + method: &str, + base_url: &str, + path: impl Display, + ) -> Result { let token = self.access_token()?; let request = self .agent - .request(method, &format!("https://api.spotify.com/{}", path)) + .request(method, &format!("https://{}/{}", base_url, path)) .set("Authorization", &format!("Bearer {}", &token)); Ok(request) } - fn get(&self, path: impl Display) -> Result { - self.request("GET", path) + fn request(&self, method: &str, base_url: &str, path: impl Display) -> Result { + self.build_request(method, base_url, path) + } + + fn get(&self, path: impl Display, base_url: Option<&str>) -> Result { + self.request("GET", base_url.unwrap_or("api.spotify.com"), path) } - fn put(&self, path: impl Display) -> Result { - self.request("PUT", path) + fn put(&self, path: impl Display, base_url: Option<&str>) -> Result { + self.request("GET", base_url.unwrap_or("api.spotify.com"), path) } - fn post(&self, path: impl Display) -> Result { - self.request("POST", path) + fn post(&self, path: impl Display, base_url: Option<&str>) -> Result { + self.request("GET", base_url.unwrap_or("api.spotify.com"), path) } - fn delete(&self, path: impl Display) -> Result { - self.request("DELETE", path) + fn delete(&self, path: impl Display, base_url: Option<&str>) -> Result { + self.request("GET", base_url.unwrap_or("api.spotify.com"), path) } fn with_retry(f: impl Fn() -> Result) -> Result { @@ -187,6 +198,49 @@ impl WebApi { Ok(()) } + /// Very similar to `for_all_pages`, but only returns a certain number of results + fn for_some_pages( + &self, + request: Request, + lim: usize, + mut func: impl FnMut(Page) -> Result<(), Error>, + ) -> Result<(), Error> { + let mut limit = 50; + let mut offset = 0; + if lim < limit { + limit = lim; + let req = request + .clone() + .query("limit", &limit.to_string()) + .query("offset", &offset.to_string()); + + let page: Page = self.load(req)?; + + func(page)?; + } else { + loop { + let req = request + .clone() + .query("limit", &limit.to_string()) + .query("offset", &offset.to_string()); + + let page: Page = self.load(req)?; + + let page_total = limit / lim; + let page_offset = page.offset; + let page_limit = page.limit; + func(page)?; + + if page_total > offset && offset < self.paginated_limit { + limit = page_limit; + offset = page_offset + page_limit; + } else { + break; + } + } + } + Ok(()) + } /// Load a paginated result set by sending `request` with added pagination /// parameters and return the aggregated results. Use with GET requests. fn load_all_pages( @@ -203,6 +257,22 @@ impl WebApi { Ok(results) } + /// Does a similar thing as `load_all_pages`, but limiting the number of results + fn load_some_pages( + &self, + request: Request, + number: usize, + ) -> Result, Error> { + let mut results = Vector::new(); + + self.for_some_pages(request, number, |page| { + results.append(page.items); + Ok(()) + })?; + + Ok(results) + } + /// Load local track files from the official client's database. pub fn load_local_tracks(&self, username: &str) { if let Err(err) = self @@ -213,8 +283,348 @@ impl WebApi { log::error!("failed to read local tracks: {}", err); } } -} + fn load_and_return_home_section(&self, request: Request) -> Result { + #[derive(Deserialize)] + pub struct Welcome { + data: WelcomeData, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct WelcomeData { + home_sections: HomeSections, + } + + #[derive(Deserialize)] + pub struct HomeSections { + sections: Vec
, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Section { + data: SectionData, + section_items: SectionItems, + } + + #[derive(Deserialize)] + pub struct SectionData { + title: Title, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Title { + text: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct SectionItems { + items: Vec, + } + + #[derive(Deserialize)] + pub struct Item { + content: Content, + } + + #[derive(Deserialize)] + pub struct Content { + data: ContentData, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ContentData { + #[serde(rename = "__typename")] + typename: DataTypename, + name: Option, + uri: String, + + // Playlist-specific fields + attributes: Option>, + description: Option, + images: Option, + owner_v2: Option, + + // Artist-specific fields + artists: Option, + profile: Option, + visuals: Option, + + // Show-specific fields + cover_art: Option, + publisher: Option, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Visuals { + avatar_image: CoverArt, + } + + #[derive(Deserialize)] + pub struct Artists { + items: Vec, + } + + #[derive(Deserialize)] + pub struct ArtistsItem { + profile: Profile, + uri: String, + } + + #[derive(Deserialize)] + pub struct Profile { + name: String, + } + + #[derive(Deserialize)] + pub struct Attribute { + key: String, + value: String, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct CoverArt { + sources: Vec, + } + + #[derive(Deserialize)] + pub struct Source { + url: String, + } + + #[derive(Deserialize)] + pub enum MediaType { + #[serde(rename = "AUDIO")] + Audio, + #[serde(rename = "MIXED")] + Mixed, + } + + #[derive(Deserialize)] + pub struct Publisher { + name: String, + } + + #[derive(Deserialize)] + pub enum DataTypename { + Podcast, + Playlist, + Artist, + Album, + } + + #[derive(Deserialize)] + pub struct Images { + items: Vec, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ImagesItem { + sources: Vec, + } + + #[derive(Deserialize)] + pub struct OwnerV2 { + data: OwnerV2Data, + } + + #[derive(Deserialize)] + pub struct OwnerV2Data { + #[serde(rename = "__typename")] + name: String, + } + + // Extract the playlists + let result: Welcome = self.load(request)?; + + let mut title: Arc = Arc::from(""); + let mut playlist: Vector = Vector::new(); + let mut album: Vector> = Vector::new(); + let mut artist: Vector = Vector::new(); + let mut show: Vector> = Vector::new(); + + result + .data + .home_sections + .sections + .iter() + .for_each(|section| { + title = section.data.title.text.clone().into(); + + section.section_items.items.iter().for_each(|item| { + let uri = item.content.data.uri.clone(); + let id = uri.split(':').last().unwrap_or("").to_string(); + + match item.content.data.typename { + DataTypename::Playlist => { + playlist.push_back(Playlist { + id: id.into(), + name: Arc::from(item.content.data.name.clone().unwrap()), + images: Some(item.content.data.images.as_ref().map_or_else( + Vector::new, + |images| { + images + .items + .iter() + .map(|img| data::utils::Image { + url: Arc::from( + img.sources + .first() + .map(|s| s.url.as_str()) + .unwrap_or_default(), + ), + width: None, + height: None, + }) + .collect() + }, + )), + description: { + let desc = sanitize_str( + &DEFAULT, + item.content + .data + .description + .as_deref() + .unwrap_or_default(), + ) + .unwrap_or_default(); + // This is roughly 3 lines of description, truncated if too long + if desc.chars().count() > 55 { + desc.chars().take(52).collect::() + "..." + } else { + desc + } + .into() + }, + track_count: item.content.data.attributes.as_ref().and_then( + |attrs| { + attrs + .iter() + .find(|attr| attr.key == "track_count") + .and_then(|attr| attr.value.parse().ok()) + }, + ), + owner: PublicUser { + id: Arc::from(""), + display_name: item + .content + .data + .owner_v2 + .as_ref() + .map(|owner| Arc::from(owner.data.name.as_str())) + .unwrap_or_else(|| Arc::from("")), + }, + collaborative: false, + }); + } + DataTypename::Artist => artist.push_back(Artist { + id: id.into(), + name: Arc::from( + item.content.data.profile.as_ref().unwrap().name.clone(), + ), + images: item.content.data.visuals.as_ref().map_or_else( + Vector::new, + |images| { + images + .avatar_image + .sources + .iter() + .map(|img| data::utils::Image { + url: Arc::from(img.url.as_str()), + width: None, + height: None, + }) + .collect() + }, + ), + }), + DataTypename::Album => album.push_back(Arc::new(Album { + id: id.into(), + name: Arc::from(item.content.data.name.clone().unwrap()), + album_type: AlbumType::Album, + images: item.content.data.cover_art.as_ref().map_or_else( + Vector::new, + |images| { + images + .sources + .iter() + .map(|src| data::utils::Image { + url: Arc::from(src.url.clone()), + width: None, + height: None, + }) + .collect() + }, + ), + artists: item.content.data.artists.as_ref().map_or_else( + Vector::new, + |artists| { + artists + .items + .iter() + .map(|artist| ArtistLink { + id: Arc::from( + artist + .uri + .split(':') + .last() + .unwrap_or("") + .to_string(), + ), + name: Arc::from(artist.profile.name.clone()), + }) + .collect() + }, + ), + copyrights: Vector::new(), + label: "".into(), + tracks: Vector::new(), + release_date: None, + release_date_precision: None, + })), + DataTypename::Podcast => show.push_back(Arc::new(Show { + id: id.into(), + name: Arc::from(item.content.data.name.clone().unwrap()), + images: item.content.data.cover_art.as_ref().map_or_else( + Vector::new, + |images| { + images + .sources + .iter() + .map(|src| data::utils::Image { + url: Arc::from(src.url.clone()), + width: None, + height: None, + }) + .collect() + }, + ), + publisher: Arc::from( + item.content.data.publisher.as_ref().unwrap().name.clone(), + ), + description: "".into(), + })), + } + }); + }); + + Ok(MixedView { + title, + playlists: playlist, + artists: artist, + albums: album, + shows: show, + }) + } +} static GLOBAL_WEBAPI: OnceCell> = OnceCell::new(); /// Global instance. @@ -231,20 +641,47 @@ impl WebApi { } } -/// Other endpoints. +/// User endpoints. impl WebApi { + // https://developer.spotify.com/documentation/web-api/reference/get-users-profile pub fn get_user_profile(&self) -> Result { - let request = self.get("v1/me")?; + let request = self.get("v1/me", None)?; let result = self.load(request)?; Ok(result) } + + // https://developer.spotify.com/documentation/web-api/reference/get-users-top-artists-and-tracks + pub fn get_user_top_tracks(&self) -> Result>, Error> { + let request = self + .get("v1/me/top/tracks", None)? + .query("market", "from_token"); + + let result: Vector> = self.load_some_pages(request, 30)?; + + Ok(result) + } + + pub fn get_user_top_artist(&self) -> Result, Error> { + #[derive(Clone, Data, Deserialize)] + struct Artists { + artists: Artist, + } + + let request = self.get("v1/me/top/artists", None)?; + + Ok(self + .load_some_pages(request, 10)? + .into_iter() + .map(|item: Artist| item) + .collect()) + } } /// Artist endpoints. impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-artist/ pub fn get_artist(&self, id: &str) -> Result { - let request = self.get(format!("v1/artists/{}", id))?; + let request = self.get(format!("v1/artists/{}", id), None)?; let result = self.load_cached(request, "artist", id)?; Ok(result.data) } @@ -252,7 +689,7 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-albums/ pub fn get_artist_albums(&self, id: &str) -> Result { let request = self - .get(format!("v1/artists/{}/albums", id))? + .get(format!("v1/artists/{}/albums", id), None)? .query("market", "from_token"); let result: Vector> = self.load_all_pages(request)?; @@ -295,7 +732,7 @@ impl WebApi { Ok(artist_albums) } - // https://developer.spotify.com/documentation/web-api/reference/get-artists-top-tracks/ + // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-top-tracks pub fn get_artist_top_tracks(&self, id: &str) -> Result>, Error> { #[derive(Deserialize)] struct Tracks { @@ -303,20 +740,20 @@ impl WebApi { } let request = self - .get(format!("v1/artists/{}/top-tracks", id))? + .get(format!("v1/artists/{}/top-tracks", id), None)? .query("market", "from_token"); let result: Tracks = self.load(request)?; Ok(result.tracks) } - // https://developer.spotify.com/documentation/web-api/reference/get-related-artists/ + // https://developer.spotify.com/documentation/web-api/reference/get-an-artists-related-artists pub fn get_related_artists(&self, id: &str) -> Result>, Error> { #[derive(Clone, Data, Deserialize)] struct Artists { artists: Vector, } - let request = self.get(format!("v1/artists/{}/related-artists", id))?; + let request = self.get(format!("v1/artists/{}/related-artists", id), None)?; let result: Cached = self.load_cached(request, "related-artists", id)?; Ok(result.map(|result| result.artists)) } @@ -327,7 +764,7 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-an-album/ pub fn get_album(&self, id: &str) -> Result>, Error> { let request = self - .get(format!("v1/albums/{}", id))? + .get(format!("v1/albums/{}", id), None)? .query("market", "from_token"); let result = self.load_cached(request, "album", id)?; Ok(result) @@ -347,7 +784,7 @@ impl WebApi { } let request = self - .get("v1/episodes")? + .get("v1/episodes", None)? .query("ids", &ids.into_iter().map(|id| id.0.to_base62()).join(",")) .query("market", "from_token"); let result: Episodes = self.load(request)?; @@ -357,7 +794,7 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-a-shows-episodes pub fn get_show_episodes(&self, id: &str) -> Result>, Error> { let request = self - .get(format!("v1/shows/{}/episodes", id))? + .get(format!("v1/shows/{}/episodes", id), None)? .query("market", "from_token"); let mut results = Vector::new(); @@ -379,7 +816,7 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-track pub fn get_track(&self, id: &str) -> Result, Error> { let request = self - .get(format!("v1/tracks/{}", id))? + .get(format!("v1/tracks/{}", id), None)? .query("market", "from_token"); let result = self.load(request)?; Ok(result) @@ -395,7 +832,9 @@ impl WebApi { album: Arc, } - let request = self.get("v1/me/albums")?.query("market", "from_token"); + let request = self + .get("v1/me/albums", None)? + .query("market", "from_token"); Ok(self .load_all_pages(request)? @@ -406,14 +845,14 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/save-albums-user/ pub fn save_album(&self, id: &str) -> Result<(), Error> { - let request = self.put("v1/me/albums")?.query("ids", id); + let request = self.put("v1/me/albums", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } // https://developer.spotify.com/documentation/web-api/reference/remove-albums-user/ pub fn unsave_album(&self, id: &str) -> Result<(), Error> { - let request = self.delete("v1/me/albums")?.query("ids", id); + let request = self.delete("v1/me/albums", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } @@ -425,7 +864,9 @@ impl WebApi { track: Arc, } - let request = self.get("v1/me/tracks")?.query("market", "from_token"); + let request = self + .get("v1/me/tracks", None)? + .query("market", "from_token"); Ok(self .load_all_pages(request)? @@ -441,7 +882,7 @@ impl WebApi { show: Arc, } - let request = self.get("v1/me/shows")?.query("market", "from_token"); + let request = self.get("v1/me/shows", None)?.query("market", "from_token"); Ok(self .load_all_pages(request)? @@ -452,28 +893,28 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/save-tracks-user/ pub fn save_track(&self, id: &str) -> Result<(), Error> { - let request = self.put("v1/me/tracks")?.query("ids", id); + let request = self.put("v1/me/tracks", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } // https://developer.spotify.com/documentation/web-api/reference/remove-tracks-user/ pub fn unsave_track(&self, id: &str) -> Result<(), Error> { - let request = self.delete("v1/me/tracks")?.query("ids", id); + let request = self.delete("v1/me/tracks", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } // https://developer.spotify.com/documentation/web-api/reference/save-shows-user pub fn save_show(&self, id: &str) -> Result<(), Error> { - let request = self.put("v1/me/shows")?.query("ids", id); + let request = self.put("v1/me/shows", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } // https://developer.spotify.com/documentation/web-api/reference/remove-shows-user pub fn unsave_show(&self, id: &str) -> Result<(), Error> { - let request = self.delete("v1/me/shows")?.query("ids", id); + let request = self.delete("v1/me/shows", None)?.query("ids", id); self.send_empty_json(request)?; Ok(()) } @@ -481,46 +922,225 @@ impl WebApi { /// View endpoints. impl WebApi { - pub fn get_made_for_you(&self) -> Result, Error> { - #[derive(Deserialize)] - struct View { - content: Page, + pub fn get_user_info(&self) -> Result<(String, String), Error> { + #[derive(Deserialize, Clone, Data)] + struct User { + region: String, + timezone: String, } + let token = self.access_token()?; + let request = self + .agent + .request("GET", &format!("http://{}/{}", "ip-api.com", "json")) + .query("fields", "260") + .set("Authorization", &format!("Bearer {}", &token)); + + let result: Cached = self.load_cached(request, "User_info", "usrinfo")?; + + Ok((result.data.region.clone(), result.data.timezone.clone())) + } + + fn build_home_request(&self, section_uri: &str) -> (String, String) { + let extensions = json!({ + "persistedQuery": { + "version": 1, + // From https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib%2Fservices%2Fcustom_spotify_endpoints%2Fspotify_endpoints.dart keep and eye on this and change accordingly + "sha256Hash": "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be" + } + }); + + let variables = json!( { + "uri": section_uri, + "timeZone": self.get_user_info().unwrap().0, + "sp_t": self.access_token().unwrap(), // Assuming this returns a Result + "country": self.get_user_info().unwrap().1, + "sectionItemsOffset": 0, + "sectionItemsLimit": 20, + }); + + let variables_json = serde_json::to_string(&variables); + let extensions_json = serde_json::to_string(&extensions); + + (variables_json.unwrap(), extensions_json.unwrap()) + } + + pub fn get_made_for_you(&self) -> Result { + // 0JQ5DAUnp4wcj0bCb3wh3S -> Daily mixes + let json_query = self.build_home_request("spotify:section:0JQ5DAUnp4wcj0bCb3wh3S"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + pub fn get_top_mixes(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu89 -> Top mixes + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu89"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + pub fn recommended_stations(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu3R -> Recommended stations + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu3R"); + + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + pub fn uniquely_yours(&self) -> Result { + // 0JQ5DAqAJXkJGsa2DyEjKi -> Uniquely yours + let json_query = self.build_home_request("spotify:section:0JQ5DAqAJXkJGsa2DyEjKi"); let request = self - .get("v1/views/made-for-x")? - .query("types", "playlist") - .query("limit", "20") - .query("offset", "0"); - let result: View = self.load(request)?; - Ok(result.content.items) + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + pub fn best_of_artists(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu3n -> Best of artists + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu3n"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + // Need to make a mix of it! + pub fn jump_back_in(&self) -> Result { + // 0JQ5DAIiKWzVFULQfUm85X -> Jump back in + let json_query = self.build_home_request("spotify:section:0JQ5DAIiKWzVFULQfUm85X"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + // Shows + pub fn your_shows(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu3N -> Your shows + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu3N"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + pub fn shows_that_you_might_like(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu3P -> Shows that you might like + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu3P"); + let request = self + .get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + /* + // TODO: Episodes for you, implement this to redesign the podcast page + pub fn new_episodes(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu3K -> New episodes + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu3K"); + let request = self.get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + + Ok(result) + } + + // Episodes for you, this needs to have its own thing or be part of a mixed view as it is in episode form + pub fn episode_for_you(&self) -> Result { + // 0JQ5DAnM3wGh0gz1MXnu9e -> Episodes for you + let json_query = self.build_home_request("spotify:section:0JQ5DAnM3wGh0gz1MXnu9e"); + let request = self.get("pathfinder/v1/query", Some("api-partner.spotify.com"))? + .query("operationName", "homeSection") + .query("variables", &json_query.0.to_string()) + .query("extensions", &json_query.1.to_string()); + + // Extract the playlists + let result = self.load_and_return_home_section(request)?; + Ok(result) } + */ } /// Playlist endpoints. impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-a-list-of-current-users-playlists pub fn get_playlists(&self) -> Result, Error> { - let request = self.get("v1/me/playlists")?; + let request = self.get("v1/me/playlists", None)?; let result = self.load_all_pages(request)?; Ok(result) } pub fn follow_playlist(&self, id: &str) -> Result<(), Error> { - let request = self.put(format!("v1/playlists/{}/followers", id))?; + let request = self.put(format!("v1/playlists/{}/followers", id), None)?; request.send_json(json!({"public": false,}))?; Ok(()) } pub fn unfollow_playlist(&self, id: &str) -> Result<(), Error> { - let request = self.delete(format!("v1/playlists/{}/followers", id))?; + let request = self.delete(format!("v1/playlists/{}/followers", id), None)?; self.send_empty_json(request)?; Ok(()) } // https://developer.spotify.com/documentation/web-api/reference/get-playlist pub fn get_playlist(&self, id: &str) -> Result { - let request = self.get(format!("v1/me/playlists/{}", id))?; + let request = self.get(format!("v1/me/playlists/{}", id), None)?; let result = self.load(request)?; Ok(result) } @@ -543,7 +1163,7 @@ impl WebApi { } let request = self - .get(format!("v1/playlists/{}/tracks", id))? + .get(format!("v1/playlists/{}/tracks", id), None)? .query("marker", "from_token") .query("additional_types", "track"); let result: Vector = self.load_all_pages(request)?; @@ -565,7 +1185,7 @@ impl WebApi { } pub fn change_playlist_details(&self, id: &str, name: &str) -> Result<(), Error> { - let request = self.put(format!("v1/playlists/{}", id))?; + let request = self.put(format!("v1/playlists/{}", id), None)?; request.send_json(json!({ "name": name }))?; Ok(()) } @@ -573,7 +1193,7 @@ impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/add-tracks-to-playlist pub fn add_track_to_playlist(&self, playlist_id: &str, track_uri: &str) -> Result<(), Error> { let request = self - .post(format!("v1/playlists/{}/tracks", playlist_id))? + .post(format!("v1/playlists/{}/tracks", playlist_id), None)? .query("uris", track_uri); self.send_empty_json(request) } @@ -584,7 +1204,7 @@ impl WebApi { playlist_id: &str, track_pos: usize, ) -> Result<(), Error> { - self.delete(format!("v1/playlists/{}/tracks", playlist_id))? + self.delete(format!("v1/playlists/{}/tracks", playlist_id), None)? .send_json(ureq::json!({ "positions": [track_pos] }))?; Ok(()) } @@ -610,7 +1230,7 @@ impl WebApi { let topics = topics.iter().map(SearchTopic::as_str).join(","); let request = self - .get("v1/search")? + .get("v1/search", None)? .query("q", query) .query("type", &topics) .query("limit", &limit.to_string()) @@ -620,14 +1240,14 @@ impl WebApi { let artists = result.artists.map_or_else(Vector::new, |page| page.items); let albums = result.albums.map_or_else(Vector::new, |page| page.items); let tracks = result.tracks.map_or_else(Vector::new, |page| page.items); - let playlists = result.playlists.map_or_else(Vector::new, |page| page.items); + let playlist = result.playlists.map_or_else(Vector::new, |page| page.items); let shows = result.shows.map_or_else(Vector::new, |page| page.items); Ok(SearchResults { query: query.into(), artists, albums, tracks, - playlists, + playlists: playlist, shows, }) } @@ -664,7 +1284,7 @@ impl WebApi { .join(", "); let mut request = self - .get("v1/recommendations")? + .get("v1/recommendations", None)? .query("marker", "from_token") .query("limit", "100") .query("seed_artists", &seed_artists) @@ -707,7 +1327,7 @@ impl WebApi { impl WebApi { // https://developer.spotify.com/documentation/web-api/reference/get-audio-analysis/ pub fn _get_audio_analysis(&self, track_id: &str) -> Result { - let request = self.get(format!("v1/audio-analysis/{}", track_id))?; + let request = self.get(format!("v1/audio-analysis/{}", track_id), None)?; let result = self.load_cached(request, "audio-analysis", track_id)?; Ok(result.data) } diff --git a/psst-gui/src/widget/icons.rs b/psst-gui/src/widget/icons.rs index c9f1a144..a9290ea7 100644 --- a/psst-gui/src/widget/icons.rs +++ b/psst-gui/src/widget/icons.rs @@ -141,6 +141,12 @@ pub static PLAYLIST: SvgIcon = SvgIcon { svg_size: Size::new(22.0, 22.0), op: PaintOp::Fill, }; +// SFSymbols - house.fill +pub static HOME: SvgIcon = SvgIcon { + svg_path: "M5.47851563 25.97460938c0 .23632812.171875.4296875.47265624.4296875.23632813 0 .34375-.10742188.47265626-.23632813L27.54296874 6.89648437c.32226563-.30078124.6875-.45117187 1.05273438-.45117187.32226562 0 .6875.171875.98828124.45117188L50.703125 26.16796874c.12890625.12890625.23632813.23632813.47265625.23632813.27929688 0 .47265625-.19335938.47265625-.4296875 0-.19335938-.06445313-.30078126-.19335938-.4296875l-5.95117187-5.45703126V6.12304689c0-.45117188-.32226563-.75195313-.75195313-.75195313h-.90234375c-.4296875 0-.75195312.30078125-.75195312.75195313v11.7734375L30.3359375 6.16601561c-.55859375-.49414062-1.18164063-.73046874-1.8046875-.73046874-.6015625 0-1.203125.23632812-1.74023438.73046874L5.671875 25.54492189c-.15039063.12890624-.19335938.23632812-.19335938.4296875ZM10.52734374 44.515625c0 2.25585938 1.33203125 3.58789063 3.609375 3.58789063h9.13085938V33c0-.70898438.47265624-1.16015625 1.18164062-1.16015625h8.22851563c.70898437 0 1.16015625.45117188 1.16015625 1.16015625v15.10351563h9.15234375c2.25585937 0 3.58789062-1.33203125 3.58789062-3.58789063V25.28710937L29.79882812 10.44140626c-.47265624-.40820313-.90234374-.6015625-1.33203124-.6015625-.4296875 0-.88085938.19335938-1.31054688.6015625L10.52734375 25.84570313Z", + svg_size: Size::new(57.0, 53.0), + op: PaintOp::Fill, +}; // SF Pro Regular - mic.circle pub static PODCAST: SvgIcon = SvgIcon { svg_path: "M10.9957 20C15.9285 20 20 15.9265 20 11C20 6.0735 15.9198 2 10.987 2C6.06283 2 2 6.0735 2 11C2 15.9265 6.07153 20 10.9957 20ZM10.9957 18.207C7.00242 18.207 3.80957 14.9952 3.80957 11C3.80957 7.00484 7.00242 3.80174 10.987 3.80174C14.9802 3.80174 18.1904 7.00484 18.1991 11C18.2078 14.9952 14.9889 18.207 10.9957 18.207ZM10.9957 12.5928C11.8395 12.5928 12.4746 11.9313 12.4746 11.0348V7.42263C12.4746 6.51741 11.8395 5.8646 10.9957 5.8646C10.1431 5.8646 9.50797 6.51741 9.50797 7.42263V11.0348C9.50797 11.9313 10.1431 12.5928 10.9957 12.5928ZM8.82939 16.1789H13.1619C13.4316 16.1789 13.6752 15.9439 13.6752 15.6741C13.6752 15.3956 13.4403 15.1605 13.1619 15.1605H11.5002V14.3598C13.2228 14.1509 14.4234 12.854 14.4234 11.087V10.0077C14.4234 9.73791 14.1885 9.51161 13.9188 9.51161C13.6404 9.51161 13.4055 9.73791 13.4055 10.0077V11.0783C13.4055 12.4536 12.3876 13.4371 10.987 13.4371C9.59497 13.4371 8.57709 12.4536 8.57709 11.0783V10.0077C8.57709 9.73791 8.34219 9.51161 8.0638 9.51161C7.7941 9.51161 7.55921 9.73791 7.55921 10.0077V11.087C7.55921 12.854 8.76849 14.1596 10.4911 14.3598V15.1605H8.82939C8.55099 15.1605 8.30739 15.3956 8.30739 15.6741C8.30739 15.9526 8.55099 16.1789 8.82939 16.1789Z",