1use serde::{Deserialize, Serialize};
12
13use crate::config::Config;
14use crate::player::commands::PlayerCommand;
15use crate::player::state::SharedPlayerState;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct LinkOutputs {
21 #[serde(default)]
23 pub devices: Vec<LinkOutput>,
24 #[serde(default)]
26 pub renderers: Vec<LinkOutput>,
27 #[serde(default)]
29 pub current: OutputChoice,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub volume: Option<u8>,
33 #[serde(default)]
36 pub profiles: Vec<String>,
37 #[serde(default)]
38 pub dsp_enabled: bool,
39}
40
41#[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 #[serde(default)]
49 pub kind: String,
50 #[serde(default, skip_serializing_if = "String::is_empty")]
52 pub detail: String,
53 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
55 pub busy: bool,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub preset: Option<String>,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "camelCase")]
64pub enum OutputChoice {
65 #[default]
67 Default,
68 Device {
69 name: String,
70 },
71 Renderer {
72 udn: String,
73 },
74}
75
76static DEVICES: parking_lot::Mutex<Option<Vec<(String, String)>>> = parking_lot::Mutex::new(None);
80
81pub 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
102pub 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
155pub 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
176pub 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 #[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 #[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 #[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}