1use std::sync::OnceLock;
13use std::time::Instant;
14
15use parking_lot::Mutex;
16
17use crate::config::Config;
18use crate::remote::link::{self, LinkCommand, LinkDevice, LinkHello, LinkReport, LinkState, Local};
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct Device {
23 pub id: String,
24 pub name: String,
25 pub platform: String,
26 pub account: bool,
28 pub nearby: bool,
30 pub awake: bool,
33 pub same_library: bool,
35 pub state: Option<LinkState>,
37 pub heard: Instant,
39 pub problem: Option<String>,
42}
43
44impl Device {
45 pub fn position_ms(&self) -> u64 {
47 let Some(state) = &self.state else { return 0 };
48 if !state.playing {
49 return state.position_ms;
50 }
51 let pos = state.position_ms + self.heard.elapsed().as_millis() as u64;
52 if state.duration_ms > 0 {
53 pos.min(state.duration_ms)
54 } else {
55 pos
56 }
57 }
58}
59
60#[derive(Default)]
61struct Store {
62 linked: bool,
63 account: Vec<(LinkDevice, Instant)>,
64 nearby: Vec<Nearby>,
65 seen: Vec<SeenNearby>,
66 target: Option<Remembered>,
67 version: u64,
68}
69
70#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
72struct Saved {
73 target: Option<Remembered>,
74 account: Vec<LinkDevice>,
75 #[serde(default)]
76 nearby: Vec<SeenNearby>,
77}
78
79#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
82pub struct SeenNearby {
83 pub id: String,
84 pub name: String,
85 pub platform: String,
86 pub addr: String,
87 pub at: i64,
89}
90
91const NEARBY_KEPT: i64 = 7 * 24 * 60 * 60;
93
94#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
97struct Remembered {
98 id: String,
99 name: String,
100 platform: String,
101}
102
103struct Nearby {
104 hello: LinkHello,
105 state: Option<LinkState>,
106 at: Instant,
107}
108
109static STORE: Mutex<Option<Store>> = Mutex::new(None);
110static LOCAL: OnceLock<Local> = OnceLock::new();
111
112fn with<R>(f: impl FnOnce(&mut Store) -> R) -> R {
113 let mut store = STORE.lock();
114 f(store.get_or_insert_with(Store::default))
115}
116
117fn changed<R>(f: impl FnOnce(&mut Store) -> R) -> R {
119 let out = with(|s| {
120 let out = f(s);
121 s.version += 1;
122 out
123 });
124 crate::signal::engine_changed().bump();
125 out
126}
127
128impl Store {
129 fn save(&self) {
130 let saved = Saved {
131 target: self.target.clone(),
132 account: self.account.iter().map(|(d, _)| d.clone()).collect(),
133 nearby: self.seen.clone(),
134 };
135 if let Ok(json) = serde_json::to_string(&saved) {
136 let _ = std::fs::write(saved_path(), json);
137 }
138 }
139}
140
141fn saved_path() -> std::path::PathBuf {
142 crate::config::config_dir().join("devices.json")
143}
144
145fn restore() {
147 let Ok(text) = std::fs::read_to_string(saved_path()) else {
148 return;
149 };
150 let Ok(saved) = serde_json::from_str::<Saved>(&text) else {
151 return;
152 };
153 let now = Instant::now();
154 let cutoff = chrono::Utc::now().timestamp() - NEARBY_KEPT;
155 changed(|s| {
156 s.target = saved.target;
157 s.seen = saved
158 .nearby
159 .into_iter()
160 .filter(|n| n.at >= cutoff)
161 .collect();
162 s.account = saved
164 .account
165 .into_iter()
166 .map(|d| (LinkDevice { linked: false, ..d }, now))
167 .collect();
168 });
169}
170
171pub fn start(local: Local) {
174 if LOCAL.set(local.clone()).is_err() {
175 return;
176 }
177 restore();
178 link::spawn(local.clone());
179 crate::remote::nearby::start(local);
180}
181
182pub fn local() -> Option<&'static Local> {
184 LOCAL.get()
185}
186
187pub fn set_linked(linked: bool) {
196 changed(|s| s.linked = linked);
197}
198
199pub fn set_account(devices: Vec<LinkDevice>) {
200 let now = Instant::now();
201 changed(|s| {
202 s.account = devices.into_iter().map(|d| (d, now)).collect();
203 s.save();
204 });
205}
206
207pub fn remembered_nearby() -> Vec<SeenNearby> {
209 with(|s| s.seen.clone())
210}
211
212pub fn nearby_hello(hello: LinkHello, addr: &str) {
213 changed(|s| {
214 s.seen.retain(|n| n.id != hello.id);
215 s.seen.push(SeenNearby {
216 id: hello.id.clone(),
217 name: hello.name.clone(),
218 platform: hello.platform.clone(),
219 addr: addr.to_string(),
220 at: chrono::Utc::now().timestamp(),
221 });
222 s.save();
223 s.nearby.retain(|n| n.hello.id != hello.id);
224 s.nearby.push(Nearby {
225 hello,
226 state: None,
227 at: Instant::now(),
228 });
229 });
230}
231
232pub fn nearby_state(id: &str, state: LinkState) {
233 changed(|s| {
234 if let Some(n) = s.nearby.iter_mut().find(|n| n.hello.id == id) {
235 n.state = Some(state);
236 n.at = Instant::now();
237 }
238 });
239}
240
241pub fn nearby_gone(id: &str) {
242 changed(|s| s.nearby.retain(|n| n.hello.id != id));
243}
244
245pub fn touch() {
248 changed(|_| ());
249}
250
251pub fn version() -> u64 {
253 with(|s| s.version)
254}
255
256pub fn linked() -> bool {
258 with(|s| s.linked)
259}
260
261pub fn list() -> Vec<Device> {
263 let cfg = Config::load().unwrap_or_default();
264 let ours = link::library_fingerprint(&cfg);
265 with(|s| {
266 let mut out: Vec<Device> = s
267 .account
268 .iter()
269 .map(|(d, at)| {
270 let near = s.nearby.iter().find(|n| n.hello.id == d.id);
271 Device {
272 id: d.id.clone(),
273 name: d.name.clone(),
274 platform: d.platform.clone(),
275 account: true,
276 nearby: near.is_some(),
277 awake: d.linked || near.is_some(),
278 same_library: true,
279 state: match near {
280 Some(n) => n.state.clone(),
281 None => d.state.clone(),
282 },
283 heard: near.map_or(*at, |n| n.at),
284 problem: None,
285 }
286 })
287 .collect();
288 for n in &s.nearby {
289 if out.iter().any(|d| d.id == n.hello.id) {
290 continue;
291 }
292 out.push(Device {
293 id: n.hello.id.clone(),
294 name: n.hello.name.clone(),
295 platform: n.hello.platform.clone(),
296 account: false,
297 nearby: true,
298 awake: true,
299 same_library: ours.is_some() && n.hello.library == ours,
300 state: n.state.clone(),
301 heard: n.at,
302 problem: None,
303 });
304 }
305 for f in crate::remote::nearby::found() {
307 let id =
308 f.id.clone()
309 .unwrap_or_else(|| format!("bonjour:{}", f.name));
310 if out.iter().any(|d| d.id == id) || f.id.is_some() && f.id == this_id() {
311 continue;
312 }
313 out.push(Device {
314 id,
315 name: f.name,
316 platform: f.platform.unwrap_or_default(),
317 account: false,
318 nearby: false,
319 awake: false,
320 same_library: false,
321 state: None,
322 heard: Instant::now(),
323 problem: Some(f.problem.unwrap_or_else(|| "Connecting…".into())),
324 });
325 }
326 if let Some(t) = &s.target
328 && !out.iter().any(|d| d.id == t.id)
329 {
330 out.push(Device {
331 id: t.id.clone(),
332 name: t.name.clone(),
333 platform: t.platform.clone(),
334 account: false,
335 nearby: false,
336 awake: false,
337 same_library: true,
338 state: None,
339 heard: Instant::now(),
340 problem: Some("Out of reach".into()),
341 });
342 }
343 out.sort_by_key(|d| {
344 (
345 !d.state.as_ref().is_some_and(|st| st.playing),
346 !d.awake,
347 d.name.to_lowercase(),
348 )
349 });
350 out
351 })
352}
353
354pub fn target() -> Option<String> {
356 with(|s| s.target.as_ref().map(|t| t.id.clone()))
357}
358
359pub fn set_target(id: Option<String>) {
360 let listed = id
361 .as_ref()
362 .and_then(|id| list().into_iter().find(|d| d.id == *id));
363 changed(|s| {
364 s.target = id.map(|id| match listed {
365 Some(d) => Remembered {
366 id,
367 name: d.name,
368 platform: d.platform,
369 },
370 None => Remembered {
371 name: id.clone(),
372 id,
373 platform: String::new(),
374 },
375 });
376 s.save();
377 });
378}
379
380pub fn resume() {
385 link::nudge();
386 crate::remote::wire::probe_all();
387 crate::remote::nearby::refresh();
388}
389
390pub fn target_device() -> Option<Device> {
392 let id = target()?;
393 list().into_iter().find(|d| d.id == id)
394}
395
396pub fn send(id: &str, cmd: LinkCommand) -> Result<(), String> {
400 let (nearby, account) = with(|s| {
401 (
402 s.nearby.iter().any(|n| n.hello.id == id),
403 s.account.iter().any(|(d, _)| d.id == id),
404 )
405 });
406 if nearby && crate::remote::nearby::send(id, cmd.clone()) {
407 return Ok(());
408 }
409 if link::report(LinkReport::Command {
410 to: id.to_string(),
411 command: cmd.clone(),
412 }) {
413 return Ok(());
414 }
415 if !account && !nearby {
416 log::info!("devices: {id} is not listed; asking the server");
419 }
420 let cfg = Config::load().unwrap_or_default();
421 let client = crate::helpers::subsonic_client(&cfg).ok_or("not signed in to a server")?;
422 let json = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
423 client.koan_command(id, &json).map_err(|e| e.to_string())
424}
425
426pub fn this_id() -> Option<String> {
428 local().map(|l| l.identity.device_id.clone())
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 fn device(id: &str, playing: bool) -> LinkDevice {
436 LinkDevice {
437 id: id.into(),
438 name: id.into(),
439 platform: "macos".into(),
440 linked: true,
441 state: Some(LinkState {
442 playing,
443 position_ms: 1000,
444 duration_ms: 2000,
445 ..Default::default()
446 }),
447 }
448 }
449
450 #[test]
453 fn a_device_found_both_ways_is_one_and_a_lost_target_is_kept() {
454 crate::config::isolate_config_for_tests();
455 set_account(vec![device("mac", false), device("phone", true)]);
456 let listed = list();
457 assert_eq!(listed[0].id, "phone", "playing first");
458 assert_eq!(listed.len(), 2);
459
460 nearby_hello(
461 LinkHello {
462 id: "mac".into(),
463 name: "Mac".into(),
464 platform: "macos".into(),
465 library: None,
466 },
467 "mac.local:5626",
468 );
469 nearby_hello(
470 LinkHello {
471 id: "tv".into(),
472 name: "Living room".into(),
473 platform: "macos".into(),
474 library: Some("elsewhere".into()),
475 },
476 "10.0.0.9:5626",
477 );
478 let listed = list();
479 assert_eq!(listed.len(), 3);
480 let mac = listed.iter().find(|d| d.id == "mac").unwrap();
481 assert!(mac.account && mac.nearby && mac.same_library);
482 let tv = listed.iter().find(|d| d.id == "tv").unwrap();
483 assert!(!tv.account && !tv.same_library);
484
485 set_target(Some("tv".into()));
486 nearby_gone("tv");
487 assert_eq!(target(), Some("tv".into()), "kept while out of reach");
488 let tv = list().into_iter().find(|d| d.id == "tv").unwrap();
489 assert_eq!(tv.name, "Living room");
490 assert!(!tv.awake && tv.problem.is_some());
491
492 with(|s| *s = Store::default());
494 restore();
495 assert_eq!(target(), Some("tv".into()));
496 let listed = list();
497 assert!(
498 listed.iter().any(|d| d.id == "mac" && !d.awake),
499 "last heard of, not linked"
500 );
501 let remembered = remembered_nearby();
502 assert!(
503 remembered
504 .iter()
505 .any(|n| n.id == "tv" && n.addr == "10.0.0.9:5626")
506 );
507
508 set_target(None);
509 set_account(Vec::new());
510 nearby_gone("mac");
511 assert!(list().is_empty());
512 }
513}