use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::player::commands::PlayerCommand;
use crate::player::state::SharedPlayerState;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkOutputs {
#[serde(default)]
pub devices: Vec<LinkOutput>,
#[serde(default)]
pub renderers: Vec<LinkOutput>,
#[serde(default)]
pub current: OutputChoice,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub volume: Option<u8>,
#[serde(default)]
pub profiles: Vec<String>,
#[serde(default)]
pub dsp_enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkOutput {
pub id: String,
pub name: String,
#[serde(default)]
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub detail: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub busy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unsaved: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum OutputChoice {
#[default]
Default,
Device {
name: String,
},
Renderer {
udn: String,
},
}
static DEVICES: parking_lot::Mutex<Option<Vec<(String, String)>>> = parking_lot::Mutex::new(None);
pub fn refresh_devices() {
let now = list_devices();
let mut cached = DEVICES.lock();
if cached.as_ref() != Some(&now) {
*cached = Some(now);
drop(cached);
crate::signal::engine_changed().bump();
}
}
fn list_devices() -> Vec<(String, String)> {
crate::audio::list_output_devices()
.unwrap_or_default()
.into_iter()
.map(|d| (d.name, d.kind.as_str().to_string()))
.collect()
}
#[derive(Default)]
struct RefreshGate {
running: bool,
queued: bool,
}
impl RefreshGate {
fn ask(&mut self) -> bool {
if self.running {
self.queued = true;
return false;
}
self.running = true;
true
}
fn done(&mut self) -> bool {
self.running = std::mem::take(&mut self.queued);
self.running
}
}
static REFRESH: parking_lot::Mutex<RefreshGate> = parking_lot::Mutex::new(RefreshGate {
running: false,
queued: false,
});
pub fn refresh_for_controller() {
if !REFRESH.lock().ask() {
return;
}
let spawned = std::thread::Builder::new()
.name("koan-outputs".into())
.spawn(|| {
loop {
refresh_devices();
#[cfg(not(any(target_os = "ios", target_os = "tvos")))]
crate::upnp::discovery::search_if_stale();
if !REFRESH.lock().done() {
break;
}
}
});
if let Err(e) = spawned {
log::warn!("outputs: no refresh thread: {e}");
REFRESH.lock().done();
}
}
fn preset(dsp: &crate::config::DspConfig, device: &str) -> (Option<String>, bool) {
let flat = dsp.profile_for(device).is_none() && !dsp.tunings.iter().any(|t| t.device == device);
match crate::audio::dsp::profiles::preset_for(device) {
Some((name, edited)) => (Some(name), edited),
None => (None, !flat),
}
}
fn device_outputs(devices: &[(String, String)], dsp: &crate::config::DspConfig) -> Vec<LinkOutput> {
devices
.iter()
.map(|(name, kind)| {
let (preset, unsaved) = preset(dsp, name);
LinkOutput {
preset,
unsaved,
id: name.clone(),
name: name.clone(),
kind: kind.clone(),
..Default::default()
}
})
.collect()
}
pub fn local(state: &SharedPlayerState) -> LinkOutputs {
let cfg = Config::cached();
let devices = device_outputs(DEVICES.lock().get_or_insert_with(list_devices), &cfg.dsp);
let renderers = crate::upnp::discovery::renderers()
.into_iter()
.map(|r| {
let (preset, unsaved) = preset(&cfg.dsp, r.device_name());
LinkOutput {
preset,
unsaved,
busy: crate::upnp::discovery::busy(&r.udn),
detail: [r.manufacturer.as_str(), r.model.as_str()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" "),
id: r.udn,
name: r.name,
kind: "upnp".into(),
}
})
.collect();
let renderer = state.renderer();
LinkOutputs {
devices,
renderers,
current: match (&renderer, &cfg.playback.output_device) {
(Some(r), _) => OutputChoice::Renderer { udn: r.udn.clone() },
(None, Some(name)) => OutputChoice::Device { name: name.clone() },
(None, None) => OutputChoice::Default,
},
volume: renderer.and_then(|r| r.volume),
profiles: cfg
.dsp
.profiles
.iter()
.filter(|p| p.preset)
.map(|p| p.name.clone())
.collect(),
dsp_enabled: cfg.dsp.enabled,
}
}
pub fn set(
output: OutputChoice,
choice: u64,
player: &crossbeam_channel::Sender<PlayerCommand>,
) -> Result<(), String> {
let send = |cmd| {
player
.send(cmd)
.map_err(|_| "The player has stopped.".to_string())
};
match output {
OutputChoice::Default => send(PlayerCommand::ClearOutputDevice),
OutputChoice::Device { name } => send(PlayerCommand::SetOutputDevice(name)),
OutputChoice::Renderer { udn } => crate::upnp::connect(&udn, choice, player),
}
}
pub fn set_preset(
device: &str,
profile: Option<&str>,
player: &crossbeam_channel::Sender<PlayerCommand>,
) -> Result<(), String> {
match profile {
None => crate::audio::dsp::profiles::apply_preset(device, None)?,
Some(name) => crate::audio::dsp::profiles::assign(Some(name), device)?,
}
player
.send(PlayerCommand::ReloadDsp)
.map_err(|_| "The player has stopped.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DspProfile;
use crate::remote::link::{LinkCommand, LinkState};
#[test]
fn outputs_cross_the_link_whole() {
let state = LinkState {
playing: true,
outputs: Some(LinkOutputs {
devices: vec![LinkOutput {
id: "Topping E30".into(),
name: "Topping E30".into(),
kind: "usb".into(),
preset: Some("Harman".into()),
..Default::default()
}],
renderers: vec![LinkOutput {
id: "uuid:arcam".into(),
name: "Arcam".into(),
kind: "upnp".into(),
detail: "Arcam SA30".into(),
..Default::default()
}],
current: OutputChoice::Renderer {
udn: "uuid:arcam".into(),
},
volume: Some(30),
profiles: vec!["Harman".into()],
dsp_enabled: true,
}),
..Default::default()
};
let json = serde_json::to_string(&state).unwrap();
assert_eq!(serde_json::from_str::<LinkState>(&json).unwrap(), state);
let older: LinkState = serde_json::from_str(r#"{"playing":false}"#).unwrap();
assert_eq!(older.outputs, None);
let cmd = LinkCommand::SetOutput {
output: OutputChoice::Device {
name: "Topping E30".into(),
},
};
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
}
#[test]
fn a_route_is_published_by_name_with_its_preset() {
let _guard = crate::config::tests::PERSIST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
crate::config::set_config_dir(dir.path());
Config::persist(|c| {
c.dsp.profiles.push(DspProfile {
name: "Qudelix Harman".into(),
devices: vec!["Qudelix-5K".into(), "Car".into()],
role: Some(crate::config::DspRole::Correction),
..Default::default()
})
})
.unwrap();
crate::audio::dsp::profiles::save_preset("Qudelix-5K", "Harman").unwrap();
let dsp = Config::cached().dsp.clone();
let route = [("Qudelix-5K".to_string(), String::new())];
assert_eq!(
device_outputs(&route, &dsp),
vec![LinkOutput {
id: "Qudelix-5K".into(),
name: "Qudelix-5K".into(),
preset: Some("Harman".into()),
..Default::default()
}]
);
let car = [("Car".to_string(), String::new())];
assert_eq!(device_outputs(&car, &dsp)[0].preset, None);
assert!(device_outputs(&car, &dsp)[0].unsaved);
let speaker = [("Speaker".to_string(), String::new())];
assert_eq!(device_outputs(&speaker, &dsp)[0].preset, None);
assert!(!device_outputs(&speaker, &dsp)[0].unsaved, "flat");
}
#[test]
fn a_stranger_on_the_network_cannot_move_the_output() {
for cmd in [
LinkCommand::SetOutput {
output: OutputChoice::Default,
},
LinkCommand::SetRendererVolume { volume: 100 },
LinkCommand::SetPreset {
device: "Speakers".into(),
profile: None,
},
LinkCommand::RefreshOutputs,
] {
assert!(!cmd.allowed_nearby(), "{cmd:?}");
}
assert!(LinkCommand::Pause.allowed_nearby());
}
#[test]
fn a_controller_can_ask_for_the_outputs_again() {
let cmd = LinkCommand::RefreshOutputs;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(serde_json::from_str::<LinkCommand>(&json).unwrap(), cmd);
assert!(cmd.relayable());
assert!(cmd.allowed_playback());
}
#[test]
fn refreshes_for_controllers_coalesce() {
let mut gate = RefreshGate::default();
assert!(gate.ask());
assert!(!gate.ask());
assert!(!gate.ask());
assert!(gate.done());
assert!(!gate.done());
assert!(gate.ask());
assert!(!gate.done());
}
#[test]
fn a_refresh_is_never_queued_for_an_absent_device() {
assert!(LinkCommand::RefreshOutputs.live_only());
assert!(
LinkCommand::Shared {
command: Box::new(LinkCommand::RefreshOutputs)
}
.live_only()
);
assert!(!LinkCommand::Pause.live_only());
assert!(!crate::remote::devices::send_live(
"nowhere-at-all",
LinkCommand::RefreshOutputs
));
}
#[test]
fn setting_a_device_output_is_the_menus_switch() {
let (tx, rx) = crossbeam_channel::unbounded();
set(
OutputChoice::Device {
name: "Topping E30".into(),
},
crate::upnp::choose(),
&tx,
)
.unwrap();
assert!(matches!(
rx.try_recv(),
Ok(PlayerCommand::SetOutputDevice(name)) if name == "Topping E30"
));
set(OutputChoice::Default, crate::upnp::choose(), &tx).unwrap();
assert!(matches!(
rx.try_recv(),
Ok(PlayerCommand::ClearOutputDevice)
));
}
}