koan_core/player/commands.rs
1use std::path::PathBuf;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::Duration;
4
5use crossbeam_channel::{Receiver, SendTimeoutError, Sender, bounded};
6
7use super::state::{PlayMode, PlaylistItem, QueueItemId, QueueMode, Repeat, SleepTimer};
8
9/// Commands from the UI layer to the audio engine.
10#[derive(Debug)]
11pub enum PlayerCommand {
12 /// Set the cursor and start playback.
13 Play(QueueItemId),
14 /// Set the cursor and load `id` at `position_ms`, playing or paused.
15 ///
16 /// What a restored session does. Play, Seek and Pause in turn would start
17 /// the track from the top and let a moment of it out before the pause.
18 Cue {
19 id: QueueItemId,
20 position_ms: u64,
21 play: bool,
22 },
23 Pause,
24 /// Pause, and answer with the playhead once the output has gone silent:
25 /// after the fade, where one runs. What a hand-off resumes from.
26 PauseAndReport(Sender<u64>),
27 /// Answer once every command sent before this one has been applied and
28 /// published: the queue, the cursor and the playback state all as they
29 /// left them. For a caller that must look at what it asked for, not at
30 /// what was there before.
31 Barrier(Sender<()>),
32 Resume,
33 Stop,
34 Seek(u64), // position in ms
35 NextTrack,
36 PrevTrack,
37 AddToPlaylist(Vec<PlaylistItem>),
38 /// Append these items and play the first of them, as one command: sent
39 /// on to a controlled device it stays one, where an add and a `Play`
40 /// would name an entry that device never had.
41 AddAndPlay(Vec<PlaylistItem>),
42 RemoveFromPlaylist(QueueItemId),
43 /// Batch remove: delete multiple items as a single undoable operation.
44 RemoveFromPlaylistBatch(Vec<QueueItemId>),
45 MoveInPlaylist {
46 id: QueueItemId,
47 target: QueueItemId,
48 after: bool,
49 },
50 /// Batch move: extract `ids` and reinsert them at `target` position.
51 MoveItemsInPlaylist {
52 ids: Vec<QueueItemId>,
53 target: QueueItemId,
54 after: bool,
55 },
56 /// Put the queue in exactly this order, keeping every item.
57 ///
58 /// A queue locked to a playlist follows it, and following a reorder means
59 /// moving the items that are already there — rebuilding them would issue
60 /// new ids and throw away what has played, what is mid-download and where
61 /// the cursor is. Ids not named keep their relative order at the end.
62 ReorderPlaylist(Vec<QueueItemId>),
63 /// Update file paths for playlist items after an organize operation.
64 /// On Unix, rename() doesn't invalidate open FDs so playback continues.
65 UpdatePaths(Vec<(QueueItemId, PathBuf)>),
66 /// Insert items after a specific queue item (for drag/drop at cursor position).
67 InsertInPlaylist {
68 items: Vec<PlaylistItem>,
69 after: QueueItemId,
70 },
71 /// Clear the entire playlist (stop + remove all items).
72 ClearPlaylist,
73 /// Replace the playlist and open the track at `start`, as one operation:
74 /// from `position_ms`, playing or paused.
75 ///
76 /// Doing this as ClearPlaylist + AddToPlaylist + Play sends three commands
77 /// down a bounded channel, and the player acts on each as it arrives: the
78 /// first track starts, then the cursor jumps, so clicking track nine of an
79 /// album shows track one playing first. It is three undo entries for one
80 /// user action. And between the clear and the add the playlist is empty,
81 /// which tells the download queue that nothing is wanted.
82 ///
83 /// `start` past the end starts at the beginning. It opens at
84 /// `position_ms`, playing or paused, as `Cue` does: a hand-off picks up
85 /// where the source stopped without the top of the track being heard.
86 ///
87 /// `mode` says whether the play mode is reset, as a play from a play
88 /// button resets it. Shuffled, the track opened is the play order's
89 /// first, not `start`.
90 ReplacePlaylist {
91 items: Vec<PlaylistItem>,
92 start: usize,
93 position_ms: u64,
94 play: bool,
95 mode: QueueMode,
96 },
97 /// Download complete — check if cursor is waiting on this item.
98 TrackReady(QueueItemId),
99 /// Enough data buffered for streaming playback — check if cursor is waiting.
100 TrackStreamReady(QueueItemId),
101 /// A partial file has been probed off-thread and can be started.
102 ///
103 /// Probing reads as much of the container as it takes to describe itself,
104 /// which for Ogg means its last page — the whole remaining download. That
105 /// cannot happen on this loop, so it happens on its own thread and arrives
106 /// here as a command like anything else. Stale by the time it lands is the
107 /// normal case, and simply ignored.
108 StreamProbed {
109 id: QueueItemId,
110 info: Box<crate::audio::buffer::StreamInfo>,
111 /// What the probe had to settle for. Decoding has to be opened the same
112 /// way — given a length, a container that went looking for its tail
113 /// once will do it again, on the decode thread, where the cost is
114 /// silence instead of a busy player.
115 mode: crate::audio::streaming::ProbeMode,
116 },
117 /// Download failed — a cursor parked on this item must stop waiting.
118 ///
119 /// Without it the player sits on a `Pending` item forever: `Ready` is the
120 /// only thing it listens for, and a track that cannot be fetched never
121 /// becomes Ready. That is the offline-library stall.
122 TrackFailed(QueueItemId),
123 /// Fetch these tracks into the cache, with no queue entry to play them.
124 CacheTracks(Vec<i64>),
125 /// Decode thread exhausted the playlist — auto-advance or stop. Carries
126 /// the session it came from, so one sent just before a play or seek is
127 /// recognised as stale.
128 DecodeFinished(u64),
129 /// The decoder queued the next track, so when the playhead reaches it is
130 /// now known.
131 TrackQueued,
132 /// Undo the last reversible playlist operation.
133 Undo,
134 /// Redo the last undone operation.
135 Redo,
136 /// Begin collecting undo entries into a single batch (e.g. drag operations).
137 BeginUndoBatch,
138 /// End the batch — collapse collected entries into one undo step.
139 EndUndoBatch,
140 /// Switch output audio device by name. Restarts engine on current track.
141 SetOutputDevice(String),
142 /// Clear the configured output device, reverting to system default.
143 ClearOutputDevice,
144 /// The DSP profiles, or the device they key on, changed: load them again
145 /// and carry on where playback is.
146 ReloadDsp,
147 /// The iOS audio route changed. Its rate is read again, and the output's
148 /// rate asked for on it, then the DSP profiles reloaded for it.
149 RouteChanged,
150 /// Build the output again on the same device and carry on from where the
151 /// current track is, paused if it was. For an output the system stopped
152 /// underneath us: an iOS interruption (a call, Siri) or a reset of its
153 /// media services leaves the old unit unable to start again.
154 RestartOutput,
155 /// Play to this renderer from now on, carrying on from where the current
156 /// track is; `None` brings the music back to this device's own output.
157 /// The session is opened by the caller, off this thread: see
158 /// `upnp::connect`.
159 UseRenderer(Option<Box<crate::upnp::Connection>>),
160 /// The renderer last used, found on the network after launch. Taken as
161 /// `UseRenderer` would be, unless playback or the output has moved since
162 /// launch, in which case it is dropped: see `upnp::resume`.
163 ResumeRenderer(Box<crate::upnp::Connection>),
164 /// The renderer used last time did not turn up, or is someone else's:
165 /// play here what was held for it.
166 ResumeRendererMissed,
167 /// The app is quitting. Stop the renderer, which would otherwise play out
168 /// its buffer and be left on a URL nothing serves, then answer. Nothing
169 /// else changes: the session as saved is what the next launch resumes.
170 ReleaseRenderer(crossbeam_channel::Sender<()>),
171 /// Set the volume of the renderer being played to, 0–100.
172 SetRendererVolume(u8),
173 /// Turn shuffle on or off. The queue never moves: shuffle chooses which
174 /// of the items yet to play this pass plays next.
175 SetShuffle(bool),
176 /// What follows a track at its end: the queue's next, the first again
177 /// after the last, or the same item.
178 SetRepeat(Repeat),
179 /// Set the sleep timer, or with `None` cancel it.
180 SetSleepTimer(Option<SleepTimer>),
181 /// Take the mode a saved session had. Sent before its queue, so a
182 /// shuffled one is played in a play order drawn as the queue arrives.
183 RestorePlayMode(PlayMode),
184 /// What the renderer was heard to do, during the session numbered
185 /// `session`. Dropped once that session is over, like `DecodeFinished`.
186 Renderer {
187 session: u64,
188 event: crate::upnp::session::Event,
189 },
190}
191
192/// Bounded command channel.
193///
194/// Small capacity — we don't want commands queuing up. If the engine is busy,
195/// the UI should know about it, not silently buffer 50 seeks.
196pub struct CommandChannel {
197 pub tx: Sender<PlayerCommand>,
198 pub rx: Receiver<PlayerCommand>,
199}
200
201impl Default for CommandChannel {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl CommandChannel {
208 pub fn new() -> Self {
209 let (tx, rx) = bounded(16);
210 Self { tx, rx }
211 }
212}
213
214/// Sends `command` from a thread the player may be about to join.
215///
216/// A plain send blocks while the channel is full, and the player, which is
217/// the channel's only reader, does not read while it joins: the two would wait
218/// on each other for good. This waits for room only until `stop` is set, which
219/// the player does before joining; a command given up then belonged to a
220/// session the player has already ended.
221pub fn send_unless_stopped(tx: &Sender<PlayerCommand>, command: PlayerCommand, stop: &AtomicBool) {
222 let mut command = command;
223 while !stop.load(Ordering::Relaxed) {
224 match tx.send_timeout(command, Duration::from_millis(10)) {
225 Err(SendTimeoutError::Timeout(unsent)) => command = unsent,
226 Ok(()) | Err(SendTimeoutError::Disconnected(_)) => return,
227 }
228 }
229}
230
231impl PlayerCommand {
232 /// Whether it asks for something to be heard.
233 pub fn asks_to_play(&self) -> bool {
234 matches!(
235 self,
236 Self::Play(_)
237 | Self::AddAndPlay(_)
238 | Self::Cue { play: true, .. }
239 | Self::Resume
240 | Self::NextTrack
241 | Self::PrevTrack
242 | Self::ReplacePlaylist { play: true, .. }
243 )
244 }
245}
246
247/// Stop the renderer playing, if one is, before the app quits, waiting at
248/// most `timeout` for it to answer. Call it after the session is saved.
249pub fn release_renderer(
250 player: &crossbeam_channel::Sender<PlayerCommand>,
251 timeout: std::time::Duration,
252) {
253 let (tx, rx) = crossbeam_channel::bounded(1);
254 if player.send(PlayerCommand::ReleaseRenderer(tx)).is_ok() {
255 let _ = rx.recv_timeout(timeout);
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use std::sync::Arc;
262 use std::time::{Duration, Instant};
263
264 use super::*;
265
266 use crate::audio::buffer::{self, PlaybackTimeline, Processing, SourceEntry};
267
268 #[test]
269 fn a_full_channel_never_holds_up_stopping_the_decoder() {
270 let channel = CommandChannel::new();
271 while channel.tx.try_send(PlayerCommand::Stop).is_ok() {}
272 let tx = channel.tx.clone();
273 let (producer, _consumer) = rtrb::RingBuffer::new(1024);
274 // An unreadable file ends the session at once, so the decode thread
275 // reaches the full channel straight away.
276 let mut decode = buffer::start_decode(
277 SourceEntry::from_file(QueueItemId::new(), "/nonexistent/koan.flac".into()),
278 producer,
279 0,
280 || None,
281 PlaybackTimeline::new(),
282 None,
283 Processing::default(),
284 move |stop| send_unless_stopped(&tx, PlayerCommand::DecodeFinished(1), stop),
285 )
286 .unwrap();
287 std::thread::sleep(Duration::from_millis(50));
288
289 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
290 std::thread::spawn(move || {
291 decode.stop();
292 done_tx.send(()).ok();
293 });
294 let started = Instant::now();
295 done_rx
296 .recv_timeout(Duration::from_secs(5))
297 .expect("stopping the decoder waited on the full channel");
298 assert!(started.elapsed() < Duration::from_secs(1));
299 assert_eq!(channel.rx.len(), 16);
300 }
301
302 #[test]
303 fn a_natural_end_waits_for_room() {
304 let channel = CommandChannel::new();
305 while channel.tx.try_send(PlayerCommand::Stop).is_ok() {}
306 let stop = Arc::new(AtomicBool::new(false));
307 let tx = channel.tx.clone();
308 let flag = stop.clone();
309 let sender = std::thread::spawn(move || {
310 send_unless_stopped(&tx, PlayerCommand::DecodeFinished(7), &flag)
311 });
312 std::thread::sleep(Duration::from_millis(50));
313 for _ in 0..16 {
314 channel.rx.recv().unwrap();
315 }
316 sender.join().unwrap();
317 assert!(matches!(
318 channel.rx.try_recv(),
319 Ok(PlayerCommand::DecodeFinished(7))
320 ));
321 }
322}