From ca71e898132ec0270b3def94daa3e46b4831a8db Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:52:26 -0400 Subject: [PATCH] fix(locallibrary-cli): player output picking, active loops, shifted keys Planned as these commits, folded into one because they share files: - fix(locallibrary-cli): open the player's outputs in the background - feat(locallibrary): read active loops from the analysis and loop them in the player - fix(locallibrary-cli): take a shifted letter sent as its code for the capital - test(locallibrary-cli): the mixes-into panel's keys and quantize on any grid --- apps/locallibrary/CHANGELOG.md | 10 ++ apps/locallibrary/TODO.md | 4 - apps/locallibrary/docs/rpc/locallibrary.d.ts | 5 + apps/locallibrary/docs/rpc/schema.json | 8 +- apps/locallibrary/src/tui/app.rs | 158 +++++++++++++---- apps/locallibrary/src/tui/app/props.rs | 48 ++++-- apps/locallibrary/src/tui/app/testing.rs | 49 +++++- apps/locallibrary/src/tui/app/tests.rs | 113 ++++++++++++- apps/locallibrary/src/tui/deck.rs | 80 +++++++-- apps/locallibrary/src/tui/deck/tests.rs | 168 +++++++++++++++++++ apps/locallibrary/tests/it/interop.rs | 1 + crates/locallibrary/src/anlz.rs | 87 ++++++---- crates/locallibrary/src/anlz/tests.rs | 46 ++++- crates/locallibrary/src/anlz/write.rs | 2 +- crates/locallibrary/src/masterdb.rs | 4 +- crates/locallibrary/src/mix/export/tests.rs | 1 + crates/locallibrary/src/mix/report/tests.rs | 2 + crates/locallibrary/tests/it/props_anlz.rs | 21 +++ crates/loclib-model/src/analysis.rs | 4 + crates/loclib-model/src/analysis/tests.rs | 9 +- crates/loclib-waveform/README.md | 1 + crates/loclib-waveform/tests/props.rs | 1 + crates/loclib-waveform/tests/render.rs | 11 +- 23 files changed, 713 insertions(+), 120 deletions(-) diff --git a/apps/locallibrary/CHANGELOG.md b/apps/locallibrary/CHANGELOG.md index 4c89929f..28beb7fb 100644 --- a/apps/locallibrary/CHANGELOG.md +++ b/apps/locallibrary/CHANGELOG.md @@ -768,6 +768,16 @@ their old names. `older_than_store` error leaves out `fields.snapshot` when no snapshot holds the state). `status`, `drive ls --known` and the player say such a drive that isn't plugged in is behind, where they said it had changes not in the store. +- The player opens its outputs in the background: the one picked from the outputs list (`d`), and + the one it plays through at start and on the first play (or after the output went away). The + player used to freeze until a slow Bluetooth or AirPlay device answered. Picking one, the track + plays on through the output it was on meanwhile; playing a track, its file opens meanwhile. +- Loops rekordbox marks active play round on their own once playback reaches them, as on a CDJ. + A track's analysis says which loops are active (`active_loop` on each cue, in the sidecar's + `Cue` too). +- In a terminal that reports keys by their codes (CSI u, or xterm's modifyOtherKeys), `Q` and the + player's other capital keys are no longer taken for their lowercase keys. `Q` quit the player + where it should turn quantize off. ### Security diff --git a/apps/locallibrary/TODO.md b/apps/locallibrary/TODO.md index cfb4dd0d..ad1171bb 100644 --- a/apps/locallibrary/TODO.md +++ b/apps/locallibrary/TODO.md @@ -210,10 +210,6 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12 status KEYCHAIN` next time it's mounted. - [ ] `check.rs` findings name CLI commands and flags; structured actions like errors have would let the TUI offer one-key fixes. -- [ ] Loops rekordbox marks active (PCPT status 4) don't engage on their own: `anlz::cues` takes the - .EXT list, which doesn't carry the flag (fixing it changes loclib-model's `analysis::Cue`). -- [ ] `d` lists the outputs (cpal asks each device for its configs) and opens the chosen one on the - UI thread, so a slow Bluetooth or AirPlay device can stall it briefly: move both to a worker. - [ ] `mix`: riding the pitch fader splits a play into chains with slightly different speeds; the report shows one averaged speed. - [ ] `mix --cover FILE`: reports and exports carry a cover only when it's embedded in the diff --git a/apps/locallibrary/docs/rpc/locallibrary.d.ts b/apps/locallibrary/docs/rpc/locallibrary.d.ts index 1382648d..b6c0af49 100644 --- a/apps/locallibrary/docs/rpc/locallibrary.d.ts +++ b/apps/locallibrary/docs/rpc/locallibrary.d.ts @@ -3188,6 +3188,11 @@ export type Cue = { * when it was set on the grid. Only the extended list has it. */ loop_beats: LoopBeats | null; + /** + * A loop that plays round by itself once playback reaches it (rekordbox's active loop). Only + * the basic list has it. + */ + active_loop: boolean; }; export type LoopBeats = [numerator: number, denominator: number]; diff --git a/apps/locallibrary/docs/rpc/schema.json b/apps/locallibrary/docs/rpc/schema.json index 14e428fb..955e7bbe 100644 --- a/apps/locallibrary/docs/rpc/schema.json +++ b/apps/locallibrary/docs/rpc/schema.json @@ -14604,6 +14604,11 @@ } ], "description": "A loop's length in beats as rekordbox stored it, numerator and denominator (4/1, 1/2),\nwhen it was set on the grid. Only the extended list has it." + }, + "active_loop": { + "type": "boolean", + "description": "A loop that plays round by itself once playback reaches it (rekordbox's active loop). Only\nthe basic list has it.", + "default": false } }, "required": [ @@ -14613,7 +14618,8 @@ "comment", "color", "color_id", - "loop_beats" + "loop_beats", + "active_loop" ] }, "LoopBeats": { diff --git a/apps/locallibrary/src/tui/app.rs b/apps/locallibrary/src/tui/app.rs index bf8988b2..2e3f9ead 100644 --- a/apps/locallibrary/src/tui/app.rs +++ b/apps/locallibrary/src/tui/app.rs @@ -31,6 +31,7 @@ use super::analyzer::Analyzer; use super::audio::{self, Audio, Outputs}; use super::browse::{self, Category, Group, Node, NodeId, Problems, SavedNode}; use super::crate_list::{Crate, CrateTrack}; +use super::deck::{OpenedOutput, OutputAsk}; use super::drives::{DriveKey, DriveRow}; use super::edits::{Action, Job, Menu, Modal, Pick}; use super::keymap::{self, Act}; @@ -751,6 +752,9 @@ pub enum Msg { Opened(u64, Result), /// The output devices and the system's default, for the listing with this id (`d`). Outputs(u64, Result), + /// An output opened in the background (or not), for the ask with this id, as it was asked + /// for. + Output(u64, OutputAsk, Result), /// Something background work has to say (a report written, say). Note(String, Level), /// A line of a plugin's log, for the messages (`!`) alone. @@ -893,6 +897,20 @@ struct ViewKeep { offset: usize, } +/// A letter typed with shift, as a terminal reporting keys by their codes sends it (CSI u, or +/// xterm's modifyOtherKeys): the lowercase letter, and shift. Taken as the capital, as other +/// terminals send it, so `Q` isn't `q` (quit). +fn capital(mut key: KeyEvent) -> KeyEvent { + let typed = !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + if let KeyCode::Char(c) = key.code + && typed + && key.modifiers.contains(KeyModifiers::SHIFT) + { + key.code = KeyCode::Char(c.to_ascii_uppercase()); + } + key +} + /// Edit a line of text with a key: typing, backspace, ctrl-u to clear, ctrl-w to delete a word. /// Returns whether the text changed. pub fn edit_line(text: &mut String, key: KeyEvent) -> bool { @@ -1025,6 +1043,9 @@ pub struct App { opener: Opener, /// The next track, opened ahead of the playing one's end. upcoming: Option, + /// The playing track's file (for the play with this id), opened before the output it plays + /// through: it waits for the output (see `output_ready`). + waiting_file: Option<(u64, audio::OpenedFile)>, pub queue: Queue, pub shuffle: bool, pub repeat: Repeat, @@ -1203,6 +1224,7 @@ impl App { now: None, opener: Opener::default(), upcoming: None, + waiting_file: None, queue: Queue::default(), shuffle: false, repeat: Repeat::Off, @@ -1661,6 +1683,7 @@ impl App { if let Some(color) = crossed { self.flash = Some((color, Instant::now())); } + super::deck::reach_active_loop(self, prev, pos_ms); self.last_pos_ms = pos_ms; } @@ -1695,6 +1718,7 @@ impl App { || self.now.as_ref().is_some_and(|n| n.loading) || self.loaded.as_ref().is_some_and(Loaded::reading_set) || self.deck.outputs.as_ref().is_some_and(|o| o.listing.is_some()) + || self.deck.opening.is_some() || !self.analyzer.is_idle() || self.is_playing() || self.flash().is_some() @@ -1861,6 +1885,7 @@ impl App { } Msg::Opened(id, r) => self.opened(id, r), Msg::Outputs(id, r) => super::deck::listed_outputs(self, id, r), + Msg::Output(id, ask, r) => self.output_opened(id, ask, r), Msg::Note(msg, level) => self.toast(msg, level), Msg::Log(line) => self.remember(line, Level::Info), Msg::Notice(notice) => self.told(notice), @@ -2484,41 +2509,94 @@ impl App { self.play_tracks(Arc::new(Library { tracks, ..Default::default() }), items, root, start); } - /// The output to play through: the one open, else (there was none, or it went away) the one - /// picked, or the default, opened now. False, with `audio_error` saying why, when none opens. - pub(super) fn open_output(&mut self) -> bool { - if self.audio.is_none() { - let (audio, missing) = self.outputs.open_preferring(self.deck.output.as_deref()); - match audio { - Ok(mut a) => { - a.set_volume(self.volume); - self.audio = Some(a); - self.audio_error = None; - if let Some(why) = missing { - self.toast(format!("{why}: playing through the default output"), Level::Info); + /// With no output open or opening (there was none, or it went away), open the one to play + /// through: the one picked, else the default. In the background, as a slow device (Bluetooth, + /// AirPlay) can take seconds (see `output_ready` and `no_output`). + pub(super) fn open_output(&mut self) { + if self.audio.is_none() && self.deck.opening.is_none() { + self.open_in_background(OutputAsk::Play); + } + } + + /// Open an output in the background, as `ask` says. The last asked for wins: what comes + /// back for one asked for before it is dropped. + pub(super) fn open_in_background(&mut self, ask: OutputAsk) { + let (id, outputs, preferred) = (self.next_id(), self.outputs, self.deck.output.clone()); + self.deck.opening = Some(id); + let failed = ask.clone(); + self.spawn_or( + move || { + let opened = match &ask { + OutputAsk::Pick(name) => (outputs.open)(name.as_deref()).map(|a| (a, None)), + OutputAsk::Play => { + let (audio, missing) = outputs.open_preferring(preferred.as_deref()); + audio.map(|a| (a, missing)) } - } - Err(e) => self.audio_error = Some(format!("{e:#}")), - } + }; + Msg::Output(id, ask, opened.map(|(a, missing)| (Box::new(a), missing)).map_err(|e| format!("{e:#}"))) + }, + move |e| Msg::Output(id, failed, Err(e)), + ); + } + + /// An output opened in the background (or didn't), for the ask with this id. + fn output_opened(&mut self, id: u64, ask: OutputAsk, opened: Result) { + if self.deck.opening != Some(id) { + return; + } + self.deck.opening = None; + match (ask, opened) { + (OutputAsk::Pick(name), Ok((a, _))) => super::deck::picked_output(self, name, *a), + // (One is open already, given it meanwhile: it stays.) + (OutputAsk::Play, Ok(_)) if self.audio.is_some() => {} + (OutputAsk::Play, Ok((a, missing))) => self.output_ready(*a, missing), + // (Playing on through the output it was, if any.) + (OutputAsk::Pick(_), Err(e)) => self.toast(e, Level::Error), + (OutputAsk::Play, Err(e)) => self.no_output(e), } - self.audio.is_some() } - /// Stop what's playing, to play something else. False, saying why, when there's no output. - fn ready_to_play(&mut self) -> bool { - if !self.open_output() { - let e = self.audio_error.clone().unwrap_or_default(); - self.toast(format!("can't play: {e}"), Level::Error); - return false; + /// The output to play through is open: the track waiting for it plays. + fn output_ready(&mut self, mut a: Audio, missing: Option) { + a.set_volume(self.volume); + self.audio = Some(a); + self.audio_error = None; + if let Some(why) = missing { + self.toast(format!("{why}: playing through the default output"), Level::Info); } + if let Some((id, file)) = self.waiting_file.take() { + self.opened(id, Ok(file)); + } + } + + /// No output opens, `why`. A track waiting for one can't play: it stops, and space carries on + /// with it from where it was to start. + fn no_output(&mut self, why: String) { + if self.audio.is_some() { + return; + } + self.audio_error = Some(why.clone()); + if self.now.as_ref().is_some_and(|n| n.opening) { + let at = self.pending_seek.unwrap_or_default(); + let queued = self.playing_queue(); + self.stop(); + self.resume = queued.then_some(at); + self.toast(format!("can't play: {why}"), Level::Error); + } + } + + /// Stop what's playing, to play something else. With no output open, one opens in the + /// background meanwhile (see `open_output`). + fn ready_to_play(&mut self) { + self.open_output(); if let Some(a) = &mut self.audio { a.stop(); } + self.waiting_file = None; self.upcoming = None; self.pending_seek = None; self.start_paused = false; self.deck.looped = None; - true } /// The output's news: a seek it couldn't make, or its stream failing. When the output is @@ -2554,15 +2632,14 @@ impl App { /// Start the queue's current track. It shows at once; its audio file is opened in the /// background (see `opened`), and its analysis, artwork and matches are read in the - /// background too, filling in when they arrive. False when there's no track or no audio. + /// background too, filling in when they arrive (and the output, when none is open). False + /// when there's no track. fn start_current(&mut self) -> bool { let Some(&i) = self.queue.order.get(self.queue.pos) else { return false }; let track = self.queue.track(i).clone(); // Where the last run left off was for the track it was on: once another starts, it's gone. self.resume = None; - if !self.ready_to_play() { - return false; - } + self.ready_to_play(); self.now = Some(self.open(track, true)); true } @@ -2680,7 +2757,8 @@ impl App { } else { // A recorded set, played on its own. let (path, track) = (PathBuf::from(&now.track.file_path), now.track.clone()); - self.play_file(&path, track, Duration::ZERO) + self.play_file(&path, track, Duration::ZERO); + true }; if !started { self.resume = at.or(Some(Duration::ZERO)); @@ -2743,9 +2821,12 @@ impl App { let Some(now) = self.now.as_mut().filter(|n| n.id == id && n.opening) else { return; // another track was started since: this one's dropped }; - now.opening = false; + let output_opening = self.audio.is_none() && self.deck.opening.is_some(); match file { + // Opened before the output it plays through: it waits for it. + Ok(file) if output_opening => self.waiting_file = Some((id, file)), Ok(file) => { + now.opening = false; now.duration = file_duration(&now.track, &file); let paused = std::mem::take(&mut self.start_paused); // (The output went away while it opened: it waits for the next play.) @@ -2756,7 +2837,10 @@ impl App { self.seek_to(at); } } - Err(e) => self.wont_play(e), + Err(e) => { + now.opening = false; + self.wont_play(e); + } } } @@ -2825,6 +2909,7 @@ impl App { } self.now = None; self.upcoming = None; + self.waiting_file = None; self.opener.drop_all(); } @@ -2849,9 +2934,12 @@ impl App { } fn toggle_pause(&mut self) { + let output_opening = self.audio.is_none() && self.deck.opening.is_some(); match (&self.audio, &self.now) { - // Still opening (a slow drive): it starts paused or playing as last asked. + // Still opening (a slow drive, or a slow output): it starts paused or playing as last + // asked. (Some(_), Some(n)) if n.opening => self.start_paused = !self.start_paused, + (None, Some(n)) if n.opening && output_opening => self.start_paused = !self.start_paused, (Some(a), Some(_)) => a.toggle_pause(), // The output went away: play on from where it was, through what there is now. (None, Some(_)) => { @@ -2901,10 +2989,8 @@ impl App { /// Play a file that isn't one of a drive's tracks (a recorded set) on its own, from `start` /// (the queue stays, for afterwards). `track` is what the player shows for it. The file is /// opened in the background. - pub(super) fn play_file(&mut self, path: &Path, track: Track, start: Duration) -> bool { - if !self.ready_to_play() { - return false; - } + pub(super) fn play_file(&mut self, path: &Path, track: Track, start: Duration) { + self.ready_to_play(); self.resume = None; self.pending_seek = (start > Duration::ZERO).then_some(start); let id = self.next_id(); @@ -2930,7 +3016,6 @@ impl App { opening: true, advancing: false, }); - true } fn jump_cue(&mut self, forward: bool) { @@ -3145,6 +3230,7 @@ impl App { // ----------------------------------------------------------------------------------------- pub fn on_key(&mut self, key: KeyEvent) { + let key = capital(key); // The player may have gone on to the next track since the last tick: keys act on the one // playing (a seek asked of the one that ended would be lost). self.follow_queue(); diff --git a/apps/locallibrary/src/tui/app/props.rs b/apps/locallibrary/src/tui/app/props.rs index e15fce7c..57091e7b 100644 --- a/apps/locallibrary/src/tui/app/props.rs +++ b/apps/locallibrary/src/tui/app/props.rs @@ -578,6 +578,7 @@ fn background_results_land_where_they_belong(tc: TestCase) { app.focus = Focus::Tracks; app.view = View::Library; press(&mut app, "\n"); + output_opened(&mut app); if stale { assert_eq!(last_message(&app), "the drive was unplugged"); } @@ -797,7 +798,12 @@ fn the_queue_plays_on_through_what_goes_wrong(tc: TestCase) { } let idle = app.opener_idle(); let busy = idle.lock().unwrap(); - while app.rx.try_recv().is_ok() {} + // (What tracks opened says is dropped; an output opening is still wanted.) + while let Ok(msg) = app.rx.try_recv() { + if matches!(msg, Msg::Output(..)) { + app.handle_msg(msg); + } + } for _ in 0..tc.draw(gs::integers::().min_value(1).max_value(4)) { press(&mut app, "n"); } @@ -976,6 +982,7 @@ fn the_queue_plays_on_through_what_goes_wrong(tc: TestCase) { let next = app.queue.pos + 1 < app.queue.order.len() || app.repeat != Repeat::Off; let before = said(&app, "can't play: no outputs"); press(&mut app, "n"); + output_opened(&mut app); assert!(app.now.is_none() && app.audio.is_none(), "stopped"); assert_eq!(said(&app, "can't play: no outputs") > before, next); app.outputs = super::super::audio::tests::OUTPUTS; @@ -984,6 +991,7 @@ fn the_queue_plays_on_through_what_goes_wrong(tc: TestCase) { // Space plays on through the output there is now, from where it was. if was.is_some() && tc.draw(gs::booleans()) { press(&mut app, " "); + output_opened(&mut app); assert!(app.audio.is_some(), "an output opened again"); // Only an output not in the list falls back ("Phones" is there). let fell_back = app.deck.output.as_deref() == Some("Old Speakers"); @@ -1030,20 +1038,19 @@ fn the_queue_plays_on_through_what_goes_wrong(tc: TestCase) { let set = root.join("Contents/1.wav"); let mut t = track(9, "DJ", "Set"); t.file_path = set.display().to_string(); - if app.play_file(&set, t, Duration::from_millis(tc.draw(gs::integers::().max_value(300)))) { - assert_eq!(app.now.as_ref().map(|n| n.drive.as_str()), Some("recorded set")); - assert!(!app.playing_queue()); - // Played to its end (through the output it started on), it stops. - let t = Instant::now(); - while app.now.as_ref().is_some_and(|n| n.opening) && t.elapsed() < Duration::from_secs(5) { - app.tick(); - std::thread::sleep(Duration::from_millis(1)); - } - if fed && files[0].1 > 0 && app.is_playing() && app.now.is_some() { - (&mut out).take(9000).for_each(drop); - app.tick(); - assert!(app.now.is_none(), "a set stops at its end"); - } + app.play_file(&set, t, Duration::from_millis(tc.draw(gs::integers::().max_value(300)))); + assert_eq!(app.now.as_ref().map(|n| n.drive.as_str()), Some("recorded set")); + assert!(!app.playing_queue()); + // Played to its end (through the output it started on), it stops. + let t = Instant::now(); + while app.now.as_ref().is_some_and(|n| n.opening) && t.elapsed() < Duration::from_secs(5) { + app.tick(); + std::thread::sleep(Duration::from_millis(1)); + } + if fed && files[0].1 > 0 && app.is_playing() && app.now.is_some() { + (&mut out).take(9000).for_each(drop); + app.tick(); + assert!(app.now.is_none(), "a set stops at its end"); } assert_eq!((app.queue.len(), app.queue.pos), (len, pos), "the queue stays, for afterwards"); } @@ -1277,14 +1284,15 @@ fn esc_puts_away_one_thing_at_a_time(tc: TestCase) { /// The screen redraws at animation rate while anything is under way (a read, a search waiting /// for typing to pause, the drives' status being read, a job, a lookup of the selected track's -/// matches, a track's files being read, the outputs being listed, a cue's flash) and rests once +/// matches, a track's files being read, the outputs being listed or one opening, a cue's flash) and +/// rests once /// nothing is. #[hegel::test(test_cases = 100)] fn the_screen_moves_only_while_something_does(tc: TestCase) { let (mut app, _dir) = app_with(library()); wait(&mut app, |a| a.loading.is_none()); assert!(!app.animating(), "at rest"); - let conditions: [fn(&mut App, bool); 7] = [ + let conditions: [fn(&mut App, bool); 8] = [ |a, on| a.loading = on.then(|| "Reading TEST…".into()), |a, on| a.search_due = on.then(|| Instant::now() + Duration::from_secs(60)), |a, on| a.status_loading = on, @@ -1294,6 +1302,8 @@ fn the_screen_moves_only_while_something_does(tc: TestCase) { |a, on| a.show_details = on, // The outputs being listed (d), or listed. |a, on| a.deck.outputs = Some(super::super::deck::Outputs { listing: on.then_some(1), ..Default::default() }), + // An output picked, opening. + |a, on| a.deck.opening = on.then_some(1), ]; // Each sets it going on its own. for c in &conditions { @@ -1731,6 +1741,7 @@ fn a_click_does_what_it_points_at(tc: TestCase) { quick_again(&mut app); mouse(&mut app, LEFT, at); // Played (no output here, which is said). + output_opened(&mut app); assert!(last_message(&app).starts_with("can't play"), "{}", last_message(&app)); } } @@ -1777,7 +1788,8 @@ fn a_click_does_what_it_points_at(tc: TestCase) { } else { mouse(&mut app, LEFT, (r.x + x, r.y)); } - let d = app.now.as_ref().unwrap().duration; + // (A play that found no output to play through stopped it.) + let Some(d) = app.now.as_ref().map(|n| n.duration) else { continue }; let want = d.mul_f64((x as f64 + 0.5) / r.width as f64).min(d - Duration::from_millis(250)); let at = app.pending_seek.unwrap_or_default(); assert!(at.abs_diff(want) < Duration::from_millis(2), "{at:?} for {want:?}"); diff --git a/apps/locallibrary/src/tui/app/testing.rs b/apps/locallibrary/src/tui/app/testing.rs index a2d12064..378aad6d 100644 --- a/apps/locallibrary/src/tui/app/testing.rs +++ b/apps/locallibrary/src/tui/app/testing.rs @@ -110,7 +110,16 @@ pub fn demo_drive_in(dir: &Path, name: &str) -> PathBuf { /// A hot cue (`hot`: 0 is A), or a memory cue, at `start_ms`; a loop when it has an end. pub fn cue(hot: Option, start_ms: u32, end_ms: Option) -> Cue { - Cue { hot_cue: hot, start_ms, end_ms, comment: String::new(), color: None, color_id: 0, loop_beats: None } + Cue { + hot_cue: hot, + start_ms, + end_ms, + comment: String::new(), + color: None, + color_id: 0, + loop_beats: None, + active_loop: false, + } } /// A grid at `bpm` from `first_ms`, `n` beats long. @@ -343,10 +352,43 @@ pub fn wait(app: &mut App, done: impl Fn(&App) -> bool) { #[track_caller] pub fn outputs_listed(app: &mut App) { let listing = |app: &App| app.deck.outputs.as_ref().is_some_and(|o| o.listing.is_some()); + handle_only(app, listing, |msg| matches!(msg, Msg::Outputs(..))); + assert!(app.deck.outputs.as_ref().is_some_and(|o| o.listing.is_none()), "no outputs listed"); +} + +/// A gate for a slow output in a test (a Bluetooth or AirPlay device): each test has its own. +pub type Gate = (std::sync::Mutex, std::sync::Condvar); + +/// The test outputs' `name`, opened once `gate` is let open (see [`let_open`]), else (after 5 s) +/// not: a player that waits for it fails rather than hangs. +pub fn open_when_let(gate: &Gate, name: Option<&str>) -> anyhow::Result