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    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}