mirror of
https://github.com/danbulant/despot
synced 2026-07-04 18:40:36 +00:00
liked songs list
This commit is contained in:
parent
780bfabaa5
commit
edcb40559f
10 changed files with 728 additions and 674 deletions
709
Cargo.lock
generated
709
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,5 @@
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use chrono::TimeDelta;
|
use chrono::TimeDelta;
|
||||||
|
|
@ -6,7 +7,7 @@ use futures_util::lock::Mutex;
|
||||||
use librespot_core::Session;
|
use librespot_core::Session;
|
||||||
use librespot_oauth::OAuthToken;
|
use librespot_oauth::OAuthToken;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rspotify::model::{Page, PrivateUser, SimplifiedPlaylist, UserId};
|
use rspotify::model::{Page, PrivateUser, SavedTrack, SimplifiedPlaylist, UserId};
|
||||||
use rspotify::prelude::*;
|
use rspotify::prelude::*;
|
||||||
use rspotify::{AuthCodeSpotify, ClientError, ClientResult, Config, Token};
|
use rspotify::{AuthCodeSpotify, ClientError, ClientResult, Config, Token};
|
||||||
use rspotify::http::HttpError;
|
use rspotify::http::HttpError;
|
||||||
|
|
@ -20,6 +21,8 @@ pub struct SpotifyContext {
|
||||||
token: Mutex<OAuthToken>
|
token: Mutex<OAuthToken>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type SpotifyContextRef = Arc<SpotifyContext>;
|
||||||
|
|
||||||
impl SpotifyContext {
|
impl SpotifyContext {
|
||||||
pub fn new(session: Session, token: OAuthToken) -> SpotifyContext {
|
pub fn new(session: Session, token: OAuthToken) -> SpotifyContext {
|
||||||
let config = Config {
|
let config = Config {
|
||||||
|
|
@ -112,6 +115,10 @@ impl SpotifyContext {
|
||||||
self.api_with_retry(|api| api.current_user_playlists_manual(limit, offset)).await.ok_or(())
|
self.api_with_retry(|api| api.current_user_playlists_manual(limit, offset)).await.ok_or(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn current_user_saved_tracks(&self, limit: Option<u32>, offset: Option<u32>) -> Result<Page<SavedTrack>, ()> {
|
||||||
|
self.api_with_retry(|api| api.current_user_saved_tracks_manual(None, limit, offset)).await.ok_or(())
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn librespot_token_to_rspotify(token: &OAuthToken) -> Token {
|
fn librespot_token_to_rspotify(token: &OAuthToken) -> Token {
|
||||||
|
|
|
||||||
95
src/main.rs
95
src/main.rs
|
|
@ -1,40 +1,30 @@
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use api::SpotifyContext;
|
use api::{SpotifyContext, SpotifyContextRef};
|
||||||
use auth::get_token;
|
use auth::get_token;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::Args;
|
use cli::Args;
|
||||||
use cushy::{figures::units::Lp, styles::Dimension, value::Dynamic, widget::MakeWidget, window::MakeWindow, Application, Open, PendingApp, Run, TokioRuntime};
|
use cushy::{
|
||||||
|
value::Dynamic, widget::MakeWidget, window::MakeWindow, Application, Open, PendingApp, Run,
|
||||||
|
TokioRuntime,
|
||||||
|
};
|
||||||
use librespot_core::{authentication::Credentials, Session, SessionConfig};
|
use librespot_core::{authentication::Credentials, Session, SessionConfig};
|
||||||
use librespot_playback::{audio_backend, config::{AudioFormat, PlayerConfig}, mixer::NoOpVolume, player::Player};
|
use librespot_playback::{
|
||||||
use widgets::{library::playlist::playlists_widget, virtual_list::{VirtualListContent, VirtualList}, ActivePage};
|
audio_backend,
|
||||||
|
config::{AudioFormat, PlayerConfig},
|
||||||
|
mixer::NoOpVolume,
|
||||||
|
player::Player,
|
||||||
|
};
|
||||||
|
use widgets::{library::playlist::playlists_widget, pages::liked::LikedSongsPage, ActivePage};
|
||||||
|
|
||||||
mod vibrancy;
|
|
||||||
mod theme;
|
|
||||||
mod cli;
|
|
||||||
mod auth;
|
|
||||||
mod widgets;
|
|
||||||
mod rt;
|
|
||||||
mod api;
|
mod api;
|
||||||
|
mod auth;
|
||||||
#[derive(Debug)]
|
mod cli;
|
||||||
struct TestVirtualList;
|
mod nodebug;
|
||||||
|
mod rt;
|
||||||
impl VirtualListContent for TestVirtualList {
|
mod theme;
|
||||||
fn item_count(&self) -> impl cushy::value::IntoValue<usize> {
|
mod vibrancy;
|
||||||
50
|
mod widgets;
|
||||||
}
|
|
||||||
fn item_height(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
|
|
||||||
cushy::styles::Dimension::Lp(Lp::inches_f(0.5))
|
|
||||||
}
|
|
||||||
fn widget_at(&self, index: usize) -> impl MakeWidget {
|
|
||||||
// println!("Creating item {}", index);
|
|
||||||
format!("Item {}", index)
|
|
||||||
}
|
|
||||||
fn width(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
|
|
||||||
Dimension::Lp(Lp::inches_f(10.))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> cushy::Result {
|
fn main() -> cushy::Result {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
@ -47,36 +37,44 @@ fn main() -> cushy::Result {
|
||||||
let audio_format = AudioFormat::default();
|
let audio_format = AudioFormat::default();
|
||||||
let credentials = Credentials::with_access_token(&token.access_token);
|
let credentials = Credentials::with_access_token(&token.access_token);
|
||||||
let backend = audio_backend::find(None).unwrap();
|
let backend = audio_backend::find(None).unwrap();
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
|
|
||||||
{
|
{
|
||||||
let guard = app.cushy().enter_runtime();
|
let guard = app.cushy().enter_runtime();
|
||||||
session = Session::new(session_config, None);
|
session = Session::new(session_config, None);
|
||||||
|
|
||||||
let player = Player::new(player_config, session.clone(), Box::new(NoOpVolume), move || {
|
let player = Player::new(
|
||||||
backend(None, audio_format)
|
player_config,
|
||||||
});
|
session.clone(),
|
||||||
|
Box::new(NoOpVolume),
|
||||||
tokio::spawn({ let session = session.clone(); async move {
|
move || backend(None, audio_format),
|
||||||
if let Err(e) = session.connect(credentials, false).await {
|
);
|
||||||
println!("Error connecting: {}", e);
|
|
||||||
|
tokio::spawn({
|
||||||
|
let session = session.clone();
|
||||||
|
async move {
|
||||||
|
if let Err(e) = session.connect(credentials, false).await {
|
||||||
|
println!("Error connecting: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}});
|
});
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut channel = player.get_player_event_channel();
|
let mut channel = player.get_player_event_channel();
|
||||||
loop {
|
loop {
|
||||||
let event = channel.blocking_recv();
|
let event = channel.blocking_recv();
|
||||||
if let Some(event) = event {
|
if let Some(event) = event {
|
||||||
dbg!(event);
|
dbg!(event);
|
||||||
} else { break; }
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dbg!(session.user_data());
|
dbg!(session.user_data());
|
||||||
|
|
||||||
let context = SpotifyContext::new(session, token);
|
let context = SpotifyContextRef::new(SpotifyContext::new(session, token));
|
||||||
|
|
||||||
let mut app = app.as_app();
|
let mut app = app.as_app();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|
@ -88,11 +86,10 @@ fn main() -> cushy::Result {
|
||||||
|
|
||||||
let selected_page = Dynamic::new(ActivePage::default());
|
let selected_page = Dynamic::new(ActivePage::default());
|
||||||
|
|
||||||
// playlists_widget(playlists.items, selected_page)
|
playlists_widget(playlists.items, selected_page)
|
||||||
// .and(
|
.and(LikedSongsPage::new(context.clone()).into_widget())
|
||||||
VirtualList::new(TestVirtualList)
|
.into_columns()
|
||||||
// )
|
.expand()
|
||||||
// .into_columns()
|
|
||||||
.make_window()
|
.make_window()
|
||||||
.open(&mut app)
|
.open(&mut app)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -102,4 +99,4 @@ fn main() -> cushy::Result {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.run()
|
app.run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
src/nodebug.rs
Normal file
42
src/nodebug.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
use std::{fmt::Debug, ops::{Deref, DerefMut}};
|
||||||
|
|
||||||
|
pub struct NoDebug<T> {
|
||||||
|
inner: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Debug for NoDebug<T> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("skipped").finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<T> for NoDebug<T> {
|
||||||
|
fn from(value: T) -> Self {
|
||||||
|
Self { inner: value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Deref for NoDebug<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> DerefMut for NoDebug<T> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for NoDebug<T>
|
||||||
|
where
|
||||||
|
T: Default,
|
||||||
|
{
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: T::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,80 +1,99 @@
|
||||||
use cushy::{value::{Destination, Dynamic, IntoDynamic, IntoValue, Source, Value}, widget::{MakeWidget, WidgetList}, widgets::{button::{ButtonBackground, ButtonClick, ButtonHoverBackground}, grid::Orientation, Image, Stack}};
|
|
||||||
use rspotify::model::SimplifiedPlaylist;
|
|
||||||
use cushy::kludgine::Color;
|
use cushy::kludgine::Color;
|
||||||
|
use cushy::{
|
||||||
|
value::{Destination, Dynamic, IntoDynamic, IntoValue, Source, Value},
|
||||||
|
widget::{MakeWidget, WidgetList},
|
||||||
|
widgets::{
|
||||||
|
button::{ButtonBackground, ButtonClick, ButtonHoverBackground},
|
||||||
|
grid::Orientation,
|
||||||
|
Image, Stack,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use rspotify::model::SimplifiedPlaylist;
|
||||||
|
|
||||||
use crate::{theme::{LIBRARY_BG, LIBRARY_BG_HOVER, LIBRARY_BG_SELECTED, LIBRARY_BG_SELECTED_HOVER}, widgets::{image::ImageExt, ActivePage, SelectedPage}};
|
use crate::{
|
||||||
|
theme::{LIBRARY_BG, LIBRARY_BG_HOVER, LIBRARY_BG_SELECTED, LIBRARY_BG_SELECTED_HOVER},
|
||||||
|
widgets::{image::ImageExt, ActivePage, SelectedPage},
|
||||||
|
};
|
||||||
|
|
||||||
fn playlist_entry(playlist: impl IntoValue<SimplifiedPlaylist>, selected_page: SelectedPage) -> impl MakeWidget {
|
fn playlist_entry(
|
||||||
|
playlist: impl IntoValue<SimplifiedPlaylist>,
|
||||||
|
selected_page: SelectedPage,
|
||||||
|
) -> impl MakeWidget {
|
||||||
let playlist: Value<SimplifiedPlaylist> = playlist.into_value();
|
let playlist: Value<SimplifiedPlaylist> = playlist.into_value();
|
||||||
let id = playlist.map(|p| p.id.clone());
|
let id = playlist.map(|p| p.id.clone());
|
||||||
let is_active = selected_page.map_each(move |page|
|
let is_active =
|
||||||
matches!(page, ActivePage::Playlist(p) if p.id == id)
|
selected_page.map_each(move |page| matches!(page, ActivePage::Playlist(p) if p.id == id));
|
||||||
);
|
|
||||||
entry(
|
entry(
|
||||||
playlist
|
playlist.map_each(|p| p.name.clone()).into_dynamic(),
|
||||||
.map_each(|p| p.name.clone())
|
|
||||||
.into_dynamic(),
|
|
||||||
playlist
|
playlist
|
||||||
.map_each(|playlist| playlist.images.first().map(|image| image.url.clone()))
|
.map_each(|playlist| playlist.images.first().map(|image| image.url.clone()))
|
||||||
.into_dynamic(),
|
.into_dynamic(),
|
||||||
is_active,
|
is_active,
|
||||||
move |_| {
|
move |_| {
|
||||||
selected_page.set(ActivePage::Playlist(playlist.get()));
|
selected_page.set(ActivePage::Playlist(playlist.get()));
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn playlists_widget(playlists: impl IntoValue<Vec<SimplifiedPlaylist>>, selected_page: SelectedPage) -> impl MakeWidget {
|
pub fn playlists_widget(
|
||||||
|
playlists: impl IntoValue<Vec<SimplifiedPlaylist>>,
|
||||||
|
selected_page: SelectedPage,
|
||||||
|
) -> impl MakeWidget {
|
||||||
let playlists: Value<Vec<SimplifiedPlaylist>> = playlists.into_value();
|
let playlists: Value<Vec<SimplifiedPlaylist>> = playlists.into_value();
|
||||||
Stack::new(
|
Stack::new(
|
||||||
Orientation::Row,
|
Orientation::Row,
|
||||||
playlists
|
playlists.map_each(move |t| {
|
||||||
.map_each(move |t| {
|
let mut list = t
|
||||||
let mut list = t.clone().into_iter().map(|playlist| playlist_entry(playlist, selected_page.clone())).collect::<WidgetList>();
|
.clone()
|
||||||
list.insert(0, liked_songs_entry(selected_page.clone()));
|
.into_iter()
|
||||||
list
|
.map(|playlist| playlist_entry(playlist, selected_page.clone()))
|
||||||
})
|
.collect::<WidgetList>();
|
||||||
|
list.insert(0, liked_songs_entry(selected_page.clone()));
|
||||||
|
list
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.vertical_scroll()
|
.vertical_scroll()
|
||||||
.expand()
|
.expand_horizontally()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn liked_songs_entry(selected_page: SelectedPage) -> impl MakeWidget {
|
fn liked_songs_entry(selected_page: SelectedPage) -> impl MakeWidget {
|
||||||
let is_active = selected_page.map_each(|page| matches!(page, ActivePage::LikedSongs));
|
let is_active = selected_page.map_each(|page| matches!(page, ActivePage::LikedSongs));
|
||||||
entry(
|
entry(
|
||||||
"Liked Songs",
|
"Liked Songs",
|
||||||
Dynamic::new(Some("https://misc.scdn.co/liked-songs/liked-songs-300.png".to_string())),
|
Dynamic::new(Some(
|
||||||
|
"https://misc.scdn.co/liked-songs/liked-songs-300.png".to_string(),
|
||||||
|
)),
|
||||||
is_active,
|
is_active,
|
||||||
move |_| {
|
move |_| {
|
||||||
selected_page.set(ActivePage::LikedSongs);
|
selected_page.set(ActivePage::LikedSongs);
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry<F>(text: impl IntoValue<String>, url: Dynamic<Option<String>>, is_active: Dynamic<bool>, callback: F) -> impl MakeWidget
|
fn entry<F>(
|
||||||
where F: FnMut(Option<ButtonClick>) + Send + 'static {
|
text: impl IntoValue<String>,
|
||||||
|
url: Dynamic<Option<String>>,
|
||||||
|
is_active: Dynamic<bool>,
|
||||||
|
callback: F,
|
||||||
|
) -> impl MakeWidget
|
||||||
|
where
|
||||||
|
F: FnMut(Option<ButtonClick>) + Send + 'static,
|
||||||
|
{
|
||||||
let (background, background_hover) = get_colors(is_active);
|
let (background, background_hover) = get_colors(is_active);
|
||||||
Image::new_empty()
|
Image::new_empty()
|
||||||
.with_url(
|
.with_url(url)
|
||||||
url
|
.and(text.into_value().align_left().expand())
|
||||||
)
|
.into_columns()
|
||||||
.and(
|
.into_button()
|
||||||
text
|
.on_click(callback)
|
||||||
.into_value()
|
.with(&ButtonBackground, background)
|
||||||
.align_left()
|
.with(&ButtonHoverBackground, background_hover)
|
||||||
.expand()
|
.pad()
|
||||||
)
|
|
||||||
.into_columns()
|
|
||||||
.into_button()
|
|
||||||
.on_click(callback)
|
|
||||||
.with(&ButtonBackground, background)
|
|
||||||
.with(&ButtonHoverBackground, background_hover)
|
|
||||||
.pad()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `background` and `background_hover` colors for a library entry.
|
/// Returns `background` and `background_hover` colors for a library entry.
|
||||||
fn get_colors(is_active: impl IntoValue<bool>) -> (Value<Color>, Value<Color>) {
|
fn get_colors(is_active: impl IntoValue<bool>) -> (Value<Color>, Value<Color>) {
|
||||||
let is_active= is_active.into_value();
|
let is_active = is_active.into_value();
|
||||||
let background = is_active.map_each(|active| {
|
let background = is_active.map_each(|active| {
|
||||||
if *active {
|
if *active {
|
||||||
LIBRARY_BG_SELECTED
|
LIBRARY_BG_SELECTED
|
||||||
|
|
@ -90,4 +109,4 @@ fn get_colors(is_active: impl IntoValue<bool>) -> (Value<Color>, Value<Color>) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
(background, background_hover)
|
(background, background_hover)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ use rspotify::model::{SimplifiedAlbum, SimplifiedPlaylist};
|
||||||
|
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod virtual_list;
|
|
||||||
pub mod probe;
|
|
||||||
pub mod owned;
|
pub mod owned;
|
||||||
|
pub mod pages;
|
||||||
|
|
||||||
#[derive(PartialEq, Debug, Default)]
|
#[derive(PartialEq, Debug, Default)]
|
||||||
pub enum ActivePage {
|
pub enum ActivePage {
|
||||||
|
|
|
||||||
100
src/widgets/pages/liked.rs
Normal file
100
src/widgets/pages/liked.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
ops::Range,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use cushy::{
|
||||||
|
figures::{units::Lp, Size},
|
||||||
|
styles::{Dimension, DimensionRange},
|
||||||
|
value::{Destination, Dynamic, Source},
|
||||||
|
widget::MakeWidget,
|
||||||
|
widgets::VirtualList,
|
||||||
|
};
|
||||||
|
use futures_util::lock::Mutex;
|
||||||
|
use rspotify::model::SavedTrack;
|
||||||
|
|
||||||
|
use crate::{api::SpotifyContextRef, nodebug::NoDebug, rt::tokio_runtime};
|
||||||
|
|
||||||
|
const PER_PAGE: usize = 50;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LikedSongsPage {
|
||||||
|
tracks: Dynamic<HashMap<usize, SavedTrack>>,
|
||||||
|
total_tracks: Dynamic<usize>,
|
||||||
|
|
||||||
|
context: NoDebug<SpotifyContextRef>,
|
||||||
|
pages_loading: Arc<RwLock<HashSet<usize>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LikedSongsPage {
|
||||||
|
pub fn new(context: SpotifyContextRef) -> Self {
|
||||||
|
Self {
|
||||||
|
context: context.into(),
|
||||||
|
|
||||||
|
tracks: Default::default(),
|
||||||
|
total_tracks: Default::default(),
|
||||||
|
pages_loading: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_widget(self) -> impl MakeWidget {
|
||||||
|
let tracks = self.tracks;
|
||||||
|
let pages_loading = self.pages_loading;
|
||||||
|
let context = self.context;
|
||||||
|
let total_tracks = self.total_tracks.clone();
|
||||||
|
VirtualList::new(
|
||||||
|
total_tracks.clone().map_each(|total| (*total).max(1)),
|
||||||
|
move |index| {
|
||||||
|
let context = context.clone();
|
||||||
|
let pages_loading = pages_loading.clone();
|
||||||
|
let total_tracks = total_tracks.clone();
|
||||||
|
let page = index / PER_PAGE;
|
||||||
|
tracks.map_ref({
|
||||||
|
let tracks = tracks.clone();
|
||||||
|
|loaded_tracks| {
|
||||||
|
if !loaded_tracks.contains_key(&index)
|
||||||
|
&& !pages_loading.read().unwrap().contains(&page)
|
||||||
|
{
|
||||||
|
pages_loading.write().unwrap().insert(page);
|
||||||
|
tokio_runtime().spawn(async move {
|
||||||
|
println!("Loading page {} idx {}", page, index);
|
||||||
|
let saved_tracks = context
|
||||||
|
.current_user_saved_tracks(
|
||||||
|
Some(PER_PAGE as _),
|
||||||
|
Some((page * PER_PAGE) as _),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let Ok(saved_tracks) = saved_tracks else {
|
||||||
|
eprintln!("Failed to load page {}", page);
|
||||||
|
// pages_loading.write().unwrap().remove(&page);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
println!("Loaded page {} got tracks {}", page, saved_tracks.total);
|
||||||
|
total_tracks.set(saved_tracks.total as usize);
|
||||||
|
tracks.map_mut(|mut tracks| {
|
||||||
|
for (i, track) in saved_tracks.items.into_iter().enumerate() {
|
||||||
|
tracks.insert(i + saved_tracks.offset as usize, track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tracks
|
||||||
|
.map_each(move |tracks| {
|
||||||
|
if let Some(track) = tracks.get(&index) {
|
||||||
|
format!("{} - {}", track.track.name, track.track.artists[0].name)
|
||||||
|
} else {
|
||||||
|
format!("Loading...")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.size(Size {
|
||||||
|
width: DimensionRange::default(),
|
||||||
|
height: Dimension::Lp(Lp::points(60)).into(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expand_horizontally()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/widgets/pages/mod.rs
Normal file
2
src/widgets/pages/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
|
||||||
|
pub mod liked;
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
use cushy::{figures::Fraction, value::{Destination, Dynamic, DynamicReader, IntoReadOnly, ReadOnly}, widget::{MakeWidget, Widget, WidgetRef}};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ScalingProbe {
|
|
||||||
child: WidgetRef,
|
|
||||||
scale: Dynamic<Fraction>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScalingProbe {
|
|
||||||
pub fn new(child: impl MakeWidget) -> Self {
|
|
||||||
Self {
|
|
||||||
child: WidgetRef::new(child),
|
|
||||||
scale: Dynamic::new(Fraction::new_whole(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scale(&self) -> DynamicReader<Fraction> {
|
|
||||||
self.scale.create_reader()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for ScalingProbe {
|
|
||||||
fn redraw(&mut self, context: &mut cushy::context::GraphicsContext<'_, '_, '_, '_>) {
|
|
||||||
self.scale.set(context.gfx.scale());
|
|
||||||
context.for_other(&self.child).expect("A child").redraw();
|
|
||||||
}
|
|
||||||
fn layout(
|
|
||||||
&mut self,
|
|
||||||
available_space: cushy::figures::Size<cushy::ConstraintLimit>,
|
|
||||||
context: &mut cushy::context::LayoutContext<'_, '_, '_, '_>,
|
|
||||||
) -> cushy::figures::Size<cushy::figures::units::UPx> {
|
|
||||||
let child = self.child.mounted(context);
|
|
||||||
context.for_other(&child).layout(available_space)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
use std::{collections::VecDeque, fmt::Debug, ops::Range};
|
|
||||||
|
|
||||||
use cushy::{context::{AsEventContext, EventContext}, figures::{units::{Px, UPx}, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}, kludgine::app::winit::{event::{ MouseScrollDelta, TouchPhase}, window::CursorIcon}, styles::Dimension, value::{Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, Source}, widget::{EventHandling, MakeWidget, MountedWidget, Widget, HANDLED, IGNORED}, widgets::scroll::ScrollBar, window::DeviceId, ConstraintLimit};
|
|
||||||
|
|
||||||
use super::owned::OwnedWidget;
|
|
||||||
|
|
||||||
/// A virtual list contents.
|
|
||||||
/// This simple virtual list assumes that all items have the same height, width and that the item count is known.
|
|
||||||
/// All the values are dynamic, so the list will update when the values change.
|
|
||||||
pub trait VirtualListContent: Debug {
|
|
||||||
/// Single item height
|
|
||||||
fn item_height(&self) -> impl IntoValue<Dimension>;
|
|
||||||
/// Width of the items
|
|
||||||
fn width(&self) -> impl IntoValue<Dimension>;
|
|
||||||
/// Number of items
|
|
||||||
fn item_count(&self) -> impl IntoValue<usize>;
|
|
||||||
/// Create a widget for the item at the given index.
|
|
||||||
/// This is called when the widget comes into view. The widget may be removed at any moment (by scrolling it out of view) and recreated later.
|
|
||||||
fn widget_at(&self, index: usize) -> impl MakeWidget;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct VirtualListItem {
|
|
||||||
index: usize,
|
|
||||||
mounted: MountedWidget,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// A virtual list widget.
|
|
||||||
/// Requires a [VirtualListContent] trait implementation to render the items.
|
|
||||||
/// Items are lazily recreated as they go in and out of view.
|
|
||||||
pub struct VirtualList<T: VirtualListContent + Send + 'static> {
|
|
||||||
virtual_list: T,
|
|
||||||
vertical_scroll: OwnedWidget<ScrollBar>,
|
|
||||||
items: VecDeque<VirtualListItem>,
|
|
||||||
content_size: Dynamic<Size<UPx>>,
|
|
||||||
/// Maximum scroll value - max_scroll.y + control_size.height should be the height of the content.
|
|
||||||
pub max_scroll: DynamicReader<Point<UPx>>,
|
|
||||||
/// Current scroll value. The x value is always 0. Change the value to scroll the widget programmatically.
|
|
||||||
pub scroll: Dynamic<Point<UPx>>,
|
|
||||||
control_size: Dynamic<Size<UPx>>,
|
|
||||||
|
|
||||||
/// Height of an item. Based on [VirtualListContent::item_height].
|
|
||||||
pub item_height: DynamicReader<Dimension>,
|
|
||||||
/// Width of the items. Based on [VirtualListContent::width].
|
|
||||||
pub width: DynamicReader<Dimension>,
|
|
||||||
/// Number of items. Based on [VirtualListContent::item_count].
|
|
||||||
pub item_count: DynamicReader<usize>,
|
|
||||||
|
|
||||||
visible_range: Dynamic<Range<usize>>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
|
|
||||||
pub fn new(virtual_list: T) -> Self {
|
|
||||||
let scroll = Dynamic::<Point<UPx>>::default();
|
|
||||||
let item_height = virtual_list.item_height().into_value().into_dynamic().create_reader();
|
|
||||||
let width = virtual_list.width().into_value().into_dynamic().create_reader();
|
|
||||||
let item_count = virtual_list.item_count().into_value().into_dynamic().create_reader();
|
|
||||||
let content_size = Dynamic::new(Size::default());
|
|
||||||
|
|
||||||
let y = scroll.map_each_cloned(|scroll| scroll.y);
|
|
||||||
y.for_each_cloned({
|
|
||||||
let scroll = scroll.clone();
|
|
||||||
move |y| {
|
|
||||||
if let Ok(mut scroll) = scroll.try_lock() {
|
|
||||||
if scroll.y != y {
|
|
||||||
scroll.y = y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.persist();
|
|
||||||
let vertical =
|
|
||||||
ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true);
|
|
||||||
let max_scroll = (&vertical.max_scroll())
|
|
||||||
.map_each_cloned(|y| Point::new(UPx::ZERO, y))
|
|
||||||
.into_reader();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
virtual_list,
|
|
||||||
vertical_scroll: OwnedWidget::new(vertical),
|
|
||||||
items: VecDeque::new(),
|
|
||||||
control_size: Dynamic::new(Size::default()),
|
|
||||||
content_size,
|
|
||||||
max_scroll,
|
|
||||||
scroll,
|
|
||||||
|
|
||||||
item_height,
|
|
||||||
width,
|
|
||||||
item_count,
|
|
||||||
visible_range: Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reader for the maximum scroll value.
|
|
||||||
///
|
|
||||||
/// This represents the maximum amount that the scroll can be moved by.
|
|
||||||
#[must_use]
|
|
||||||
pub const fn max_scroll(&self) -> &DynamicReader<Point<UPx>> {
|
|
||||||
&self.max_scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reader for the size of the scrollable area.
|
|
||||||
#[must_use]
|
|
||||||
pub fn content_size(&self) -> DynamicReader<Size<UPx>> {
|
|
||||||
self.content_size.create_reader()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reader for the size of this Scroll widget.
|
|
||||||
#[must_use]
|
|
||||||
pub fn control_size(&self) -> DynamicReader<Size<UPx>> {
|
|
||||||
self.control_size.create_reader()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a reader for number of visible items. 0 indexed.
|
|
||||||
#[must_use]
|
|
||||||
pub fn visible_range(&self) -> DynamicReader<Range<usize>> {
|
|
||||||
self.visible_range.create_reader()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_scrollbars(&mut self, context: &mut EventContext<'_>) {
|
|
||||||
let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock();
|
|
||||||
vertical
|
|
||||||
.downcast_mut::<ScrollBar>()
|
|
||||||
.expect("a ScrollBar")
|
|
||||||
.show(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hide_scrollbars(&mut self, context: &mut EventContext<'_>) {
|
|
||||||
let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock();
|
|
||||||
vertical
|
|
||||||
.downcast_mut::<ScrollBar>()
|
|
||||||
.expect("a ScrollBar")
|
|
||||||
.hide(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: VirtualListContent + Send + 'static> Widget for VirtualList<T> {
|
|
||||||
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hover(
|
|
||||||
&mut self,
|
|
||||||
_location: Point<Px>,
|
|
||||||
context: &mut EventContext<'_>,
|
|
||||||
) -> Option<CursorIcon> {
|
|
||||||
self.show_scrollbars(context);
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unhover(&mut self, context: &mut EventContext<'_>) {
|
|
||||||
self.hide_scrollbars(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mounted(&mut self, context: &mut EventContext<'_>) {
|
|
||||||
for child in &mut self.items {
|
|
||||||
child.mounted.remount_if_needed(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redraw(&mut self, context: &mut cushy::context::GraphicsContext<'_, '_, '_, '_>) {
|
|
||||||
for child in &mut self.items {
|
|
||||||
context.for_other(&child.mounted).redraw();
|
|
||||||
}
|
|
||||||
let vertical = self
|
|
||||||
.vertical_scroll
|
|
||||||
.expect_made_mut()
|
|
||||||
.mounted(&mut context.as_event_context());
|
|
||||||
context.for_other(&vertical).redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(
|
|
||||||
&mut self,
|
|
||||||
available_space: Size<cushy::ConstraintLimit>,
|
|
||||||
context: &mut cushy::context::LayoutContext<'_, '_, '_, '_>,
|
|
||||||
) -> Size<UPx> {
|
|
||||||
let item_height = self.item_height.get_tracking_invalidate(context);
|
|
||||||
let item_height_upx = item_height.into_upx(context.gfx.scale());
|
|
||||||
let item_count = self.item_count.get_tracking_invalidate(context);
|
|
||||||
let content_height = item_height * item_count as i32;
|
|
||||||
let content_height = content_height.into_upx(context.gfx.scale());
|
|
||||||
let width = self.width.get_tracking_invalidate(context);
|
|
||||||
let width = width.into_upx(context.gfx.scale());
|
|
||||||
|
|
||||||
let new_control_size = Size::new(
|
|
||||||
width,
|
|
||||||
constrain_child(available_space.height, content_height),
|
|
||||||
);
|
|
||||||
|
|
||||||
let vertical = self
|
|
||||||
.vertical_scroll
|
|
||||||
.make_if_needed()
|
|
||||||
.mounted(&mut context.as_event_context());
|
|
||||||
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
|
|
||||||
context.set_child_layout(
|
|
||||||
&vertical,
|
|
||||||
Rect::new(
|
|
||||||
Point::new(
|
|
||||||
available_space.width
|
|
||||||
.fit_measured(new_control_size.width)
|
|
||||||
.saturating_sub(scrollbar_layout.width)
|
|
||||||
.into_signed(),
|
|
||||||
Px::ZERO,
|
|
||||||
),
|
|
||||||
scrollbar_layout.into_signed(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let scroll = self.scroll.get_tracking_invalidate(context);
|
|
||||||
|
|
||||||
let start_item = (scroll.y / item_height_upx).floor().get() as usize;
|
|
||||||
let end_item = ((scroll.y + new_control_size.height) / item_height_upx).ceil().get() as usize;
|
|
||||||
let end_item = end_item.min(item_count-1);
|
|
||||||
|
|
||||||
self.visible_range.set(start_item..end_item);
|
|
||||||
|
|
||||||
let first = self.items.front().map(|t| t.index);
|
|
||||||
let last = self.items.back().map(|t| t.index);
|
|
||||||
let mut closure = |index| {
|
|
||||||
let widget = self.virtual_list.widget_at(index);
|
|
||||||
let mut widget = widget.widget_ref();
|
|
||||||
let mounted = widget.mounted(&mut context.as_event_context());
|
|
||||||
VirtualListItem { index, mounted }
|
|
||||||
};
|
|
||||||
if self.items.is_empty() || first.unwrap() > end_item || last.unwrap() < start_item {
|
|
||||||
self.items.clear();
|
|
||||||
self.items.extend((start_item..=end_item).map(closure));
|
|
||||||
} else {
|
|
||||||
let first = first.expect("List is not empty");
|
|
||||||
let last = last.expect("List is not empty");
|
|
||||||
if first < start_item {
|
|
||||||
while self.items.front().is_some() && self.items.front().expect("Checked is some").index < start_item {
|
|
||||||
self.items.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if last > end_item {
|
|
||||||
while self.items.back().is_some() && self.items.back().expect("Checked is some").index > end_item {
|
|
||||||
self.items.pop_back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// no extend front :(
|
|
||||||
for item in (start_item..first).rev() {
|
|
||||||
self.items.push_front(closure(item));
|
|
||||||
}
|
|
||||||
self.items.extend(((last+1)..=end_item).map(closure));
|
|
||||||
}
|
|
||||||
|
|
||||||
let item_size = Size::new(width, item_height_upx);
|
|
||||||
let constraint = item_size.map(ConstraintLimit::Fill);
|
|
||||||
|
|
||||||
for item in &self.items {
|
|
||||||
context.for_other(&item.mounted).layout(constraint);
|
|
||||||
}
|
|
||||||
|
|
||||||
let item_size = item_size.into_signed();
|
|
||||||
let scroll = self.scroll.get_tracking_invalidate(context).into_signed();
|
|
||||||
|
|
||||||
for item in &self.items {
|
|
||||||
context.set_child_layout(
|
|
||||||
&item.mounted,
|
|
||||||
Rect::new(
|
|
||||||
Point::new(Px::ZERO, (item_height_upx * item.index as f32).into_signed() - scroll.y),
|
|
||||||
item_size,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.control_size.set(new_control_size);
|
|
||||||
self.content_size.set(Size::new(width, content_height));
|
|
||||||
|
|
||||||
new_control_size
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mouse_wheel(
|
|
||||||
&mut self,
|
|
||||||
_device_id: DeviceId,
|
|
||||||
delta: MouseScrollDelta,
|
|
||||||
_phase: TouchPhase,
|
|
||||||
context: &mut EventContext<'_>,
|
|
||||||
) -> EventHandling {
|
|
||||||
let mut handled = false;
|
|
||||||
{
|
|
||||||
let mut vertical = self.vertical_scroll.expect_made().widget().lock();
|
|
||||||
handled |= vertical
|
|
||||||
.downcast_mut::<ScrollBar>()
|
|
||||||
.expect("a ScrollBar")
|
|
||||||
.mouse_wheel(delta, context)
|
|
||||||
.is_break();
|
|
||||||
}
|
|
||||||
if handled {
|
|
||||||
self.show_scrollbars(context);
|
|
||||||
context.set_needs_redraw();
|
|
||||||
|
|
||||||
HANDLED
|
|
||||||
} else {
|
|
||||||
IGNORED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn constrain_child(constraint: ConstraintLimit, measured: UPx) -> UPx {
|
|
||||||
match constraint {
|
|
||||||
ConstraintLimit::Fill(size) => size.min(measured),
|
|
||||||
// change from Scroll widget: returning just measured here would break the functionality (render too many items)
|
|
||||||
ConstraintLimit::SizeToFit(size) => size.min(measured),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue