Compare commits

...

27 Commits

Author SHA1 Message Date
d73a114d35 Simplify search results and make more efficient using tokio join through general search server function 2024-12-06 19:37:48 -05:00
f8a28ca75c fix search bar bugs and style search bar and results 2024-12-06 19:12:08 -05:00
0afdbf2ad5 Refactor search bar to be in column format (testing) 2024-12-06 16:59:21 -05:00
03889d4efe Refactor upload to handle differently formatted search server functions 2024-11-22 17:26:59 -05:00
f6fb92e361 Add search component 2024-11-22 17:26:04 -05:00
00f4b2e28f Separate Song component from queue styling to use for general case 2024-11-19 22:28:35 +00:00
340129a859 Modify playbar to not allow audio control shortcuts based on search active 2024-11-19 22:27:00 +00:00
7828992e80 Add search_active to playstatus 2024-11-19 22:26:32 +00:00
9600e8c442 Modify search functions to include distance for each result 2024-11-19 21:31:54 +00:00
b1a62ee284 Merge pull request 'Switch from dotenv to dotenvy' (#146) from 127-switch-from-dotenv-to-dotenvy into main
Reviewed-on: LibreTunes/LibreTunes#146
2024-11-19 00:44:27 +00:00
0c1bab9fbb Switch from dotenv to dotenvy 2024-11-18 18:34:20 -05:00
190df0edfd Merge pull request 'Implement play/pause for songs in SongList' (#145) from 122-implement-playpause-for-songs-in-songlist into main
Reviewed-on: LibreTunes/LibreTunes#145
2024-11-18 00:13:47 +00:00
a3b3174306 Handle SongListItem play/pause clicks 2024-11-17 15:39:08 -05:00
f633cffe69 Remove unnecessary .into() 2024-11-15 22:45:47 -05:00
cf6f7b7db7 Refactor SongList/SongListExtra
Don't use MaybeSignal
Combine into SongListInner
2024-11-15 22:44:57 -05:00
8f01ff24d8 Merge pull request 'Use provide_context for global state' (#143) from 142-use-providecontext-for-global-state into main
Reviewed-on: LibreTunes/LibreTunes#143
2024-11-16 01:13:25 +00:00
f0f34d4abe Use GlobalState instead of passing play_status/logged_in_user everywhere 2024-11-15 18:49:19 -05:00
d42737f856 Create GlobalState 2024-11-15 18:47:13 -05:00
47fc35814d Merge pull request 'Implement SongList like/dislike functionality' (#141) from 123-implement-likedislike-for-songs-in-songlist into main
Reviewed-on: LibreTunes/LibreTunes#141
2024-11-15 21:45:06 +00:00
fe21785f2d Implement SongList like/dislike functionality 2024-11-15 15:33:05 -05:00
c6024a7457 Merge pull request 'Update wasm-bindgen to 0.2.95' (#139) from 132-update-wasmbindgen-to-0295 into main
Reviewed-on: LibreTunes/LibreTunes#139
2024-11-15 18:42:18 +00:00
41327c8ed6 Update wasm-bindgen to 0.2.95 2024-11-15 13:23:20 -05:00
d09060959a Merge pull request '111-create-playlist-database-tables' (#137) from 111-create-playlist-database-tables into main
Reviewed-on: LibreTunes/LibreTunes#137
Reviewed-by: Ethan Girouard <ethan@girouard.com>
2024-11-12 22:21:52 +00:00
7f298f75ce Change Playlist Model to use chronos time 2024-11-12 17:09:08 -05:00
74044f2e17 Merge remote-tracking branch 'origin/134-fix-time-not-implemented-on-wasm' into 111-create-playlist-database-tables 2024-11-12 17:06:52 -05:00
0335e3d255 Created model 2024-11-01 16:48:56 -04:00
81b1490cce Create playlist database migration 2024-11-01 16:37:22 -04:00
25 changed files with 630 additions and 199 deletions

32
Cargo.lock generated
View File

@ -755,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"
@ -1566,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",
@ -1841,7 +1841,7 @@ dependencies = [
"console_error_panic_hook",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"flexi_logger",
"http 1.1.0",
"icondata",
@ -3444,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",
@ -3455,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",
@ -3482,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",
@ -3492,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",
@ -3505,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"

View File

@ -15,10 +15,9 @@ 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", "chrono"], default-features = false, optional = true }
lazy_static = { version = "1.4.0", optional = true }
serde = { version = "1.0.195", features = ["derive"], default-features = false }
@ -42,6 +41,7 @@ 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 = [
@ -57,7 +57,7 @@ ssr = [
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
"dotenv",
"dotenvy",
"diesel",
"lazy_static",
"openssl",

View File

@ -0,0 +1,5 @@
DROP INDEX playlists_owner_idx;
DROP TABLE playlists;
DROP INDEX playlist_songs_playlist_idx;
DROP TABLE playlist_songs;

View 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);

View File

@ -1,40 +1,23 @@
use crate::playbar::PlayBar;
use crate::playbar::CustomTitle;
use crate::playstatus::PlayStatus;
use crate::queue::Queue;
use leptos::*;
use leptos::logging::*;
use leptos_meta::*;
use leptos_router::*;
use crate::pages::login::*;
use crate::pages::signup::*;
use crate::pages::profile::*;
use crate::error_template::{AppError, ErrorTemplate};
use crate::auth::get_logged_in_user;
use crate::models::User;
pub type LoggedInUserResource = Resource<(), Option<User>>;
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);
let upload_open = create_rw_signal(false);
provide_context(GlobalState::new());
// 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
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()
});
let upload_open = create_rw_signal(false);
view! {
// injects a stylesheet into the document <head>
@ -42,7 +25,7 @@ pub fn App() -> impl IntoView {
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
// sets the document title
<CustomTitle play_status=play_status/>
<CustomTitle />
// content for this welcome page
<Router fallback=|| {
@ -55,15 +38,15 @@ pub fn App() -> impl IntoView {
}>
<main>
<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="dashboard" view=Dashboard />
<Route path="search" view=Search />
<Route path="user/:id" view=move || view!{ <Profile logged_in_user /> } />
<Route path="user" view=move || view!{ <Profile logged_in_user /> } />
<Route path="user/:id" view=Profile />
<Route path="user" view=Profile />
</Route>
<Route path="/login" view=move || view!{ <Login user=logged_in_user /> } />
<Route path="/signup" view=move || view!{ <Signup user=logged_in_user /> } />
<Route path="/login" view=Login />
<Route path="/signup" view=Signup />
</Routes>
</main>
</Router>
@ -78,7 +61,7 @@ use crate::components::upload::*;
/// Renders the home page of your application.
#[component]
fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> impl IntoView {
fn HomePage(upload_open: RwSignal<bool>) -> impl IntoView {
view! {
<div class="home-container">
<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
<Outlet />
<Personal />
<PlayBar status=play_status/>
<Queue status=play_status/>
<PlayBar />
<Queue />
</div>
}
}

View File

@ -1,10 +1,152 @@
use html::Li;
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]
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! {
<div class="search-container home-component">
<h1>Searching...</h1>
</div>
}
<div class="search-container home-component">
<div class="search-bar">
<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>
}
}

View File

@ -1,61 +1,88 @@
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<Vec<SongData>>) -> impl IntoView {
view! {
<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>
}
pub fn SongList(songs: Vec<SongData>) -> impl IntoView {
__SongListInner(songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(), false)
}
#[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
{
__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! {
<table class="song-list">
{
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, extra)| {
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! {
<SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) />
}
}).collect::<Vec<_>>()
})
view! {
<SongListItem song={song.clone()} song_playing=playing.into()
extra={if show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
}
}).collect::<Vec<_>>()
}
</table>
}
}
#[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
{
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! {
<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-list-spacer"></td>
<td class="song-artists"><SongArtists artists=song.artists /></td>
<td class="song-list-spacer"></td>
<td class="song-album"><SongAlbum album=song.album /></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>
{extra.map(|extra| view! {
<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
/// When the song list item is hovered, the overlay will show the play button
#[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! {
<img class="song-image" src={image_path}/>
{if song_playing.get() {
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view()
{move || if song_playing.get() {
view! { <Icon class="song-image-overlay song-playing-overlay"
icon=icondata::BsPauseFill on:click=pause_song /> }.into_view()
} 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
#[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 || {
if liked.get() {
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 |_| {
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! {

View File

@ -124,7 +124,7 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
>
<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/>
}).collect::<Vec<_>>()
}
@ -142,7 +142,7 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
>
<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/>
}).collect::<Vec<_>>()
}
@ -182,15 +182,15 @@ pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
}
#[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
let add_artist = move |_| {
//Create an empty string to hold previous artist ids
let mut s: String = String::from("");
//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
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 ids.len() == 1 {
let value_str = match artist.id.clone() {
@ -227,7 +227,7 @@ pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSig
}
}
#[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
let add_album = move |_| {
let value_str = match album.id.clone() {

View File

@ -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...");

View File

@ -531,7 +531,7 @@ impl Album {
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Song {
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
@ -625,3 +625,24 @@ pub struct HistoryEntry {
/// 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<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,
}

View File

@ -1,12 +1,12 @@
use crate::auth::login;
use crate::util::state::GlobalState;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::*;
use crate::users::UserCredentials;
use crate::app::LoggedInUserResource;
#[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 (password, set_password) = create_signal("".to_string());
@ -28,6 +28,8 @@ pub fn Login(user: LoggedInUserResource) -> 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 {

View File

@ -11,9 +11,9 @@ use crate::components::error::*;
use crate::api::profile::*;
use crate::app::LoggedInUserResource;
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;
@ -29,7 +29,7 @@ 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(logged_in_user: LoggedInUserResource) -> impl IntoView {
pub fn Profile() -> impl IntoView {
let params = use_params_map();
view! {
@ -38,7 +38,7 @@ pub fn Profile(logged_in_user: LoggedInUserResource) -> impl IntoView {
match params.get("id").map(|id| id.parse::<i32>()) {
None => {
// No id specified, show the current user's profile
view! { <OwnProfile logged_in_user /> }.into_view()
view! { <OwnProfile /> }.into_view()
},
Some(Ok(id)) => {
// 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
#[component]
fn OwnProfile(logged_in_user: LoggedInUserResource) -> impl IntoView {
fn OwnProfile() -> impl IntoView {
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || logged_in_user.get().map(|user| {
{move || GlobalState::logged_in_user().get().map(|user| {
match user {
Some(user) => {
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.map(|top_songs| {
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.map(|recent_songs| {
view! {
<SongList songs={recent_songs.into()} />
<SongList songs=recent_songs />
}
})
})

View File

@ -1,12 +1,12 @@
use crate::auth::signup;
use crate::models::User;
use crate::util::state::GlobalState;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::*;
use crate::app::LoggedInUserResource;
#[component]
pub fn Signup(user: LoggedInUserResource) -> impl IntoView {
pub fn Signup() -> impl IntoView {
let (username, set_username) = create_signal("".to_string());
let (email, set_email) = 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);
let user = GlobalState::logged_in_user();
spawn_local(async move {
if let Err(err) = signup(new_user.clone()).await {
// Handle the error here, e.g., log it or display to the user

View File

@ -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<Value = PlayStatus>) -> 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<Value = PlayStatu
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `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() {
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<Value = PlayStatus>, 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<Value = PlayStatus>, 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<Value = PlayStatus>, play: bool) {
});
}
fn toggle_queue(status: impl SignalUpdate<Value = PlayStatus>) {
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<Value = PlayStatus>) {
/// * `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<Value = PlayStatus>, 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<Value = PlayStatus>, src: String) {
/// The play, pause, and skip buttons
#[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
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<PlayStatus>) -> 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<PlayStatus>) -> 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<PlayStatus>) -> 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<i64>, total_secs: MaybeSignal<i64>) ->
/// The name, artist, and album of the current song
#[component]
fn MediaInfo(status: RwSignal<PlayStatus>) -> 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<PlayStatus>) -> impl IntoView {
/// The like and dislike buttons
#[component]
fn LikeDislike(status: RwSignal<PlayStatus>) -> 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<PlayStatus>) -> impl IntoView {
/// The play progress bar, and click handler for skipping to a certain time in the song
#[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
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 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<f64>, status: RwSignal<PlayStatus>) -> im
}
#[component]
fn QueueToggle(status: RwSignal<PlayStatus>) -> 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<PlayStatus>) -> impl IntoView {
/// Renders the title of the page based on the currently playing song
#[component]
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> 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,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
#[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
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();
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");
}
} else if e.key() == "ArrowLeft" {
} else if e.key() == "ArrowLeft" && status.with_untracked(|status| status.search_active) == false {
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");
}
@ -511,12 +519,12 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
// Listen for space bar presses to play/pause
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();
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<PlayStatus>) -> impl IntoView {
<audio _ref=audio_ref on:play=on_play on:pause=on_pause
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
<div class="playbar">
<ProgressBar percentage=percentage.into() status=status />
<ProgressBar percentage=percentage.into() />
<div class="playbar-left-group">
<MediaInfo status=status />
<LikeDislike status=status />
<MediaInfo />
<LikeDislike />
</div>
<PlayControls status=status />
<PlayControls />
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
<QueueToggle status=status />
<QueueToggle />
</div>
}
}

View File

@ -11,6 +11,8 @@ pub struct PlayStatus {
pub playing: bool,
/// Whether or not the queue is open
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
pub audio_player: Option<NodeRef<Audio>>,
/// A queue of songs that have been played, ordered from oldest to newest
@ -56,6 +58,7 @@ impl Default for PlayStatus {
Self {
playing: false,
queue_open: false,
search_active: false,
audio_player: None,
history: VecDeque::new(),
queue: VecDeque::new(),

View File

@ -1,6 +1,6 @@
use crate::models::Artist;
use crate::playstatus::PlayStatus;
use crate::song::Song;
use crate::util::state::GlobalState;
use leptos::ev::MouseEvent;
use leptos::leptos_dom::*;
use leptos::*;
@ -9,22 +9,23 @@ use leptos::ev::DragEvent;
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 {
log!("Error: Trying to remove currently playing song (index 0) from queue");
} else {
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);
});
}
}
#[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| {
remove_song_fn(index, status);
remove_song_fn(index);
log!("Removed song {}", index + 1);
};

View File

@ -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! {
song_artists (song_id, artist_id) {
song_id -> Int4,
@ -95,6 +112,9 @@ diesel::table! {
diesel::joinable!(album_artists -> albums (album_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 -> songs (song_id));
diesel::joinable!(song_dislikes -> songs (song_id));
@ -111,6 +131,8 @@ diesel::allow_tables_to_appear_in_same_query!(
artists,
friend_requests,
friendships,
playlist_songs,
playlists,
song_artists,
song_dislikes,
song_history,

View File

@ -39,10 +39,11 @@ if #[cfg(feature = "ssr")] {
/// # Returns
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
#[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::*;
Ok(albums
.select((albums::all_columns(), trgm_distance(title, query.clone())))
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
@ -58,10 +59,11 @@ pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, Serv
/// # Returns
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
#[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::*;
Ok(artists
.select((artists::all_columns(), trgm_distance(name, query.clone())))
.filter(trgm_similar(name, query.clone()))
.order_by(trgm_distance(name, query))
.limit(limit)
@ -77,10 +79,11 @@ pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, Se
/// # Returns
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
#[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::*;
Ok(songs
.select((songs::all_columns(), trgm_distance(title, query.clone())))
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.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
///
/// # 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")]
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 artists = search_artists(query.clone(), limit);
let songs = search_songs(query.clone(), limit);

View File

@ -3,9 +3,9 @@ use leptos::*;
#[component]
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
view!{
<div class="queue-song">
<div class="song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<div class="song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
</div>

View File

@ -5,3 +5,5 @@ cfg_if! {
pub mod audio;
}
}
pub mod state;

49
src/util/state.rs Normal file
View 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
}
}

View File

@ -5,10 +5,12 @@
top: 0;
right: 0;
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 */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
overflow-y: auto; /* Add scroll bar when queue is too long */
margin: 5px;
border-radius: 5px;
.queue-header {
background-color: #333; /* Header background color */
@ -29,31 +31,6 @@
padding-right: 10px;
padding-top: 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 {
background: none;

View File

@ -1,4 +1,92 @@
@import "theme.scss";
.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
View 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 */
}
}
}

View File

@ -11,6 +11,8 @@ $play-bar-background-color: #212121;
$play-grad-start: #0a0533;
$play-grad-end: $accent-color;
$queue-background-color: $play-bar-background-color;
$search-background: $play-bar-background-color;
$search-border: #3f3f3f;
$auth-inputs: #796dd4;
$auth-containers: white;