diff --git a/src/app.rs b/src/app.rs index d7a4794..831b0d9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -211,6 +211,9 @@ pub struct App { lib_filter: String, lib_filtered: Vec, lib_key: (String, u64), + /// lower-case "artist\ntitle\nalbum\ngenre" of every library track and where its album ends, made + /// once per library version instead of on every keystroke of a search + lib_lower: (u64, Vec<(String, usize)>), /// Search tab: (search text, library version) and its hits, so the library isn't searched every frame search_hits: ((String, u64), Vec), /// selected library tracks (library indexes) @@ -264,6 +267,8 @@ pub struct App { icon_tex: Option, view_pl: usize, renaming: Option<(usize, String)>, + /// the playlist tab under the mouse: only its total length is added up (for its tooltip) + tab_hovered: Option, tab_ops: Vec, scroll_to: Option, /// (tab, on-air row) the playlist last scrolled to follow @@ -550,6 +555,7 @@ impl App { lib_filter: String::new(), lib_filtered: vec![], lib_key: (String::new(), 0), + lib_lower: (0, vec![]), search_hits: ((String::new(), 0), vec![]), lib_sel: BTreeSet::new(), lib_anchor: None, @@ -603,6 +609,7 @@ impl App { icon_tex: None, view_pl: usize::MAX, renaming: None, + tab_hovered: None, tab_ops: vec![], scroll_to: None, followed: None, @@ -1587,20 +1594,8 @@ impl App { } let key = (self.lib_filter.to_lowercase(), core.library_ver); if key != self.search_hits.0 { - let needle = &key.0; - let hits = core - .library - .iter() - .enumerate() - .filter(|(_, t)| { - t.artist.to_lowercase().contains(needle) - || t.title.to_lowercase().contains(needle) - || t.album.to_lowercase().contains(needle) - || t.genre.to_lowercase().contains(needle) - }) - .take(150) - .map(|(i, _)| i) - .collect(); + let needle = key.0.as_str(); + let hits = self.lib_lower(core).iter().enumerate().filter(|(_, (t, _))| t.contains(needle)).take(150).map(|(i, _)| i).collect(); self.search_hits = (key, hits); } let hits = &self.search_hits.1; @@ -1661,11 +1656,11 @@ impl App { ui.label(if on.is_empty() { "Silence / idle".to_string() } else { on }); ui.separator(); ui.strong(tr("Projected start times")); - let starts = core.projected_starts(true); + let mut starts = core.starts(true); let next = core.current.map(|n| n + 1).unwrap_or(0); for (i, e) in core.playlist.iter().enumerate().skip(next).take(14) { ui.horizontal(|ui| { - let t = starts.get(i).copied().flatten(); + let t = starts.get(i); ui.monospace(t.map(|s| s.at.format("%H:%M:%S").to_string()).unwrap_or_else(|| "--:--:--".into())); let mut txt = RichText::new(e.title()); if t.is_some_and(|s| s.skipped) { @@ -2142,23 +2137,34 @@ impl App { self.show_profiles = open; } - /// Keep the filtered library list (library tab, track editor list) in step with the search text. - pub(super) fn refresh_lib_filter(&mut self, core: &Playout) { - let key = (self.lib_filter.to_lowercase(), core.library_ver); - if key != self.lib_key { - let f = key.0.clone(); - self.lib_filtered = core + /// See `lib_lower`. + fn lib_lower(&mut self, core: &Playout) -> &[(String, usize)] { + if self.lib_lower.0 != core.library_ver { + let text = core .library .iter() - .enumerate() - .filter(|(_, t)| { - f.is_empty() - || t.artist.to_lowercase().contains(&f) - || t.title.to_lowercase().contains(&f) - || t.album.to_lowercase().contains(&f) + .map(|t| { + let s = format!("{}\n{}\n{}", t.artist, t.title, t.album).to_lowercase(); + let album_end = s.len(); + (s + "\n" + &t.genre.to_lowercase(), album_end) }) - .map(|(i, _)| i) .collect(); + self.lib_lower = (core.library_ver, text); + } + &self.lib_lower.1 + } + + /// Keep the filtered library list (library tab, track editor list) in step with the search text. + pub(super) fn refresh_lib_filter(&mut self, core: &Playout) { + let key = (self.lib_filter.to_lowercase(), core.library_ver); + if key != self.lib_key { + // typing more letters only narrows the tracks already shown + let narrow = key.1 == self.lib_key.1 && key.0.contains(self.lib_key.0.as_str()); + let shown = std::mem::take(&mut self.lib_filtered); + let f = key.0.as_str(); + let text = self.lib_lower(core); + let hit = |&i: &usize| f.is_empty() || text[i].0[..text[i].1].contains(f); + self.lib_filtered = if narrow { shown.into_iter().filter(hit).collect() } else { (0..text.len()).filter(hit).collect() }; self.lib_key = key; } } diff --git a/src/fade.rs b/src/fade.rs index 5060a32..dd77569 100644 --- a/src/fade.rs +++ b/src/fade.rs @@ -13,7 +13,7 @@ //! tiny overlap so they are not chopped; long fading outros get a longer overlap. use anyhow::Result; -use rodio::{source::UniformSourceIterator, Decoder, Source}; +use rodio::{Decoder, Source}; use serde::{Deserialize, Serialize}; use std::{ fs::File, @@ -43,6 +43,37 @@ impl> Iterator for Gentle { } } +/// Interleaved samples with any number of channels as left/right pairs: mono is doubled, channels +/// after the first two are left out. +struct Stereo { + inner: I, + channels: usize, + /// position in the current frame + k: usize, + /// a mono sample still to be given as the right channel + held: Option, +} + +impl> Iterator for Stereo { + type Item = f32; + fn next(&mut self) -> Option { + if let Some(x) = self.held.take() { + return Some(x); + } + if self.channels == 1 { + let x = self.inner.next()?; + self.held = Some(x); + return Some(x); + } + while self.k >= 2 { + self.inner.next()?; + self.k = (self.k + 1) % self.channels; + } + self.k = (self.k + 1) % self.channels; + self.inner.next() + } +} + /// Seconds per analysis bin. pub const BIN: f32 = 0.02; @@ -62,8 +93,9 @@ impl Waveform { /// rests as long as it works, so on a slow PC playback and the window keep a core to themselves. pub fn analyze(path: &Path) -> Result { let dec = Decoder::new(BufReader::with_capacity(256 * 1024, File::open(path)?))?; - let rate = 22_050u32; // half rate is plenty for envelopes and twice as fast - let src = UniformSourceIterator::<_, f32>::new(dec.convert_samples::(), 2, rate); + // the file's own rate: envelopes don't need resampling, which cost as much as the decoding + let (rate, channels) = (dec.sample_rate().max(1), dec.channels().max(1) as usize); + let src = Stereo { inner: dec.convert_samples::(), channels, k: 0, held: None }; let gentle = Gentle { inner: src, n: 0, every: rate as usize * 2 * 5, t: Instant::now() }; Ok(Self::from_interleaved(gentle, rate)) } @@ -292,6 +324,14 @@ pub fn detect_level(w: &Waveform, target_db: f32) -> f32 { mod tests { use super::*; + #[test] + fn any_channel_count_becomes_left_right_pairs() { + let pairs = |v: Vec, channels| Stereo { inner: v.into_iter(), channels, k: 0, held: None }.collect::>(); + assert_eq!(pairs(vec![1.0, 2.0], 1), vec![1.0, 1.0, 2.0, 2.0]); + assert_eq!(pairs(vec![1.0, 2.0, 3.0, 4.0], 2), vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(pairs((1..=12).map(|x| x as f32).collect(), 6), vec![1.0, 2.0, 7.0, 8.0]); + } + /// Synthetic track: `lead` s silence, `body` s of loud tone, then an outro shaped by `outro(t)`. fn synth(lead: f32, body: f32, outro_secs: f32, outro: impl Fn(f32) -> f32, trail: f32) -> Waveform { let rate = 8000u32; diff --git a/src/panes.rs b/src/panes.rs index b09b067..357f30b 100644 --- a/src/panes.rs +++ b/src/panes.rs @@ -663,81 +663,84 @@ impl App { }); self.aux_pl.insert(key.clone(), which); let on_air = which == core.playing_tab; - let (starts, cur): (Vec>, Option) = - core.with_tab(which, |c| (c.projected_starts(on_air), c.current)); let state = core.state; - let rows = core.tab_rows(which); let sel = self.aux_sel.entry(key.clone()).or_default(); let mut play: Option = None; let mut remove = false; let playing = state != PlayState::Stopped; let dark = ui_dark(&self.workzones); - TableBuilder::new(ui) - .id_salt(("aux_pl", key.clone())) - .striped(true) - .sense(egui::Sense::click()) - .column(Column::exact(34.0)) - .column(Column::remainder().at_least(80.0).clip(true)) - .column(Column::exact(52.0)) - .column(Column::exact(70.0)) - .header(20.0, |mut h| { - for t in ["#", "Title", "Length", "Start"] { - h.col(|ui| { - ui.strong(tr(t)); - }); - } - }) - .body(|body| { - body.rows(22.0, rows.len(), |mut row| { - let i = row.index(); - let e = &rows[i]; - 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, 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)); - }); - row.col(|ui| { - row_background(ui, e, is_cur); - ui.add(egui::Label::new(RichText::new(e.title()).color(col)).truncate()); - }); - row.col(|ui| { - row_background(ui, e, is_cur); - ui.label(RichText::new(e.length_text()).color(col)); - }); - row.col(|ui| { - row_background(ui, e, is_cur); - let t = match st { - Some(s) if s.skipped => "skip".to_string(), - Some(s) => s.at.format("%H:%M:%S").to_string(), - None => String::new(), - }; - ui.label(RichText::new(t).color(col)); - }); - let r = row.response(); - if r.clicked() { - sel.clear(); - sel.insert(i); - } - if r.double_clicked() { - play = Some(i); + // the table is drawn with the tab swapped in, so start times are only worked out for the rows in view + core.with_tab(which, |c| { + let mut starts = c.starts(on_air); + let cur = c.current; + let rows = &c.playlist; + TableBuilder::new(ui) + .id_salt(("aux_pl", key.clone())) + .striped(true) + .sense(egui::Sense::click()) + .column(Column::exact(34.0)) + .column(Column::remainder().at_least(80.0).clip(true)) + .column(Column::exact(52.0)) + .column(Column::exact(70.0)) + .header(20.0, |mut h| { + for t in ["#", "Title", "Length", "Start"] { + h.col(|ui| { + ui.strong(tr(t)); + }); } - r.context_menu(|ui| { - if ui.button(tr("▶ Play now")).clicked() { - play = Some(i); - ui.close_menu(); - } - if ui.button(tr("🗑 Remove")).clicked() { + }) + .body(|body| { + body.rows(22.0, rows.len(), |mut row| { + let i = row.index(); + let e = &rows[i]; + let is_cur = on_air && playing && cur == Some(i); + row.set_selected(sel.contains(&i)); + let st = starts.get(i); + let col = row_text_color(e, is_cur, st.is_some_and(|s| s.skipped), dark, c.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)); + }); + row.col(|ui| { + row_background(ui, e, is_cur); + ui.add(egui::Label::new(RichText::new(e.title()).color(col)).truncate()); + }); + row.col(|ui| { + row_background(ui, e, is_cur); + ui.label(RichText::new(e.length_text()).color(col)); + }); + row.col(|ui| { + row_background(ui, e, is_cur); + let t = match st { + Some(s) if s.skipped => "skip".to_string(), + Some(s) => s.at.format("%H:%M:%S").to_string(), + None => String::new(), + }; + ui.label(RichText::new(t).color(col)); + }); + let r = row.response(); + if r.clicked() { sel.clear(); sel.insert(i); - remove = true; - ui.close_menu(); } + if r.double_clicked() { + play = Some(i); + } + r.context_menu(|ui| { + if ui.button(tr("▶ Play now")).clicked() { + play = Some(i); + ui.close_menu(); + } + if ui.button(tr("🗑 Remove")).clicked() { + sel.clear(); + sel.insert(i); + remove = true; + ui.close_menu(); + } + }); }); }); - }); + }); if let Some(i) = play { let xf = if core.state == PlayState::Playing { core.cfg.crossfade_secs } else { 0.0 }; core.with_tab(which, |c| c.play_index(i, xf)); diff --git a/src/playout.rs b/src/playout.rs index 391fa54..c196acd 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -196,6 +196,69 @@ pub struct Start { pub skipped: bool, } +fn span_ms(s: f32) -> CDur { + CDur::milliseconds((s.max(0.0) * 1000.0) as i64) +} + +/// See `Playout::starts`. +pub struct Starts<'a> { + p: &'a Playout, + out: Vec>, + /// the first row not worked out yet + next: usize, + /// the first row after the last hour marker + since: usize, + /// when row `next` starts + t: DateTime, +} + +impl Starts<'_> { + /// When row `r` starts. Exact: an hour marker can skip the rows before it, so rows are worked + /// out up to the first marker at or after `r`. Rows already worked out are kept, so use one + /// `Starts` for all the rows of a frame rather than a new one per row. + pub fn get(&mut self, r: usize) -> Option { + self.fill(r); + self.out.get(r).copied().flatten() + } + + fn fill(&mut self, r: usize) { + let pl = &self.p.playlist; + let n = pl.len(); + if n == 0 { + return; + } + let r = r.min(n - 1); + if r < self.since && r < self.next { + return; // worked out, and the marker after it too + } + let from = r.max(self.next); + let end = (from..n).find(|&i| matches!(pl[i].kind, ItemKind::HourMarker { .. })).unwrap_or(r); + while self.next < n && self.next <= end { + let i = self.next; + if let ItemKind::HourMarker { hour, minute, hard, wait } = pl[i].kind { + let target = marker_target(hour, minute, self.t); + let late = (self.since..i).find(|&r| self.out[r].is_some_and(|s| s.at > target) && self.p.row_span(&pl[r]) > 0.0); + if let Some(r0) = late { + // rows that would start after the marker time are skipped + let resume = if hard { target } else { self.out[r0].unwrap().at }; + for s in self.out[r0..i].iter_mut().flatten() { + s.skipped = true; + } + self.t = resume; + } else if wait && self.t < target { + self.t = target; + } + self.out[i] = Some(Start { at: self.t, skipped: false }); + self.since = i + 1; + } else { + self.out[i] = Some(Start { at: self.t, skipped: false }); + self.t += span_ms(self.p.row_span(&pl[i])); + } + self.next += 1; + } + } +} + enum ScanMsg { Progress(usize), Done(Vec), @@ -1245,30 +1308,37 @@ impl Playout { /// Approximate air time of every row of the list currently in `playlist` /// (`on_air` = it is the list that is playing). Hour markers wait or skip rows. pub fn projected_starts(&self, on_air: bool) -> Vec> { + let mut s = self.starts(on_air); + s.fill(usize::MAX); + s.out + } + + /// Start times worked out only as far as they are asked for (`Starts::get`), so drawing the rows + /// in view of a long playlist doesn't add up the whole list every frame. + pub fn starts(&self, on_air: bool) -> Starts<'_> { let n = self.playlist.len(); let mut out: Vec> = vec![None; n]; + let now = self.now(); if n == 0 { - return out; + return Starts { p: self, out, next: 0, since: 0, t: 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); let xf = self.cfg.crossfade_secs; - let (first, mut t) = match self.current { + let (first, t) = match self.current { Some(c) if live && c < n => { if let Some(u) = self.wait_until { out[c] = Some(Start { at: u, skipped: false }); (c + 1, u.max(now)) } else if st.has_track { - out[c] = Some(Start { at: now - ms(st.pos), skipped: false }); + out[c] = Some(Start { at: now - span_ms(st.pos), skipped: false }); let next_at = match self.active_cues { Some(cu) => cu.mix - st.pos, None if st.total > 0.0 => st.total - st.pos - xf.min(st.total * 0.5), None => self.playlist[c].est_secs() - st.pos, }; let rest: f32 = self.sub_queue.iter().map(|t| self.span_of(t)).sum(); - (c + 1, now + ms(next_at + rest)) + (c + 1, now + span_ms(next_at + rest)) } else { (c + 1, now) } @@ -1276,29 +1346,7 @@ impl Playout { Some(c) if c < n => (c, now), // stopped: Play resumes here _ => (0, now), }; - let mut since = first; - for i in first..n { - if let ItemKind::HourMarker { hour, minute, hard, wait } = self.playlist[i].kind { - let target = marker_target(hour, minute, t); - let late = (since..i).find(|&r| out[r].is_some_and(|s| s.at > target) && self.row_span(&self.playlist[r]) > 0.0); - if let Some(r0) = late { - // rows that would start after the marker time are skipped - let resume = if hard { target } else { out[r0].unwrap().at }; - for s in out[r0..i].iter_mut().flatten() { - s.skipped = true; - } - t = resume; - } else if wait && t < target { - t = target; - } - out[i] = Some(Start { at: t, skipped: false }); - since = i + 1; - } else { - out[i] = Some(Start { at: t, skipped: false }); - t += ms(self.row_span(&self.playlist[i])); - } - } - out + Starts { p: self, out, next: first, since: first, t } } /// Where a row for time `h:m` belongs: before the first upcoming row that starts at or after it. @@ -1329,7 +1377,7 @@ impl Playout { } let Some(st) = starts[i].filter(|s| !s.skipped) else { continue }; if let Some(p) = prev { - let hour_of = |t: DateTime| t.format("%Y%m%d%H").to_string(); + let hour_of = |t: DateTime| (t.date_naive(), chrono::Timelike::hour(&t)); if hour_of(p) != hour_of(st.at) { // only one row per hour change, and none right after a manual marker for that hour let manual = @@ -1419,12 +1467,9 @@ impl Playout { /// Seconds until the next hour marker in the on-air list (if any). pub fn secs_to_next_marker(&self) -> Option { - let starts = self.projected_starts(true); let from = self.current.map(|c| c + 1).unwrap_or(0); - (from..self.playlist.len()) - .find(|&i| matches!(self.playlist[i].kind, ItemKind::HourMarker { .. })) - .and_then(|i| starts[i]) - .map(|s| (s.at - self.now()).num_seconds().max(0)) + let i = (from..self.playlist.len()).find(|&i| matches!(self.playlist[i].kind, ItemKind::HourMarker { .. }))?; + self.starts(true).get(i).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}, @@ -2960,6 +3005,43 @@ mod tests { assert_eq!(s[5].unwrap().at, target); } + #[test] + fn start_times_worked_out_on_demand_match_the_whole_list() { + let mut p = playout(); + p.cfg.crossfade_secs = 3.0; + p.cfg.fade.mode = FadeMode::Fixed; + let now = Local::now(); + let at = |mins: i64| { + let m = now + CDur::minutes(mins); + marker_target(m.hour(), m.minute(), now) + }; + // soft, hard and waiting markers, some reached late (rows before them skipped), some early + p.playlist = vec![]; + for (k, (mins, hard, wait)) in + [(5, false, true), (9, true, true), (40, false, true), (41, true, false), (90, false, false)].into_iter().enumerate() + { + for j in 0..4 { + p.playlist.push(file(&format!("{k}-{j}"), 100.0 + j as f32 * 20.0)); + } + p.playlist.push(marker(at(mins), hard, wait)); + } + p.playlist.push(file("last", 60.0)); + let full = p.projected_starts(false); + assert!(full.iter().flatten().any(|s| s.skipped), "the list has skipped rows to check"); + let same = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => a.skipped == b.skipped && (a.at - b.at).num_milliseconds().abs() < 1000, + (a, b) => a.is_none() && b.is_none(), + }; + // each row on its own, and rows asked for out of order (scrolled down, then back up) + for (r, &want) in full.iter().enumerate() { + assert!(same(p.starts(false).get(r), want), "row {r}"); + } + let mut s = p.starts(false); + for r in (12..20).chain(0..full.len()).chain(30..40) { + assert!(same(s.get(r), full.get(r).copied().flatten()), "row {r} out of order"); + } + } + #[test] fn playing_from_another_tab_puts_it_on_air() { let mut p = playout(); diff --git a/src/plview.rs b/src/plview.rs index 6c5820c..dba4173 100644 --- a/src/plview.rs +++ b/src/plview.rs @@ -142,8 +142,10 @@ impl App { if self.view_pl >= core.tabs.len() { self.view_pl = core.playing_tab; // first frame, or the viewed tab was closed } - let strip: Vec<(String, usize, f32)> = - (0..core.tabs.len()).map(|i| (core.tabs[i].name.clone(), core.tab_len(i), core.tab_secs(i))).collect(); + let hovered = self.tab_hovered.take(); + let strip: Vec<(String, usize, f32)> = (0..core.tabs.len()) + .map(|i| (core.tabs[i].name.clone(), core.tab_len(i), if hovered == Some(i) { core.tab_secs(i) } else { 0.0 })) + .collect(); let v = self.view_pl; let on_air_tab = core.playing_tab; core.with_tab(v, |core| self.playlist_view(ctx, core, &strip, on_air_tab)); @@ -266,6 +268,9 @@ impl App { fmt(*secs), if live { " · ON AIR" } else { "" } )); + if r.hovered() { + self.tab_hovered = Some(i); + } if r.clicked() { self.tab_ops.push(TabOp::View(i)); } @@ -374,7 +379,8 @@ impl App { } }); - let starts = core.projected_starts(viewing_on_air); + // only the rows drawn are worked out (up to the next hour marker, which may skip them) + let mut starts = core.starts(viewing_on_air); let mods = ui.input(|i| i.modifiers); let view_tab = self.view_pl; let avail_w = ui.available_width(); @@ -443,7 +449,7 @@ impl App { let i = row.index(); let e = &pl[i]; let is_cur = cur == Some(i) && playing && viewing_on_air; - let st = starts.get(i).copied().flatten(); + let st = starts.get(i); let skipped = st.is_some_and(|s| s.skipped); let col = row_text_color(e, is_cur, skipped, dark, core.row_missing(e)); row.set_selected(sel.contains(&i)); diff --git a/src/serverui.rs b/src/serverui.rs index 6ac89c7..57a4197 100644 --- a/src/serverui.rs +++ b/src/serverui.rs @@ -505,7 +505,10 @@ impl App { self.anchor = None; self.followed = None; self.lib_sel.clear(); + // the other session's library can have the same version number self.lib_key = (String::new(), 0); + self.lib_lower = (0, vec![]); + self.search_hits = ((String::new(), 0), vec![]); } fn server_status_window(&mut self, ctx: &egui::Context, core: &mut Playout) {