Merge pull request 'Use provide_context for global state' (#143) from 142-use-providecontext-for-global-state into main

Reviewed-on: LibreTunes/LibreTunes#143
This commit is contained in:
Ethan Girouard 2024-11-16 01:13:25 +00:00
commit 8f01ff24d8
8 changed files with 136 additions and 89 deletions

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,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();

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,18 +484,20 @@ 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" {
e.prevent_default();
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
if let Some(duration) = get_song_time_duration(status) {
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(status, time);
set_playing(status, true);
skip_to(time);
set_playing(true);
} else {
error!("Unable to skip forward: Unable to get current duration");
}
@ -498,11 +506,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
e.prevent_default();
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
if let Some(duration) = get_song_time_duration(status) {
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(status, time);
set_playing(status, true);
skip_to(time);
set_playing(true);
} else {
error!("Unable to skip backward: Unable to get current duration");
}
@ -516,7 +524,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
log!("Space bar pressed, toggling play/pause");
let playing = status.with_untracked(|status| status.playing);
set_playing(status, !playing);
set_playing(!playing);
}
});
@ -659,14 +667,14 @@ pub fn PlayBar(status: RwSignal<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

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

@ -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
}
}