diff --git a/src/lib.rs b/src/lib.rs index 261a8f5..17add12 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod app; pub mod songdata; pub mod playstatus; +pub mod playbar; use cfg_if::cfg_if; cfg_if! { diff --git a/src/playbar.rs b/src/playbar.rs new file mode 100644 index 0000000..0af7698 --- /dev/null +++ b/src/playbar.rs @@ -0,0 +1,459 @@ +use std::time::Duration; + +use crate::playstatus::PlayStatus; +use leptos::ev::MouseEvent; +use leptos::html::{Audio, Div}; +use leptos::leptos_dom::*; +use leptos::*; +use leptos_icons::BsIcon::*; +use leptos_icons::*; + +/// Width and height of the forward/backward skip buttons +const SKIP_BTN_SIZE: &str = "3.5em"; +/// Width and height of the play/pause button +const PLAY_BTN_SIZE: &str = "5em"; + +/// Threshold in seconds for skipping to the previous song instead of skipping to the start of the current song +const MIN_SKIP_BACK_TIME: f64 = 5.0; + +/// How many seconds to skip forward/backward when the user presses the arrow keys +const ARROW_KEY_SKIP_TIME: f64 = 5.0; + +/// How often to update the progress bar and song time +const PROGRESS_UPDATE_TIME: Duration = Duration::from_millis(200); + +// TODO Handle errors better, when getting audio HTML element and when playing/pausing audio + +/// Get the current time and duration of the current song, if available +/// +/// # Arguments +/// +/// * `status` - The `PlayStatus` to get the audio element from, as a signal +/// +/// # Returns +/// +/// * `None` if the audio element is not available +/// * `Some((current_time, duration))` if the audio element is available +/// +fn get_song_time_duration(status: impl SignalWithUntracked) -> Option<(f64, f64)> { + status.with_untracked(|status| { + if let Some(audio) = status.get_audio() { + Some((audio.current_time(), audio.duration())) + } else { + error!("Unable to get current duration: Audio element not available"); + None + } + }) +} + +/// Skip to a certain time in the current song, optionally playing it +/// +/// If the given time is +/- infinity or NaN, logs an error and returns +/// Logs an error if the audio element is not available, or if playing the song fails +/// +/// # Arguments +/// +/// * `status` - The `PlayStatus` to get the audio element from, as a signal +/// * `time` - The time to skip to, in seconds +/// +fn skip_to(status: impl SignalUpdate, time: f64) { + if time.is_infinite() || time.is_nan() { + error!("Unable to skip to non-finite time: {}", time); + return + } + + status.update(|status| { + if let Some(audio) = status.get_audio() { + audio.set_current_time(time); + log!("Player skipped to time: {}", time); + } else { + error!("Unable to skip to time: Audio element not available"); + } + }); +} + +/// Play or pause the current song +/// +/// Logs an error if the audio element is not available, or if playing/pausing the song fails +/// +/// # Arguments +/// * `status` - The `PlayStatus` to get the audio element from, as a signal +/// * `play` - `true` to play the song, `false` to pause it +/// +fn set_playing(status: impl SignalUpdate, play: bool) { + status.update(|status| { + if let Some(audio) = status.get_audio() { + if play { + if let Err(e) = audio.play() { + error!("Unable to play audio: {:?}", e); + } else { + status.playing = true; + log!("Successfully played audio"); + } + } else { + if let Err(e) = audio.pause() { + error!("Unable to pause audio: {:?}", e); + } else { + status.playing = false; + log!("Successfully paused audio"); + } + } + } else { + error!("Unable to play/pause audio: Audio element not available"); + } + }); +} + +/// Set the source of the audio player +/// +/// Logs an error if the audio element is not available +/// +/// +/// # Arguments +/// * `status` - The `PlayStatus` to get the audio element from, as a signal +/// * `src` - The source to set the audio player to +/// +fn set_play_src(status: impl SignalUpdate, src: String) { + status.update(|status| { + if let Some(audio) = status.get_audio() { + audio.set_src(&src); + log!("Player set src to: {}", src); + } else { + error!("Unable to set src: Audio element not available"); + } + }); +} + +/// The play, pause, and skip buttons +#[component] +fn PlayControls(status: RwSignal) -> impl IntoView { + // On click handlers for the skip and play/pause buttons + + let skip_back = move |_| { + if let Some(duration) = get_song_time_duration(status) { + // 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 + // but our queue will be empty. We *do* want to "skip the start of the current song", + // but first we need to get the *previous* song from the history since that's what we were playing before + if duration.0 < MIN_SKIP_BACK_TIME || duration.0 >= duration.1 { + log!("Skipping to the previous song"); + + // Pop the most recently played song from the history if possible + let mut last_played_song = None; + status.update(|status| last_played_song = status.history.pop_back()); + + if let Some(last_played_song) = last_played_song { + // 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); + } else { + warn!("Unable to skip back: No previous song"); + } + } + } + + // 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); + }; + + let skip_forward = move |_| { + if let Some(duration) = get_song_time_duration(status) { + skip_to(status, duration.1); + set_playing(status, true); + } else { + error!("Unable to skip forward: Unable to get current duration"); + } + }; + + let toggle_play = move |_| { + let playing = status.with_untracked(|status| { status.playing }); + set_playing(status, !playing); + }; + + // We use this to prevent the buttons from being focused when clicked + // If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button + // and trigger unwanted behavior + let prevent_focus = move |e: MouseEvent| { + e.prevent_default(); + }; + + // Change the icon based on whether the song is playing or not + let icon = Signal::derive(move || { + status.with(|status| { + if status.playing { + Icon::from(BsPauseFill) + } else { + Icon::from(BsPlayFill) + } + }) + }); + + view! { +
+ + + + + + + +
+ } +} + +/// The elapsed time and total time of the current song +#[component] +fn PlayDuration(elapsed_secs: MaybeSignal, total_secs: MaybeSignal) -> impl IntoView { + // Create a derived signal that formats the elapsed and total seconds into a string + let play_duration = Signal::derive(move || { + let elapsed_mins = (elapsed_secs.get() - elapsed_secs.get() % 60) / 60; + let total_mins = (total_secs.get() - total_secs.get() % 60) / 60; + let elapsed_secs = elapsed_secs.get() % 60; + let total_secs = total_secs.get() % 60; + + // Format as "MM:SS / MM:SS" + format!("{}:{:0>2} / {}:{:0>2}", elapsed_mins, elapsed_secs, total_mins, total_secs) + }); + + view! { +
+ {play_duration} +
+ } +} + +/// The name, artist, and album of the current song +#[component] +fn MediaInfo(status: RwSignal) -> impl IntoView { + let name = Signal::derive(move || { + status.with(|status| { + status.queue.front().map_or("No media playing".into(), |song| song.name.clone()) + }) + }); + + let artist = Signal::derive(move || { + status.with(|status| { + status.queue.front().map_or("".into(), |song| song.artist.clone()) + }) + }); + + let album = Signal::derive(move || { + status.with(|status| { + status.queue.front().map_or("".into(), |song| song.album.clone()) + }) + }); + + let image = Signal::derive(move || { + status.with(|status| { + // TODO Use some default / unknown image? + status.queue.front().map_or("".into(), |song| song.image_path.clone()) + }) + }); + + view! { +
+ +
+ {name} +
+ {artist} - {album} +
+
+ } +} + +/// The play progress bar, and click handler for skipping to a certain time in the song +#[component] +fn ProgressBar(percentage: MaybeSignal, status: RwSignal) -> impl IntoView { + // 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::
(); + + let progress_jump = move |e: MouseEvent| { + let x_click_pos = e.offset_x() as f64; + log!("Progress bar clicked at x: {}", x_click_pos); + + if let Some(progress_bar) = progress_bar_ref.get() { + 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) { + let time = duration.1 * percentage / 100.0; + skip_to(status, time); + set_playing(status, true); + } else { + error!("Unable to skip to time: Unable to get current duration"); + } + } else { + error!("Unable to skip to time: Progress bar not available"); + } + }; + + // Create a derived signal that formats the song percentage into a CSS style string for width + let bar_width_style = Signal::derive(move || format!("width: {}%;", percentage.get())); + + view! { +
// Larger click area +
// "Unfilled" progress bar +
// "Filled" progress bar +
+
+
+ } +} + +/// The main play bar component, containing the progress bar, media info, play controls, and play duration +#[component] +pub fn PlayBar(status: RwSignal) -> 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" { + 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) { + let mut time = duration.0 + ARROW_KEY_SKIP_TIME; + time = time.clamp(0.0, duration.1); + skip_to(status, time); + set_playing(status, true); + } else { + error!("Unable to skip forward: Unable to get current duration"); + } + + } else if e.key() == "ArrowLeft" { + 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) { + let mut time = duration.0 - ARROW_KEY_SKIP_TIME; + time = time.clamp(0.0, duration.1); + skip_to(status, time); + set_playing(status, true); + } else { + error!("Unable to skip backward: Unable to get current duration"); + } + } + }); + + // Listen for space bar presses to play/pause + let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| { + if e.key() == " " { + e.prevent_default(); + log!("Space bar pressed, toggling play/pause"); + + let playing = status.with_untracked(|status| status.playing); + set_playing(status, !playing); + } + }); + + // Keep a reference to the audio element so we can set its source and play/pause it + let audio_ref = create_node_ref::