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