Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/locallibrary/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,6 @@ Open work left by the 2026-10-11 and 2026-10-12 sessions was added on 2026-10-12
finishes it, but taking it back needs `undo --force`.
- [ ] KEYCHAIN had an "interrupted operation" marker from 2026-10-04 16:56: run `locallibrary
status KEYCHAIN` next time it's mounted.
- [ ] 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` matches one drive's library; tracks only on another drive (or only in rekordbox) show
as "not in the library". `collection` could supply rekordbox's tracks.
- [ ] `cue_edit.rs` sums its backup's size itself; it could use `backup::needed_bytes` like pack
Expand Down
12 changes: 12 additions & 0 deletions apps/locallibrary/changes/player-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Fixed

- 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.
5 changes: 5 additions & 0 deletions apps/locallibrary/docs/rpc/locallibrary.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3211,6 +3211,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];
Expand Down
8 changes: 7 additions & 1 deletion apps/locallibrary/docs/rpc/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -14713,6 +14713,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": [
Expand All @@ -14722,7 +14727,8 @@
"comment",
"color",
"color_id",
"loop_beats"
"loop_beats",
"active_loop"
]
},
"LoopBeats": {
Expand Down
158 changes: 122 additions & 36 deletions apps/locallibrary/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -751,6 +752,9 @@ pub enum Msg {
Opened(u64, Result<audio::OpenedFile, String>),
/// The output devices and the system's default, for the listing with this id (`d`).
Outputs(u64, Result<audio::Listed, String>),
/// An output opened in the background (or not), for the ask with this id, as it was asked
/// for.
Output(u64, OutputAsk, Result<OpenedOutput, String>),
/// Something background work has to say (a report written, say).
Note(String, Level),
/// A line of a plugin's log, for the messages (`!`) alone.
Expand Down Expand Up @@ -896,6 +900,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 {
Expand Down Expand Up @@ -1030,6 +1048,9 @@ pub struct App {
opener: Opener,
/// The next track, opened ahead of the playing one's end.
upcoming: Option<Upcoming>,
/// 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,
Expand Down Expand Up @@ -1213,6 +1234,7 @@ impl App {
now: None,
opener: Opener::default(),
upcoming: None,
waiting_file: None,
queue: Queue::default(),
shuffle: false,
repeat: Repeat::Off,
Expand Down Expand Up @@ -1672,6 +1694,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;
}

Expand Down Expand Up @@ -1706,6 +1729,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()
Expand Down Expand Up @@ -1872,6 +1896,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),
Expand Down Expand Up @@ -2496,41 +2521,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<OpenedOutput, String>) {
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<String>) {
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
Expand Down Expand Up @@ -2566,15 +2644,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
}
Expand Down Expand Up @@ -2695,7 +2772,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));
Expand Down Expand Up @@ -2758,9 +2836,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.)
Expand All @@ -2771,7 +2852,10 @@ impl App {
self.seek_to(at);
}
}
Err(e) => self.wont_play(e),
Err(e) => {
now.opening = false;
self.wont_play(e);
}
}
}

Expand Down Expand Up @@ -2840,6 +2924,7 @@ impl App {
}
self.now = None;
self.upcoming = None;
self.waiting_file = None;
self.opener.drop_all();
}

Expand All @@ -2864,9 +2949,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(_)) => {
Expand Down Expand Up @@ -2916,10 +3004,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();
Expand All @@ -2945,7 +3031,6 @@ impl App {
opening: true,
advancing: false,
});
true
}

fn jump_cue(&mut self, forward: bool) {
Expand Down Expand Up @@ -3160,6 +3245,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();
Expand Down
Loading
Loading