Skip to main content

koan_core/remote/
control.rs

1//! Playing for other devices, and on them, for a front end that holds the
2//! player's command channel itself: the terminal UI. The apps do the same
3//! through koan-ffi's engine, which has pages to tell about each step.
4//!
5//! `serve` makes this process a device: linked to the server, listening and
6//! announced on the local network as `devices.*` says, its levels on offer to
7//! a controller, and every command another device sends turned into player
8//! commands (`apply`). `send` goes the other way: a player command for the
9//! device being controlled, as the link carries it.
10
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use crossbeam_channel::Sender;
15
16use crate::audio::viz::VizSnapshot;
17use crate::db::connection::Database;
18use crate::db::queries;
19use crate::player::commands::PlayerCommand;
20use crate::player::state::{
21    PlaybackState, PlaylistItem, QueueItemId, QueueMode, SharedPlayerState,
22};
23use crate::remote::acks::{AckOutcome, Pending};
24use crate::remote::devices;
25use crate::remote::link::{
26    CommandSource, LinkCommand, LinkIdentity, LinkQueueEntry, LinkState, Local,
27};
28
29/// Offer this player to the server and the local network as `identity`.
30/// Commands are acted on one at a time, in the order they came, on a thread
31/// of their own: resolving a track the library lacks can mean a sync.
32///
33/// `library_changed` is told when a command changed what the library holds.
34/// With `here`, this device is the one controlled from the start, whichever
35/// the last run left controlled: music the process was started with plays
36/// here.
37pub fn serve(
38    identity: LinkIdentity,
39    state: Arc<SharedPlayerState>,
40    viz: Arc<VizSnapshot>,
41    tx: Sender<PlayerCommand>,
42    library_changed: fn(),
43    here: bool,
44) {
45    let playhead = state.clone();
46    crate::remote::levels::feed().provide(viz, move || playhead.position_ms());
47    crate::remote::dsp_sync::set_device_name(Some(identity.name.clone()));
48
49    let (commands, inbox) =
50        crossbeam_channel::unbounded::<(LinkCommand, CommandSource, Option<Pending>)>();
51    {
52        let (state, tx) = (state.clone(), tx);
53        std::thread::Builder::new()
54            .name("koan-link-commands".into())
55            .spawn(move || {
56                for (cmd, source, pending) in inbox {
57                    let outcome = match apply(cmd, source, &tx, &state, &library_changed) {
58                        Ok(()) => AckOutcome::Done,
59                        Err(error) => {
60                            log::warn!("link: {error}");
61                            AckOutcome::Failed { error }
62                        }
63                    };
64                    if let Some(pending) = pending {
65                        pending.finish(outcome);
66                    }
67                }
68            })
69            .expect("failed to spawn the link command thread");
70    }
71
72    // The queue is read again only when it has changed: this runs on every
73    // change to the engine, and naming its tracks is a query.
74    let held = parking_lot::Mutex::new(None::<(u64, Vec<LinkQueueEntry>)>);
75    devices::start(Local {
76        identity,
77        on_command: Arc::new(move |cmd, source, pending| {
78            let _ = commands.send((cmd, source, pending));
79        }),
80        state: Arc::new(move || {
81            let item = state.cursor().and_then(|c| state.get_item(c));
82            let version = state.playlist_version();
83            let mut held = held.lock();
84            if held.as_ref().is_none_or(|(v, _)| *v != version) {
85                *held = Some((version, link_queue(&state)));
86            }
87            LinkState {
88                playing: state.playback_state() == PlaybackState::Playing,
89                title: item.as_ref().map(|i| i.title.clone()),
90                artist: item.as_ref().map(|i| i.artist.clone()),
91                album: item.as_ref().map(|i| i.album.clone()),
92                position_ms: state.position_ms(),
93                duration_ms: state.duration_ms(),
94                queue: held.as_ref().map(|(_, q)| q.clone()).unwrap_or_default(),
95                outputs: Some(crate::remote::outputs::local(&state)),
96                shuffle: state.play_mode().shuffle,
97                repeat: state.play_mode().repeat,
98                sleep: state.sleep(),
99                sleep_fading: state.sleep_fading(),
100            }
101        }),
102    });
103    if here {
104        devices::set_target(None);
105    }
106}
107
108/// Hang up on the server and the network: the process is ending, and the
109/// devices that can see this one should stop listing it now rather than when
110/// its sockets time out.
111pub fn stop() {
112    crate::remote::nearby::suspend();
113    crate::remote::link::hang_up();
114}
115
116/// The queue as other devices are told it, with each track's id on the
117/// server. At most `LINK_QUEUE_MAX` entries, from a few before the current one.
118pub fn link_queue(state: &SharedPlayerState) -> Vec<LinkQueueEntry> {
119    const LINK_QUEUE_MAX: usize = 300;
120    let (window, cursor) = state.playlist_window(20, LINK_QUEUE_MAX);
121    let remote = crate::db::pool::shared()
122        .get()
123        .map(|db| remote_ids(&db, &window))
124        .unwrap_or_default();
125    window
126        .iter()
127        .map(|i| LinkQueueEntry {
128            id: Some(i.id.0.to_string()),
129            track_id: i.db_id.and_then(|id| remote.get(&id).cloned()),
130            title: i.title.clone(),
131            artist: i.artist.clone(),
132            album: i.album.clone(),
133            duration_ms: i.duration_ms.unwrap_or(0),
134            current: Some(i.id) == cursor,
135        })
136        .collect()
137}
138
139/// The server's ids for these items' tracks, where they have one.
140fn remote_ids(db: &Database, items: &[PlaylistItem]) -> HashMap<i64, String> {
141    let ids: Vec<i64> = items.iter().filter_map(|i| i.db_id).collect();
142    queries::tracks_by_ids(&db.conn, &ids)
143        .unwrap_or_default()
144        .into_iter()
145        .filter_map(|t| t.remote_id.map(|r| (t.id, r)))
146        .collect()
147}
148
149fn parse_qid(s: &str) -> Result<QueueItemId, String> {
150    uuid::Uuid::parse_str(s)
151        .map(QueueItemId)
152        .map_err(|_| format!("not a queue item id: {s}"))
153}
154
155fn parse_qids(ids: &[String]) -> Result<Vec<QueueItemId>, String> {
156    ids.iter().map(|s| parse_qid(s)).collect()
157}
158
159/// Act on what another device asked of this one, as player commands.
160/// `library_changed` is told when the command brought rows into the library
161/// or changed what is on disk, for a front end whose pages have to read them
162/// again.
163pub fn apply(
164    cmd: LinkCommand,
165    source: CommandSource,
166    tx: &Sender<PlayerCommand>,
167    state: &SharedPlayerState,
168    library_changed: &dyn Fn(),
169) -> Result<(), String> {
170    let send = |cmd: PlayerCommand| {
171        tx.send(cmd)
172            .map_err(|_| "the player has stopped".to_string())
173    };
174    // Only the account may cost a sync: anyone on the network can send a
175    // track id this library has never heard of.
176    let may_sync = source == CommandSource::Account;
177    let db = || {
178        crate::db::pool::shared()
179            .get()
180            .map_err(|e| format!("library: {e}"))
181    };
182    let resolve = |db: &Database, ids: &[String]| {
183        let (found, synced) = crate::remote::link::resolve_each(db, ids, may_sync);
184        if synced {
185            library_changed();
186        }
187        found
188    };
189    let items_of = |db: &Database, ids: &[i64]| {
190        let rows = queries::tracks_by_ids(&db.conn, ids).unwrap_or_default();
191        crate::helpers::playlist_items_for_tracks(db, &rows)
192    };
193    let items = |track_ids: &[String]| -> Result<Vec<PlaylistItem>, String> {
194        let db = db()?;
195        let ids: Vec<i64> = resolve(&db, track_ids).into_iter().flatten().collect();
196        Ok(items_of(&db, &ids))
197    };
198    match cmd {
199        LinkCommand::Play {
200            track_ids,
201            start_at,
202            position_ms,
203            paused,
204            handoff,
205            mode,
206        } => {
207            // The music is coming back here: stop controlling whatever this
208            // was controlling.
209            if handoff && devices::target().is_some() {
210                log::info!("link: handed the music; taking control back");
211                devices::set_target(None);
212            }
213            let db = db()?;
214            let found = resolve(&db, &track_ids);
215            let (start, position_ms) = start_among(&found, start_at as usize, position_ms);
216            let ids: Vec<i64> = found.into_iter().flatten().collect();
217            let items = items_of(&db, &ids);
218            if items.is_empty() {
219                return Err(format!(
220                    "none of {} tracks are in the library",
221                    track_ids.len()
222                ));
223            }
224            send(PlayerCommand::ReplacePlaylist {
225                start: start.min(items.len() - 1),
226                items,
227                position_ms,
228                play: !paused,
229                mode,
230            })
231        }
232        LinkCommand::Enqueue { track_ids, play } => {
233            let items = items(&track_ids)?;
234            if items.is_empty() {
235                return Ok(());
236            }
237            if play || state.is_idle() {
238                send(PlayerCommand::AddAndPlay(items))
239            } else {
240                send(PlayerCommand::AddToPlaylist(items))
241            }
242        }
243        LinkCommand::PlayNext { track_ids } => {
244            let items = items(&track_ids)?;
245            if items.is_empty() {
246                return Ok(());
247            }
248            match state.cursor() {
249                Some(after) => send(PlayerCommand::InsertInPlaylist { items, after }),
250                None => send(PlayerCommand::AddToPlaylist(items)),
251            }
252        }
253        LinkCommand::Insert { track_ids, after } => {
254            let after = parse_qid(&after)?;
255            let items = items(&track_ids)?;
256            if items.is_empty() {
257                return Ok(());
258            }
259            send(PlayerCommand::InsertInPlaylist { items, after })
260        }
261        LinkCommand::JumpTo { track_id } => {
262            let db = db()?;
263            let Some(local) = resolve(&db, std::slice::from_ref(&track_id))
264                .first()
265                .copied()
266                .flatten()
267            else {
268                return Err(format!("track {track_id} is not in the library"));
269            };
270            let (queued, cursor) = state.snapshot_playlist();
271            if let Some(item) = queued.iter().find(|i| i.db_id == Some(local)) {
272                return send(PlayerCommand::Play(item.id));
273            }
274            let Some(item) = items_of(&db, &[local]).pop() else {
275                return Err(format!("track {track_id} is not in the library"));
276            };
277            let id = item.id;
278            match cursor {
279                Some(after) => send(PlayerCommand::InsertInPlaylist {
280                    items: vec![item],
281                    after,
282                })?,
283                None => send(PlayerCommand::AddToPlaylist(vec![item]))?,
284            }
285            send(PlayerCommand::Play(id))
286        }
287        LinkCommand::Remove { track_ids } => {
288            let db = db()?;
289            let found: HashSet<i64> = resolve(&db, &track_ids).into_iter().flatten().collect();
290            let (queued, _) = state.snapshot_playlist();
291            let gone: Vec<QueueItemId> = queued
292                .iter()
293                .filter(|i| i.db_id.is_some_and(|id| found.contains(&id)))
294                .map(|i| i.id)
295                .collect();
296            if gone.is_empty() {
297                return Ok(());
298            }
299            send(PlayerCommand::RemoveFromPlaylistBatch(gone))
300        }
301        LinkCommand::PlayItem { id } => {
302            let id = parse_qid(&id)?;
303            match state.get_item(id) {
304                Some(_) => send(PlayerCommand::Play(id)),
305                None => Ok(()),
306            }
307        }
308        LinkCommand::RemoveItems { ids } => {
309            send(PlayerCommand::RemoveFromPlaylistBatch(parse_qids(&ids)?))
310        }
311        LinkCommand::MoveItems { ids, target, after } => send(PlayerCommand::MoveItemsInPlaylist {
312            ids: parse_qids(&ids)?,
313            target: parse_qid(&target)?,
314            after,
315        }),
316        LinkCommand::Clear => send(PlayerCommand::ClearPlaylist),
317        LinkCommand::Undo => send(PlayerCommand::Undo),
318        LinkCommand::Redo => send(PlayerCommand::Redo),
319        LinkCommand::Seek { position_ms } => send(PlayerCommand::Seek(position_ms)),
320        LinkCommand::Pause => send(PlayerCommand::Pause),
321        LinkCommand::Resume => send(PlayerCommand::Resume),
322        LinkCommand::Next => send(PlayerCommand::NextTrack),
323        LinkCommand::Previous => send(PlayerCommand::PrevTrack),
324        LinkCommand::Shuffle { on } => send(PlayerCommand::SetShuffle(on)),
325        LinkCommand::Repeat { mode } => send(PlayerCommand::SetRepeat(mode)),
326        LinkCommand::SleepTimer { timer } => send(PlayerCommand::SetSleepTimer(timer)),
327        // Asked for by another device, which learns the outcome from what
328        // the destination reports. Refused there, the music plays on here.
329        LinkCommand::HandOff { to } => {
330            let was_playing = state.playback_state() == PlaybackState::Playing;
331            let player = tx.clone();
332            let then: devices::Then = Box::new(move |outcome| {
333                let refused = matches!(
334                    outcome,
335                    Some(AckOutcome::Refused { .. } | AckOutcome::Failed { .. })
336                );
337                if refused && was_playing {
338                    let _ = player.send(PlayerCommand::Resume);
339                }
340            });
341            hand_off(&to, source, tx, state, Some(then)).map(|_| ())
342        }
343        // From a notification tapped or an outbox: the link's own check,
344        // made again.
345        LinkCommand::Shared { command } => {
346            if command.allowed_playback() {
347                apply(*command, CommandSource::Shared, tx, state, library_changed)?;
348            }
349            Ok(())
350        }
351        LinkCommand::SetOutput { output } => {
352            crate::remote::outputs::set(output, crate::upnp::choose(), tx)
353        }
354        LinkCommand::RefreshOutputs => {
355            crate::remote::outputs::refresh_for_controller();
356            Ok(())
357        }
358        LinkCommand::SetRendererVolume { volume } => send(PlayerCommand::SetRendererVolume(volume)),
359        LinkCommand::SetPreset { device, profile } => {
360            crate::remote::outputs::set_preset(&device, profile.as_deref(), tx)
361        }
362        LinkCommand::Evict { track_ids } => {
363            let db = db()?;
364            let ids: Vec<i64> = resolve(&db, &track_ids).into_iter().flatten().collect();
365            let rows = queries::tracks_by_ids(&db.conn, &ids).unwrap_or_default();
366            for path in rows.iter().filter_map(|t| t.cached_path.as_deref()) {
367                let _ = std::fs::remove_file(path);
368            }
369            queries::clear_cached_paths_for(&db.conn, &ids).map_err(|e| e.to_string())?;
370            log::info!("link: evicted {} cached tracks", ids.len());
371            library_changed();
372            Ok(())
373        }
374        LinkCommand::HistoryChanged => {
375            // History and Recently played follow `player::history::changed`,
376            // which the sync rings; a track it had to sync for is the
377            // library's news too.
378            if crate::remote::history::sync(&*db()?).library_synced {
379                library_changed();
380            }
381            Ok(())
382        }
383        LinkCommand::DspProfilesChanged => {
384            if crate::remote::dsp_sync::sync(&*db()?).changed() {
385                // Pages showing profiles follow the library.
386                library_changed();
387                send(PlayerCommand::ReloadDsp)?;
388            }
389            Ok(())
390        }
391        LinkCommand::Sync { full } => {
392            let walk = if full {
393                crate::helpers::Walk::Always
394            } else {
395                crate::helpers::Walk::IfChanged
396            };
397            crate::remote::link::sync(&*db()?, walk);
398            library_changed();
399            Ok(())
400        }
401        // Answered by the link session itself.
402        LinkCommand::Devices { .. }
403        | LinkCommand::DeviceKeys { .. }
404        | LinkCommand::Shares { .. }
405        | LinkCommand::Forgotten { .. }
406        | LinkCommand::WatchLevels { .. }
407        | LinkCommand::Levels { .. }
408        | LinkCommand::Acked { .. } => Ok(()),
409    }
410}
411
412/// Where a `Play` starts once the tracks this library lacks are left out: on
413/// the track asked for, or the next one that remains, from the start when
414/// none does. The playhead holds only on the track asked for itself.
415fn start_among(found: &[Option<i64>], start_at: usize, position_ms: u64) -> (usize, u64) {
416    let start = found.iter().take(start_at).filter(|f| f.is_some()).count();
417    let kept = found.get(start_at).is_some_and(|f| f.is_some());
418    let remaining = found.iter().skip(start_at).any(|f| f.is_some());
419    match (kept, remaining) {
420        (true, _) => (start, position_ms),
421        (false, true) => (start, 0),
422        (false, false) => (0, 0),
423    }
424}
425
426/// Music sent to another device by `hand_off`.
427pub struct HandOff {
428    /// Tracks left out because the server does not have them.
429    pub left_out: u32,
430    /// The server's id for the track the destination should now be on.
431    pub track: String,
432    /// What the destination last reported before the music was sent.
433    pub before: Option<LinkState>,
434    /// This device was playing, and should play on if the music is refused.
435    pub was_playing: bool,
436}
437
438/// Send this device's queue and playhead to `to`, asked by `source`, and
439/// pause here; `then` is handed the destination's answer. The tracks the
440/// server does not know are left out, since the other device could not play
441/// them.
442pub fn hand_off(
443    to: &str,
444    source: CommandSource,
445    tx: &Sender<PlayerCommand>,
446    state: &SharedPlayerState,
447    then: Option<devices::Then>,
448) -> Result<HandOff, String> {
449    let (items, cursor) = state.snapshot_playlist();
450    let remote = crate::db::pool::shared()
451        .get()
452        .map(|db| remote_ids(&db, &items))
453        .unwrap_or_default();
454    let at = cursor
455        .and_then(|c| items.iter().position(|i| i.id == c))
456        .unwrap_or(0);
457    let kept: Vec<(usize, String)> = items
458        .iter()
459        .enumerate()
460        .filter_map(|(n, i)| {
461            i.db_id
462                .and_then(|id| remote.get(&id).cloned())
463                .map(|r| (n, r))
464        })
465        .collect();
466    if kept.is_empty() {
467        return Err(
468            "nothing in the queue is on the server, so the other device cannot play it".into(),
469        );
470    }
471    let start_at = kept.iter().position(|(n, _)| *n >= at).unwrap_or(0);
472    // Silent here first, and the playhead read where it went silent: anything
473    // heard while the command travels would be heard twice.
474    let paused = state.playback_state() != PlaybackState::Playing;
475    let (reply, silent) = crossbeam_channel::bounded(1);
476    tx.send(PlayerCommand::PauseAndReport(reply))
477        .map_err(|_| "the player has stopped".to_string())?;
478    let position_ms = silent
479        .recv_timeout(std::time::Duration::from_secs(1))
480        .unwrap_or_else(|_| state.position_ms());
481    let position_ms = if kept.get(start_at).is_some_and(|(n, _)| *n == at) {
482        position_ms
483    } else {
484        0
485    };
486    let left_out = (items.len() - kept.len()) as u32;
487    let track = kept[start_at].1.clone();
488    let before = devices::last_report(to);
489    let play = LinkCommand::Play {
490        track_ids: kept.into_iter().map(|(_, r)| r).collect(),
491        start_at: start_at as u32,
492        position_ms,
493        paused,
494        handoff: true,
495        mode: QueueMode::Keep,
496    };
497    if let Err(e) = devices::send_for_then(source, to, play, then) {
498        if !paused {
499            let _ = tx.send(PlayerCommand::Resume);
500        }
501        return Err(e);
502    }
503    log::info!("devices: sent the queue to {to}, {left_out} left out");
504    Ok(HandOff {
505        left_out,
506        track,
507        before,
508        was_playing: !paused,
509    })
510}
511
512/// Control the device `id`, or this one with `None`. This one pauses, and
513/// what a front end shows and commands is that device's until another is
514/// picked. Nothing moves; see `move_music`.
515pub fn control(
516    id: Option<String>,
517    tx: &Sender<PlayerCommand>,
518    state: &SharedPlayerState,
519) -> Result<(), String> {
520    if let Some(id) = &id {
521        devices::choosable(id)?;
522        if state.playback_state() == PlaybackState::Playing {
523            let _ = tx.send(PlayerCommand::Pause);
524        }
525    }
526    devices::set_target(id.clone());
527    // Asleep: wake it, and let the list say how that is going.
528    if let Some(id) = id {
529        devices::wake(&id);
530    }
531    Ok(())
532}
533
534/// How `move_music` moved the music.
535pub enum Moved {
536    /// This device's own, sent by `hand_off`; `then` hears the destination.
537    Sent(Box<HandOff>),
538    /// The device being controlled was asked to send its music on; `then`
539    /// hears whether it did.
540    Asked,
541}
542
543/// Move the music of the device being controlled (this one, when none is)
544/// to `to` (this one with `None`), and control `to` from then on. `None` when
545/// there was nothing to move: `to` is already the one controlled.
546pub fn move_music(
547    to: Option<String>,
548    tx: &Sender<PlayerCommand>,
549    state: &SharedPlayerState,
550    then: Option<devices::Then>,
551) -> Result<Option<Moved>, String> {
552    let from = devices::target();
553    if from == to {
554        return Ok(None);
555    }
556    let moved = match (&from, &to) {
557        (None, Some(to)) => Some(Moved::Sent(Box::new(hand_off(
558            to,
559            CommandSource::Account,
560            tx,
561            state,
562            then,
563        )?))),
564        (Some(from), to) => {
565            let to = match to {
566                Some(to) => to.clone(),
567                None => devices::this_id()
568                    .ok_or_else(|| "this device is not set up to be reached".to_string())?,
569            };
570            devices::send_then(from, LinkCommand::HandOff { to }, then)?;
571            Some(Moved::Asked)
572        }
573        (None, None) => None,
574    };
575    devices::set_target(to);
576    Ok(moved)
577}
578
579/// Why a command for the device being controlled did not reach it.
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub enum SendError {
582    /// It names music that device cannot play.
583    Unplayable(String),
584    /// The device could not be reached.
585    Missed(String),
586    /// This device's player has stopped.
587    Stopped,
588}
589
590impl std::fmt::Display for SendError {
591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592        match self {
593            Self::Unplayable(m) | Self::Missed(m) => f.write_str(m),
594            Self::Stopped => f.write_str("the player has stopped"),
595        }
596    }
597}
598
599/// Send `cmd` to the device being controlled, as the link carries it, or to
600/// this device's player when none is. A device out of reach is an error, and
601/// stays the one controlled: nothing meant for it plays here instead.
602pub fn send(cmd: PlayerCommand, tx: &Sender<PlayerCommand>) -> Result<(), SendError> {
603    let here_now = |cmd| tx.send(cmd).map_err(|_| SendError::Stopped);
604    let Some(to) = devices::target() else {
605        return here_now(cmd);
606    };
607    match to_link(cmd).map_err(SendError::Unplayable)? {
608        Routed::There(link) => devices::send(&to, link).map_err(SendError::Missed),
609        Routed::Here(local) => here_now(local),
610        Routed::Dropped => Ok(()),
611    }
612}
613
614/// Where a player command goes while another device is controlled.
615pub enum Routed {
616    /// To that device, as this.
617    There(LinkCommand),
618    /// This device's own: its output, its DSP, the player's own events.
619    Here(PlayerCommand),
620    /// Bookkeeping of this device's own queue, which the other device keeps
621    /// for itself.
622    Dropped,
623}
624
625/// The server's id for each item's track, in order, `None` where the server
626/// has none. Only a device playing from the same library can resolve them,
627/// and a list the server has none of cannot be sent at all.
628fn server_ids(items: &[PlaylistItem]) -> Result<Vec<Option<String>>, String> {
629    if !devices::target_device().is_some_and(|d| d.same_library) {
630        return Err("that device plays from a different library".into());
631    }
632    let remote = crate::db::pool::shared()
633        .get()
634        .map(|db| remote_ids(&db, items))
635        .unwrap_or_default();
636    let out: Vec<Option<String>> = items
637        .iter()
638        .map(|i| i.db_id.and_then(|id| remote.get(&id).cloned()))
639        .collect();
640    if out.iter().all(Option::is_none) {
641        return Err("none of these are on the server, so the other device cannot play them".into());
642    }
643    Ok(out)
644}
645
646/// A player command as the device being controlled is sent it. Queue entries
647/// are named by the ids that device reported them under; tracks by the
648/// server's ids.
649pub fn to_link(cmd: PlayerCommand) -> Result<Routed, String> {
650    let entry = |id: QueueItemId| id.0.to_string();
651    let tracks = |items: &[PlaylistItem]| -> Result<Vec<String>, String> {
652        Ok(server_ids(items)?.into_iter().flatten().collect())
653    };
654    Ok(Routed::There(match cmd {
655        PlayerCommand::Play(id) => LinkCommand::PlayItem { id: entry(id) },
656        PlayerCommand::Pause | PlayerCommand::Stop => LinkCommand::Pause,
657        PlayerCommand::Resume => LinkCommand::Resume,
658        PlayerCommand::Seek(position_ms) => LinkCommand::Seek { position_ms },
659        PlayerCommand::NextTrack => LinkCommand::Next,
660        PlayerCommand::PrevTrack => LinkCommand::Previous,
661        PlayerCommand::SetShuffle(on) => LinkCommand::Shuffle { on },
662        PlayerCommand::SetRepeat(mode) => LinkCommand::Repeat { mode },
663        PlayerCommand::SetSleepTimer(timer) => LinkCommand::SleepTimer { timer },
664        PlayerCommand::AddToPlaylist(items) => LinkCommand::Enqueue {
665            track_ids: tracks(&items)?,
666            play: false,
667        },
668        PlayerCommand::AddAndPlay(items) => LinkCommand::Enqueue {
669            track_ids: tracks(&items)?,
670            play: true,
671        },
672        PlayerCommand::InsertInPlaylist { items, after } => LinkCommand::Insert {
673            track_ids: tracks(&items)?,
674            after: entry(after),
675        },
676        PlayerCommand::ReplacePlaylist {
677            items,
678            start,
679            position_ms,
680            play,
681            mode,
682        } => {
683            let ids = server_ids(&items)?;
684            let found: Vec<Option<i64>> = ids.iter().map(|r| r.as_ref().map(|_| 0)).collect();
685            let (start_at, position_ms) = start_among(&found, start, position_ms);
686            LinkCommand::Play {
687                track_ids: ids.into_iter().flatten().collect(),
688                start_at: start_at as u32,
689                position_ms,
690                paused: !play,
691                handoff: false,
692                mode,
693            }
694        }
695        PlayerCommand::RemoveFromPlaylist(id) => LinkCommand::RemoveItems {
696            ids: vec![entry(id)],
697        },
698        PlayerCommand::RemoveFromPlaylistBatch(ids) => LinkCommand::RemoveItems {
699            ids: ids.into_iter().map(entry).collect(),
700        },
701        PlayerCommand::MoveInPlaylist { id, target, after } => LinkCommand::MoveItems {
702            ids: vec![entry(id)],
703            target: entry(target),
704            after,
705        },
706        PlayerCommand::MoveItemsInPlaylist { ids, target, after } => LinkCommand::MoveItems {
707            ids: ids.into_iter().map(entry).collect(),
708            target: entry(target),
709            after,
710        },
711        PlayerCommand::ClearPlaylist => LinkCommand::Clear,
712        PlayerCommand::Undo => LinkCommand::Undo,
713        PlayerCommand::Redo => LinkCommand::Redo,
714        PlayerCommand::ReorderPlaylist(_)
715        | PlayerCommand::BeginUndoBatch
716        | PlayerCommand::EndUndoBatch => return Ok(Routed::Dropped),
717        // Named rather than caught by a wildcard, so a command added later is
718        // placed here or mapped above, never acted on locally by default
719        // while another device is controlled.
720        local @ (PlayerCommand::Cue { .. }
721        | PlayerCommand::PauseAndReport(_)
722        | PlayerCommand::Barrier(_)
723        | PlayerCommand::UpdatePaths(_)
724        | PlayerCommand::TrackReady(_)
725        | PlayerCommand::TrackStreamReady(_)
726        | PlayerCommand::StreamProbed { .. }
727        | PlayerCommand::TrackFailed(_)
728        | PlayerCommand::CacheTracks(_)
729        | PlayerCommand::DecodeFinished(_)
730        | PlayerCommand::TrackQueued
731        | PlayerCommand::SetOutputDevice(_)
732        | PlayerCommand::ClearOutputDevice
733        | PlayerCommand::ReloadDsp
734        | PlayerCommand::RouteChanged
735        | PlayerCommand::RestartOutput
736        | PlayerCommand::UseRenderer(_)
737        | PlayerCommand::ResumeRenderer(_)
738        | PlayerCommand::ResumeRendererMissed
739        | PlayerCommand::ReleaseRenderer(_)
740        | PlayerCommand::SetRendererVolume(_)
741        | PlayerCommand::RestorePlayMode(_)
742        | PlayerCommand::Renderer { .. }) => return Ok(Routed::Here(local)),
743    }))
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn a_play_starts_on_the_track_asked_for_among_those_found() {
752        let found = [Some(1), None, Some(3), Some(4)];
753        // On it, with its playhead.
754        assert_eq!(start_among(&found, 2, 500), (1, 500));
755        // Missing: the next one found, from its start.
756        assert_eq!(start_among(&found, 1, 500), (1, 0));
757        // Missing and nothing after: from the top.
758        assert_eq!(start_among(&[Some(1), None], 1, 500), (0, 0));
759        assert_eq!(start_among(&found, 0, 0), (0, 0));
760    }
761
762    #[test]
763    fn an_enqueue_that_does_not_play_reads_as_it_always_did() {
764        let plain = LinkCommand::Enqueue {
765            track_ids: vec!["t".into()],
766            play: false,
767        };
768        let json = serde_json::to_value(&plain).unwrap();
769        assert!(json.get("play").is_none(), "{json}");
770        let old: LinkCommand =
771            serde_json::from_value(serde_json::json!({"type": "enqueue", "trackIds": ["t"]}))
772                .unwrap();
773        assert_eq!(old, plain);
774    }
775}