diff --git a/lang/template.json b/lang/template.json index 7fd5402..823a22f 100644 --- a/lang/template.json +++ b/lang/template.json @@ -2,6 +2,7 @@ " Join us on Discord ": "", " OK ": "", " OK ": "", + "\"Fit each screen\" keeps the layout you see on a 1920×1080 screen, so moving the window to a laptop or a 1440p or 4K screen shows the same amount of everything. \"Same size in pixels\" ignores the display scaling set in Windows.": "", "(chosen files are used instead of the folder)": "", "(deleted rule)": "", "(not connected)": "", @@ -102,6 +103,7 @@ "Browse": "", "COMING UP NEXT": "", "Can't be read (damaged or not audio)": "", + "Can't show the waveform": "", "Cancel": "", "Cart wall": "", "Categories": "", @@ -224,11 +226,13 @@ "Find stations on this server": "", "Find this song in the local library and add it to the playlist": "", "First run: create your administrator account.": "", + "Fit each screen like 1920×1080": "", "Fixed crossfade": "", "Fixed keys": "", "Flat": "", "Folder…": "", "Follow": "", + "Follow each screen's display scaling": "", "Forget the hand-set cues for this track": "", "Format": "", "Forward": "", @@ -403,6 +407,7 @@ "On": "", "On / off": "", "On air": "", + "On screens with other display scaling or resolutions": "", "Only": "", "Only administrators can manage user accounts.": "", "Only allow during scheduled times": "", @@ -532,6 +537,7 @@ "STUDIO CLOCK": "", "Same artist and title, and lengths within 3 seconds. Off: only byte-for-byte identical copies.": "", "Same as time announcements": "", + "Same size in pixels on every screen": "", "Save": "", "Save and connect": "", "Save configuration": "", @@ -737,12 +743,14 @@ "all day": "", "all files in order": "", "all of them": "", + "analysing next track…": "", "analysing…": "", "any": "", "any date": "", "at": "", "audio file": "", "below": "", + "can't analyse the next track": "", "command, e.g. PLAYLIST Evening": "", "cut tail after": "", "delete for good": "", diff --git a/src/app.rs b/src/app.rs index 30cd53c..d7a4794 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1286,7 +1286,7 @@ impl App { ui.horizontal(|ui| { ui.label(core.notify.lock().clone()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(Local::now().format("%a %d %b %H:%M:%S").to_string()); + ui.label(core.now().format("%a %d %b %H:%M:%S").to_string()); ui.separator(); let live = core.handles.iter().flatten().filter(|h| h.status.lock().state == EncState::Live).count(); ui.label(RichText::new(format!("● {live} stream(s) live")).color(if live > 0 { GREEN } else { GRAY })); @@ -1303,7 +1303,7 @@ impl App { fn player(&mut self, ctx: &egui::Context, core: &mut Playout) { egui::TopBottomPanel::top("player").show(ctx, |ui| { ui.add_space(4.0); - let now = Local::now(); + let now = core.now(); let st = core.status.clone(); let title = Some(core.on_air_text()).filter(|t| !t.is_empty()).unwrap_or_else(|| "No track playing".into()); let next_i = core.current.map(|i| i + 1).unwrap_or(0); @@ -1645,8 +1645,9 @@ impl App { ui.vertical_centered(|ui| { ui.add_space(12.0); ui.label(RichText::new(tr("STUDIO CLOCK")).small().color(GRAY)); - ui.label(RichText::new(Local::now().format("%H:%M:%S").to_string()).monospace().size(51.0).strong().color(ACCENT)); - ui.label(Local::now().format("%A, %d %B %Y").to_string()); + let now = core.now(); + ui.label(RichText::new(now.format("%H:%M:%S").to_string()).monospace().size(51.0).strong().color(ACCENT)); + ui.label(now.format("%A, %d %B %Y").to_string()); if let Some(m) = core.secs_to_next_marker() { ui.label( RichText::new(format!("Next hour marker in {:02}:{:02}:{:02}", m / 3600, (m / 60) % 60, m % 60)) @@ -1818,6 +1819,12 @@ impl App { } }); ui.small(tr("Make everything larger or smaller if the text is hard to read.")); + ui.add_space(4.0); + ui.label(tr("On screens with other display scaling or resolutions")); + for m in crate::theme::ScaleMode::ALL { + ui.radio_value(&mut self.workzones.scale_mode, m, tr(m.label())); + } + ui.small(tr("\"Fit each screen\" keeps the layout you see on a 1920×1080 screen, so moving the window to a laptop or a 1440p or 4K screen shows the same amount of everything. \"Same size in pixels\" ignores the display scaling set in Windows.")); ui.separator(); ui.horizontal(|ui| { ui.label(tr("Work zone")); @@ -2409,7 +2416,7 @@ impl App { let mut del = None; let mut run_now = None; let opts = ActionOpts::of(core); - let now = Local::now(); + let now = core.now(); egui::ScrollArea::vertical().show(ui, |ui| { if core.cfg.events.is_empty() { ui.label(RichText::new(tr("No events yet.")).color(GRAY)); @@ -3394,6 +3401,7 @@ impl eframe::App for App { } fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + crate::theme::fit_zoom(ctx, self.workzones.scale_mode); // redraw ~10×/s while audio is moving (VU meters, elapsed time), ~3×/s when idle (the clock) // (once a second while minimized with no Additional windows: nothing is on screen, and playback // runs on its own threads) diff --git a/src/link.rs b/src/link.rs index 727426b..3c789af 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1,7 +1,7 @@ //! The desktop app's side of RadioMaster Server: signs in, keeps the mirror playout in step with the //! server (about four times a second) and sends it the actions taken in the app. -use crate::playout::sync::{self, digest, Cmd, Snapshot, TabsWire}; +use crate::playout::sync::{self, digest, Cmd, Snapshot, TabsWire, WaveReply}; use crate::playout::Playout; use crossbeam_channel::{Receiver, RecvTimeoutError}; use parking_lot::Mutex; @@ -263,11 +263,20 @@ impl Link { log_rev: 0, hist_rev: 0, lib_ver: 0, + miss_rev: 0, + clock: None, tabs_hash: None, cfg_hash: None, notify: String::new(), queued: vec![], }; + // the track editor's waveforms come from the server, which has the files + let (wtx, wrx) = crossbeam_channel::unbounded(); + s.core.lock().set_wave_source(wtx); + { + let (core, base, pin, token, stop) = (s.core.clone(), session.base.clone(), session.pin.clone(), token.clone(), stop.clone()); + thread::spawn(move || fetch_waves(core, base, pin, token, wrx, &stop)); + } let st = stop.clone(); thread::spawn(move || s.run(&st, repaint)); Link { stop, state, session, token, new_password } @@ -458,6 +467,9 @@ struct Syncer { log_rev: u64, hist_rev: u64, lib_ver: u64, + miss_rev: u64, + /// the server's clock minus this machine's (ms), from the quickest poll lately: (offset, round trip, when) + clock: Option<(i64, u32, Instant)>, /// fingerprints of the mirror's playlists / station settings as last agreed with the server /// (None until the first copy arrives, and after a clash: the server's copy then wins) tabs_hash: Option, @@ -605,8 +617,20 @@ impl Syncer { } } // 3. what the server has now - let snap: Snapshot = - self.get(&format!("state?rev={}&cfg={}&log={}&hist={}", self.rev, self.cfg_rev, self.log_rev, self.hist_rev))?; + let sent = chrono::Utc::now().timestamp_millis(); + let t0 = Instant::now(); + let snap: Snapshot = self.get(&format!( + "state?rev={}&cfg={}&log={}&hist={}&miss={}", + self.rev, self.cfg_rev, self.log_rev, self.hist_rev, self.miss_rev + ))?; + if snap.live.now_ms > 0 { + // the server read its clock about halfway through the round trip + let rtt = t0.elapsed().as_millis() as u32; + let offset = snap.live.now_ms - (sent + rtt as i64 / 2); + if self.clock.is_none_or(|(_, best, at)| rtt <= best || at.elapsed() > Duration::from_secs(30)) { + self.clock = Some((offset, rtt, Instant::now())); + } + } let library = if snap.lib_ver != self.lib_ver { Some(self.get::>("library")?) } else { None }; if stop.load(Ordering::Relaxed) { return Ok(()); @@ -616,6 +640,7 @@ impl Syncer { return Ok(()); // disconnected meanwhile: this is the local playout again } let notify = snap.live.notify.clone(); + c.clock_offset_ms = self.clock.map(|(o, _, _)| o).unwrap_or(0); c.apply_live(snap.live); if notify != self.notify { *c.notify.lock() = notify.clone(); @@ -648,6 +673,52 @@ impl Syncer { c.set_library(lib); self.lib_ver = snap.lib_ver; } + if let Some(m) = snap.missing { + c.set_missing(m); + self.miss_rev = snap.missing_rev; + } Ok(()) } } + +/// How long an app waits for the server to analyse one track. +const WAVE_WAIT: Duration = Duration::from_secs(180); + +/// Ask the server for the waveforms the track editor wants, one file at a time. +fn fetch_waves( + core: Arc>, + base: String, + pin: String, + token: Arc>, + asks: Receiver, + stop: &AtomicBool, +) { + let agent = agent(&pin); + while !stop.load(Ordering::Relaxed) { + let path = match asks.recv_timeout(Duration::from_millis(500)) { + Ok(p) => p, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => return, + }; + let since = Instant::now(); + let r = loop { + if stop.load(Ordering::Relaxed) { + return; + } + let tok = token.lock().clone(); + match call::(&agent, &base, &tok, "wave", Some(serde_json::json!({ "path": path }))) { + Ok(WaveReply { wave: Some(w), .. }) => break Ok(w), + Ok(_) if since.elapsed() < WAVE_WAIT => thread::sleep(Duration::from_millis(700)), + Ok(_) => break Err("the server took too long to analyse this track".to_string()), + // signed out for a moment (the server restarted): the syncer signs in again + Err(Fail::Auth(_)) if since.elapsed() < WAVE_WAIT => thread::sleep(Duration::from_secs(1)), + Err(Fail::Other(e)) if e == "unknown call" => break Err("update the server to see waveforms here".to_string()), + Err(e) => break Err(e.text()), + } + }; + let mut c = core.lock(); + if c.link.is_some() { + c.put_remote_wave(path, r); + } + } +} diff --git a/src/panes.rs b/src/panes.rs index 01cd2b7..b09b067 100644 --- a/src/panes.rs +++ b/src/panes.rs @@ -694,7 +694,7 @@ impl App { let is_cur = on_air && playing && cur == Some(i); row.set_selected(sel.contains(&i)); let st = starts.get(i).copied().flatten(); - let col = row_text_color(e, is_cur, st.is_some_and(|s| s.skipped), dark); + let col = row_text_color(e, is_cur, st.is_some_and(|s| s.skipped), dark, core.row_missing(e)); row.col(|ui| { row_background(ui, e, is_cur); ui.label(RichText::new(if is_cur { "▶".to_string() } else { (i + 1).to_string() }).color(col)); @@ -754,7 +754,8 @@ pub(super) fn ui_dark(w: &WorkzoneSet) -> bool { } /// Text colour of a playlist row. -pub(super) fn row_text_color(e: &PlEntry, is_cur: bool, skipped: bool, dark: bool) -> Color32 { +/// `missing`: the file isn't there (on the server, when connected to one). +pub(super) fn row_text_color(e: &PlEntry, is_cur: bool, skipped: bool, dark: bool, missing: bool) -> Color32 { if matches!(e.kind, ItemKind::HourInfo { .. }) { return if dark { Color32::from_rgb(255, 150, 200) } else { Color32::from_rgb(170, 30, 110) }; } @@ -765,7 +766,7 @@ pub(super) fn row_text_color(e: &PlEntry, is_cur: bool, skipped: bool, dark: boo if skipped || e.played { return Color32::from_gray(140); } - if e.is_file() && crate::library::file_missing(&e.track.path) { + if missing { return Color32::from_rgb(200, 50, 50); } match e.kind { diff --git a/src/playout.rs b/src/playout.rs index a632ccf..391fa54 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -353,6 +353,16 @@ pub struct Playout { pub link: Option>, /// jingles playing on the server mirror_carts: Vec, + /// playlist files the server can't find (a mirror never checks this machine's disk) + mirror_missing: std::collections::HashSet, + /// where a mirror asks the server for waveforms (instead of decoding files it doesn't have) + remote_waves: Option>, + /// the on-air position the server last reported and when it applies here (counted on between polls) + mirror_pos: Option<(f32, Instant)>, + /// the server's clock minus this machine's, in ms (0 when not mirroring) + pub clock_offset_ms: i64, + /// files that couldn't be analysed, and why + wave_errors: HashMap, /// lines ever written to the log (how the server's clients follow it) pub log_total: u64, } @@ -477,6 +487,11 @@ impl Playout { plugins, link: None, mirror_carts: vec![], + mirror_missing: Default::default(), + remote_waves: None, + mirror_pos: None, + clock_offset_ms: 0, + wave_errors: HashMap::new(), log_total: 0, } } @@ -507,6 +522,7 @@ impl Playout { if self.link.is_some() { // mirroring a server: the server plays and schedules, this copy only shows it self.drain_log(); + self.follow_mirror_pos(); return; } self.drain_scan(); @@ -1234,7 +1250,7 @@ impl Playout { if n == 0 { return out; } - let now = Local::now(); + let now = self.now(); let ms = |s: f32| CDur::milliseconds((s.max(0.0) * 1000.0) as i64); let st = &self.status; let live = on_air && matches!(self.state, PlayState::Playing | PlayState::Paused); @@ -1408,7 +1424,7 @@ impl Playout { (from..self.playlist.len()) .find(|&i| matches!(self.playlist[i].kind, ItemKind::HourMarker { .. })) .and_then(|i| starts[i]) - .map(|s| (s.at - Local::now()).num_seconds().max(0)) + .map(|s| (s.at - self.now()).num_seconds().max(0)) } /// Fill in a speech row's text: the time placeholders plus {next}, {next_artist}, {next_title}, @@ -1475,7 +1491,7 @@ impl Playout { /// Text for the On Air box. pub fn on_air_text(&self) -> String { if let Some(u) = self.wait_until { - let left = (u - Local::now()).num_seconds().max(0); + let left = (u - self.now()).num_seconds().max(0); return format!("{} ({:02}:{:02}:{:02})", self.wait_label, left / 3600, (left / 60) % 60, left % 60); } if self.state == PlayState::Stopped || !self.status.has_track { @@ -1640,7 +1656,48 @@ impl Playout { return; } self.pending.insert(path.to_path_buf()); - let _ = self.analyze_tx.send(path.to_path_buf()); + self.wave_errors.remove(path); + match &self.remote_waves { + Some(tx) => { + let _ = tx.send(path.to_path_buf()); + } + None => { + let _ = self.analyze_tx.send(path.to_path_buf()); + } + } + } + + /// Why a file couldn't be analysed (None while it is being analysed, or once it has been). + pub fn wave_error(&self, path: &Path) -> Option<&str> { + self.wave_errors.get(path).map(String::as_str) + } + + /// Keep a waveform (and the cues worked out for it), dropping the oldest beyond 48. + fn keep_wave(&mut self, path: PathBuf, w: Waveform) { + self.waves.insert(path.clone(), Arc::new(w)); + self.wave_order.retain(|p| p != &path); + self.wave_order.push(path); + while self.wave_order.len() > 48 { + let old = self.wave_order.remove(0); + self.waves.remove(&old); + } + } + + /// The time here, or on the server when this is a mirror (so every clock shows the station's time). + pub fn now(&self) -> DateTime { + Local::now() + CDur::milliseconds(self.clock_offset_ms) + } + + /// Whether a playlist row's file is missing: on this machine, or on the server for a mirror. + pub fn row_missing(&self, e: &PlEntry) -> bool { + if !e.is_file() { + return false; + } + if self.link.is_some() { + self.mirror_missing.contains(&e.track.path) + } else { + library::file_missing(&e.track.path) + } } fn on_air_path(&self) -> Option { @@ -1650,19 +1707,25 @@ impl Playout { fn drain_analysis(&mut self) { while let Ok((path, wave)) = self.analyze_rx.try_recv() { self.pending.remove(&path); - let Some(w) = wave else { continue }; + match wave { + Some(w) => self.adopt_wave(path, w), + None => { + self.wave_errors.insert(path, "this file can't be read or decoded".into()); + } + } + } + } + + /// Take a finished analysis: smart cues for the file (unless set by hand), and the track on air + /// picks them up if it started before they existed. + pub fn adopt_wave(&mut self, path: PathBuf, w: Waveform) { + { if !self.cues.get(&path).is_some_and(|c| c.manual) { let c = smart_cues(&w, &self.cfg.fade); self.cues.insert(path.clone(), c); self.cues_dirty = true; } - self.waves.insert(path.clone(), Arc::new(w)); - self.wave_order.retain(|p| p != &path); - self.wave_order.push(path.clone()); - while self.wave_order.len() > 48 { - let old = self.wave_order.remove(0); - self.waves.remove(&old); - } + self.keep_wave(path.clone(), w); // the track on air was analysed after it started: hand its cues to the engine now if self.on_air_path().as_deref() == Some(path.as_path()) && self.active_cues.is_none() && !self.triggered_next { if let Some(c) = self.cues_for(&path) { @@ -1684,12 +1747,13 @@ impl Playout { /// Store hand-edited cues (from the track editor). pub fn set_manual_cues(&mut self, path: &Path, mut c: Cues, duration: f32) { - if self.forward(|_| sync::Cmd::SetCues { path: path.to_path_buf(), cues: c, duration }) { - return; - } + let mirror = self.forward(|_| sync::Cmd::SetCues { path: path.to_path_buf(), cues: c, duration }); c.manual = true; c.clamp(duration); self.cues.insert(path.to_path_buf(), c); + if mirror { + return; // (kept here too, so the editor shows them; the server saves them) + } self.cues_dirty = true; self.refresh_active_cues(path); } @@ -1701,14 +1765,15 @@ impl Playout { /// Forget hand-edited cues; smart analysis takes over again. pub fn clear_manual_cues(&mut self, path: &Path) { - if self.forward(|_| sync::Cmd::ClearCues { path: path.to_path_buf() }) { - return; - } + let mirror = self.forward(|_| sync::Cmd::ClearCues { path: path.to_path_buf() }); if let Some(w) = self.waves.get(path).cloned() { self.cues.insert(path.to_path_buf(), smart_cues(&w, &self.cfg.fade)); } else { self.cues.remove(path); } + if mirror { + return; + } self.cues_dirty = true; self.refresh_active_cues(path); } diff --git a/src/plview.rs b/src/plview.rs index 63787d0..6c5820c 100644 --- a/src/plview.rs +++ b/src/plview.rs @@ -445,7 +445,7 @@ impl App { let is_cur = cur == Some(i) && playing && viewing_on_air; let st = starts.get(i).copied().flatten(); let skipped = st.is_some_and(|s| s.skipped); - let col = row_text_color(e, is_cur, skipped, dark); + let col = row_text_color(e, is_cur, skipped, dark, core.row_missing(e)); row.set_selected(sel.contains(&i)); let txt = |s: String| { let mut r = RichText::new(s).color(col); diff --git a/src/server.rs b/src/server.rs index dd77a67..2545a18 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,7 +3,8 @@ //! GET /api/v1/ping who is this (no sign-in needed) //! POST /api/v1/login {user, password} → {token, name, version, protocol} //! POST /api/v1/logout -//! GET /api/v1/state?rev=&cfg=&log=&hist= → sync::Snapshot (lists only when they changed) +//! GET /api/v1/state?rev=&cfg=&log=&hist=&miss= → sync::Snapshot (lists only when they changed) +//! POST /api/v1/wave {path} → sync::WaveReply (202 while the server analyses it) //! GET /api/v1/library → [Track] //! POST /api/v1/cmd [sync::Cmd] //! POST /api/v1/tabs {base, tabs} → {rev}, or 409 when the server's playlists changed first @@ -28,6 +29,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, io::{Read, Write}, + path::PathBuf, sync::Arc, thread, time::{Duration, Instant}, @@ -101,6 +103,10 @@ struct Revs { cfg_hash: u64, cfg: Arc, hist_rev: u64, + /// playlist files the server can't find, looked at again every couple of seconds + missing_rev: u64, + missing: Arc>, + ticks: u32, } impl Revs { @@ -112,11 +118,21 @@ impl Revs { (c.tabs_wire(), sync::station_cfg(&c.cfg), h) }; let th = digest(&tabs); - if th != self.tabs_hash { + let changed = th != self.tabs_hash; + if changed { self.tabs_hash = th; self.tabs = Arc::new(tabs); self.rev += 1; } + self.ticks = self.ticks.wrapping_add(1); + if changed || self.ticks.is_multiple_of(8) { + let m = sync::missing_in(&self.tabs); + let mh = digest(&m); + if mh != self.missing_rev { + self.missing_rev = mh; + self.missing = Arc::new(m); + } + } let ch = digest(&cfg); if ch != self.cfg_hash { self.cfg_hash = ch; @@ -154,8 +170,19 @@ struct Api { hsts: bool, /// token → the mic audio of that app, while its mic is on mics: Mutex>>>, + /// waveforms being worked out for apps' track editors + waves: Arc>>, +} + +enum WaveJob { + Busy, + Done(crate::fade::Waveform), + Failed(String), } +/// Files analysed at the same time for apps. +const WAVE_JOBS: usize = 3; + /// Where the server is listening. pub struct Listening { pub addr: std::net::SocketAddr, @@ -209,8 +236,18 @@ pub fn spawn(core: Arc>, cfg: ServerCfg, host: Host) -> Result
  • >, cfg: ServerCfg, host: Host) -> Result
  • { - let (rev, tabs, cfg_rev, cfg, hist_rev) = { + let (rev, tabs, cfg_rev, cfg, hist_rev, missing_rev, missing) = { let r = self.revs.lock(); - (r.rev, r.tabs.clone(), r.cfg_rev, r.cfg.clone(), r.hist_rev) + (r.rev, r.tabs.clone(), r.cfg_rev, r.cfg.clone(), r.hist_rev, r.missing_rev, r.missing.clone()) }; let c = self.core.lock(); let snap = Snapshot { @@ -415,6 +453,8 @@ impl Api { hist_rev, history: (param("hist") != hist_rev).then(|| c.history_wire()), lib_ver: c.library_ver, + missing_rev, + missing: (param("miss") != missing_rev).then(|| (*missing).clone()), }; drop(c); json(200, snap) @@ -463,6 +503,7 @@ impl Api { r.refresh(&self.core); json(200, serde_json::json!({ "ok": true, "rev": r.cfg_rev })) } + ("/api/v1/wave", true) => self.wave(body), ("/api/v1/mic", true) => self.mic(token, body.to_vec()), ("/api/v1/mic/off", true) => { // the mic's thread ends once its audio runs out @@ -478,6 +519,54 @@ impl Api { } } + /// A track's waveform for an app's track editor. The file is analysed here (the app doesn't have + /// it), on a thread of its own: until that is done the answer is "pending" and the app asks again. + fn wave(&self, body: &[u8]) -> Reply { + #[derive(Deserialize)] + struct Ask { + path: PathBuf, + } + let Ok(a) = serde_json::from_slice::(body) else { return error(400, "bad request") }; + let done = { + let c = self.core.lock(); + if !c.knows_file(&a.path) { + return error(404, "that file isn't in the server's library or playlists"); + } + c.wave_wire(&a.path) + }; + if let Some(w) = done { + return json(200, sync::WaveReply { wave: Some(w), pending: false }); + } + let mut jobs = self.waves.lock(); + match jobs.remove(&a.path) { + Some(WaveJob::Done(w)) => { + let mut c = self.core.lock(); + c.adopt_wave(a.path.clone(), w); + json(200, sync::WaveReply { wave: c.wave_wire(&a.path), pending: false }) + } + Some(WaveJob::Failed(e)) => error(422, &e), + Some(WaveJob::Busy) => { + jobs.insert(a.path, WaveJob::Busy); + json(202, sync::WaveReply { wave: None, pending: true }) + } + None => { + // a few at a time; the others are started when the apps ask again + if jobs.len() < WAVE_JOBS { + jobs.insert(a.path.clone(), WaveJob::Busy); + let done = self.waves.clone(); + thread::spawn(move || { + let r = match crate::fade::Waveform::analyze(&a.path) { + Ok(w) => WaveJob::Done(w), + Err(e) => WaveJob::Failed(format!("the server can't read this file: {e}")), + }; + done.lock().insert(a.path, r); + }); + } + json(202, sync::WaveReply { wave: None, pending: true }) + } + } + } + fn user_of(&self, token: &str) -> String { self.tokens.lock().get(token).map(|(u, _)| u.clone()).unwrap_or_default() } @@ -725,6 +814,14 @@ mod tests { } } + /// A station with just these rows. (The data folder is shared by the tests running at the same + /// time, so the playlists another test saved there are replaced, not added to.) + fn station_with(rows: Vec) -> Arc> { + let mut p = Playout::new(Engine::new_detached(), crate::config::Config::default()); + p.set_tabs(TabsWire { tabs: vec![sync::TabWire { name: "Main".into(), entries: rows, current: None }], playing: 0 }); + Arc::new(Mutex::new(p)) + } + fn row(name: &str) -> PlEntry { PlEntry::file(Track { path: format!("/srv/music/{name}.mp3").into(), title: name.into(), ..Default::default() }) } @@ -833,8 +930,7 @@ mod tests { #[test] fn app_and_server_stay_in_step() { crate::config::set_data_dir(std::env::temp_dir().join(format!("rm-server-test-{}", std::process::id()))); - let station = Arc::new(Mutex::new(Playout::new(Engine::new_detached(), crate::config::Config::default()))); - station.lock().append_to_tab(0, vec![row("one"), row("two")]); + let station = station_with(vec![row("one"), row("two")]); let cfg = ServerCfg { user: "studio".into(), password_hash: crate::auth::hash("correct horse battery").unwrap(), @@ -890,6 +986,89 @@ mod tests { link.stop(); } + /// A short 16-bit stereo WAV file with a tone in it. + fn write_wav(path: &std::path::Path, secs: f32) { + let rate = 22_050u32; + let n = (rate as f32 * secs) as u32; + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + n * 4).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 4).to_le_bytes()); + b.extend_from_slice(&4u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(n * 4).to_le_bytes()); + for k in 0..n { + let v = ((k as f32 * 440.0 * std::f32::consts::TAU / rate as f32).sin() * 12000.0) as i16; + b.extend_from_slice(&v.to_le_bytes()); + b.extend_from_slice(&v.to_le_bytes()); + } + std::fs::write(path, b).unwrap(); + } + + /// Connected to a server, the app colours rows by what the server has, gets the track editor's + /// waveforms from it, and keeps its clock. + #[test] + fn app_sees_the_servers_files_and_clock() { + // (the data folder is shared by every test in this process: the files go in a folder of their own) + crate::config::set_data_dir(std::env::temp_dir().join(format!("rm-server-test-{}", std::process::id()))); + let dir = std::env::temp_dir().join(format!("rm-server-files-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let tone = dir.join("tone.wav"); + write_wav(&tone, 3.0); + let gone = dir.join("gone.mp3"); + let file = |p: &std::path::Path| PlEntry::file(Track { path: p.into(), title: "t".into(), ..Default::default() }); + let station = station_with(vec![file(&tone), file(&gone)]); + let cfg = ServerCfg { + user: "studio".into(), + password_hash: crate::auth::hash("correct horse battery").unwrap(), + bind: "127.0.0.1".into(), + port: 0, + ..Default::default() + }; + let host = Host { reapply_audio: Box::new(|| {}), audio_report: Default::default(), forced_silent: true }; + let addr = spawn(station.clone(), cfg, host).unwrap().addr; + let session = crate::link::login(&format!("127.0.0.1:{}", addr.port()), "studio", "correct horse battery", "").unwrap(); + let local = Playout::new(Engine::new_detached(), crate::config::Config::default()); + let (tx, rx) = crossbeam_channel::unbounded(); + let mirror = Arc::new(Mutex::new(Playout::mirror_of(&local, tx))); + let link = crate::link::Link::start(mirror.clone(), session, rx, Box::new(|| {})); + wait_for("the first copy", || mirror.lock().playlist.len() == 2); + + // missing is what the server says, whatever this machine has + wait_for("the server's missing files", || { + let m = mirror.lock(); + let (a, b) = (m.playlist[0].clone(), m.playlist[1].clone()); + !m.row_missing(&a) && m.row_missing(&b) + }); + + // the waveform is worked out on the server + mirror.lock().request_analysis(&tone); + wait_for("the waveform from the server", || mirror.lock().waves.contains_key(&tone)); + let w = mirror.lock().waves.get(&tone).cloned().unwrap(); + assert!((w.duration() - 3.0).abs() < 0.1, "{}", w.duration()); + assert!(mirror.lock().cues.contains_key(&tone)); + // a file the server can't read, and one it won't send, say why instead of spinning forever + mirror.lock().request_analysis(&gone); + wait_for("the error for a missing file", || mirror.lock().wave_error(&gone).is_some()); + let elsewhere = dir.join("not-in-a-playlist.wav"); + write_wav(&elsewhere, 1.0); + mirror.lock().request_analysis(&elsewhere); + wait_for("the refusal", || mirror.lock().wave_error(&elsewhere).is_some()); + assert!(!mirror.lock().waves.contains_key(&elsewhere)); + + // same machine: the clocks agree to well within a second + assert!(mirror.lock().clock_offset_ms.abs() < 500, "{}", mirror.lock().clock_offset_ms); + link.stop(); + let _ = std::fs::remove_dir_all(&dir); + } + /// HTTPS with the server's own certificate: the app has to trust it once, finds the https:// /// address by itself, and refuses a different certificate later. The web portal is served too. #[test] @@ -898,8 +1077,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); crate::config::set_data_dir(dir.clone()); - let station = Arc::new(Mutex::new(Playout::new(Engine::new_detached(), crate::config::Config::default()))); - station.lock().append_to_tab(0, vec![row("one")]); + let station = station_with(vec![row("one")]); let cfg = ServerCfg { user: "studio".into(), password_hash: crate::auth::hash("correct horse battery").unwrap(), diff --git a/src/sync.rs b/src/sync.rs index 0825503..9035348 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -112,6 +112,8 @@ pub struct Live { pub scanning: bool, pub scan_progress: usize, pub notify: String, + /// the server's clock when this was sent (unix ms; 0 from older servers) + pub now_ms: i64, } /// One poll's answer. The lists come only when they changed since the revision the app has. @@ -129,6 +131,39 @@ pub struct Snapshot { pub history: Option>, /// the library is fetched on its own when this changes pub lib_ver: u64, + /// playlist files the server can't find (sent when the list changed) + pub missing_rev: u64, + pub missing: Option>, +} + +/// A waveform for the track editor of an app (levels 0..1 scaled to 0..65535), with the cues the +/// server uses for that file. +#[derive(Serialize, Deserialize, Clone, Default)] +#[serde(default)] +pub struct WaveWire { + pub peaks: Vec, + pub rms: Vec, + pub cues: Option, +} + +impl WaveWire { + pub fn of(w: &Waveform, cues: Option) -> WaveWire { + let q = |v: &[f32]| v.iter().map(|x| (x.clamp(0.0, 1.0) * 65535.0).round() as u16).collect(); + WaveWire { peaks: q(&w.peaks), rms: q(&w.rms), cues } + } + + pub fn waveform(&self) -> Waveform { + let f = |v: &[u16]| v.iter().map(|&x| x as f32 / 65535.0).collect(); + Waveform { peaks: f(&self.peaks), rms: f(&self.rms) } + } +} + +/// The answer to an app asking for a waveform: it, or "still analysing, ask again". +#[derive(Serialize, Deserialize, Default)] +#[serde(default)] +pub struct WaveReply { + pub wave: Option, + pub pending: bool, } /// The server's own settings, as the app's Server settings window shows them. @@ -177,6 +212,20 @@ pub struct ServerUpdate { pub portal_port: u16, } +/// Playlist files that are missing (on the server, which is the machine that plays them). +pub fn missing_in(t: &TabsWire) -> Vec { + let mut v: Vec = t + .tabs + .iter() + .flat_map(|t| t.entries.iter()) + .filter(|e| e.is_file() && library::file_missing(&e.track.path)) + .map(|e| e.track.path.clone()) + .collect(); + v.sort(); + v.dedup(); + v +} + /// How many log lines and history items a client is sent. pub const TAIL: usize = 300; @@ -366,6 +415,7 @@ impl Playout { scanning: self.scanning, scan_progress: self.scan_progress, notify: self.notify.lock().clone(), + now_ms: chrono::Utc::now().timestamp_millis(), } } @@ -375,7 +425,19 @@ impl Playout { "paused" => PlayState::Paused, _ => PlayState::Stopped, }; - self.status = Status { has_track: l.has_track, pos: l.pos, total: l.total, peak_l: l.peak_l, peak_r: l.peak_r }; + // the position is counted on here between polls, so it ticks with the clocks instead of in + // uneven steps; it only jumps when it is more than a moment out (a seek or a new track) + let playing = self.state == PlayState::Playing && l.has_track; + let age = if l.now_ms > 0 { ((self.now().timestamp_millis() - l.now_ms).clamp(0, 2000)) as f32 / 1000.0 } else { 0.0 }; + let est = if playing { (l.pos + age).min(l.total.max(l.pos)) } else { l.pos }; + let same = self.on_air.as_ref().map(|t| &t.path) == l.on_air.as_ref().map(|t| &t.path); + self.mirror_pos = match self.mirror_pos { + Some((p, t)) if playing && same && (p + t.elapsed().as_secs_f32() - est).abs() < 0.3 => Some((p, t)), + _ if playing => Some((est, Instant::now())), + _ => None, + }; + self.status = Status { has_track: l.has_track, pos: est, total: l.total, peak_l: l.peak_l, peak_r: l.peak_r }; + self.follow_mirror_pos(); self.on_air = l.on_air; self.on_air_label = l.on_air_label; self.wait_until = l.wait_until.and_then(|t| Local.timestamp_opt(t, 0).single()); @@ -400,6 +462,56 @@ impl Playout { self.scan_progress = l.scan_progress; } + /// Move a mirror's on-air position on to now. + pub(super) fn follow_mirror_pos(&mut self) { + if let Some((p, t)) = self.mirror_pos { + if self.state == PlayState::Playing { + self.status.pos = (p + t.elapsed().as_secs_f32()).min(self.status.total.max(p)); + } + } + } + + // ------------------------------------------------------------ files on the server + pub fn set_missing(&mut self, v: Vec) { + self.mirror_missing = v.into_iter().collect(); + } + + /// A file in the library or a playlist (apps may only ask about those). + pub fn knows_file(&self, p: &Path) -> bool { + self.lib_index.contains_key(p) + || self.playlist.iter().chain(self.tabs.iter().flat_map(|t| t.entries.iter())).any(|e| e.track.path == p) + } + + /// The waveform and cues of a file, if it has been analysed. + pub fn wave_wire(&self, p: &Path) -> Option { + self.waves.get(p).map(|w| WaveWire::of(w, self.cues.get(p).copied())) + } + + /// From now on this mirror asks the server for waveforms (see `link`). + pub fn set_wave_source(&mut self, tx: crossbeam_channel::Sender) { + self.remote_waves = Some(tx); + self.pending.clear(); + } + + /// A waveform the server sent, or why it couldn't. + pub fn put_remote_wave(&mut self, path: PathBuf, r: Result) { + self.pending.remove(&path); + match r { + Ok(w) => { + // cues set by hand here meanwhile win; otherwise the server's (or worked out here) + let wave = w.waveform(); + if !self.cues.get(&path).is_some_and(|c| c.manual) { + let c = w.cues.unwrap_or_else(|| smart_cues(&wave, &self.cfg.fade)); + self.cues.insert(path.clone(), c); + } + self.keep_wave(path, wave); + } + Err(e) => { + self.wave_errors.insert(path, e); + } + } + } + pub fn log_wire(&self) -> Vec { let from = self.log.len().saturating_sub(TAIL); self.log[from..].iter().map(|l| LogWire { time: l.time.clone(), text: l.text.clone(), error: l.error }).collect() @@ -514,6 +626,69 @@ mod tests { assert_eq!(m.playing_tab, 0); } + #[test] + fn waveforms_survive_the_trip() { + let w = Waveform { peaks: vec![0.0, 0.5, 1.0, 2.0], rms: vec![0.0, 0.25, 0.7, 0.001] }; + let back = serde_json::from_str::(&serde_json::to_string(&WaveWire::of(&w, None)).unwrap()).unwrap().waveform(); + assert_eq!(back.peaks.len(), 4); + for (a, b) in w.rms.iter().zip(&back.rms) { + assert!((a - b).abs() < 1e-4); + } + assert_eq!(back.peaks[3], 1.0); // (levels are 0..1) + } + + #[test] + fn mirror_counts_the_position_on_between_polls() { + let local = Playout::new(Engine::new_detached(), Config::default()); + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut m = Playout::mirror_of(&local, tx); + let track = Track { path: PathBuf::from("/music/a.mp3"), ..Default::default() }; + let live = |pos: f32| Live { + state: "playing".into(), + has_track: true, + pos, + total: 200.0, + on_air: Some(track.clone()), + now_ms: chrono::Utc::now().timestamp_millis(), + ..Default::default() + }; + m.apply_live(live(10.0)); + std::thread::sleep(std::time::Duration::from_millis(300)); + m.tick(); + assert!(m.status.pos > 10.2 && m.status.pos < 10.6, "{}", m.status.pos); + // a poll that is a little behind doesn't pull it back (no stutter)… + let before = m.status.pos; + m.apply_live(live(10.2)); + assert!(m.status.pos >= before); + // …but a seek does + m.apply_live(live(90.0)); + assert!((m.status.pos - 90.0).abs() < 0.1); + // paused: shown as the server has it + let mut p = live(42.0); + p.state = "paused".into(); + m.apply_live(p); + m.tick(); + assert_eq!(m.status.pos, 42.0); + } + + #[test] + fn mirror_keeps_hand_set_cues_it_sent() { + let local = Playout::new(Engine::new_detached(), Config::default()); + let (tx, rx) = crossbeam_channel::unbounded(); + let mut m = Playout::mirror_of(&local, tx); + let p = PathBuf::from("/music/a.mp3"); + m.set_manual_cues(&p, Cues::plain(100.0), 100.0); + assert!(matches!(rx.try_recv(), Ok(Cmd::SetCues { .. }))); + assert!(m.manual_cues_for(&p)); + // a waveform arriving later doesn't overwrite them + let w = Waveform { peaks: vec![0.5; 100], rms: vec![0.3; 100] }; + m.put_remote_wave(p.clone(), Ok(WaveWire::of(&w, None))); + assert!(m.manual_cues_for(&p)); + assert!(m.waves.contains_key(&p)); + m.put_remote_wave(PathBuf::from("/music/b.mp3"), Err("nope".into())); + assert_eq!(m.wave_error(Path::new("/music/b.mp3")), Some("nope")); + } + #[test] fn local_settings_stay_put() { let mut here = Config::default(); diff --git a/src/theme.rs b/src/theme.rs index 8c14377..05ee142 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,5 +1,68 @@ //! Readable light/dark themes, comfortable spacing and UI scale. use eframe::egui::{self, Color32, FontFamily, FontId, Stroke, TextStyle}; +use serde::{Deserialize, Serialize}; + +/// How big the interface is drawn on each screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScaleMode { + /// follow the display scaling the operating system sets for each screen + #[default] + Follow, + /// the same size in pixels on every screen (display scaling is ignored) + Fixed, + /// fit each screen: the layout looks the way it does on a 1920×1080 screen at 100% + Fit, +} + +impl ScaleMode { + pub const ALL: [ScaleMode; 3] = [ScaleMode::Follow, ScaleMode::Fixed, ScaleMode::Fit]; + + pub fn label(self) -> &'static str { + match self { + ScaleMode::Follow => "Follow each screen's display scaling", + ScaleMode::Fixed => "Same size in pixels on every screen", + ScaleMode::Fit => "Fit each screen like 1920×1080", + } + } +} + +/// The screen height the Fit mode keeps the layout of. +const FIT_HEIGHT: f32 = 1080.0; + +fn scale_id() -> egui::Id { + egui::Id::new("radiomaster_ui_scale") +} + +/// Set the zoom for the screen the main window is on now. Called every frame: it only changes +/// anything when the window moved to a screen with other scaling or the settings changed. +pub fn fit_zoom(ctx: &egui::Context, mode: ScaleMode) { + let mut scale = ctx.data(|d| d.get_temp::(scale_id())).unwrap_or(1.0); + // Ctrl + / Ctrl − zoomed since the last frame: keep that (for this session) instead of undoing it + let last = ctx.data(|d| d.get_temp::(last_zoom_id())); + if let Some(last) = last.filter(|l| (ctx.zoom_factor() - l).abs() > 0.005 && *l > 0.0) { + scale = (scale * ctx.zoom_factor() / last).clamp(0.5, 3.0); + ctx.data_mut(|d| d.insert_temp(scale_id(), scale)); + } + let (native, monitor) = ctx.input(|i| (i.viewport().native_pixels_per_point.unwrap_or(1.0), i.viewport().monitor_size)); + let native = if native.is_finite() && native > 0.0 { native } else { 1.0 }; + let ppp = match (mode, monitor) { + (ScaleMode::Follow, _) => native * scale, + (ScaleMode::Fixed, _) => scale, + // (the monitor size is given in points at the current zoom) + (ScaleMode::Fit, Some(m)) if m.y > 100.0 => (m.y * ctx.pixels_per_point()).round() / FIT_HEIGHT * scale, + (ScaleMode::Fit, _) => native * scale, + }; + let zoom = (ppp / native).clamp(0.4, 3.0); + if (ctx.zoom_factor() - zoom).abs() > 0.005 { + ctx.set_zoom_factor(zoom); + } + ctx.data_mut(|d| d.insert_temp(last_zoom_id(), zoom)); +} + +fn last_zoom_id() -> egui::Id { + egui::Id::new("radiomaster_ui_zoom") +} pub fn apply(ctx: &egui::Context, dark: bool, scale: f32) { let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() }; @@ -60,7 +123,8 @@ pub fn apply(ctx: &egui::Context, dark: bool, scale: f32) { s.spacing.window_margin = egui::Margin::same(12); s.spacing.menu_margin = egui::Margin::same(8); ctx.set_style(s); - ctx.set_zoom_factor(scale.clamp(0.8, 1.7)); + // the zoom itself is set by `fit_zoom`, which also knows the screen + ctx.data_mut(|d| d.insert_temp(scale_id(), scale.clamp(0.8, 1.7))); } /// Fonts from the computer that cover what egui's built-in fonts don't (symbols, Chinese, diff --git a/src/tools.rs b/src/tools.rs index d60f710..0892b89 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -685,8 +685,19 @@ impl App { }; ui.label(RichText::new(badge).small().color(col)); if wave.is_none() { - ui.spinner(); - ui.label(RichText::new(tr("analysing…")).small().color(GRAY)); + match core.wave_error(&fe.path) { + Some(e) => { + ui.label( + RichText::new(format!("{}: {e}", tr("Can't show the waveform"))) + .small() + .color(Color32::from_rgb(200, 60, 60)), + ); + } + None => { + ui.spinner(); + ui.label(RichText::new(tr("analysing…")).small().color(GRAY)); + } + } } // zoom buttons follow the text and wrap onto a new line when narrow (never overlap it) ui.separator(); @@ -781,10 +792,11 @@ impl App { let nl = 10f32.powf(nc.gain_db / 20.0); draw_lane(lane_b, w, &|t| t - off, &|t| nc.gain_at(t) * nl, Color32::from_rgb(120, 220, 140)); } else if has_next { + let failed = next.as_ref().is_some_and(|(np, _, _)| core.wave_error(np).is_some()); p.text( lane_b.center(), Align2::CENTER_CENTER, - "analysing next track…", + if failed { tr("can't analyse the next track") } else { tr("analysing next track…") }, FontId::proportional(12.0), Color32::from_gray(120), ); diff --git a/src/workzones.rs b/src/workzones.rs index 7c67bc9..50dede8 100644 --- a/src/workzones.rs +++ b/src/workzones.rs @@ -255,6 +255,8 @@ pub struct WorkzoneSet { /// the playlist scrolls along with the on-air track pub follow: bool, pub scale: f32, + /// how `scale` is applied on screens with different display scaling + pub scale_mode: crate::theme::ScaleMode, /// Four user-defined web panels (name + address). Nothing is built in. pub web: Vec, // ---- legacy fields, read once to migrate older layouts @@ -285,6 +287,7 @@ impl Default for WorkzoneSet { dark: false, follow: true, scale: 1.0, + scale_mode: Default::default(), web: default_web(), zones: vec![], active: 0,