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.and_then(|n| n.state.clone()), &d.state) {
282 (Some(mut fresh), Some(linked)) => {
283 fresh.outputs = linked.outputs.clone();
284 Some(fresh)
285 }
286 (Some(fresh), None) => Some(fresh),
287 (None, linked) => linked.clone(),
288 },
289 heard: near.map_or(*at, |n| n.at),
290 problem: None,
291 }
292 })
293 .collect();
294 for n in &s.nearby {
295 if out.iter().any(|d| d.id == n.hello.id) {
296 continue;
297 }
298 out.push(Device {
299 id: n.hello.id.clone(),
300 name: n.hello.name.clone(),
301 platform: n.hello.platform.clone(),
302 account: false,
303 nearby: true,
304 awake: true,
305 same_library: ours.is_some() && n.hello.library == ours,
306 state: n.state.clone(),
307 heard: n.at,
308 problem: None,
309 });
310 }
311 if let Some(t) = &s.target
316 && !out.iter().any(|d| d.id == t.id)
317 {
318 out.push(Device {
319 id: t.id.clone(),
320 name: t.name.clone(),
321 platform: t.platform.clone(),
322 account: false,
323 nearby: false,
324 awake: false,
325 same_library: true,
326 state: None,
327 heard: Instant::now(),
328 problem: Some("Out of reach".into()),
329 });
330 }
331 out.sort_by_key(|d| {
332 (
333 !d.state.as_ref().is_some_and(|st| st.playing),
334 !d.awake,
335 d.name.to_lowercase(),
336 )
337 });
338 out
339 })
340}
341
342pub fn target() -> Option<String> {
344 with(|s| s.target.as_ref().map(|t| t.id.clone()))
345}
346
347pub fn set_target(id: Option<String>) {
348 let listed = id
349 .as_ref()
350 .and_then(|id| list().into_iter().find(|d| d.id == *id));
351 changed(|s| {
352 s.target = id.map(|id| match listed {
353 Some(d) => Remembered {
354 id,
355 name: d.name,
356 platform: d.platform,
357 },
358 None => Remembered {
359 name: id.clone(),
360 id,
361 platform: String::new(),
362 },
363 });
364 s.save();
365 });
366 crate::quiet::reapply();
368}
369
370pub fn resume() {
375 link::nudge();
376 crate::remote::wire::probe_all();
377 crate::remote::nearby::refresh();
378}
379
380pub fn target_device() -> Option<Device> {
382 let id = target()?;
383 list().into_iter().find(|d| d.id == id)
384}
385
386pub fn send(id: &str, cmd: LinkCommand) -> Result<(), String> {
390 let (nearby, account) = with(|s| {
391 (
392 s.nearby.iter().any(|n| n.hello.id == id),
393 s.account.iter().any(|(d, _)| d.id == id),
394 )
395 });
396 if !cmd.allowed_nearby() && !account {
400 return Err("Only your own devices can be asked that.".into());
401 }
402 if nearby && cmd.allowed_nearby() && crate::remote::nearby::send(id, cmd.clone()) {
403 return Ok(());
404 }
405 if link::report(LinkReport::Command {
406 to: id.to_string(),
407 command: cmd.clone(),
408 }) {
409 return Ok(());
410 }
411 if !account && !nearby {
412 log::info!("devices: {id} is not listed; asking the server");
415 }
416 let cfg = Config::load().unwrap_or_default();
417 let client = crate::helpers::subsonic_client(&cfg).ok_or("not signed in to a server")?;
418 let json = serde_json::to_string(&cmd).map_err(|e| e.to_string())?;
419 client.koan_command(id, &json).map_err(|e| e.to_string())
420}
421
422pub fn this_id() -> Option<String> {
424 local().map(|l| l.identity.device_id.clone())
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn device(id: &str, playing: bool) -> LinkDevice {
432 LinkDevice {
433 id: id.into(),
434 name: id.into(),
435 platform: "macos".into(),
436 linked: true,
437 state: Some(LinkState {
438 playing,
439 position_ms: 1000,
440 duration_ms: 2000,
441 ..Default::default()
442 }),
443 }
444 }
445
446 #[test]
449 fn a_device_found_both_ways_is_one_and_a_lost_target_is_kept() {
450 crate::config::isolate_config_for_tests();
451 set_account(vec![device("mac", false), device("phone", true)]);
452 let listed = list();
453 assert_eq!(listed[0].id, "phone", "playing first");
454 assert_eq!(listed.len(), 2);
455
456 nearby_hello(
457 LinkHello {
458 id: "mac".into(),
459 name: "Mac".into(),
460 platform: "macos".into(),
461 library: None,
462 },
463 "mac.local:5626",
464 );
465 nearby_hello(
466 LinkHello {
467 id: "tv".into(),
468 name: "Living room".into(),
469 platform: "macos".into(),
470 library: Some("elsewhere".into()),
471 },
472 "10.0.0.9:5626",
473 );
474 let listed = list();
475 assert_eq!(listed.len(), 3);
476 let mac = listed.iter().find(|d| d.id == "mac").unwrap();
477 assert!(mac.account && mac.nearby && mac.same_library);
478 let tv = listed.iter().find(|d| d.id == "tv").unwrap();
479 assert!(!tv.account && !tv.same_library);
480
481 set_target(Some("tv".into()));
482 nearby_gone("tv");
483 assert_eq!(target(), Some("tv".into()), "kept while out of reach");
484 let tv = list().into_iter().find(|d| d.id == "tv").unwrap();
485 assert_eq!(tv.name, "Living room");
486 assert!(!tv.awake && tv.problem.is_some());
487
488 with(|s| *s = Store::default());
490 restore();
491 assert_eq!(target(), Some("tv".into()));
492 let listed = list();
493 assert!(
494 listed.iter().any(|d| d.id == "mac" && !d.awake),
495 "last heard of, not linked"
496 );
497 let remembered = remembered_nearby();
498 assert!(
499 remembered
500 .iter()
501 .any(|n| n.id == "tv" && n.addr == "10.0.0.9:5626")
502 );
503
504 set_target(None);
505 set_account(Vec::new());
506 nearby_gone("mac");
507 assert!(list().is_empty());
508 }
509}