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)]
37 pub profiles: Vec<String>,
38 #[serde(default)]
39 pub dsp_enabled: bool,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct LinkOutput {
46 pub id: String,
47 pub name: String,
48 #[serde(default)]
50 pub kind: String,
51 #[serde(default, skip_serializing_if = "String::is_empty")]
53 pub detail: String,
54 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
56 pub busy: bool,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub preset: Option<String>,
60 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
63 pub unsaved: bool,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "camelCase")]
69pub enum OutputChoice {
70 #[default]
72 Default,
73 Device {
74 name: String,
75 },
76 Renderer {
77 udn: String,
78 },
79}
80
81static DEVICES: parking_lot::Mutex<Option<Vec<(String, String)>>> = parking_lot::Mutex::new(None);
85
86pub fn refresh_devices() {
91 let now = list_devices();
92 let mut cached = DEVICES.lock();
93 if cached.as_ref() != Some(&now) {
94 *cached = Some(now);
95 drop(cached);
96 crate::signal::engine_changed().bump();
97 }
98}
99
100fn list_devices() -> Vec<(String, String)> {
101 crate::audio::list_output_devices()
102 .unwrap_or_default()
103 .into_iter()
104 .map(|d| (d.name, d.kind.as_str().to_string()))
105 .collect()
106}
107
108#[derive(Default)]
111struct RefreshGate {
112 running: bool,
113 queued: bool,
114}
115
116impl RefreshGate {
117 fn ask(&mut self) -> bool {
119 if self.running {
120 self.queued = true;
121 return false;
122 }
123 self.running = true;
124 true
125 }
126
127 fn done(&mut self) -> bool {
129 self.running = std::mem::take(&mut self.queued);
130 self.running
131 }
132}
133
134static REFRESH: parking_lot::Mutex<RefreshGate> = parking_lot::Mutex::new(RefreshGate {
135 running: false,
136 queued: false,
137});
138
139pub fn refresh_for_controller() {
146 if !REFRESH.lock().ask() {
147 return;
148 }
149 let spawned = std::thread::Builder::new()
150 .name("koan-outputs".into())
151 .spawn(|| {
152 loop {
153 refresh_devices();
154 #[cfg(not(any(target_os = "ios", target_os = "tvos")))]
155 crate::upnp::discovery::search_if_stale();
156 if !REFRESH.lock().done() {
157 break;
158 }
159 }
160 });
161 if let Err(e) = spawned {
162 log::warn!("outputs: no refresh thread: {e}");
163 REFRESH.lock().done();
164 }
165}
166
167fn preset(dsp: &crate::config::DspConfig, device: &str) -> (Option<String>, bool) {
171 let flat = dsp.profile_for(device).is_none() && !dsp.tunings.iter().any(|t| t.device == device);
172 match crate::audio::dsp::profiles::preset_for(device) {
173 Some((name, edited)) => (Some(name), edited),
174 None => (None, !flat),
175 }
176}
177
178fn device_outputs(devices: &[(String, String)], dsp: &crate::config::DspConfig) -> Vec<LinkOutput> {
181 devices
182 .iter()
183 .map(|(name, kind)| {
184 let (preset, unsaved) = preset(dsp, name);
185 LinkOutput {
186 preset,
187 unsaved,
188 id: name.clone(),
189 name: name.clone(),
190 kind: kind.clone(),
191 ..Default::default()
192 }
193 })
194 .collect()
195}
196
197pub fn local(state: &SharedPlayerState) -> LinkOutputs {
199 let cfg = Config::cached();
200 let devices = device_outputs(DEVICES.lock().get_or_insert_with(list_devices), &cfg.dsp);
201 let renderers = crate::upnp::discovery::renderers()
202 .into_iter()
203 .map(|r| {
204 let (preset, unsaved) = preset(&cfg.dsp, r.device_name());
205 LinkOutput {
206 preset,
207 unsaved,
208 busy: crate::upnp::discovery::busy(&r.udn),
209 detail: [r.manufacturer.as_str(), r.model.as_str()]
210 .iter()
211 .filter(|s| !s.is_empty())
212 .copied()
213 .collect::<Vec<_>>()
214 .join(" "),
215 id: r.udn,
216 name: r.name,
217 kind: "upnp".into(),
218 }
219 })
220 .collect();
221 let renderer = state.renderer();
222 LinkOutputs {
223 devices,
224 renderers,
225 current: match (&renderer, &cfg.playback.output_device) {
226 (Some(r), _) => OutputChoice::Renderer { udn: r.udn.clone() },
227 (None, Some(name)) => OutputChoice::Device { name: name.clone() },
228 (None, None) => OutputChoice::Default,
229 },
230 volume: renderer.and_then(|r| r.volume),
231 profiles: cfg
232 .dsp
233 .profiles
234 .iter()
235 .filter(|p| p.preset)
236 .map(|p| p.name.clone())
237 .collect(),
238 dsp_enabled: cfg.dsp.enabled,
239 }
240}
241
242pub fn set(
247 output: OutputChoice,
248 choice: u64,
249 player: &crossbeam_channel::Sender<PlayerCommand>,
250) -> Result<(), String> {
251 let send = |cmd| {
252 player
253 .send(cmd)
254 .map_err(|_| "The player has stopped.".to_string())
255 };
256 match output {
257 OutputChoice::Default => send(PlayerCommand::ClearOutputDevice),
258 OutputChoice::Device { name } => send(PlayerCommand::SetOutputDevice(name)),
259 OutputChoice::Renderer { udn } => crate::upnp::connect(&udn, choice, player),
260 }
261}
262
263pub fn set_preset(
267 device: &str,
268 profile: Option<&str>,
269 player: &crossbeam_channel::Sender<PlayerCommand>,
270) -> Result<(), String> {
271 match profile {
272 None => crate::audio::dsp::profiles::apply_preset(device, None)?,
273 Some(name) => crate::audio::dsp::profiles::assign(Some(name), device)?,
274 }
275 player
276 .send(PlayerCommand::ReloadDsp)
277 .map_err(|_| "The player has stopped.".to_string())
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::config::DspProfile;
284 use crate::remote::link::{LinkCommand, LinkState};
285
286 #[test]
289 fn outputs_cross_the_link_whole() {
290 let state = LinkState {
291 playing: true,
292 outputs: Some(LinkOutputs {
293 devices: vec![LinkOutput {
294 id: "Topping E30".into(),
295 name: "Topping E30".into(),
296 kind: "usb".into(),
297 preset: Some("Harman".into()),
298 ..Default::default()
299 }],
300 renderers: vec![LinkOutput {
301 id: "uuid:arcam".into(),
302 name: "Arcam".into(),
303 kind: "upnp".into(),
304 detail: "Arcam SA30".into(),
305 ..Default::default()
306 }],
307 current: OutputChoice::Renderer {
308 udn: "uuid:arcam".into(),
309 },
310 volume: Some(30),
311 profiles: vec!["Harman".into()],
312 dsp_enabled: true,
313 }),
314 ..Default::default()
315 };
316 let json = serde_json::to_string(&state).unwrap();
317 assert_eq!(serde_json::from_str::<LinkState>(&json).unwrap(), state);
318
319 let older: LinkState = serde_json::from_str(r#"{"playing":false}"#).unwrap();
320 assert_eq!(older.outputs, None);
321
322 let cmd = LinkCommand::SetOutput {
323 output: OutputChoice::Device {
324 name: "Topping E30".into(),
325 },
326 };
327 let json = serde_json::to_string(&cmd).unwrap();
328 assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
329 }
330
331 #[test]
336 fn a_route_is_published_by_name_with_its_preset() {
337 let _guard = crate::config::tests::PERSIST_LOCK
338 .lock()
339 .unwrap_or_else(|e| e.into_inner());
340 let dir = tempfile::tempdir().unwrap();
341 crate::config::set_config_dir(dir.path());
342 Config::persist(|c| {
343 c.dsp.profiles.push(DspProfile {
344 name: "Qudelix Harman".into(),
345 devices: vec!["Qudelix-5K".into(), "Car".into()],
346 role: Some(crate::config::DspRole::Correction),
347 ..Default::default()
348 })
349 })
350 .unwrap();
351 crate::audio::dsp::profiles::save_preset("Qudelix-5K", "Harman").unwrap();
352 let dsp = Config::cached().dsp.clone();
353 let route = [("Qudelix-5K".to_string(), String::new())];
354 assert_eq!(
355 device_outputs(&route, &dsp),
356 vec![LinkOutput {
357 id: "Qudelix-5K".into(),
358 name: "Qudelix-5K".into(),
359 preset: Some("Harman".into()),
360 ..Default::default()
361 }]
362 );
363 let car = [("Car".to_string(), String::new())];
364 assert_eq!(device_outputs(&car, &dsp)[0].preset, None);
365 assert!(device_outputs(&car, &dsp)[0].unsaved);
366 let speaker = [("Speaker".to_string(), String::new())];
367 assert_eq!(device_outputs(&speaker, &dsp)[0].preset, None);
368 assert!(!device_outputs(&speaker, &dsp)[0].unsaved, "flat");
369 }
370
371 #[test]
374 fn a_stranger_on_the_network_cannot_move_the_output() {
375 for cmd in [
376 LinkCommand::SetOutput {
377 output: OutputChoice::Default,
378 },
379 LinkCommand::SetRendererVolume { volume: 100 },
380 LinkCommand::SetPreset {
381 device: "Speakers".into(),
382 profile: None,
383 },
384 LinkCommand::RefreshOutputs,
385 ] {
386 assert!(!cmd.allowed_nearby(), "{cmd:?}");
387 }
388 assert!(LinkCommand::Pause.allowed_nearby());
389 }
390
391 #[test]
395 fn a_controller_can_ask_for_the_outputs_again() {
396 let cmd = LinkCommand::RefreshOutputs;
397 let json = serde_json::to_string(&cmd).unwrap();
398 assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
399 assert!(cmd.relayable());
400 assert!(cmd.allowed_playback());
401 }
402
403 #[test]
406 fn refreshes_for_controllers_coalesce() {
407 let mut gate = RefreshGate::default();
408 assert!(gate.ask());
409 assert!(!gate.ask());
410 assert!(!gate.ask());
411 assert!(gate.done());
413 assert!(!gate.done());
414 assert!(gate.ask());
415 assert!(!gate.done());
416 }
417
418 #[test]
421 fn a_refresh_is_never_queued_for_an_absent_device() {
422 assert!(LinkCommand::RefreshOutputs.live_only());
423 assert!(
424 LinkCommand::Shared {
425 command: Box::new(LinkCommand::RefreshOutputs)
426 }
427 .live_only()
428 );
429 assert!(!LinkCommand::Pause.live_only());
430 assert!(!crate::remote::devices::send_live(
431 "nowhere-at-all",
432 LinkCommand::RefreshOutputs
433 ));
434 }
435
436 #[test]
438 fn setting_a_device_output_is_the_menus_switch() {
439 let (tx, rx) = crossbeam_channel::unbounded();
440 set(
441 OutputChoice::Device {
442 name: "Topping E30".into(),
443 },
444 crate::upnp::choose(),
445 &tx,
446 )
447 .unwrap();
448 assert!(matches!(
449 rx.try_recv(),
450 Ok(PlayerCommand::SetOutputDevice(name)) if name == "Topping E30"
451 ));
452 set(OutputChoice::Default, crate::upnp::choose(), &tx).unwrap();
453 assert!(matches!(
454 rx.try_recv(),
455 Ok(PlayerCommand::ClearOutputDevice)
456 ));
457 }
458}