Skip to main content

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}