diff --git a/cli/__pycache__/sjnmusiccpython-312.pyc b/cli/__pycache__/sjnmusiccpython-312.pyc new file mode 100644 index 0000000..995fda5 Binary files /dev/null and b/cli/__pycache__/sjnmusiccpython-312.pyc differ diff --git a/cli/sjnmusic b/cli/sjnmusic index 1c4f233..3953fb6 100755 --- a/cli/sjnmusic +++ b/cli/sjnmusic @@ -84,6 +84,10 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("resume", help="resume playback") sub.add_parser("shuffle", help="shuffle the queue in place") 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("playlists", help="list all playlists") sub.add_parser("downloads", help="list recent yt-dlp download jobs") @@ -537,6 +541,9 @@ def print_response(args, status, payload): else: print("now playing: (idle)") 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: for s in obj["songs"]: print(f"[{s['id']}] {s['name']}") @@ -642,6 +649,8 @@ def main(argv=None): status, payload = request(args, host, port, "POST", "/queue/shuffle") elif args.cmd == "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": status, payload = request(args, host, port, "GET", "/songs") elif args.cmd == "downloads": diff --git a/daemon/src/server.rs b/daemon/src/server.rs index ccf4db3..a04f080 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -132,6 +132,7 @@ fn handle( "POST /del {query}", "POST /init {song} (async, returns job id, status 202)", "POST /init/batch {songs:[...]}", + "POST /play/all", "POST /skip", "POST /pause", "POST /resume", @@ -412,6 +413,14 @@ fn handle( 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 => { state.audio_handle.send(AudioCmd::Stop); // Always clear current — a skip is an explicit decision to @@ -688,6 +697,7 @@ enum Route { Del, InitAsync, InitBatch, + PlayAll, Skip, QueueShuffle, QueueClear, @@ -731,6 +741,7 @@ fn route_for(method: &Method, path: &str) -> Route { (Method::Post, "/del") => Route::Del, (Method::Post, "/init") => Route::InitAsync, (Method::Post, "/init/batch") => Route::InitBatch, + (Method::Post, "/play/all") => Route::PlayAll, (Method::Post, "/skip") => Route::Skip, (Method::Post, "/queue/shuffle") => Route::QueueShuffle, (Method::Post, "/queue/clear") => Route::QueueClear, diff --git a/daemon/src/state.rs b/daemon/src/state.rs index 74f02e7..f141f29 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -450,6 +450,40 @@ impl DaemonState { 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 { + 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. pub fn shuffle_queue(&mut self) -> Result<(), String> { if self.queue.len() < 2 { diff --git a/gui-rs/src/daemon.rs b/gui-rs/src/daemon.rs index 5ff0532..f0f1df4 100644 --- a/gui-rs/src/daemon.rs +++ b/gui-rs/src/daemon.rs @@ -282,6 +282,11 @@ pub struct ClearedResponse { pub cleared: bool, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PlayAllResponse { + pub queued: usize, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct OkResponse { pub ok: bool, @@ -539,6 +544,12 @@ impl DaemonClient { 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 { + self.post_json("/play/all", &serde_json::json!({})) + } + pub fn init(&self, name: &str, url: Option<&str>) -> Result { let mut body = serde_json::json!({"song": name, "name": name}); if let Some(u) = url { diff --git a/gui-rs/src/views/library.rs b/gui-rs/src/views/library.rs index 22f5e76..bc9a158 100644 --- a/gui-rs/src/views/library.rs +++ b/gui-rs/src/views/library.rs @@ -17,6 +17,23 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) { ui.horizontal(|ui| { ui.heading("Library"); 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( egui::TextEdit::singleline(&mut app.library_query) .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 = 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 /// fast double-click doesn't enter twice. We always push SOMETHING into /// the bus (empty list on Err) so `drain_background_results` can clear