add play-all
This commit is contained in:
Binary file not shown.
@@ -84,6 +84,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
sub.add_parser("resume", help="resume playback")
|
sub.add_parser("resume", help="resume playback")
|
||||||
sub.add_parser("shuffle", help="shuffle the queue in place")
|
sub.add_parser("shuffle", help="shuffle the queue in place")
|
||||||
sub.add_parser("clear", help="empty the queue")
|
sub.add_parser("clear", help="empty the queue")
|
||||||
|
sub.add_parser(
|
||||||
|
"play-all",
|
||||||
|
help="shuffle the entire library into the queue and start playing",
|
||||||
|
)
|
||||||
sub.add_parser("songs", help="list all known songs")
|
sub.add_parser("songs", help="list all known songs")
|
||||||
sub.add_parser("playlists", help="list all playlists")
|
sub.add_parser("playlists", help="list all playlists")
|
||||||
sub.add_parser("downloads", help="list recent yt-dlp download jobs")
|
sub.add_parser("downloads", help="list recent yt-dlp download jobs")
|
||||||
@@ -537,6 +541,9 @@ def print_response(args, status, payload):
|
|||||||
else:
|
else:
|
||||||
print("now playing: (idle)")
|
print("now playing: (idle)")
|
||||||
return
|
return
|
||||||
|
if isinstance(obj, dict) and "queued" in obj:
|
||||||
|
print(f"queued {obj['queued']} song(s) in random order")
|
||||||
|
return
|
||||||
if isinstance(obj, dict) and "songs" in obj:
|
if isinstance(obj, dict) and "songs" in obj:
|
||||||
for s in obj["songs"]:
|
for s in obj["songs"]:
|
||||||
print(f"[{s['id']}] {s['name']}")
|
print(f"[{s['id']}] {s['name']}")
|
||||||
@@ -642,6 +649,8 @@ def main(argv=None):
|
|||||||
status, payload = request(args, host, port, "POST", "/queue/shuffle")
|
status, payload = request(args, host, port, "POST", "/queue/shuffle")
|
||||||
elif args.cmd == "clear":
|
elif args.cmd == "clear":
|
||||||
status, payload = request(args, host, port, "POST", "/queue/clear")
|
status, payload = request(args, host, port, "POST", "/queue/clear")
|
||||||
|
elif args.cmd == "play-all":
|
||||||
|
status, payload = request(args, host, port, "POST", "/play/all", body={})
|
||||||
elif args.cmd == "songs":
|
elif args.cmd == "songs":
|
||||||
status, payload = request(args, host, port, "GET", "/songs")
|
status, payload = request(args, host, port, "GET", "/songs")
|
||||||
elif args.cmd == "downloads":
|
elif args.cmd == "downloads":
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ fn handle(
|
|||||||
"POST /del {query}",
|
"POST /del {query}",
|
||||||
"POST /init {song} (async, returns job id, status 202)",
|
"POST /init {song} (async, returns job id, status 202)",
|
||||||
"POST /init/batch {songs:[...]}",
|
"POST /init/batch {songs:[...]}",
|
||||||
|
"POST /play/all",
|
||||||
"POST /skip",
|
"POST /skip",
|
||||||
"POST /pause",
|
"POST /pause",
|
||||||
"POST /resume",
|
"POST /resume",
|
||||||
@@ -412,6 +413,14 @@ fn handle(
|
|||||||
Err(e) => error_response(e),
|
Err(e) => error_response(e),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Route::PlayAll => match state.play_all_random() {
|
||||||
|
Ok(len) => json_response(
|
||||||
|
200,
|
||||||
|
&serde_json::json!({ "queued": len }),
|
||||||
|
),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
Route::Skip => {
|
Route::Skip => {
|
||||||
state.audio_handle.send(AudioCmd::Stop);
|
state.audio_handle.send(AudioCmd::Stop);
|
||||||
// Always clear current — a skip is an explicit decision to
|
// Always clear current — a skip is an explicit decision to
|
||||||
@@ -688,6 +697,7 @@ enum Route {
|
|||||||
Del,
|
Del,
|
||||||
InitAsync,
|
InitAsync,
|
||||||
InitBatch,
|
InitBatch,
|
||||||
|
PlayAll,
|
||||||
Skip,
|
Skip,
|
||||||
QueueShuffle,
|
QueueShuffle,
|
||||||
QueueClear,
|
QueueClear,
|
||||||
@@ -731,6 +741,7 @@ fn route_for(method: &Method, path: &str) -> Route {
|
|||||||
(Method::Post, "/del") => Route::Del,
|
(Method::Post, "/del") => Route::Del,
|
||||||
(Method::Post, "/init") => Route::InitAsync,
|
(Method::Post, "/init") => Route::InitAsync,
|
||||||
(Method::Post, "/init/batch") => Route::InitBatch,
|
(Method::Post, "/init/batch") => Route::InitBatch,
|
||||||
|
(Method::Post, "/play/all") => Route::PlayAll,
|
||||||
(Method::Post, "/skip") => Route::Skip,
|
(Method::Post, "/skip") => Route::Skip,
|
||||||
(Method::Post, "/queue/shuffle") => Route::QueueShuffle,
|
(Method::Post, "/queue/shuffle") => Route::QueueShuffle,
|
||||||
(Method::Post, "/queue/clear") => Route::QueueClear,
|
(Method::Post, "/queue/clear") => Route::QueueClear,
|
||||||
|
|||||||
@@ -450,6 +450,40 @@ impl DaemonState {
|
|||||||
Ok(song)
|
Ok(song)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the queue with a shuffled copy of the entire library.
|
||||||
|
///
|
||||||
|
/// Mirrors `/playlists/{name}/play`: clear the queue, re-insert in
|
||||||
|
/// random order, persist to the DB, and refresh `cycle_snapshot` so
|
||||||
|
/// `RepeatMode::All` keeps looping over the same shuffle. The daemon
|
||||||
|
/// tick loop picks up the new head of queue and starts playback
|
||||||
|
/// automatically once the audio engine is idle (same model as
|
||||||
|
/// `/play`); calling `play_all_random` while a song is playing only
|
||||||
|
/// queues the rest of the library behind the current track.
|
||||||
|
///
|
||||||
|
/// Returns the number of songs queued. An empty library surfaces
|
||||||
|
/// `"library is empty"` to the HTTP layer so the GUI/CLI can render
|
||||||
|
/// a useful toast instead of silently doing nothing.
|
||||||
|
pub fn play_all_random(&mut self) -> Result<usize, String> {
|
||||||
|
let mut shuffled = self.all_songs();
|
||||||
|
if shuffled.is_empty() {
|
||||||
|
return Err("library is empty".to_string());
|
||||||
|
}
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
shuffled.shuffle(&mut rand::thread_rng());
|
||||||
|
{
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
Self::queue_clear_db(&conn).map_err(|e| e.to_string())?;
|
||||||
|
for s in &shuffled {
|
||||||
|
Self::queue_append_db(&conn, s.id).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let len = shuffled.len();
|
||||||
|
self.queue = shuffled;
|
||||||
|
self.cycle_snapshot = self.queue.clone();
|
||||||
|
info!("Play-all: queued {len} songs in random order");
|
||||||
|
Ok(len)
|
||||||
|
}
|
||||||
|
|
||||||
/// In-place shuffle of the current queue.
|
/// In-place shuffle of the current queue.
|
||||||
pub fn shuffle_queue(&mut self) -> Result<(), String> {
|
pub fn shuffle_queue(&mut self) -> Result<(), String> {
|
||||||
if self.queue.len() < 2 {
|
if self.queue.len() < 2 {
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ pub struct ClearedResponse {
|
|||||||
pub cleared: bool,
|
pub cleared: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PlayAllResponse {
|
||||||
|
pub queued: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct OkResponse {
|
pub struct OkResponse {
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
@@ -539,6 +544,12 @@ impl DaemonClient {
|
|||||||
self.post_json("/queue/clear", &serde_json::json!({}))
|
self.post_json("/queue/clear", &serde_json::json!({}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the queue with the entire library in random order.
|
||||||
|
/// Server returns the number of songs queued (`{"queued": N}`).
|
||||||
|
pub fn play_all(&self) -> Result<PlayAllResponse, DaemonError> {
|
||||||
|
self.post_json("/play/all", &serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init(&self, name: &str, url: Option<&str>) -> Result<InitResponse, DaemonError> {
|
pub fn init(&self, name: &str, url: Option<&str>) -> Result<InitResponse, DaemonError> {
|
||||||
let mut body = serde_json::json!({"song": name, "name": name});
|
let mut body = serde_json::json!({"song": name, "name": name});
|
||||||
if let Some(u) = url {
|
if let Some(u) = url {
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
|
|||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.heading("Library");
|
ui.heading("Library");
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
// ▶ Play all (shuffle): replace the queue with the whole
|
||||||
|
// library in random order. Disabled while the daemon is
|
||||||
|
// unreachable so the user doesn't get a silent no-op click,
|
||||||
|
// disabled while the first library fetch is still in flight
|
||||||
|
// (so the user sees a "loading…" message instead of a
|
||||||
|
// silently dead button), and disabled until the fetch has
|
||||||
|
// returned a non-empty result (so a real empty library
|
||||||
|
// doesn't pretend to have something to shuffle).
|
||||||
|
let can_play_all = app.state.is_online()
|
||||||
|
&& !app.library_loading
|
||||||
|
&& !app.library_songs.is_empty();
|
||||||
|
if ui
|
||||||
|
.add_enabled(can_play_all, egui::Button::new("▶ Play all (shuffle)"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
play_all_now(app);
|
||||||
|
}
|
||||||
let response = ui.add(
|
let response = ui.add(
|
||||||
egui::TextEdit::singleline(&mut app.library_query)
|
egui::TextEdit::singleline(&mut app.library_query)
|
||||||
.hint_text("Filter by name (fuzzy)")
|
.hint_text("Filter by name (fuzzy)")
|
||||||
@@ -114,6 +131,25 @@ fn row(ui: &mut egui::Ui, song: &Song, app: &mut SJNMusicApp) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send `POST /play/all` to the daemon and surface the result as a
|
||||||
|
/// toast. The call is synchronous but cheap (loopback + ~10 ms server
|
||||||
|
/// time), matching how `play_playlist`, `create_playlist`, etc. are
|
||||||
|
/// handled elsewhere — fire-and-forget would lose the queued-count
|
||||||
|
/// success message and the daemon's "library is empty" rejection.
|
||||||
|
fn play_all_now(app: &mut SJNMusicApp) {
|
||||||
|
let daemon: Arc<DaemonClient> = app.daemon.clone();
|
||||||
|
match daemon.play_all() {
|
||||||
|
Ok(resp) => app.push_toast(
|
||||||
|
format!("Shuffled {} song(s) into the queue", resp.queued),
|
||||||
|
crate::state::ToastLevel::Success,
|
||||||
|
),
|
||||||
|
Err(e) => app.push_toast(
|
||||||
|
format!("Play-all failed: {e}"),
|
||||||
|
crate::state::ToastLevel::Error,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// First-paint fetch. We set `library_loading` BEFORE the call so a
|
/// First-paint fetch. We set `library_loading` BEFORE the call so a
|
||||||
/// fast double-click doesn't enter twice. We always push SOMETHING into
|
/// fast double-click doesn't enter twice. We always push SOMETHING into
|
||||||
/// the bus (empty list on Err) so `drain_background_results` can clear
|
/// the bus (empty list on Err) so `drain_background_results` can clear
|
||||||
|
|||||||
Reference in New Issue
Block a user