Implement a basic player bar

This commit is contained in:
Ethan Girouard 2024-01-03 00:31:25 -05:00
parent 2a89dac9c9
commit 97304ba1a7
Signed by: eta357
GPG Key ID: 7BCDC36DFD11C146
2 changed files with 460 additions and 0 deletions

View File

@ -1,6 +1,7 @@
pub mod app;
pub mod songdata;
pub mod playstatus;
pub mod playbar;
use cfg_if::cfg_if;
cfg_if! {

459
src/playbar.rs Normal file
View File

@ -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<Value = PlayStatus>) -> 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<Value = PlayStatus>, 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<Value = PlayStatus>, 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<Value = PlayStatus>, 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<PlayStatus>) -> 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! {
<div class="playcontrols" align="center">
<button on:click=skip_back on:mousedown=prevent_focus>
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=Icon::from(BsSkipStartFill) />
</button>
<button on:click=toggle_play on:mousedown=prevent_focus>
<Icon class="controlbtn" width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon={icon} />
</button>
<button on:click=skip_forward on:mousedown=prevent_focus>
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=Icon::from(BsSkipEndFill) />
</button>
</div>
}
}
/// The elapsed time and total time of the current song
#[component]
fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) -> 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! {
<div class="playduration" align="right">
{play_duration}
</div>
}
}
/// The name, artist, and album of the current song
#[component]
fn MediaInfo(status: RwSignal<PlayStatus>) -> 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! {
<div class="media-info">
<img class="media-info-img" align="left" src={image}/>
<div class="media-info-text">
{name}
<br/>
{artist} - {album}
</div>
</div>
}
}
/// 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 {
// Keep a reference to the progress bar div so we can get its width and calculate the time to skip to
let progress_bar_ref = create_node_ref::<Div>();
let progress_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! {
<div class="invisible-media-progress" _ref=progress_bar_ref on:click=progress_jump> // Larger click area
<div class="media-progress"> // "Unfilled" progress bar
<div class="media-progress-solid" style=bar_width_style> // "Filled" progress bar
</div>
</div>
</div>
}
}
/// 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 {
// 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::<Audio>();
status.update(|status| status.audio_player = Some(audio_ref));
// Create signals for song time and progress
let (elapsed_secs, set_elapsed_secs) = create_signal(0);
let (total_secs, set_total_secs) = create_signal(0);
let (percentage, set_percentage) = create_signal(0.0);
audio_ref.on_load(move |audio| {
log!("Audio element loaded");
status.with_untracked(|status| {
// Start playing the first song in the queue, if available
if let Some(song) = status.queue.front() {
log!("Starting playing with song: {}", song.name);
// Don't use the set_play_src / set_playing helper function
// here because we already have access to the audio element
audio.set_src(&song.song_path);
if let Err(e) = audio.play() {
error!("Error playing audio on load: {:?}", e);
} else {
log!("Audio playing on load");
}
} else {
log!("Queue is empty, no first song to play");
}
});
// We need this handle to update certain audio things
// This is because the audio element doesn't use Leptos' reactive system,
// but rather updates transparently without notifying subscribers
// TODO Use Audio's `set_on****` methods instead of this -- if possible
let progress_update_handle = set_interval_with_handle(move || {
if let Some(audio) = audio_ref.get() {
set_elapsed_secs(audio.current_time() as i64);
set_total_secs(audio.duration() as i64);
if elapsed_secs.get_untracked() > 0 {
set_percentage(elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64 * 100.0);
} else {
set_percentage(0.0);
}
// If the song has ended, move to the next song
if elapsed_secs.get_untracked() >= total_secs.get_untracked() && total_secs.get_untracked() > 0 {
log!("Song ended, moving to next song");
// Move the now-finshed song to the history
// TODO Somehow make sure next song starts playing before repeatedly jumping to next
status.update(|status| {
let prev_song = status.queue.pop_front();
if let Some(prev_song) = prev_song {
log!("Adding song to history: {}", prev_song.name);
status.history.push_back(prev_song);
} else {
log!("Queue empty, no previous song to add to history");
}
});
// Get the next song to play, if available
let next_src = status.with_untracked(|status| {
status.queue.front().map(|song| song.song_path.clone())
});
if let Some(next_src) = next_src {
log!("Playing next song: {}", next_src);
audio.set_src(&next_src);
if let Err(e) = audio.play() {
error!("Error playing audio after song change: {:?}", e);
} else {
log!("Audio playing after song change");
}
}
}
}
}, PROGRESS_UPDATE_TIME);
match progress_update_handle {
Ok(handle) => {
log!("Progress update interval started");
status.update(|status| status.progress_update_handle = Some(handle));
}
Err(e) => error!("Error starting progress update interval: {:?}", e),
}
});
view! {
<audio _ref=audio_ref type="audio/mpeg" />
<div class="playbar">
<ProgressBar percentage=percentage.into() status=status />
<MediaInfo status=status />
<PlayControls status=status />
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
</div>
}
}