Compare commits
27 Commits
136-create
...
148-create
Author | SHA1 | Date | |
---|---|---|---|
d73a114d35 | |||
f8a28ca75c | |||
0afdbf2ad5 | |||
03889d4efe | |||
f6fb92e361 | |||
00f4b2e28f | |||
340129a859 | |||
7828992e80 | |||
9600e8c442 | |||
b1a62ee284 | |||
0c1bab9fbb
|
|||
190df0edfd | |||
a3b3174306
|
|||
f633cffe69
|
|||
cf6f7b7db7
|
|||
8f01ff24d8 | |||
f0f34d4abe
|
|||
d42737f856
|
|||
47fc35814d | |||
fe21785f2d
|
|||
c6024a7457 | |||
41327c8ed6
|
|||
d09060959a | |||
7f298f75ce | |||
74044f2e17 | |||
0335e3d255 | |||
81b1490cce |
32
Cargo.lock
generated
32
Cargo.lock
generated
@ -755,10 +755,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenv"
|
name = "dotenvy"
|
||||||
version = "0.15.0"
|
version = "0.15.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drain_filter_polyfill"
|
name = "drain_filter_polyfill"
|
||||||
@ -1566,9 +1566,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leptos-use"
|
name = "leptos-use"
|
||||||
version = "0.13.5"
|
version = "0.13.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb"
|
checksum = "32d4708472867704085a2813c47cada122a6e8c3b90ccff764862c0b351bfb96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"codee",
|
"codee",
|
||||||
@ -1841,7 +1841,7 @@ dependencies = [
|
|||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"diesel",
|
"diesel",
|
||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"flexi_logger",
|
"flexi_logger",
|
||||||
"http 1.1.0",
|
"http 1.1.0",
|
||||||
"icondata",
|
"icondata",
|
||||||
@ -3444,9 +3444,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
|
checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -3455,9 +3455,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-backend"
|
name = "wasm-bindgen-backend"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
|
checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"log",
|
"log",
|
||||||
@ -3482,9 +3482,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
|
checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@ -3492,9 +3492,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@ -3505,9 +3505,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.93"
|
version = "0.2.95"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
|
checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
|
@ -15,10 +15,9 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] }
|
|||||||
leptos_meta = { version = "0.6", features = ["nightly"] }
|
leptos_meta = { version = "0.6", features = ["nightly"] }
|
||||||
leptos_axum = { version = "0.6", optional = true }
|
leptos_axum = { version = "0.6", optional = true }
|
||||||
leptos_router = { version = "0.6", features = ["nightly"] }
|
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" }
|
leptos_icons = { version = "0.3.0" }
|
||||||
icondata = { version = "0.3.0" }
|
icondata = { version = "0.3.0" }
|
||||||
dotenv = { version = "0.15.0", optional = true }
|
|
||||||
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], 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 }
|
lazy_static = { version = "1.4.0", optional = true }
|
||||||
serde = { version = "1.0.195", features = ["derive"], default-features = false }
|
serde = { version = "1.0.195", features = ["derive"], default-features = false }
|
||||||
@ -42,6 +41,7 @@ web-sys = "0.3.69"
|
|||||||
leptos-use = "0.13.5"
|
leptos-use = "0.13.5"
|
||||||
image-convert = { version = "0.18.0", optional = true, default-features = false }
|
image-convert = { version = "0.18.0", optional = true, default-features = false }
|
||||||
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
|
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
|
||||||
|
dotenvy = { version = "0.15.7", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
hydrate = [
|
hydrate = [
|
||||||
@ -57,7 +57,7 @@ ssr = [
|
|||||||
"leptos/ssr",
|
"leptos/ssr",
|
||||||
"leptos_meta/ssr",
|
"leptos_meta/ssr",
|
||||||
"leptos_router/ssr",
|
"leptos_router/ssr",
|
||||||
"dotenv",
|
"dotenvy",
|
||||||
"diesel",
|
"diesel",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
DROP INDEX playlists_owner_idx;
|
||||||
|
DROP TABLE playlists;
|
||||||
|
|
||||||
|
DROP INDEX playlist_songs_playlist_idx;
|
||||||
|
DROP TABLE playlist_songs;
|
17
migrations/2024-10-22-212759_create_playlist_tables/up.sql
Normal file
17
migrations/2024-10-22-212759_create_playlist_tables/up.sql
Normal file
@ -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);
|
41
src/app.rs
41
src/app.rs
@ -1,40 +1,23 @@
|
|||||||
use crate::playbar::PlayBar;
|
use crate::playbar::PlayBar;
|
||||||
use crate::playbar::CustomTitle;
|
use crate::playbar::CustomTitle;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::queue::Queue;
|
use crate::queue::Queue;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos::logging::*;
|
|
||||||
use leptos_meta::*;
|
use leptos_meta::*;
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
use crate::pages::login::*;
|
use crate::pages::login::*;
|
||||||
use crate::pages::signup::*;
|
use crate::pages::signup::*;
|
||||||
use crate::pages::profile::*;
|
use crate::pages::profile::*;
|
||||||
use crate::error_template::{AppError, ErrorTemplate};
|
use crate::error_template::{AppError, ErrorTemplate};
|
||||||
use crate::auth::get_logged_in_user;
|
use crate::util::state::GlobalState;
|
||||||
use crate::models::User;
|
|
||||||
|
|
||||||
pub type LoggedInUserResource = Resource<(), Option<User>>;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||||
provide_meta_context();
|
provide_meta_context();
|
||||||
|
|
||||||
let play_status = PlayStatus::default();
|
provide_context(GlobalState::new());
|
||||||
let play_status = create_rw_signal(play_status);
|
|
||||||
let upload_open = create_rw_signal(false);
|
|
||||||
|
|
||||||
// A resource that fetches the logged in user
|
let upload_open = create_rw_signal(false);
|
||||||
// This will not automatically refetch, so any login/logout related code
|
|
||||||
// should call `refetch` on this resource
|
|
||||||
let logged_in_user: LoggedInUserResource = create_resource(|| (), |_| async {
|
|
||||||
get_logged_in_user().await
|
|
||||||
.inspect_err(|e| {
|
|
||||||
error!("Error getting logged in user: {:?}", e);
|
|
||||||
})
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// injects a stylesheet into the document <head>
|
// injects a stylesheet into the document <head>
|
||||||
@ -42,7 +25,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
||||||
|
|
||||||
// sets the document title
|
// sets the document title
|
||||||
<CustomTitle play_status=play_status/>
|
<CustomTitle />
|
||||||
|
|
||||||
// content for this welcome page
|
// content for this welcome page
|
||||||
<Router fallback=|| {
|
<Router fallback=|| {
|
||||||
@ -55,15 +38,15 @@ pub fn App() -> impl IntoView {
|
|||||||
}>
|
}>
|
||||||
<main>
|
<main>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="" view=move || view! { <HomePage play_status=play_status upload_open=upload_open/> }>
|
<Route path="" view=move || view! { <HomePage upload_open=upload_open/> }>
|
||||||
<Route path="" view=Dashboard />
|
<Route path="" view=Dashboard />
|
||||||
<Route path="dashboard" view=Dashboard />
|
<Route path="dashboard" view=Dashboard />
|
||||||
<Route path="search" view=Search />
|
<Route path="search" view=Search />
|
||||||
<Route path="user/:id" view=move || view!{ <Profile logged_in_user /> } />
|
<Route path="user/:id" view=Profile />
|
||||||
<Route path="user" view=move || view!{ <Profile logged_in_user /> } />
|
<Route path="user" view=Profile />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" view=move || view!{ <Login user=logged_in_user /> } />
|
<Route path="/login" view=Login />
|
||||||
<Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } />
|
<Route path="/signup" view=Signup />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
@ -78,7 +61,7 @@ use crate::components::upload::*;
|
|||||||
|
|
||||||
/// Renders the home page of your application.
|
/// Renders the home page of your application.
|
||||||
#[component]
|
#[component]
|
||||||
fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> impl IntoView {
|
fn HomePage(upload_open: RwSignal<bool>) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="home-container">
|
<div class="home-container">
|
||||||
<Upload open=upload_open/>
|
<Upload open=upload_open/>
|
||||||
@ -86,8 +69,8 @@ fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> i
|
|||||||
// This <Outlet /> will render the child route components
|
// This <Outlet /> will render the child route components
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Personal />
|
<Personal />
|
||||||
<PlayBar status=play_status/>
|
<PlayBar />
|
||||||
<Queue status=play_status/>
|
<Queue />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,152 @@
|
|||||||
|
use html::Li;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
|
use crate::search::search;
|
||||||
|
use crate::song::Song;
|
||||||
|
use crate::models::Album;
|
||||||
|
use crate::models::Artist;
|
||||||
|
use crate::models::Song;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
use leptos::ev::*;
|
||||||
|
use leptos::leptos_dom::*;
|
||||||
|
use leptos_icons::*;
|
||||||
|
|
||||||
|
const OPTIONS_BTN_SIZE: &str = "2.5rem";
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Search() -> impl IntoView {
|
pub fn Search() -> impl IntoView {
|
||||||
|
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
|
let search_query = create_rw_signal(String::new());
|
||||||
|
let search_results = create_rw_signal((Vec::<(Album, f32)>::new(), Vec::<(Artist, f32)>::new(), Vec::<(Song, f32)>::new()));
|
||||||
|
let search_limit = 10;
|
||||||
|
|
||||||
|
let on_input = move |e: Event| {
|
||||||
|
search_query.set(event_target_value(&e));
|
||||||
|
|
||||||
|
log!("Search Query: {:?}", search_query.get_untracked());
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
log!("Searching for: {:?}", search_query.get_untracked());
|
||||||
|
let results = search(search_query.get_untracked(), search_limit).await;
|
||||||
|
|
||||||
|
match results {
|
||||||
|
Ok((albums, artists, songs)) => {
|
||||||
|
search_results.set((albums, artists, songs));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log!("Error searching albums: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_disabled = move |_e: FocusEvent| {
|
||||||
|
status.update(|status| {
|
||||||
|
status.search_active = false;
|
||||||
|
});
|
||||||
|
log!("Search Bar Disabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_enabled = move |_e: FocusEvent| {
|
||||||
|
status.update(|status| {
|
||||||
|
status.search_active = true;
|
||||||
|
});
|
||||||
|
log!("Search Bar Enabled");
|
||||||
|
};
|
||||||
|
|
||||||
|
let prevent_focus = move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="search-container home-component">
|
<div class="search-container home-component">
|
||||||
<h1>Searching...</h1>
|
<div class="search-bar">
|
||||||
</div>
|
<input type="search" placeholder="Search" on:input=on_input on:blur=on_disabled on:focus=on_enabled/>
|
||||||
}
|
</div>
|
||||||
|
<div class="search-results">
|
||||||
|
// Display 3 columns of search results: songs, albums, and artists
|
||||||
|
<ul class="search-result-list">
|
||||||
|
{move || search_results.with(|results| -> Vec<leptos::HtmlElement<Li>> {
|
||||||
|
let mut song_list = Vec::new();
|
||||||
|
for (song, _) in results.2.clone() {
|
||||||
|
song_list.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<Song song_image_path=match song.image_path.clone() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => "".to_string()
|
||||||
|
} song_title=song.title.clone() song_artist="".to_string() />
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Song)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=icondata::BsThreeDotsVertical />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
song_list
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<ul class="search-result-list">
|
||||||
|
{move || search_results.with(|results| -> Vec<leptos::HtmlElement<Li>> {
|
||||||
|
let mut album_list = Vec::new();
|
||||||
|
for (album, _) in results.0.clone() {
|
||||||
|
album_list.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<div class="search-result-album">
|
||||||
|
{album.title.clone()}
|
||||||
|
{match album.release_date {
|
||||||
|
Some(date) => format!(" ({})", date),
|
||||||
|
None => "".to_string()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Album)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=icondata::BsThreeDotsVertical />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
album_list
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<ul class="search-result-list">
|
||||||
|
{move || search_results.with(|results| -> Vec<leptos::HtmlElement<Li>> {
|
||||||
|
let mut artist_list = Vec::new();
|
||||||
|
for (artist, _) in results.1.clone() {
|
||||||
|
artist_list.push(view! {
|
||||||
|
<li class="search-result">
|
||||||
|
<div class="result-container">
|
||||||
|
<div class="search-result-artist">
|
||||||
|
{artist.name.clone()}
|
||||||
|
</div>
|
||||||
|
<div class="right-side-result">
|
||||||
|
<div class="search-item-type">
|
||||||
|
"(Artist)"
|
||||||
|
</div>
|
||||||
|
<button class="search-result-options" on:mousedown=prevent_focus>
|
||||||
|
<Icon class="search-result-options-icon" width=OPTIONS_BTN_SIZE height=OPTIONS_BTN_SIZE icon=icondata::BsThreeDotsVertical />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
});
|
||||||
|
}
|
||||||
|
artist_list
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,61 +1,88 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
|
use leptos::logging::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
|
|
||||||
|
use crate::api::songs::*;
|
||||||
use crate::songdata::SongData;
|
use crate::songdata::SongData;
|
||||||
use crate::models::{Album, Artist};
|
use crate::models::{Album, Artist};
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
|
||||||
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView {
|
pub fn SongList(songs: Vec<SongData>) -> impl IntoView {
|
||||||
view! {
|
__SongListInner(songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(), false)
|
||||||
<table class="song-list">
|
|
||||||
{
|
|
||||||
songs.with(|songs| {
|
|
||||||
let mut first_song = true;
|
|
||||||
|
|
||||||
songs.iter().map(|song| {
|
|
||||||
let playing = first_song.into();
|
|
||||||
first_song = false;
|
|
||||||
|
|
||||||
let extra = Option::<()>::None;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<SongListItem song={song.clone()} song_playing=playing extra />
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongListExtra<T>(songs: MaybeSignal<Vec<(SongData, T)>>) -> impl IntoView where
|
pub fn SongListExtra<T>(songs: Vec<(SongData, T)>) -> impl IntoView where
|
||||||
T: Clone + IntoView + 'static
|
T: Clone + IntoView + 'static
|
||||||
{
|
{
|
||||||
|
__SongListInner(songs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn SongListInner<T>(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! {
|
view! {
|
||||||
<table class="song-list">
|
<table class="song-list">
|
||||||
{
|
{
|
||||||
songs.with(|songs| {
|
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
|
||||||
let mut first_song = true;
|
let song_id = song.id;
|
||||||
|
let playing = create_rw_signal(false);
|
||||||
|
|
||||||
songs.iter().map(|(song, extra)| {
|
create_effect(move |_| {
|
||||||
let playing = first_song.into();
|
GlobalState::play_status().with(|status| {
|
||||||
first_song = false;
|
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) />
|
<SongListItem song={song.clone()} song_playing=playing.into()
|
||||||
}
|
extra={if show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
|
||||||
}).collect::<Vec<_>>()
|
}
|
||||||
})
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>) -> impl IntoView where
|
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>,
|
||||||
|
list_index: usize, do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView where
|
||||||
T: IntoView + 'static
|
T: IntoView + 'static
|
||||||
{
|
{
|
||||||
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
||||||
@ -63,14 +90,15 @@ pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: O
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<tr class="song-list-item">
|
<tr class="song-list-item">
|
||||||
<td class="song-image"><SongImage image_path=song.image_path song_playing /></td>
|
<td class="song-image"><SongImage image_path=song.image_path song_playing
|
||||||
|
list_index do_queue_remaining /></td>
|
||||||
<td class="song-title"><p>{song.title}</p></td>
|
<td class="song-title"><p>{song.title}</p></td>
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
<td class="song-album"><SongAlbum album=song.album /></td>
|
<td class="song-album"><SongAlbum album=song.album /></td>
|
||||||
<td class="song-list-spacer-big"></td>
|
<td class="song-list-spacer-big"></td>
|
||||||
<td class="song-like-dislike"><SongLikeDislike liked disliked/></td>
|
<td class="song-like-dislike"><SongLikeDislike song_id=song.id liked disliked/></td>
|
||||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
||||||
{extra.map(|extra| view! {
|
{extra.map(|extra| view! {
|
||||||
<td class="song-list-spacer"></td>
|
<td class="song-list-spacer"></td>
|
||||||
@ -83,13 +111,27 @@ pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: O
|
|||||||
/// Display the song's image, with an overlay if the song is playing
|
/// 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
|
/// When the song list item is hovered, the overlay will show the play button
|
||||||
#[component]
|
#[component]
|
||||||
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>, list_index: usize,
|
||||||
|
do_queue_remaining: WriteSignal<Option<usize>>) -> 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! {
|
view! {
|
||||||
<img class="song-image" src={image_path}/>
|
<img class="song-image" src={image_path}/>
|
||||||
{if song_playing.get() {
|
{move || if song_playing.get() {
|
||||||
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view()
|
view! { <Icon class="song-image-overlay song-playing-overlay"
|
||||||
|
icon=icondata::BsPauseFill on:click=pause_song /> }.into_view()
|
||||||
} else {
|
} else {
|
||||||
view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view()
|
view! { <Icon class="song-image-overlay hide-until-hover"
|
||||||
|
icon=icondata::BsPlayFill on:click=play_song /> }.into_view()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +177,12 @@ fn SongAlbum(album: Option<Album>) -> impl IntoView {
|
|||||||
|
|
||||||
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
||||||
#[component]
|
#[component]
|
||||||
fn SongLikeDislike(liked: RwSignal<bool>, disliked: RwSignal<bool>) -> impl IntoView {
|
fn SongLikeDislike(
|
||||||
|
#[prop(into)]
|
||||||
|
song_id: MaybeSignal<i32>,
|
||||||
|
liked: RwSignal<bool>,
|
||||||
|
disliked: RwSignal<bool>) -> impl IntoView
|
||||||
|
{
|
||||||
let like_icon = Signal::derive(move || {
|
let like_icon = Signal::derive(move || {
|
||||||
if liked.get() {
|
if liked.get() {
|
||||||
icondata::TbThumbUpFilled
|
icondata::TbThumbUpFilled
|
||||||
@ -168,14 +215,48 @@ fn SongLikeDislike(liked: RwSignal<bool>, disliked: RwSignal<bool>) -> 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 |_| {
|
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());
|
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 |_| {
|
let toggle_dislike = move |_| {
|
||||||
disliked.set(!disliked.get_untracked());
|
disliked.set(!disliked.get_untracked());
|
||||||
liked.set(liked.get_untracked() && !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! {
|
view! {
|
||||||
|
@ -124,7 +124,7 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
|
|||||||
>
|
>
|
||||||
<ul class="artist_results search-results">
|
<ul class="artist_results search-results">
|
||||||
{
|
{
|
||||||
move || filtered_artists.get().iter().enumerate().map(|(_index,filtered_artist)| view! {
|
move || filtered_artists.get().iter().enumerate().map(|(_index,(filtered_artist, _score))| view! {
|
||||||
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
|
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
@ -142,7 +142,7 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
|
|||||||
>
|
>
|
||||||
<ul class="album_results search-results">
|
<ul class="album_results search-results">
|
||||||
{
|
{
|
||||||
move || filtered_albums.get().iter().enumerate().map(|(_index,filtered_album)| view! {
|
move || filtered_albums.get().iter().enumerate().map(|(_index,(filtered_album, _score))| view! {
|
||||||
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
|
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()
|
||||||
}
|
}
|
||||||
@ -182,15 +182,15 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSignal<String>, set_filtered: WriteSignal<Vec<Artist>>) -> impl IntoView {
|
pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSignal<String>, set_filtered: WriteSignal<Vec<(Artist, f32)>>) -> impl IntoView {
|
||||||
// Converts artist name to artist id and adds it to the artist input
|
// Converts artist name to artist id and adds it to the artist input
|
||||||
let add_artist = move |_| {
|
let add_artist = move |_| {
|
||||||
//Create an empty string to hold previous artist ids
|
//Create an empty string to hold previous artist ids
|
||||||
let mut s: String = String::from("");
|
let mut s: String = String::from("");
|
||||||
//Get the current artist input
|
//Get the current artist input
|
||||||
let all_artirts: String = artists.get();
|
let all_artists: String = artists.get();
|
||||||
//Split the input into a vector of artists separated by commas
|
//Split the input into a vector of artists separated by commas
|
||||||
let mut ids: Vec<&str> = all_artirts.split(",").collect();
|
let mut ids: Vec<&str> = all_artists.split(",").collect();
|
||||||
//If there is only one artist in the input, get their id equivalent and add it to the string
|
//If there is only one artist in the input, get their id equivalent and add it to the string
|
||||||
if ids.len() == 1 {
|
if ids.len() == 1 {
|
||||||
let value_str = match artist.id.clone() {
|
let value_str = match artist.id.clone() {
|
||||||
@ -227,7 +227,7 @@ pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Album(album: Album, _albums: ReadSignal<String>, set_albums: WriteSignal<String>, set_filtered: WriteSignal<Vec<Album>>) -> impl IntoView {
|
pub fn Album(album: Album, _albums: ReadSignal<String>, set_albums: WriteSignal<String>, set_filtered: WriteSignal<Vec<(Album, f32)>>) -> impl IntoView {
|
||||||
//Converts album title to album id to upload a song
|
//Converts album title to album id to upload a song
|
||||||
let add_album = move |_| {
|
let add_album = move |_| {
|
||||||
let value_str = match album.id.clone() {
|
let value_str = match album.id.clone() {
|
||||||
|
@ -30,7 +30,7 @@ async fn main() {
|
|||||||
info!("\n{}", include_str!("../ascii_art.txt"));
|
info!("\n{}", include_str!("../ascii_art.txt"));
|
||||||
info!("Starting Leptos server...");
|
info!("Starting Leptos server...");
|
||||||
|
|
||||||
use dotenv::dotenv;
|
use dotenvy::dotenv;
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
debug!("Running database migrations...");
|
debug!("Running database migrations...");
|
||||||
|
@ -531,7 +531,7 @@ impl Album {
|
|||||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
|
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
|
||||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct Song {
|
pub struct Song {
|
||||||
/// A unique id for the song
|
/// A unique id for the song
|
||||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||||
@ -625,3 +625,24 @@ pub struct HistoryEntry {
|
|||||||
/// The id of the song that was listened to
|
/// The id of the song that was listened to
|
||||||
pub song_id: i32,
|
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<i32>,
|
||||||
|
/// The time the playlist was created
|
||||||
|
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
|
||||||
|
pub created_at: Option<NaiveDateTime>,
|
||||||
|
/// The time the playlist was last updated
|
||||||
|
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
|
||||||
|
pub updated_at: Option<NaiveDateTime>,
|
||||||
|
/// The id of the user who owns the playlist
|
||||||
|
pub owner_id: i32,
|
||||||
|
/// The name of the playlist
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
use crate::auth::login;
|
use crate::auth::login;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
use crate::users::UserCredentials;
|
use crate::users::UserCredentials;
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login(user: LoggedInUserResource) -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let (username_or_email, set_username_or_email) = create_signal("".to_string());
|
let (username_or_email, set_username_or_email) = create_signal("".to_string());
|
||||||
let (password, set_password) = create_signal("".to_string());
|
let (password, set_password) = create_signal("".to_string());
|
||||||
|
|
||||||
@ -28,6 +28,8 @@ pub fn Login(user: LoggedInUserResource) -> impl IntoView {
|
|||||||
username_or_email: username_or_email1,
|
username_or_email: username_or_email1,
|
||||||
password: password1
|
password: password1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
|
|
||||||
let login_result = login(user_credentials).await;
|
let login_result = login(user_credentials).await;
|
||||||
if let Err(err) = login_result {
|
if let Err(err) = login_result {
|
||||||
|
@ -11,9 +11,9 @@ use crate::components::error::*;
|
|||||||
|
|
||||||
use crate::api::profile::*;
|
use crate::api::profile::*;
|
||||||
|
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
use crate::users::get_user_by_id;
|
use crate::users::get_user_by_id;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
|
|
||||||
/// Duration in seconds backwards from now to aggregate history data for
|
/// Duration in seconds backwards from now to aggregate history data for
|
||||||
const HISTORY_SECS: i64 = 60 * 60 * 24 * 30;
|
const HISTORY_SECS: i64 = 60 * 60 * 24 * 30;
|
||||||
@ -29,7 +29,7 @@ const TOP_ARTISTS_COUNT: i64 = 10;
|
|||||||
/// Profile page
|
/// 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
|
/// 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]
|
#[component]
|
||||||
pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
pub fn Profile() -> impl IntoView {
|
||||||
let params = use_params_map();
|
let params = use_params_map();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
@ -38,7 +38,7 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
|||||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||||
None => {
|
None => {
|
||||||
// No id specified, show the current user's profile
|
// No id specified, show the current user's profile
|
||||||
view! { <OwnProfile logged_in_user /> }.into_view()
|
view! { <OwnProfile /> }.into_view()
|
||||||
},
|
},
|
||||||
Some(Ok(id)) => {
|
Some(Ok(id)) => {
|
||||||
// Id specified, get the user and show their profile
|
// Id specified, get the user and show their profile
|
||||||
@ -61,12 +61,12 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
|||||||
|
|
||||||
/// Show the logged in user's profile
|
/// Show the logged in user's profile
|
||||||
#[component]
|
#[component]
|
||||||
fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView {
|
fn OwnProfile() -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<Transition
|
<Transition
|
||||||
fallback=move || view! { <LoadingPage /> }
|
fallback=move || view! { <LoadingPage /> }
|
||||||
>
|
>
|
||||||
{move || logged_in_user.get().map(|user| {
|
{move || GlobalState::logged_in_user().get().map(|user| {
|
||||||
match user {
|
match user {
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
let user_id = user.id.unwrap();
|
let user_id = user.id.unwrap();
|
||||||
@ -214,7 +214,7 @@ fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
|||||||
top_songs.get().map(|top_songs| {
|
top_songs.get().map(|top_songs| {
|
||||||
top_songs.map(|top_songs| {
|
top_songs.map(|top_songs| {
|
||||||
view! {
|
view! {
|
||||||
<SongListExtra songs={top_songs.into()} />
|
<SongListExtra songs=top_songs />
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -255,7 +255,7 @@ fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
|||||||
recent_songs.get().map(|recent_songs| {
|
recent_songs.get().map(|recent_songs| {
|
||||||
recent_songs.map(|recent_songs| {
|
recent_songs.map(|recent_songs| {
|
||||||
view! {
|
view! {
|
||||||
<SongList songs={recent_songs.into()} />
|
<SongList songs=recent_songs />
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
use crate::auth::signup;
|
use crate::auth::signup;
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_icons::*;
|
use leptos_icons::*;
|
||||||
use crate::app::LoggedInUserResource;
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
|
pub fn Signup() -> impl IntoView {
|
||||||
let (username, set_username) = create_signal("".to_string());
|
let (username, set_username) = create_signal("".to_string());
|
||||||
let (email, set_email) = create_signal("".to_string());
|
let (email, set_email) = create_signal("".to_string());
|
||||||
let (password, set_password) = create_signal("".to_string());
|
let (password, set_password) = create_signal("".to_string());
|
||||||
@ -30,6 +30,8 @@ pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
|
|||||||
};
|
};
|
||||||
log!("new user: {:?}", new_user);
|
log!("new user: {:?}", new_user);
|
||||||
|
|
||||||
|
let user = GlobalState::logged_in_user();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Err(err) = signup(new_user.clone()).await {
|
if let Err(err) = signup(new_user.clone()).await {
|
||||||
// Handle the error here, e.g., log it or display to the user
|
// Handle the error here, e.g., log it or display to the user
|
||||||
|
106
src/playbar.rs
106
src/playbar.rs
@ -1,7 +1,7 @@
|
|||||||
use crate::models::Artist;
|
use crate::models::Artist;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::songdata::SongData;
|
use crate::songdata::SongData;
|
||||||
use crate::api::songs;
|
use crate::api::songs;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::MouseEvent;
|
||||||
use leptos::html::{Audio, Div};
|
use leptos::html::{Audio, Div};
|
||||||
use leptos::leptos_dom::*;
|
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
|
/// * `None` if the audio element is not available
|
||||||
/// * `Some((current_time, duration))` if the audio element is available
|
/// * `Some((current_time, duration))` if the audio element is available
|
||||||
///
|
///
|
||||||
pub fn get_song_time_duration(status: impl SignalWithUntracked<Value = PlayStatus>) -> Option<(f64, f64)> {
|
pub fn get_song_time_duration() -> Option<(f64, f64)> {
|
||||||
status.with_untracked(|status| {
|
GlobalState::play_status().with_untracked(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
Some((audio.current_time(), audio.duration()))
|
Some((audio.current_time(), audio.duration()))
|
||||||
} else {
|
} else {
|
||||||
@ -61,13 +61,13 @@ pub fn get_song_time_duration(status: impl SignalWithUntracked<Value = PlayStatu
|
|||||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `time` - The time to skip to, in seconds
|
/// * `time` - The time to skip to, in seconds
|
||||||
///
|
///
|
||||||
pub fn skip_to(status: impl SignalUpdate<Value = PlayStatus>, time: f64) {
|
pub fn skip_to(time: f64) {
|
||||||
if time.is_infinite() || time.is_nan() {
|
if time.is_infinite() || time.is_nan() {
|
||||||
error!("Unable to skip to non-finite time: {}", time);
|
error!("Unable to skip to non-finite time: {}", time);
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
audio.set_current_time(time);
|
audio.set_current_time(time);
|
||||||
log!("Player skipped to time: {}", time);
|
log!("Player skipped to time: {}", time);
|
||||||
@ -85,8 +85,8 @@ pub fn skip_to(status: impl SignalUpdate<Value = PlayStatus>, time: f64) {
|
|||||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `play` - `true` to play the song, `false` to pause it
|
/// * `play` - `true` to play the song, `false` to pause it
|
||||||
///
|
///
|
||||||
pub fn set_playing(status: impl SignalUpdate<Value = PlayStatus>, play: bool) {
|
pub fn set_playing(play: bool) {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
if play {
|
if play {
|
||||||
if let Err(e) = audio.play() {
|
if let Err(e) = audio.play() {
|
||||||
@ -109,8 +109,8 @@ pub fn set_playing(status: impl SignalUpdate<Value = PlayStatus>, play: bool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) {
|
fn toggle_queue() {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
status.queue_open = !status.queue_open;
|
status.queue_open = !status.queue_open;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -126,8 +126,8 @@ fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) {
|
|||||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||||
/// * `src` - The source to set the audio player to
|
/// * `src` - The source to set the audio player to
|
||||||
///
|
///
|
||||||
fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) {
|
fn set_play_src(src: String) {
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
if let Some(audio) = status.get_audio() {
|
if let Some(audio) = status.get_audio() {
|
||||||
audio.set_src(&src);
|
audio.set_src(&src);
|
||||||
log!("Player set src to: {}", src);
|
log!("Player set src to: {}", src);
|
||||||
@ -139,11 +139,13 @@ fn set_play_src(status: impl SignalUpdate<Value = PlayStatus>, src: String) {
|
|||||||
|
|
||||||
/// The play, pause, and skip buttons
|
/// The play, pause, and skip buttons
|
||||||
#[component]
|
#[component]
|
||||||
fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn PlayControls() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
// On click handlers for the skip and play/pause buttons
|
// On click handlers for the skip and play/pause buttons
|
||||||
|
|
||||||
let skip_back = move |_| {
|
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
|
// 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
|
// 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
|
// 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<PlayStatus>) -> impl IntoView {
|
|||||||
// Push the popped song to the front of the queue, and play it
|
// Push the popped song to the front of the queue, and play it
|
||||||
let next_src = last_played_song.song_path.clone();
|
let next_src = last_played_song.song_path.clone();
|
||||||
status.update(|status| status.queue.push_front(last_played_song));
|
status.update(|status| status.queue.push_front(last_played_song));
|
||||||
set_play_src(status, next_src);
|
set_play_src(next_src);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
warn!("Unable to skip back: No previous song");
|
warn!("Unable to skip back: No previous song");
|
||||||
}
|
}
|
||||||
@ -170,14 +172,14 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
// Default to skipping to start of current song, and playing
|
// Default to skipping to start of current song, and playing
|
||||||
log!("Skipping to start of current song");
|
log!("Skipping to start of current song");
|
||||||
skip_to(status, 0.0);
|
skip_to(0.0);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
let skip_forward = move |_| {
|
let skip_forward = move |_| {
|
||||||
if let Some(duration) = get_song_time_duration(status) {
|
if let Some(duration) = get_song_time_duration() {
|
||||||
skip_to(status, duration.1);
|
skip_to(duration.1);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip forward: Unable to get current duration");
|
error!("Unable to skip forward: Unable to get current duration");
|
||||||
}
|
}
|
||||||
@ -185,7 +187,7 @@ fn PlayControls(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
let toggle_play = move |_| {
|
let toggle_play = move |_| {
|
||||||
let playing = status.with_untracked(|status| { status.playing });
|
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
|
// We use this to prevent the buttons from being focused when clicked
|
||||||
@ -248,7 +250,9 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
|
|||||||
|
|
||||||
/// The name, artist, and album of the current song
|
/// The name, artist, and album of the current song
|
||||||
#[component]
|
#[component]
|
||||||
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn MediaInfo() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
let name = Signal::derive(move || {
|
let name = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
||||||
@ -287,7 +291,9 @@ fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
/// The like and dislike buttons
|
/// The like and dislike buttons
|
||||||
#[component]
|
#[component]
|
||||||
fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn LikeDislike() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
let like_icon = Signal::derive(move || {
|
let like_icon = Signal::derive(move || {
|
||||||
status.with(|status| {
|
status.with(|status| {
|
||||||
match status.queue.front() {
|
match status.queue.front() {
|
||||||
@ -400,7 +406,7 @@ fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
/// The play progress bar, and click handler for skipping to a certain time in the song
|
/// The play progress bar, and click handler for skipping to a certain time in the song
|
||||||
#[component]
|
#[component]
|
||||||
fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
|
||||||
// Keep a reference to the progress bar div so we can get its width and calculate the time to skip to
|
// 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::<Div>();
|
let progress_bar_ref = create_node_ref::<Div>();
|
||||||
|
|
||||||
@ -412,10 +418,10 @@ fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> im
|
|||||||
let width = progress_bar.offset_width() as f64;
|
let width = progress_bar.offset_width() as f64;
|
||||||
let percentage = x_click_pos / width * 100.0;
|
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;
|
let time = duration.1 * percentage / 100.0;
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip to time: Unable to get current duration");
|
error!("Unable to skip to time: Unable to get current duration");
|
||||||
}
|
}
|
||||||
@ -438,11 +444,11 @@ fn ProgressBar(percentage: MaybeSignal<f64>, status: RwSignal<PlayStatus>) -> im
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
fn QueueToggle() -> impl IntoView {
|
||||||
|
|
||||||
let update_queue = move |_| {
|
let update_queue = move |_| {
|
||||||
toggle_queue(status);
|
toggle_queue();
|
||||||
log!("queue button pressed, queue status: {:?}", status.with_untracked(|status| status.queue_open));
|
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
|
// We use this to prevent the buttons from being focused when clicked
|
||||||
@ -463,9 +469,9 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
/// Renders the title of the page based on the currently playing song
|
/// Renders the title of the page based on the currently playing song
|
||||||
#[component]
|
#[component]
|
||||||
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
pub fn CustomTitle() -> impl IntoView {
|
||||||
let title = create_memo(move |_| {
|
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| {
|
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
|
||||||
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
||||||
})
|
})
|
||||||
@ -478,31 +484,33 @@ pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PlayBar(status: RwSignal<PlayStatus>) -> 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
|
// 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| {
|
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| {
|
||||||
if e.key() == "ArrowRight" {
|
if e.key() == "ArrowRight" && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
|
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;
|
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
|
||||||
time = time.clamp(0.0, duration.1);
|
time = time.clamp(0.0, duration.1);
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip forward: Unable to get current duration");
|
error!("Unable to skip forward: Unable to get current duration");
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if e.key() == "ArrowLeft" {
|
} else if e.key() == "ArrowLeft" && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
|
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;
|
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
|
||||||
time = time.clamp(0.0, duration.1);
|
time = time.clamp(0.0, duration.1);
|
||||||
skip_to(status, time);
|
skip_to(time);
|
||||||
set_playing(status, true);
|
set_playing(true);
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to skip backward: Unable to get current duration");
|
error!("Unable to skip backward: Unable to get current duration");
|
||||||
}
|
}
|
||||||
@ -511,12 +519,12 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
|
|
||||||
// Listen for space bar presses to play/pause
|
// Listen for space bar presses to play/pause
|
||||||
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| {
|
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| {
|
||||||
if e.key() == " " {
|
if e.key() == " " && status.with_untracked(|status| status.search_active) == false {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
log!("Space bar pressed, toggling play/pause");
|
log!("Space bar pressed, toggling play/pause");
|
||||||
|
|
||||||
let playing = status.with_untracked(|status| status.playing);
|
let playing = status.with_untracked(|status| status.playing);
|
||||||
set_playing(status, !playing);
|
set_playing(!playing);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -659,14 +667,14 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
|||||||
<audio _ref=audio_ref on:play=on_play on:pause=on_pause
|
<audio _ref=audio_ref on:play=on_play on:pause=on_pause
|
||||||
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
||||||
<div class="playbar">
|
<div class="playbar">
|
||||||
<ProgressBar percentage=percentage.into() status=status />
|
<ProgressBar percentage=percentage.into() />
|
||||||
<div class="playbar-left-group">
|
<div class="playbar-left-group">
|
||||||
<MediaInfo status=status />
|
<MediaInfo />
|
||||||
<LikeDislike status=status />
|
<LikeDislike />
|
||||||
</div>
|
</div>
|
||||||
<PlayControls status=status />
|
<PlayControls />
|
||||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||||
<QueueToggle status=status />
|
<QueueToggle />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,8 @@ pub struct PlayStatus {
|
|||||||
pub playing: bool,
|
pub playing: bool,
|
||||||
/// Whether or not the queue is open
|
/// Whether or not the queue is open
|
||||||
pub queue_open: bool,
|
pub queue_open: bool,
|
||||||
|
/// Whether or not the search bar is active (useful for knowing when spacebar to play/pause, etc should be disabled)
|
||||||
|
pub search_active: bool,
|
||||||
/// A reference to the HTML audio element
|
/// A reference to the HTML audio element
|
||||||
pub audio_player: Option<NodeRef<Audio>>,
|
pub audio_player: Option<NodeRef<Audio>>,
|
||||||
/// A queue of songs that have been played, ordered from oldest to newest
|
/// A queue of songs that have been played, ordered from oldest to newest
|
||||||
@ -56,6 +58,7 @@ impl Default for PlayStatus {
|
|||||||
Self {
|
Self {
|
||||||
playing: false,
|
playing: false,
|
||||||
queue_open: false,
|
queue_open: false,
|
||||||
|
search_active: false,
|
||||||
audio_player: None,
|
audio_player: None,
|
||||||
history: VecDeque::new(),
|
history: VecDeque::new(),
|
||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
|
11
src/queue.rs
11
src/queue.rs
@ -1,6 +1,6 @@
|
|||||||
use crate::models::Artist;
|
use crate::models::Artist;
|
||||||
use crate::playstatus::PlayStatus;
|
|
||||||
use crate::song::Song;
|
use crate::song::Song;
|
||||||
|
use crate::util::state::GlobalState;
|
||||||
use leptos::ev::MouseEvent;
|
use leptos::ev::MouseEvent;
|
||||||
use leptos::leptos_dom::*;
|
use leptos::leptos_dom::*;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
@ -9,22 +9,23 @@ use leptos::ev::DragEvent;
|
|||||||
|
|
||||||
const RM_BTN_SIZE: &str = "2.5rem";
|
const RM_BTN_SIZE: &str = "2.5rem";
|
||||||
|
|
||||||
fn remove_song_fn(index: usize, status: RwSignal<PlayStatus>) {
|
fn remove_song_fn(index: usize) {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
||||||
} else {
|
} else {
|
||||||
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
|
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
|
||||||
status.update(|status| {
|
GlobalState::play_status().update(|status| {
|
||||||
status.queue.remove(index);
|
status.queue.remove(index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView {
|
pub fn Queue() -> impl IntoView {
|
||||||
|
let status = GlobalState::play_status();
|
||||||
|
|
||||||
let remove_song = move |index: usize| {
|
let remove_song = move |index: usize| {
|
||||||
remove_song_fn(index, status);
|
remove_song_fn(index);
|
||||||
log!("Removed song {}", index + 1);
|
log!("Removed song {}", index + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -39,6 +39,23 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
playlist_songs (playlist_id, song_id) {
|
||||||
|
playlist_id -> Int4,
|
||||||
|
song_id -> Int4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
playlists (id) {
|
||||||
|
id -> Int4,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
updated_at -> Timestamp,
|
||||||
|
owner_id -> Int4,
|
||||||
|
name -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
song_artists (song_id, artist_id) {
|
song_artists (song_id, artist_id) {
|
||||||
song_id -> Int4,
|
song_id -> Int4,
|
||||||
@ -95,6 +112,9 @@ diesel::table! {
|
|||||||
|
|
||||||
diesel::joinable!(album_artists -> albums (album_id));
|
diesel::joinable!(album_artists -> albums (album_id));
|
||||||
diesel::joinable!(album_artists -> artists (artist_id));
|
diesel::joinable!(album_artists -> artists (artist_id));
|
||||||
|
diesel::joinable!(playlist_songs -> playlists (playlist_id));
|
||||||
|
diesel::joinable!(playlist_songs -> songs (song_id));
|
||||||
|
diesel::joinable!(playlists -> users (owner_id));
|
||||||
diesel::joinable!(song_artists -> artists (artist_id));
|
diesel::joinable!(song_artists -> artists (artist_id));
|
||||||
diesel::joinable!(song_artists -> songs (song_id));
|
diesel::joinable!(song_artists -> songs (song_id));
|
||||||
diesel::joinable!(song_dislikes -> songs (song_id));
|
diesel::joinable!(song_dislikes -> songs (song_id));
|
||||||
@ -111,6 +131,8 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
artists,
|
artists,
|
||||||
friend_requests,
|
friend_requests,
|
||||||
friendships,
|
friendships,
|
||||||
|
playlist_songs,
|
||||||
|
playlists,
|
||||||
song_artists,
|
song_artists,
|
||||||
song_dislikes,
|
song_dislikes,
|
||||||
song_history,
|
song_history,
|
||||||
|
@ -39,10 +39,11 @@ if #[cfg(feature = "ssr")] {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_albums")]
|
#[server(endpoint = "search_albums")]
|
||||||
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
|
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<(Album, f32)>, ServerFnError> {
|
||||||
use crate::schema::albums::dsl::*;
|
use crate::schema::albums::dsl::*;
|
||||||
|
|
||||||
Ok(albums
|
Ok(albums
|
||||||
|
.select((albums::all_columns(), trgm_distance(title, query.clone())))
|
||||||
.filter(trgm_similar(title, query.clone()))
|
.filter(trgm_similar(title, query.clone()))
|
||||||
.order_by(trgm_distance(title, query))
|
.order_by(trgm_distance(title, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -58,10 +59,11 @@ pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, Serv
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_artists")]
|
#[server(endpoint = "search_artists")]
|
||||||
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
|
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<(Artist, f32)>, ServerFnError> {
|
||||||
use crate::schema::artists::dsl::*;
|
use crate::schema::artists::dsl::*;
|
||||||
|
|
||||||
Ok(artists
|
Ok(artists
|
||||||
|
.select((artists::all_columns(), trgm_distance(name, query.clone())))
|
||||||
.filter(trgm_similar(name, query.clone()))
|
.filter(trgm_similar(name, query.clone()))
|
||||||
.order_by(trgm_distance(name, query))
|
.order_by(trgm_distance(name, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -77,10 +79,11 @@ pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, Se
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
||||||
#[server(endpoint = "search_songs")]
|
#[server(endpoint = "search_songs")]
|
||||||
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
|
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<(Song, f32)>, ServerFnError> {
|
||||||
use crate::schema::songs::dsl::*;
|
use crate::schema::songs::dsl::*;
|
||||||
|
|
||||||
Ok(songs
|
Ok(songs
|
||||||
|
.select((songs::all_columns(), trgm_distance(title, query.clone())))
|
||||||
.filter(trgm_similar(title, query.clone()))
|
.filter(trgm_similar(title, query.clone()))
|
||||||
.order_by(trgm_distance(title, query))
|
.order_by(trgm_distance(title, query))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@ -95,9 +98,10 @@ pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, Server
|
|||||||
/// `limit` - The maximum number of results to return for each type
|
/// `limit` - The maximum number of results to return for each type
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
|
/// A Result containing a tuple of vectors of albums, artists, and songs,
|
||||||
|
/// along with respective similarity scores, if the search was successful.
|
||||||
#[server(endpoint = "search")]
|
#[server(endpoint = "search")]
|
||||||
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
|
pub async fn search(query: String, limit: i64) -> Result<(Vec<(Album, f32)>, Vec<(Artist, f32)>, Vec<(Song, f32)>), ServerFnError> {
|
||||||
let albums = search_albums(query.clone(), limit);
|
let albums = search_albums(query.clone(), limit);
|
||||||
let artists = search_artists(query.clone(), limit);
|
let artists = search_artists(query.clone(), limit);
|
||||||
let songs = search_songs(query.clone(), limit);
|
let songs = search_songs(query.clone(), limit);
|
||||||
|
@ -3,9 +3,9 @@ use leptos::*;
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
|
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
|
||||||
view!{
|
view!{
|
||||||
<div class="queue-song">
|
<div class="song">
|
||||||
<img src={song_image_path} alt={song_title.clone()} />
|
<img src={song_image_path} alt={song_title.clone()} />
|
||||||
<div class="queue-song-info">
|
<div class="song-info">
|
||||||
<h3>{song_title}</h3>
|
<h3>{song_title}</h3>
|
||||||
<p>{song_artist}</p>
|
<p>{song_artist}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,3 +5,5 @@ cfg_if! {
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod state;
|
||||||
|
49
src/util/state.rs
Normal file
49
src/util/state.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
use leptos::*;
|
||||||
|
use leptos::logging::*;
|
||||||
|
|
||||||
|
use crate::playstatus::PlayStatus;
|
||||||
|
use crate::models::User;
|
||||||
|
use crate::auth::get_logged_in_user;
|
||||||
|
|
||||||
|
/// Global front-end state
|
||||||
|
/// Contains anything frequently needed across multiple components
|
||||||
|
/// Behaves like a singleton, in that provide/expect_context will
|
||||||
|
/// always return the same instance
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GlobalState {
|
||||||
|
/// A resource that fetches the logged in user
|
||||||
|
/// This will not automatically refetch, so any login/logout related code
|
||||||
|
/// should call `refetch` on this resource
|
||||||
|
pub logged_in_user: Resource<(), Option<User>>,
|
||||||
|
|
||||||
|
/// The current play status
|
||||||
|
pub play_status: RwSignal<PlayStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let play_status = create_rw_signal(PlayStatus::default());
|
||||||
|
|
||||||
|
let logged_in_user = create_resource(|| (), |_| async {
|
||||||
|
get_logged_in_user().await
|
||||||
|
.inspect_err(|e| {
|
||||||
|
error!("Error getting logged in user: {:?}", e);
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
logged_in_user,
|
||||||
|
play_status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logged_in_user() -> Resource<(), Option<User>> {
|
||||||
|
expect_context::<Self>().logged_in_user
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_status() -> RwSignal<PlayStatus> {
|
||||||
|
expect_context::<Self>().play_status
|
||||||
|
}
|
||||||
|
}
|
@ -5,10 +5,12 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: calc(100% - 78.9px); /* Adjust height to fit the queue */
|
height: calc(100% - 87px); /* Adjust height to fit the queue */
|
||||||
background-color: #424242; /* Queue background color */
|
background-color: #424242; /* Queue background color */
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
overflow-y: auto; /* Add scroll bar when queue is too long */
|
overflow-y: auto; /* Add scroll bar when queue is too long */
|
||||||
|
margin: 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
.queue-header {
|
.queue-header {
|
||||||
background-color: #333; /* Header background color */
|
background-color: #333; /* Header background color */
|
||||||
@ -29,31 +31,6 @@
|
|||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
|
|
||||||
.queue-song {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #ccc; /* Separator line color */
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 50px; /* Adjust maximum width for images */
|
|
||||||
margin-right: 10px; /* Add spacing between image and text */
|
|
||||||
border-radius: 5px; /* Add border radius to image */
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-song-info {
|
|
||||||
h3 {
|
|
||||||
margin: 0; /* Remove default margin for heading */
|
|
||||||
color: #fff; /* Adjust text color for song */
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0; /* Remove default margin for paragraph */
|
|
||||||
color: #aaa; /* Adjust text color for artist */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
|
@ -1,4 +1,92 @@
|
|||||||
@import "theme.scss";
|
@import "theme.scss";
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
input[type="search"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid $search-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent-color;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.search-result-list {
|
||||||
|
flex: 1;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.search-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid $search-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: $search-background;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
margin: 0.2rem;
|
||||||
|
min-height: 6rem;
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-result-album,
|
||||||
|
.search-result-artist {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item-type {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $text-controls-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-side-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.search-result-options {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.search-result-options-icon {
|
||||||
|
fill: $text-controls-color;
|
||||||
|
transition: fill 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: $controls-hover-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
fill: $controls-click-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
22
style/song.scss
Normal file
22
style/song.scss
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
.song {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 50px; /* Adjust maximum width for images */
|
||||||
|
margin-right: 10px; /* Add spacing between image and text */
|
||||||
|
border-radius: 5px; /* Add border radius to image */
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff; /* Adjust text color for song */
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #aaa; /* Adjust text color for artist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,6 +11,8 @@ $play-bar-background-color: #212121;
|
|||||||
$play-grad-start: #0a0533;
|
$play-grad-start: #0a0533;
|
||||||
$play-grad-end: $accent-color;
|
$play-grad-end: $accent-color;
|
||||||
$queue-background-color: $play-bar-background-color;
|
$queue-background-color: $play-bar-background-color;
|
||||||
|
$search-background: $play-bar-background-color;
|
||||||
|
$search-border: #3f3f3f;
|
||||||
|
|
||||||
$auth-inputs: #796dd4;
|
$auth-inputs: #796dd4;
|
||||||
$auth-containers: white;
|
$auth-containers: white;
|
||||||
|
Reference in New Issue
Block a user