Compare commits

...

8 Commits

13 changed files with 317 additions and 39 deletions

View File

@ -27,7 +27,8 @@ leptos_icons = { version = "0.1.0", default_features = false, features = [
"CgTrash",
"IoReturnUpBackSharp",
"AiEyeFilled",
"AiEyeInvisibleFilled"
"AiEyeInvisibleFilled",
"BsThreeDotsVertical",
] }
dotenv = { version = "0.15.0", optional = true }
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "time"], optional = true }

View File

@ -1,6 +1,7 @@
use crate::playbar::PlayBar;
use crate::playstatus::PlayStatus;
use crate::queue::Queue;
use crate::searchbar::SearchBar;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
@ -41,8 +42,11 @@ fn HomePage() -> impl IntoView {
let play_status = create_rw_signal(play_status);
view! {
<div class="home">
<PlayBar status=play_status/>
<Queue status=play_status/>
<SearchBar status=play_status/>
</div>
}
}

View File

@ -10,6 +10,7 @@ pub mod models;
pub mod pages;
pub mod users;
pub mod search;
pub mod searchbar;
use cfg_if::cfg_if;
cfg_if! {

View File

@ -350,7 +350,7 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
// 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);
@ -363,7 +363,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
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);
@ -380,7 +380,7 @@ 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");

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

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

154
src/searchbar.rs Normal file
View File

@ -0,0 +1,154 @@
use crate::search::search;
use crate::playstatus::PlayStatus;
use crate::song::Song;
use crate::models::Album;
use crate::models::Artist;
use crate::models::Song;
use leptos::*;
use leptos::ev::*;
use leptos::leptos_dom::*;
use leptos_icons::*;
use leptos_icons::BsIcon::*;
const OPTIONS_BTN_SIZE: &str = "2.5rem";
#[component]
pub fn SearchBar(status: RwSignal<PlayStatus>) -> impl IntoView {
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());
if search_query.get_untracked().len() < 3 {
search_results.set((Vec::<(Album, f32)>::new(), Vec::<(Artist, f32)>::new(), Vec::<(Song, f32)>::new()));
return;
}
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: {:?}", err);
}
}
});
};
let on_disabled = move |_e: FocusEvent| {
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">
<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">
<ul class="search-results-list">
{
move || search_results.with(|(albums, artists, songs)| -> Vec<_> {
let mut album_index = 0;
let mut artist_index = 0;
let mut song_index = 0;
let mut views = Vec::new();
while album_index < albums.len() || artist_index < artists.len() || song_index < songs.len() {
const RM_BTN_SIZE: &str = "2.5rem";
let album_score = if album_index < albums.len() { albums[album_index].1 } else { f32::MAX };
let artist_score = if artist_index < artists.len() { artists[artist_index].1 } else { f32::MAX };
let song_score = if song_index < songs.len() { songs[song_index].1 } else { f32::MAX };
if artist_score <= album_score && artist_score <= song_score {
let artist = &artists[artist_index].0;
artist_index += 1;
views.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=Icon::from(BsThreeDotsVertical) />
</button>
</div>
</div>
</li>
});
}
else if album_score <= artist_score && album_score <= song_score {
let album = &albums[album_index].0;
album_index += 1;
views.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=Icon::from(BsThreeDotsVertical) />
</button>
</div>
</div>
</li>
});
}
else if song_score <= artist_score && song_score <= album_score {
let song = &songs[song_index].0;
song_index += 1;
views.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=Icon::from(BsThreeDotsVertical) />
</button>
</div>
</div>
</li>
});
}
}
views
})
}
</ul>
</div>
</div>
}
}

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

@ -3,6 +3,8 @@
@import 'queue.scss';
@import 'login.scss';
@import 'signup.scss';
@import 'song.scss';
@import 'searchbar.scss';
body {
font-family: sans-serif;
@ -11,3 +13,8 @@ body {
margin: 0;
padding: 0;
}
.home {
width: 100%;
height: 100%;
}

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 */
@ -30,31 +32,6 @@
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;
border: none;

99
style/searchbar.scss Normal file
View File

@ -0,0 +1,99 @@
@import 'theme.scss';
.search-container {
display: flex;
margin: 5px auto;
margin-left: 282px;
border-radius: 5px;
height: 100%;
width: calc(100% - 690px);
background-color: $search-background-color;
}
.search-bar {
background-color: transparent;
border-radius: 5px;
padding: 10px;
display: flex;
justify-content: left;
z-index: 10;
}
.search-bar input[type="search"] {
background-color: $search-bar-input-background-color;
border: none;
outline: none;
color: $search-bar-input-color;
padding: 10px;
border-radius: 5px;
font-size: 16px;
&::placeholder {
color: rgb(61, 61, 61);
}
}
.search-results {
background-color: $search-background-color;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
display: flex;
margin-top: 55px;
height: calc(100% - 143px);
width: calc(100% - 690px);
position: absolute;
ul {
list-style: none;
padding: 0;
margin: 0;
width: 100%;
li {
width: calc(100% - 20px);
padding: 10px;
border-bottom: 1px solid $search-highlight-color;
border-radius: 5px;
color: white;
font-size: 16px;
cursor: pointer;
&:hover {
background-color: $search-highlight-color;
}
&:first-child {
border-top: 2px solid $search-highlight-color;
}
.result-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.right-side-result {
display: flex;
align-items: center;
justify-content: space-between;
.search-item-type {
color: $search-item-type-color;
margin-right: 10px;
}
.search-result-options {
background-color: transparent;
border: none;
&:hover {
color: $search-options-color;
}
&:active {
color: $search-options-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

@ -7,6 +7,12 @@ $play-bar-background-color: #212121;
$play-grad-start: #0a0533;
$play-grad-end: $accent-color;
$queue-background-color: $play-bar-background-color;
$search-background-color: $play-bar-background-color;
$search-bar-input-background-color: gray;
$search-bar-input-color: black;
$search-highlight-color: #333;
$search-item-type-color: #666;
$search-options-color: #666;
$auth-inputs: #796dd4;
$auth-containers: white;