Implement a basic player bar
This commit is contained in:
parent
2a89dac9c9
commit
97304ba1a7
@ -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
459
src/playbar.rs
Normal 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>
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user