diff --git a/.env.example b/.env.example index ef8c8dd..893f587 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes # POSTGRES_HOST=localhost # POSTGRES_PORT=5432 # POSTGRES_DB=libretunes + +LIBRETUNES_AUDIO_PATH=assets/audio +LIBRETUNES_IMAGE_PATH=assets/images diff --git a/.gitignore b/.gitignore index ab27e5e..f472188 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ playwright/.cache/ *.jpeg *.png *.gif +*.webp # Environment variables .env diff --git a/Cargo.lock b/Cargo.lock index 44b8a0a..a726130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -378,13 +378,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.37" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-targets 0.52.4", ] @@ -690,11 +693,11 @@ checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" dependencies = [ "bitflags 2.5.0", "byteorder", + "chrono", "diesel_derives", "itoa", "pq-sys", "r2d2", - "time", ] [[package]] @@ -752,10 +755,10 @@ dependencies = [ ] [[package]] -name = "dotenv" -version = "0.15.0" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "drain_filter_polyfill" @@ -1563,9 +1566,9 @@ dependencies = [ [[package]] name = "leptos-use" -version = "0.13.5" +version = "0.13.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb" +checksum = "32d4708472867704085a2813c47cada122a6e8c3b90ccff764862c0b351bfb96" dependencies = [ "cfg-if", "codee", @@ -1834,10 +1837,11 @@ dependencies = [ "axum", "axum-login", "cfg-if", + "chrono", "console_error_panic_hook", "diesel", "diesel_migrations", - "dotenv", + "dotenvy", "flexi_logger", "http 1.1.0", "icondata", @@ -1857,7 +1861,6 @@ dependencies = [ "server_fn", "symphonia", "thiserror", - "time", "tokio", "tower 0.5.1", "tower-http", @@ -3441,9 +3444,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -3452,9 +3455,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -3479,9 +3482,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3489,9 +3492,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -3502,9 +3505,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-streams" diff --git a/Cargo.toml b/Cargo.toml index 2bdcda4..1f30a39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,15 +15,13 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] } leptos_meta = { version = "0.6", features = ["nightly"] } leptos_axum = { version = "0.6", optional = true } leptos_router = { version = "0.6", features = ["nightly"] } -wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true } +wasm-bindgen = { version = "=0.2.95", default-features = false, optional = true } leptos_icons = { version = "0.3.0" } icondata = { version = "0.3.0" } -dotenv = { version = "0.15.0", optional = true } -diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], default-features = false, optional = true } +diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true } lazy_static = { version = "1.4.0", optional = true } serde = { version = "1.0.195", features = ["derive"], default-features = false } openssl = { version = "0.10.63", optional = true } -time = { version = "0.3.34", features = ["serde"], default-features = false } diesel_migrations = { version = "2.1.0", optional = true } pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } tokio = { version = "1", optional = true, features = ["rt-multi-thread"] } @@ -42,6 +40,8 @@ flexi_logger = { version = "0.28.0", optional = true, default-features = false } web-sys = "0.3.69" leptos-use = "0.13.5" image-convert = { version = "0.18.0", optional = true, default-features = false } +chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] } +dotenvy = { version = "0.15.7", optional = true } [features] hydrate = [ @@ -50,13 +50,14 @@ hydrate = [ "leptos_router/hydrate", "console_error_panic_hook", "wasm-bindgen", + "chrono/wasmbind", ] ssr = [ "dep:leptos_axum", "leptos/ssr", "leptos_meta/ssr", "leptos_router/ssr", - "dotenv", + "dotenvy", "diesel", "lazy_static", "openssl", diff --git a/docker-compose.yml b/docker-compose.yml index c2d0865..c3d466d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,11 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} + LIBRETUNES_AUDIO_PATH: /assets/audio + LIBRETUNES_IMAGE_PATH: /assets/images volumes: - - libretunes-audio:/site/audio + - libretunes-audio:/assets/audio + - libretunes-images:/assets/images depends_on: - redis - postgres @@ -50,5 +53,6 @@ services: volumes: libretunes-audio: + libretunes-images: libretunes-redis: libretunes-postgres: diff --git a/migrations/2024-10-22-212759_create_playlist_tables/down.sql b/migrations/2024-10-22-212759_create_playlist_tables/down.sql new file mode 100644 index 0000000..defde9d --- /dev/null +++ b/migrations/2024-10-22-212759_create_playlist_tables/down.sql @@ -0,0 +1,5 @@ +DROP INDEX playlists_owner_idx; +DROP TABLE playlists; + +DROP INDEX playlist_songs_playlist_idx; +DROP TABLE playlist_songs; diff --git a/migrations/2024-10-22-212759_create_playlist_tables/up.sql b/migrations/2024-10-22-212759_create_playlist_tables/up.sql new file mode 100644 index 0000000..cf35cb8 --- /dev/null +++ b/migrations/2024-10-22-212759_create_playlist_tables/up.sql @@ -0,0 +1,17 @@ +CREATE TABLE playlists ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + owner_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + name TEXT NOT NULL +); + +CREATE INDEX playlists_owner_idx ON playlists(owner_id); + +CREATE TABLE playlist_songs ( + playlist_id INTEGER REFERENCES playlists(id) ON DELETE CASCADE NOT NULL, + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (playlist_id, song_id) +); + +CREATE INDEX playlist_songs_playlist_idx ON playlist_songs(playlist_id); diff --git a/src/albumdata.rs b/src/albumdata.rs index e42baea..2b86b25 100644 --- a/src/albumdata.rs +++ b/src/albumdata.rs @@ -1,7 +1,7 @@ use crate::models::Artist; use crate::components::dashboard_tile::DashboardTile; -use time::Date; +use chrono::NaiveDate; /// Holds information about an album /// @@ -14,7 +14,7 @@ pub struct AlbumData { /// Album artists pub artists: Vec, /// Album release date - pub release_date: Option, + pub release_date: Option, /// Path to album image, relative to the root of the web server. /// For example, `"/assets/images/Album.jpg"` pub image_path: String, diff --git a/src/api/history.rs b/src/api/history.rs index 5f6cabb..697b255 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -1,4 +1,4 @@ -use std::time::SystemTime; +use chrono::NaiveDateTime; use leptos::*; use crate::models::HistoryEntry; use crate::models::Song; @@ -25,7 +25,7 @@ pub async fn get_history(limit: Option) -> Result, Server /// Get the listen dates and songs of the current user. #[server(endpoint = "history/get_songs")] -pub async fn get_history_songs(limit: Option) -> Result, ServerFnError> { +pub async fn get_history_songs(limit: Option) -> Result, ServerFnError> { let user = get_user().await?; let db_con = &mut get_db_conn(); let songs = user.get_history_songs(limit, db_con) diff --git a/src/api/profile.rs b/src/api/profile.rs index 790af13..f994b49 100644 --- a/src/api/profile.rs +++ b/src/api/profile.rs @@ -3,10 +3,23 @@ use server_fn::codec::{MultipartData, MultipartFormData}; use cfg_if::cfg_if; +use crate::songdata::SongData; +use crate::artistdata::ArtistData; + +use chrono::NaiveDateTime; + cfg_if! { if #[cfg(feature = "ssr")] { use crate::auth::get_user; use server_fn::error::NoCustomError; + + use crate::database::get_db_conn; + use diesel::prelude::*; + use diesel::dsl::count; + use crate::models::*; + use crate::schema::*; + + use std::collections::HashMap; } } @@ -47,3 +60,241 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> { Ok(()) } + +/// Get a user's recent songs listened to +/// Optionally takes a limit parameter to limit the number of songs returned. +/// If not provided, all songs ever listend to are returned. +/// Returns a list of tuples with the date the song was listened to +/// and the song data, sorted by date (most recent first). +#[server(endpoint = "/profile/recent_songs")] +pub async fn recent_songs(for_user_id: i32, limit: Option) -> Result, ServerFnError> { + let mut db_con = get_db_conn(); + + // Get the ids of the most recent songs listened to + let history_items: Vec = + if let Some(limit) = limit { + song_history::table + .filter(song_history::user_id.eq(for_user_id)) + .order(song_history::date.desc()) + .limit(limit) + .select(song_history::id) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::user_id.eq(for_user_id)) + .order(song_history::date.desc()) + .select(song_history::id) + .load(&mut db_con)? + }; + + // Take the history ids and get the song data for them + let history: Vec<(HistoryEntry, Song, Option, Option, Option<(i32, i32)>, Option<(i32, i32)>)> + = song_history::table + .filter(song_history::id.eq_any(history_items)) + .inner_join(songs::table) + .left_join(albums::table.on(songs::album_id.eq(albums::id.nullable()))) + .left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id))) + .left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id)))) + .left_join(song_dislikes::table.on( + songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id)))) + .select(( + song_history::all_columns, + songs::all_columns, + albums::all_columns.nullable(), + artists::all_columns.nullable(), + song_likes::all_columns.nullable(), + song_dislikes::all_columns.nullable(), + )) + .load(&mut db_con)?; + + // Process the history data into a map of song ids to song data + let mut history_songs: HashMap = HashMap::with_capacity(history.len()); + + for (history, song, album, artist, like, dislike) in history { + let song_id = history.song_id; + + if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) { + // If the song is already in the map, update the artists + if let Some(artist) = artist { + stored_songdata.artists.push(artist); + } + } else { + let like_dislike = match (like, dislike) { + (Some(_), Some(_)) => Some((true, true)), + (Some(_), None) => Some((true, false)), + (None, Some(_)) => Some((false, true)), + _ => None, + }; + + let image_path = song.image_path.unwrap_or( + album.as_ref().map(|album| album.image_path.clone()).flatten() + .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())); + + let songdata = SongData { + id: song_id, + title: song.title, + artists: artist.map(|artist| vec![artist]).unwrap_or_default(), + album: album, + track: song.track, + duration: song.duration, + release_date: song.release_date, + song_path: song.storage_path, + image_path: image_path, + like_dislike: like_dislike, + }; + + history_songs.insert(song_id, (history.date, songdata)); + } + } + + // Sort the songs by date + let mut history_songs: Vec<(NaiveDateTime, SongData)> = history_songs.into_values().collect(); + history_songs.sort_by(|a, b| b.0.cmp(&a.0)); + Ok(history_songs) +} + +/// Get a user's top songs by play count from a date range +/// Optionally takes a limit parameter to limit the number of songs returned. +/// If not provided, all songs listened to in the date range are returned. +/// Returns a list of tuples with the play count and the song data, sorted by play count (most played first). +#[server(endpoint = "/profile/top_songs")] +pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option) + -> Result, ServerFnError> +{ + let mut db_con = get_db_conn(); + + // Get the play count and ids of the songs listened to in the date range + let history_counts: Vec<(i32, i64)> = + if let Some(limit) = limit { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .group_by(song_history::song_id) + .select((song_history::song_id, count(song_history::song_id))) + .order(count(song_history::song_id).desc()) + .limit(limit) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .group_by(song_history::song_id) + .select((song_history::song_id, count(song_history::song_id))) + .load(&mut db_con)? + }; + + let history_counts: HashMap = history_counts.into_iter().collect(); + let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::>(); + + // Get the song data for the songs listened to in the date range + let history_songs: Vec<(Song, Option, Option, Option<(i32, i32)>, Option<(i32, i32)>)> + = songs::table + .filter(songs::id.eq_any(history_song_ids)) + .left_join(albums::table.on(songs::album_id.eq(albums::id.nullable()))) + .left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id))) + .left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id)))) + .left_join(song_dislikes::table.on( + songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id)))) + .select(( + songs::all_columns, + albums::all_columns.nullable(), + artists::all_columns.nullable(), + song_likes::all_columns.nullable(), + song_dislikes::all_columns.nullable(), + )) + .load(&mut db_con)?; + + // Process the history data into a map of song ids to song data + let mut history_songs_map: HashMap = HashMap::with_capacity(history_counts.len()); + + for (song, album, artist, like, dislike) in history_songs { + let song_id = song.id + .ok_or(ServerFnError::ServerError::("Song id not found in database".to_string()))?; + + if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) { + // If the song is already in the map, update the artists + if let Some(artist) = artist { + stored_songdata.artists.push(artist); + } + } else { + let like_dislike = match (like, dislike) { + (Some(_), Some(_)) => Some((true, true)), + (Some(_), None) => Some((true, false)), + (None, Some(_)) => Some((false, true)), + _ => None, + }; + + let image_path = song.image_path.unwrap_or( + album.as_ref().map(|album| album.image_path.clone()).flatten() + .unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())); + + let songdata = SongData { + id: song_id, + title: song.title, + artists: artist.map(|artist| vec![artist]).unwrap_or_default(), + album: album, + track: song.track, + duration: song.duration, + release_date: song.release_date, + song_path: song.storage_path, + image_path: image_path, + like_dislike: like_dislike, + }; + + let plays = history_counts.get(&song_id) + .ok_or(ServerFnError::ServerError::("Song id not found in history counts".to_string()))?; + + history_songs_map.insert(song_id, (*plays, songdata)); + } + } + + // Sort the songs by play count + let mut history_songs: Vec<(i64, SongData)> = history_songs_map.into_values().collect(); + history_songs.sort_by(|a, b| b.0.cmp(&a.0)); + Ok(history_songs) +} + +/// Get a user's top artists by play count from a date range +/// Optionally takes a limit parameter to limit the number of artists returned. +/// If not provided, all artists listened to in the date range are returned. +/// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first). +#[server(endpoint = "/profile/top_artists")] +pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option) + -> Result, ServerFnError> +{ + let mut db_con = get_db_conn(); + + let artist_counts: Vec<(i64, Artist)> = + if let Some(limit) = limit { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) + .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) + .group_by(artists::id) + .select((count(artists::id), artists::all_columns)) + .order(count(artists::id).desc()) + .limit(limit) + .load(&mut db_con)? + } else { + song_history::table + .filter(song_history::date.between(start_date, end_date)) + .filter(song_history::user_id.eq(for_user_id)) + .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) + .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) + .group_by(artists::id) + .select((count(artists::id), artists::all_columns)) + .order(count(artists::id).desc()) + .load(&mut db_con)? + }; + + let artist_data: Vec<(i64, ArtistData)> = artist_counts.into_iter().map(|(plays, artist)| { + (plays, ArtistData { + id: artist.id.unwrap(), + name: artist.name, + image_path: format!("/assets/images/artists/{}.webp", artist.id.unwrap()), + }) + }).collect(); + + Ok(artist_data) +} diff --git a/src/app.rs b/src/app.rs index e3e9226..4784702 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,23 +1,23 @@ use crate::playbar::PlayBar; use crate::playbar::CustomTitle; -use crate::playstatus::PlayStatus; use crate::queue::Queue; use leptos::*; use leptos_meta::*; use leptos_router::*; use crate::pages::login::*; use crate::pages::signup::*; +use crate::pages::profile::*; use crate::pages::albumpage::*; use crate::error_template::{AppError, ErrorTemplate}; - +use crate::util::state::GlobalState; #[component] pub fn App() -> impl IntoView { // Provides context that manages stylesheets, titles, meta tags, etc. provide_meta_context(); - let play_status = PlayStatus::default(); - let play_status = create_rw_signal(play_status); + provide_context(GlobalState::new()); + let upload_open = create_rw_signal(false); view! { @@ -26,7 +26,7 @@ pub fn App() -> impl IntoView { // sets the document title - + // content for this welcome page impl IntoView { }>
- }> + }> + + @@ -56,12 +58,12 @@ pub fn App() -> impl IntoView { use crate::components::sidebar::*; use crate::components::dashboard::*; use crate::components::search::*; -use crate::components::personal::*; +use crate::components::personal::Personal; use crate::components::upload::*; /// Renders the home page of your application. #[component] -fn HomePage(play_status: RwSignal, upload_open: RwSignal) -> impl IntoView { +fn HomePage(upload_open: RwSignal) -> impl IntoView { view! {
@@ -69,8 +71,8 @@ fn HomePage(play_status: RwSignal, upload_open: RwSignal) -> i // This will render the child route components - - + +
} } diff --git a/src/artistdata.rs b/src/artistdata.rs index e799679..401979d 100644 --- a/src/artistdata.rs +++ b/src/artistdata.rs @@ -1,8 +1,10 @@ use crate::components::dashboard_tile::DashboardTile; +use serde::{Serialize, Deserialize}; /// Holds information about an artist /// /// Intended to be used in the front-end +#[derive(Clone, Serialize, Deserialize)] pub struct ArtistData { /// Artist id pub id: i32, diff --git a/src/auth.rs b/src/auth.rs index 37f861f..558bfe2 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -57,7 +57,7 @@ pub async fn signup(new_user: User) -> Result<(), ServerFnError> { /// Takes in a username or email and a password in plaintext /// Returns a Result with a boolean indicating if the login was successful #[server(endpoint = "login")] -pub async fn login(credentials: UserCredentials) -> Result { +pub async fn login(credentials: UserCredentials) -> Result, ServerFnError> { use crate::users::validate_user; let mut auth_session = extract::>().await @@ -66,12 +66,14 @@ pub async fn login(credentials: UserCredentials) -> Result let user = validate_user(credentials).await .map_err(|e| ServerFnError::::ServerError(format!("Error validating user: {}", e)))?; - if let Some(user) = user { + if let Some(mut user) = user { auth_session.login(&user).await .map_err(|e| ServerFnError::::ServerError(format!("Error logging in user: {}", e)))?; - Ok(true) + + user.password = None; + Ok(Some(user)) } else { - Ok(false) + Ok(None) } } @@ -145,6 +147,19 @@ pub async fn get_user() -> Result { auth_session.user.ok_or(ServerFnError::::ServerError("User not logged in".to_string())) } +#[server(endpoint = "get_logged_in_user")] +pub async fn get_logged_in_user() -> Result, ServerFnError> { + let auth_session = extract::>().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting auth session: {}", e)))?; + + let user = auth_session.user.map(|mut user| { + user.password = None; + user + }); + + Ok(user) +} + /// Check if a user is an admin /// Returns a Result with a boolean indicating if the user is logged in and an admin #[server(endpoint = "check_admin")] diff --git a/src/components.rs b/src/components.rs index 893727c..2624877 100644 --- a/src/components.rs +++ b/src/components.rs @@ -6,3 +6,5 @@ pub mod dashboard_tile; pub mod dashboard_row; pub mod upload; pub mod song_list; +pub mod loading; +pub mod error; diff --git a/src/components/error.rs b/src/components/error.rs new file mode 100644 index 0000000..ae2f18a --- /dev/null +++ b/src/components/error.rs @@ -0,0 +1,45 @@ +use leptos::*; +use leptos_icons::*; +use std::fmt::Display; + +#[component] +pub fn ServerError( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option>, +) -> impl IntoView { + view!{ +
+
+ +

{title}

+
+

{message}

+

{error.map(|error| format!("{}", error))}

+
+ } +} + +#[component] +pub fn Error( + #[prop(optional, into, default="An Error Occurred".into())] + title: TextProp, + #[prop(optional, into)] + message: TextProp, + #[prop(optional, into)] + error: Option, +) -> impl IntoView { + view! { +
+
+ +

{title}

+
+

{message}

+

{error.map(|error| format!("{}", error))}

+
+ } +} diff --git a/src/components/loading.rs b/src/components/loading.rs new file mode 100644 index 0000000..b3de9cd --- /dev/null +++ b/src/components/loading.rs @@ -0,0 +1,19 @@ +use leptos::*; + +/// A loading indicator +#[component] +pub fn Loading() -> impl IntoView { + view! { +
+ } +} + +/// A full page, centered loading indicator +#[component] +pub fn LoadingPage() -> impl IntoView { + view!{ +
+ +
+ } +} diff --git a/src/components/song_list.rs b/src/components/song_list.rs index 2ace92a..3da379a 100644 --- a/src/components/song_list.rs +++ b/src/components/song_list.rs @@ -1,49 +1,109 @@ +use std::rc::Rc; + use leptos::*; +use leptos::logging::*; use leptos_icons::*; +use crate::api::songs::*; use crate::songdata::SongData; use crate::models::{Album, Artist}; +use crate::util::state::GlobalState; const LIKE_DISLIKE_BTN_SIZE: &str = "2em"; #[component] -pub fn SongList(songs: MaybeSignal>) -> impl IntoView { +pub fn SongList(songs: Vec) -> impl IntoView { + __SongListInner(songs.into_iter().map(|song| (song, ())).collect::>(), false) +} + +#[component] +pub fn SongListExtra(songs: Vec<(SongData, T)>) -> impl IntoView where + T: Clone + IntoView + 'static +{ + __SongListInner(songs, true) +} + +#[component] +fn SongListInner(songs: Vec<(SongData, T)>, show_extra: bool) -> impl IntoView where + T: Clone + IntoView + 'static +{ + let songs = Rc::new(songs); + let songs_2 = songs.clone(); + + // Signal that acts as a callback for a song list item to queue songs after it in the list + let (handle_queue_remaining, do_queue_remaining) = create_signal(None); + create_effect(move |_| { + let clicked_index = handle_queue_remaining.get(); + + if let Some(index) = clicked_index { + GlobalState::play_status().update(|status| { + let song: &(SongData, T) = songs.get(index).expect("Invalid song list item index"); + + if status.queue.front().map(|song| song.id) == Some(song.0.id) { + // If the clicked song is already at the front of the queue, just play it + status.playing = true; + } else { + // Otherwise, add the currently playing song to the history, + // clear the queue, and queue the clicked song and other after it + if let Some(last_playing) = status.queue.pop_front() { + status.history.push_back(last_playing); + } + + status.queue.clear(); + status.queue.extend(songs.iter().skip(index).map(|(song, _)| song.clone())); + status.playing = true; + } + }); + } + }); + view! { { - songs.with(|songs| { - let mut first_song = true; + songs_2.iter().enumerate().map(|(list_index, (song, extra))| { + let song_id = song.id; + let playing = create_rw_signal(false); - songs.iter().map(|song| { - let playing = first_song.into(); - first_song = false; + create_effect(move |_| { + GlobalState::play_status().with(|status| { + playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing); + }); + }); - view! { - - } - }).collect::>() - }) + view! { + + } + }).collect::>() }
} } #[component] -pub fn SongListItem(song: SongData, song_playing: MaybeSignal) -> impl IntoView { +pub fn SongListItem(song: SongData, song_playing: MaybeSignal, extra: Option, + list_index: usize, do_queue_remaining: WriteSignal>) -> impl IntoView where + T: IntoView + 'static +{ let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false)); let disliked = create_rw_signal(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false)); view! { - +

{song.title}

- + {format!("{}:{:02}", song.duration / 60, song.duration % 60)} + {extra.map(|extra| view! { + + {extra} + })} } } @@ -51,13 +111,27 @@ pub fn SongListItem(song: SongData, song_playing: MaybeSignal) -> impl Int /// Display the song's image, with an overlay if the song is playing /// When the song list item is hovered, the overlay will show the play button #[component] -fn SongImage(image_path: String, song_playing: MaybeSignal) -> impl IntoView { +fn SongImage(image_path: String, song_playing: MaybeSignal, list_index: usize, + do_queue_remaining: WriteSignal>) -> impl IntoView +{ + let play_song = move |_| { + do_queue_remaining.set(Some(list_index)); + }; + + let pause_song = move |_| { + GlobalState::play_status().update(|status| { + status.playing = false; + }); + }; + view! { - {if song_playing.get() { - view! { }.into_view() + {move || if song_playing.get() { + view! { }.into_view() } else { - view! { }.into_view() + view! { }.into_view() }} } } @@ -103,7 +177,12 @@ fn SongAlbum(album: Option) -> impl IntoView { /// Display like and dislike buttons for a song, and indicate if the song is liked or disliked #[component] -fn SongLikeDislike(liked: RwSignal, disliked: RwSignal) -> impl IntoView { +fn SongLikeDislike( + #[prop(into)] + song_id: MaybeSignal, + liked: RwSignal, + disliked: RwSignal) -> impl IntoView +{ let like_icon = Signal::derive(move || { if liked.get() { icondata::TbThumbUpFilled @@ -136,14 +215,48 @@ fn SongLikeDislike(liked: RwSignal, disliked: RwSignal) -> impl Into } }); + // If an error occurs, check the like/dislike status again to ensure consistency + let check_like_dislike = move || { + spawn_local(async move { + match get_like_dislike_song(song_id.get_untracked()).await { + Ok((like, dislike)) => { + liked.set(like); + disliked.set(dislike); + }, + Err(_) => {} + } + }); + }; + let toggle_like = move |_| { - liked.set(!liked.get_untracked()); + let new_liked = !liked.get_untracked(); + liked.set(new_liked); disliked.set(disliked.get_untracked() && !liked.get_untracked()); + + spawn_local(async move { + match set_like_song(song_id.get_untracked(), new_liked).await { + Ok(_) => {}, + Err(e) => { + error!("Error setting like: {}", e); + check_like_dislike(); + } + } + }); }; let toggle_dislike = move |_| { disliked.set(!disliked.get_untracked()); liked.set(liked.get_untracked() && !disliked.get_untracked()); + + spawn_local(async move { + match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await { + Ok(_) => {}, + Err(e) => { + error!("Error setting dislike: {}", e); + check_like_dislike(); + } + } + }); }; view! { diff --git a/src/fileserv.rs b/src/fileserv.rs index 4fe7a30..dbfccc6 100644 --- a/src/fileserv.rs +++ b/src/fileserv.rs @@ -12,6 +12,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { use tower_http::services::ServeDir; use leptos::*; use crate::app::App; + use std::str::FromStr; pub async fn file_and_error_handler(uri: Uri, State(options): State, req: Request) -> AxumResponse { let root = options.site_root.clone(); @@ -27,6 +28,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { pub async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); + // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // This path is relative to the cargo root match ServeDir::new(root).oneshot(req).await.ok() { @@ -37,4 +39,32 @@ cfg_if! { if #[cfg(feature = "ssr")] { )), } } + + pub enum AssetType { + Audio, + Image, + } + + pub async fn get_asset_file(filename: String, asset_type: AssetType) -> Result, (StatusCode, String)> { + const DEFAULT_AUDIO_PATH: &str = "assets/audio"; + const DEFAULT_IMAGE_PATH: &str = "assets/images"; + + let root = match asset_type { + AssetType::Audio => std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string()), + AssetType::Image => std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string()), + }; + + // Create a Uri from the filename + // ServeDir expects a leading `/` + let uri = Uri::from_str(format!("/{}", filename).as_str()); + + match uri { + Ok(uri) => get_static_file(uri, root.as_str()).await, + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Attempted to serve an invalid file"), + )), + } + } + }} diff --git a/src/main.rs b/src/main.rs index 6e50edf..25a5a61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,11 +14,11 @@ extern crate diesel_migrations; #[cfg(feature = "ssr")] #[tokio::main] async fn main() { - use axum::{routing::get, Router}; + use axum::{routing::get, Router, extract::Path}; use leptos::*; use leptos_axum::{generate_route_list, LeptosRoutes}; use libretunes::app::*; - use libretunes::fileserv::{file_and_error_handler, get_static_file}; + use libretunes::fileserv::{file_and_error_handler, get_asset_file, get_static_file, AssetType}; use axum_login::tower_sessions::SessionManagerLayer; use tower_sessions_redis_store::{fred::prelude::*, RedisStore}; use axum_login::AuthManagerLayerBuilder; @@ -30,7 +30,7 @@ async fn main() { info!("\n{}", include_str!("../ascii_art.txt")); info!("Starting Leptos server..."); - use dotenv::dotenv; + use dotenvy::dotenv; dotenv().ok(); debug!("Running database migrations..."); @@ -60,6 +60,8 @@ async fn main() { let app = Router::new() .leptos_routes(&leptos_options, routes, App) + .route("/assets/audio/:song", get(|Path(song) : Path| get_asset_file(song, AssetType::Audio))) + .route("/assets/images/:image", get(|Path(image) : Path| get_asset_file(image, AssetType::Image))) .route("/assets/*uri", get(|uri| get_static_file(uri, ""))) .layer(auth_layer) .fallback(file_and_error_handler) diff --git a/src/models.rs b/src/models.rs index ed28ba6..4a34b86 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,5 @@ -use std::time::SystemTime; +use chrono::{NaiveDate, NaiveDateTime}; use leptos::{server, ServerFnError}; -use time::Date; use serde::{Deserialize, Serialize}; use crate::songdata::SongData; @@ -41,8 +40,8 @@ pub struct User { #[cfg_attr(feature = "ssr", diesel(deserialize_as = String))] pub password: Option, /// The time the user was created - #[cfg_attr(feature = "ssr", diesel(deserialize_as = SystemTime))] - pub created_at: Option, + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub created_at: Option, /// Whether the user is an admin pub admin: bool, } @@ -105,7 +104,7 @@ impl User { /// #[cfg(feature = "ssr")] pub fn get_history_songs(self: &Self, limit: Option, conn: &mut PgPooledConn) -> - Result, Box> { + Result, Box> { use crate::schema::songs::dsl::*; use crate::schema::song_history::dsl::*; @@ -469,7 +468,7 @@ pub struct Album { /// The album's title pub title: String, /// The album's release date - pub release_date: Option, + pub release_date: Option, /// The path to the album's image file pub image_path: Option, } @@ -639,7 +638,7 @@ pub struct Song { /// The duration of the song in seconds pub duration: i32, /// The song's release date - pub release_date: Option, + pub release_date: Option, /// The path to the song's audio file pub storage_path: String, /// The path to the song's image file @@ -715,7 +714,28 @@ pub struct HistoryEntry { /// The id of the user who listened to the song pub user_id: i32, /// The date the song was listened to - pub date: SystemTime, + pub date: NaiveDateTime, /// The id of the song that was listened to pub song_id: i32, } + +/// Model for a playlist +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::playlists))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct Playlist { + /// A unique id for the playlist + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option, + /// The time the playlist was created + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub created_at: Option, + /// The time the playlist was last updated + #[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))] + pub updated_at: Option, + /// The id of the user who owns the playlist + pub owner_id: i32, + /// The name of the playlist + pub name: String, +} diff --git a/src/pages.rs b/src/pages.rs index 057a60a..0ad5d6f 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -1,3 +1,4 @@ pub mod login; pub mod signup; -pub mod albumpage; \ No newline at end of file +pub mod profile; +pub mod albumpage; diff --git a/src/pages/login.rs b/src/pages/login.rs index 62aca49..7b9bffa 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -1,4 +1,5 @@ use crate::auth::login; +use crate::util::state::GlobalState; use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; @@ -27,18 +28,29 @@ pub fn Login() -> impl IntoView { username_or_email: username_or_email1, password: password1 }; + + let user = GlobalState::logged_in_user(); let login_result = login(user_credentials).await; if let Err(err) = login_result { // Handle the error here, e.g., log it or display to the user log!("Error logging in: {:?}", err); - } else if let Ok(true) = login_result { + + // Since we're not sure what the state is, manually refetch the user + user.refetch(); + } else if let Ok(Some(login_user)) = login_result { + // Manually set the user to the new user, avoiding a refetch + user.set(Some(login_user)); + // Redirect to the login page log!("Logged in Successfully!"); leptos_router::use_navigate()("/", Default::default()); log!("Navigated to home page after login"); - } else if let Ok(false) = login_result { + } else if let Ok(None) = login_result { log!("Invalid username or password"); + + // User could be already logged in or not, so refetch the user + user.refetch(); } }); }; diff --git a/src/pages/profile.rs b/src/pages/profile.rs new file mode 100644 index 0000000..b6fd5b9 --- /dev/null +++ b/src/pages/profile.rs @@ -0,0 +1,316 @@ +use leptos::*; +use leptos_router::use_params_map; +use leptos_icons::*; +use server_fn::error::NoCustomError; + +use crate::components::dashboard_row::DashboardRow; +use crate::components::dashboard_tile::DashboardTile; +use crate::components::song_list::*; +use crate::components::loading::*; +use crate::components::error::*; + +use crate::api::profile::*; + +use crate::models::User; +use crate::users::get_user_by_id; +use crate::util::state::GlobalState; + +/// Duration in seconds backwards from now to aggregate history data for +const HISTORY_SECS: i64 = 60 * 60 * 24 * 30; +const HISTORY_MESSAGE: &str = "Last Month"; + +/// How many top songs to show +const TOP_SONGS_COUNT: i64 = 10; +/// How many recent songs to show +const RECENT_SONGS_COUNT: i64 = 5; +/// How many recent artists to show +const TOP_ARTISTS_COUNT: i64 = 10; + +/// Profile page +/// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path +#[component] +pub fn Profile() -> impl IntoView { + let params = use_params_map(); + + view! { +
+ {move || params.with(|params| { + match params.get("id").map(|id| id.parse::()) { + None => { + // No id specified, show the current user's profile + view! { }.into_view() + }, + Some(Ok(id)) => { + // Id specified, get the user and show their profile + view! { }.into_view() + }, + Some(Err(e)) => { + // Invalid id, return an error + view! { + + title="Invalid User ID" + error=e.to_string() + /> + }.into_view() + } + } + })} +
+ } +} + +/// Show the logged in user's profile +#[component] +fn OwnProfile() -> impl IntoView { + view! { + } + > + {move || GlobalState::logged_in_user().get().map(|user| { + match user { + Some(user) => { + let user_id = user.id.unwrap(); + view! { + + + + + }.into_view() + }, + None => view! { + + title="Not Logged In" + message="You must be logged in to view your profile" + /> + }.into_view(), + } + })} + + } +} + +/// Show a user's profile by ID +#[component] +fn UserIdProfile(#[prop(into)] id: MaybeSignal) -> impl IntoView { + let user_info = create_resource(move || id.get(), move |id| { + get_user_by_id(id) + }); + + // Show the details if the user is found + let show_details = create_rw_signal(false); + + view!{ + } + > + {move || user_info.get().map(|user| { + match user { + Ok(Some(user)) => { + show_details.set(true); + + view! { }.into_view() + }, + Ok(None) => { + show_details.set(false); + + view! { + + title="User Not Found" + message=format!("User with ID {} not found", id.get()) + /> + }.into_view() + }, + Err(error) => { + show_details.set(false); + + view! { + + title="Error Getting User" + error + /> + }.into_view() + } + } + })} + + + } +} + +/// Show a profile for a User object +#[component] +fn UserProfile(user: User) -> impl IntoView { + let user_id = user.id.unwrap(); + let profile_image_path = format!("/assets/images/profile/{}.webp", user_id); + + view! { +
+ + + +

{user.username}

+
+
+

+ {user.email} + { + user.created_at.map(|created_at| { + format!(" • Joined {}", created_at.format("%B %Y")) + }) + } + { + if user.admin { + " • Admin" + } else { + "" + } + } +

+
+ } +} + +/// Show a list of top songs for a user +#[component] +fn TopSongs(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let top_songs = create_resource(move || user_id.get(), |user_id| async move { + use chrono::{Local, Duration}; + let now = Local::now(); + let start = now - Duration::seconds(HISTORY_SECS); + let top_songs = top_songs(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_SONGS_COUNT)).await; + + top_songs.map(|top_songs| { + top_songs.into_iter().map(|(plays, song)| { + let plays = if plays == 1 { + format!("{} Play", plays) + } else { + format!("{} Plays", plays) + }; + + (song, plays) + }).collect::>() + }) + }); + + view! { +

{format!("Top Songs {}", HISTORY_MESSAGE)}

+ } + > + {e.to_string()}

}) + .collect_view() + } + } + > + {move || + top_songs.get().map(|top_songs| { + top_songs.map(|top_songs| { + view! { + + } + }) + }) + } +
+
+ } +} + +/// Show a list of recently played songs for a user +#[component] +fn RecentSongs(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let recent_songs = create_resource(move || user_id.get(), |user_id| async move { + let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await; + + recent_songs.map(|recent_songs| { + recent_songs.into_iter().map(|(_date, song)| { + song + }).collect::>() + }) + }); + + view! { +

"Recently Played"

+ } + > + {e.to_string()}

}) + .collect_view() + } + } + > + {move || + recent_songs.get().map(|recent_songs| { + recent_songs.map(|recent_songs| { + view! { + + } + }) + }) + } +
+
+ } +} + +/// Show a list of top artists for a user +#[component] +fn TopArtists(#[prop(into)] user_id: MaybeSignal) -> impl IntoView { + let top_artists = create_resource(move || user_id.get(), |user_id| async move { + use chrono::{Local, Duration}; + + let now = Local::now(); + let start = now - Duration::seconds(HISTORY_SECS); + let top_artists = top_artists(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_ARTISTS_COUNT)).await; + + top_artists.map(|top_artists| { + top_artists.into_iter().map(|(_plays, artist)| { + artist + }).collect::>() + }) + }); + + view! { + {format!("Top Artists {}", HISTORY_MESSAGE)} + + } + > + {format!("Top Artists {}", HISTORY_MESSAGE)} + {move || errors.get() + .into_iter() + .map(|(_, e)| view! {

{e.to_string()}

}) + .collect_view() + } + } + > + {move || + top_artists.get().map(|top_artists| { + top_artists.map(|top_artists| { + let tiles = top_artists.into_iter().map(|artist| { + Box::new(artist) as Box + }).collect::>(); + + DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles) + }) + }) + } +
+
+ } +} diff --git a/src/pages/signup.rs b/src/pages/signup.rs index f02dfab..69fe77d 100644 --- a/src/pages/signup.rs +++ b/src/pages/signup.rs @@ -1,5 +1,6 @@ use crate::auth::signup; use crate::models::User; +use crate::util::state::GlobalState; use leptos::leptos_dom::*; use leptos::*; use leptos_icons::*; @@ -19,7 +20,7 @@ pub fn Signup() -> impl IntoView { let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); - let new_user = User { + let mut new_user = User { id: None, username: username.get(), email: email.get(), @@ -29,11 +30,20 @@ pub fn Signup() -> impl IntoView { }; log!("new user: {:?}", new_user); + let user = GlobalState::logged_in_user(); + spawn_local(async move { - if let Err(err) = signup(new_user).await { + if let Err(err) = signup(new_user.clone()).await { // Handle the error here, e.g., log it or display to the user log!("Error signing up: {:?}", err); + + // Since we're not sure what the state is, manually refetch the user + user.refetch(); } else { + // Manually set the user to the new user, avoiding a refetch + new_user.password = None; + user.set(Some(new_user)); + // Redirect to the login page log!("Signed up successfully!"); leptos_router::use_navigate()("/", Default::default()); diff --git a/src/playbar.rs b/src/playbar.rs index c14e9f7..f8e656a 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,7 +1,7 @@ use crate::models::Artist; -use crate::playstatus::PlayStatus; use crate::songdata::SongData; use crate::api::songs; +use crate::util::state::GlobalState; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; use leptos::leptos_dom::*; @@ -40,8 +40,8 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64; /// * `None` if the audio element is not available /// * `Some((current_time, duration))` if the audio element is available /// -pub fn get_song_time_duration(status: impl SignalWithUntracked) -> Option<(f64, f64)> { - status.with_untracked(|status| { +pub fn get_song_time_duration() -> Option<(f64, f64)> { + GlobalState::play_status().with_untracked(|status| { if let Some(audio) = status.get_audio() { Some((audio.current_time(), audio.duration())) } else { @@ -61,13 +61,13 @@ pub fn get_song_time_duration(status: impl SignalWithUntracked, time: f64) { +pub fn skip_to(time: f64) { if time.is_infinite() || time.is_nan() { error!("Unable to skip to non-finite time: {}", time); return } - status.update(|status| { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { audio.set_current_time(time); log!("Player skipped to time: {}", time); @@ -85,8 +85,8 @@ pub fn skip_to(status: impl SignalUpdate, time: f64) { /// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `play` - `true` to play the song, `false` to pause it /// -pub fn set_playing(status: impl SignalUpdate, play: bool) { - status.update(|status| { +pub fn set_playing(play: bool) { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { if play { if let Err(e) = audio.play() { @@ -109,8 +109,8 @@ pub fn set_playing(status: impl SignalUpdate, play: bool) { }); } -fn toggle_queue(status: impl SignalUpdate) { - status.update(|status| { +fn toggle_queue() { + GlobalState::play_status().update(|status| { status.queue_open = !status.queue_open; }); @@ -126,8 +126,8 @@ fn toggle_queue(status: impl SignalUpdate) { /// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `src` - The source to set the audio player to /// -fn set_play_src(status: impl SignalUpdate, src: String) { - status.update(|status| { +fn set_play_src(src: String) { + GlobalState::play_status().update(|status| { if let Some(audio) = status.get_audio() { audio.set_src(&src); log!("Player set src to: {}", src); @@ -139,11 +139,13 @@ fn set_play_src(status: impl SignalUpdate, src: String) { /// The play, pause, and skip buttons #[component] -fn PlayControls(status: RwSignal) -> impl IntoView { +fn PlayControls() -> impl IntoView { + let status = GlobalState::play_status(); + // On click handlers for the skip and play/pause buttons let skip_back = move |_| { - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { // Skip to previous song if the current song is near the start // Also skip to the previous song if we're at the end of the current song // This is because after running out of songs in the queue, the current song will be at the end @@ -160,8 +162,8 @@ fn PlayControls(status: RwSignal) -> impl IntoView { // Push the popped song to the front of the queue, and play it let next_src = last_played_song.song_path.clone(); status.update(|status| status.queue.push_front(last_played_song)); - set_play_src(status, next_src); - set_playing(status, true); + set_play_src(next_src); + set_playing(true); } else { warn!("Unable to skip back: No previous song"); } @@ -170,14 +172,14 @@ fn PlayControls(status: RwSignal) -> impl IntoView { // Default to skipping to start of current song, and playing log!("Skipping to start of current song"); - skip_to(status, 0.0); - set_playing(status, true); + skip_to(0.0); + set_playing(true); }; let skip_forward = move |_| { - if let Some(duration) = get_song_time_duration(status) { - skip_to(status, duration.1); - set_playing(status, true); + if let Some(duration) = get_song_time_duration() { + skip_to(duration.1); + set_playing(true); } else { error!("Unable to skip forward: Unable to get current duration"); } @@ -185,7 +187,7 @@ fn PlayControls(status: RwSignal) -> impl IntoView { let toggle_play = move |_| { let playing = status.with_untracked(|status| { status.playing }); - set_playing(status, !playing); + set_playing(!playing); }; // We use this to prevent the buttons from being focused when clicked @@ -248,7 +250,9 @@ fn PlayDuration(elapsed_secs: MaybeSignal, total_secs: MaybeSignal) -> /// The name, artist, and album of the current song #[component] -fn MediaInfo(status: RwSignal) -> impl IntoView { +fn MediaInfo() -> impl IntoView { + let status = GlobalState::play_status(); + let name = Signal::derive(move || { status.with(|status| { status.queue.front().map_or("No media playing".into(), |song| song.title.clone()) @@ -287,7 +291,9 @@ fn MediaInfo(status: RwSignal) -> impl IntoView { /// The like and dislike buttons #[component] -fn LikeDislike(status: RwSignal) -> impl IntoView { +fn LikeDislike() -> impl IntoView { + let status = GlobalState::play_status(); + let like_icon = Signal::derive(move || { status.with(|status| { match status.queue.front() { @@ -400,7 +406,7 @@ fn LikeDislike(status: RwSignal) -> impl IntoView { /// The play progress bar, and click handler for skipping to a certain time in the song #[component] -fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> impl IntoView { +fn ProgressBar(percentage: MaybeSignal) -> impl IntoView { // Keep a reference to the progress bar div so we can get its width and calculate the time to skip to let progress_bar_ref = create_node_ref::
(); @@ -412,10 +418,10 @@ fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> im let width = progress_bar.offset_width() as f64; let percentage = x_click_pos / width * 100.0; - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let time = duration.1 * percentage / 100.0; - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip to time: Unable to get current duration"); } @@ -438,11 +444,11 @@ fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> im } #[component] -fn QueueToggle(status: RwSignal) -> impl IntoView { - +fn QueueToggle() -> impl IntoView { let update_queue = move |_| { - toggle_queue(status); - log!("queue button pressed, queue status: {:?}", status.with_untracked(|status| status.queue_open)); + toggle_queue(); + log!("queue button pressed, queue status: {:?}", + GlobalState::play_status().with_untracked(|status| status.queue_open)); }; // We use this to prevent the buttons from being focused when clicked @@ -463,9 +469,9 @@ fn QueueToggle(status: RwSignal) -> impl IntoView { /// Renders the title of the page based on the currently playing song #[component] -pub fn CustomTitle(play_status: RwSignal) -> impl IntoView { +pub fn CustomTitle() -> impl IntoView { let title = create_memo(move |_| { - play_status.with(|play_status| { + GlobalState::play_status().with(|play_status| { play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| { format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes") }) @@ -478,18 +484,20 @@ pub fn CustomTitle(play_status: RwSignal) -> impl IntoView { /// The main play bar component, containing the progress bar, media info, play controls, and play duration #[component] -pub fn PlayBar(status: RwSignal) -> impl IntoView { +pub fn PlayBar() -> impl IntoView { + let status = GlobalState::play_status(); + // Listen for key down events -- arrow keys don't seem to trigger key press events let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| { if e.key() == "ArrowRight" { e.prevent_default(); log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME); - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let mut time = duration.0 + ARROW_KEY_SKIP_TIME; time = time.clamp(0.0, duration.1); - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip forward: Unable to get current duration"); } @@ -498,11 +506,11 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { e.prevent_default(); log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME); - if let Some(duration) = get_song_time_duration(status) { + if let Some(duration) = get_song_time_duration() { let mut time = duration.0 - ARROW_KEY_SKIP_TIME; time = time.clamp(0.0, duration.1); - skip_to(status, time); - set_playing(status, true); + skip_to(time); + set_playing(true); } else { error!("Unable to skip backward: Unable to get current duration"); } @@ -516,7 +524,7 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { log!("Space bar pressed, toggling play/pause"); let playing = status.with_untracked(|status| status.playing); - set_playing(status, !playing); + set_playing(!playing); } }); @@ -659,14 +667,14 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView {