Skip to main content

koan_core/remote/
outputs.rs

1//! What a device plays through, told to the devices controlling it, and
2//! changed by them.
3//!
4//! A device publishes its outputs in its `LinkState`: its own audio devices
5//! (on a phone, the route the system chose), the UPnP renderers it can see,
6//! which of them plays now, and each one's DSP preset. A device controlling it
7//! sends `SetOutput`, `SetRendererVolume` or `SetPreset`, and `set`,
8//! `set_volume` and `set_preset` carry them out exactly as the device's own
9//! menu does, so the music carries on from where it is, playing or paused.
10
11use serde::{Deserialize, Serialize};
12
13use crate::config::Config;
14use crate::player::commands::PlayerCommand;
15use crate::player::state::SharedPlayerState;
16
17/// What a device can play through.
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct LinkOutputs {
21    /// Its own audio devices: on a phone, the route the system chose.
22    #[serde(default)]
23    pub devices: Vec<LinkOutput>,
24    /// The UPnP renderers it can see.
25    #[serde(default)]
26    pub renderers: Vec<LinkOutput>,
27    /// What it plays through now.
28    #[serde(default)]
29    pub current: OutputChoice,
30    /// The volume of the renderer it plays to, when it has one.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub volume: Option<u8>,
33    /// Its DSP profiles, which any of its outputs can be given, and whether
34    /// processing is on at all.
35    #[serde(default)]
36    pub profiles: Vec<String>,
37    #[serde(default)]
38    pub dsp_enabled: bool,
39}
40
41/// One output: a device by its name, or a renderer by its UDN.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct LinkOutput {
45    pub id: String,
46    pub name: String,
47    /// How it is connected: `usb`, `bluetooth`, `upnp` and the rest.
48    #[serde(default)]
49    pub kind: String,
50    /// What to say under its name: a renderer's make and model.
51    #[serde(default, skip_serializing_if = "String::is_empty")]
52    pub detail: String,
53    /// Playing or paused for something else: picking it takes it over.
54    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55    pub busy: bool,
56    /// The DSP profile it plays through.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub preset: Option<String>,
59}
60
61/// An output to play through.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "camelCase")]
64pub enum OutputChoice {
65    /// The system's default output.
66    #[default]
67    Default,
68    Device {
69        name: String,
70    },
71    Renderer {
72        udn: String,
73    },
74}
75
76/// This device's audio devices, by name and kind, as last listed. Read on
77/// every change to the engine, so listed only when they may have changed:
78/// see `refresh_devices`.
79static DEVICES: parking_lot::Mutex<Option<Vec<(String, String)>>> = parking_lot::Mutex::new(None);
80
81/// List this device's audio devices again: on the platform's device-change
82/// notification, and when an output menu opens. Rings the engine's change
83/// signal if they moved, so every view of them follows.
84pub fn refresh_devices() {
85    let now = list_devices();
86    let mut cached = DEVICES.lock();
87    if cached.as_ref() != Some(&now) {
88        *cached = Some(now);
89        drop(cached);
90        crate::signal::engine_changed().bump();
91    }
92}
93
94fn list_devices() -> Vec<(String, String)> {
95    crate::audio::list_output_devices()
96        .unwrap_or_default()
97        .into_iter()
98        .map(|d| (d.name, d.kind.as_str().to_string()))
99        .collect()
100}
101
102/// This device's outputs, as they are now.
103pub fn local(state: &SharedPlayerState) -> LinkOutputs {
104    let cfg = Config::cached();
105    let preset = |device: &str| {
106        cfg.dsp
107            .profiles
108            .iter()
109            .find(|p| p.devices.iter().any(|d| d == device))
110            .map(|p| p.name.clone())
111    };
112    let devices = DEVICES
113        .lock()
114        .get_or_insert_with(list_devices)
115        .iter()
116        .map(|(name, kind)| LinkOutput {
117            preset: preset(name),
118            id: name.clone(),
119            name: name.clone(),
120            kind: kind.clone(),
121            ..Default::default()
122        })
123        .collect();
124    let renderers = crate::upnp::discovery::renderers()
125        .into_iter()
126        .map(|r| LinkOutput {
127            preset: preset(r.device_name()),
128            busy: crate::upnp::discovery::busy(&r.udn),
129            detail: [r.manufacturer.as_str(), r.model.as_str()]
130                .iter()
131                .filter(|s| !s.is_empty())
132                .copied()
133                .collect::<Vec<_>>()
134                .join(" "),
135            id: r.udn,
136            name: r.name,
137            kind: "upnp".into(),
138        })
139        .collect();
140    let renderer = state.renderer();
141    LinkOutputs {
142        devices,
143        renderers,
144        current: match (&renderer, &cfg.playback.output_device) {
145            (Some(r), _) => OutputChoice::Renderer { udn: r.udn.clone() },
146            (None, Some(name)) => OutputChoice::Device { name: name.clone() },
147            (None, None) => OutputChoice::Default,
148        },
149        volume: renderer.and_then(|r| r.volume),
150        profiles: cfg.dsp.profiles.iter().map(|p| p.name.clone()).collect(),
151        dsp_enabled: cfg.dsp.enabled,
152    }
153}
154
155/// Play through `output` from now on. `choice` is what `upnp::choose`
156/// returned when it was picked, taken before anything that could reorder it:
157/// a renderer is a session to open, a few round trips that block here, and a
158/// later pick drops it. Everything else reaches the player in order.
159pub fn set(
160    output: OutputChoice,
161    choice: u64,
162    player: &crossbeam_channel::Sender<PlayerCommand>,
163) -> Result<(), String> {
164    let send = |cmd| {
165        player
166            .send(cmd)
167            .map_err(|_| "The player has stopped.".to_string())
168    };
169    match output {
170        OutputChoice::Default => send(PlayerCommand::ClearOutputDevice),
171        OutputChoice::Device { name } => send(PlayerCommand::SetOutputDevice(name)),
172        OutputChoice::Renderer { udn } => crate::upnp::connect(&udn, choice, player),
173    }
174}
175
176/// Play `device` through `profile`, or untouched, and apply it where
177/// playback is.
178pub fn set_preset(
179    device: &str,
180    profile: Option<&str>,
181    player: &crossbeam_channel::Sender<PlayerCommand>,
182) -> Result<(), String> {
183    crate::audio::dsp::profiles::assign(profile, device)?;
184    player
185        .send(PlayerCommand::ReloadDsp)
186        .map_err(|_| "The player has stopped.".to_string())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::remote::link::{LinkCommand, LinkState};
193
194    /// A device's outputs survive the trip through the link, and a device
195    /// that publishes none (an older one) reads as having none.
196    #[test]
197    fn outputs_cross_the_link_whole() {
198        let state = LinkState {
199            playing: true,
200            outputs: Some(LinkOutputs {
201                devices: vec![LinkOutput {
202                    id: "Topping E30".into(),
203                    name: "Topping E30".into(),
204                    kind: "usb".into(),
205                    preset: Some("Harman".into()),
206                    ..Default::default()
207                }],
208                renderers: vec![LinkOutput {
209                    id: "uuid:arcam".into(),
210                    name: "Arcam".into(),
211                    kind: "upnp".into(),
212                    detail: "Arcam SA30".into(),
213                    ..Default::default()
214                }],
215                current: OutputChoice::Renderer {
216                    udn: "uuid:arcam".into(),
217                },
218                volume: Some(30),
219                profiles: vec!["Harman".into()],
220                dsp_enabled: true,
221            }),
222            ..Default::default()
223        };
224        let json = serde_json::to_string(&state).unwrap();
225        assert_eq!(serde_json::from_str::<LinkState>(&json).unwrap(), state);
226
227        let older: LinkState = serde_json::from_str(r#"{"playing":false}"#).unwrap();
228        assert_eq!(older.outputs, None);
229
230        let cmd = LinkCommand::SetOutput {
231            output: OutputChoice::Device {
232                name: "Topping E30".into(),
233            },
234        };
235        let json = serde_json::to_string(&cmd).unwrap();
236        assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
237    }
238
239    /// Anyone on the network may play and pause, but where the sound goes,
240    /// how loud the amplifier is and what a preset does are the account's.
241    #[test]
242    fn a_stranger_on_the_network_cannot_move_the_output() {
243        for cmd in [
244            LinkCommand::SetOutput {
245                output: OutputChoice::Default,
246            },
247            LinkCommand::SetRendererVolume { volume: 100 },
248            LinkCommand::SetPreset {
249                device: "Speakers".into(),
250                profile: None,
251            },
252        ] {
253            assert!(!cmd.allowed_nearby(), "{cmd:?}");
254        }
255        assert!(LinkCommand::Pause.allowed_nearby());
256    }
257
258    /// A device switch reaches the player as the device's own menu sends it.
259    #[test]
260    fn setting_a_device_output_is_the_menus_switch() {
261        let (tx, rx) = crossbeam_channel::unbounded();
262        set(
263            OutputChoice::Device {
264                name: "Topping E30".into(),
265            },
266            crate::upnp::choose(),
267            &tx,
268        )
269        .unwrap();
270        assert!(matches!(
271            rx.try_recv(),
272            Ok(PlayerCommand::SetOutputDevice(name)) if name == "Topping E30"
273        ));
274        set(OutputChoice::Default, crate::upnp::choose(), &tx).unwrap();
275        assert!(matches!(
276            rx.try_recv(),
277            Ok(PlayerCommand::ClearOutputDevice)
278        ));
279    }
280}