From f89c1fa41de0c9552b07473cea8fb43fff7faf12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:42:25 +0000 Subject: [PATCH 1/2] Fix app bugs when connected to a server: red rows, track editor, clocks; screen scaling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Playlist rows are red only when the file is missing on the server (the server sends its list of missing playlist files with the state poll). - The track editor gets waveforms and cues from the server (POST /api/v1/wave), and shows why when a file can't be analysed instead of spinning on "analysing…" for ever (locally too). - The mirror follows the server's clock and counts the on-air position on between polls, so the clock, elapsed/remaining and start times tick together. - Appearance: choose how the interface size applies on other screens (follow display scaling, same pixels everywhere, or fit each screen like 1920x1080). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wktb5zD7vpunQtvFHx6adf --- lang/template.json | 8 ++ src/app.rs | 18 +++-- src/link.rs | 77 ++++++++++++++++++- src/panes.rs | 7 +- src/playout.rs | 101 ++++++++++++++++++++----- src/plview.rs | 2 +- src/server.rs | 184 +++++++++++++++++++++++++++++++++++++++++++-- src/sync.rs | 177 ++++++++++++++++++++++++++++++++++++++++++- src/theme.rs | 66 +++++++++++++++- src/tools.rs | 18 ++++- src/workzones.rs | 3 + 11 files changed, 620 insertions(+), 41 deletions(-) diff --git a/lang/template.json b/lang/template.json index 3d71f03..defc16c 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)": "", @@ -97,6 +98,7 @@ "Browse": "", "COMING UP NEXT": "", "Can't be read (damaged or not audio)": "", + "Can't show the waveform": "", "Cancel": "", "Cart wall": "", "Categories": "", @@ -214,10 +216,12 @@ "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": "", "Flat": "", "Folder…": "", "Follow": "", + "Follow each screen's display scaling": "", "Forget the hand-set cues for this track": "", "Format": "", "Forward": "", @@ -376,6 +380,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": "", @@ -495,6 +500,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": "", @@ -692,12 +698,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": "", "cut tail after": "", "delete for good": "", "e.g. station id, news": "", diff --git a/src/app.rs b/src/app.rs index f10454c..96f3359 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1262,7 +1262,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 })); @@ -1279,7 +1279,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); @@ -1621,8 +1621,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)) @@ -1794,6 +1795,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")); @@ -2385,7 +2392,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)); @@ -3433,6 +3440,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 d80722c..07fc59d 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 fd9c96f..e5ee693 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 % 8 == 0 { + 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() } @@ -890,6 +979,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() { + 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(); + crate::config::set_data_dir(dir.clone()); + let tone = dir.join("tone.wav"); + write_wav(&tone, 3.0); + let gone = dir.join("gone.mp3"); + let station = Arc::new(Mutex::new(Playout::new(Engine::new_detached(), crate::config::Config::default()))); + let file = |p: &std::path::Path| PlEntry::file(Track { path: p.into(), title: "t".into(), ..Default::default() }); + station.lock().append_to_tab(0, 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] 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 1953e6e..ed458f1 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, From 5a4d9489d2486f6ef04d7e623085967b9daa9ddb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 17:46:56 +0000 Subject: [PATCH 2/2] Server test: keep the shared data folder, put the test's files in their own folder The data folder is set once per test process. The new test could become it and then delete it while the HTTPS test was still using it (macOS CI). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Wktb5zD7vpunQtvFHx6adf --- src/server.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server.rs b/src/server.rs index e5ee693..d948b72 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1008,10 +1008,11 @@ mod tests { /// 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(); - crate::config::set_data_dir(dir.clone()); let tone = dir.join("tone.wav"); write_wav(&tone, 3.0); let gone = dir.join("gone.mp3");