From 611376abba4716338353f36b221e162f04a3e041 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 23:05:02 +0000 Subject: [PATCH 1/2] Cut more per-frame work: start times only for rows in view, cheaper search Profiled with 3 playlists (6,500 rows) and a 20,000-track library: the UI work per frame while playing went from about 2.1 ms to 0.8 ms. - Start times are worked out only as far as they are needed (the rows on screen, up to the next hour marker that may skip them) instead of for the whole list, twice per frame. The player's hour-marker countdown, the Clock tab and Additional playlist views use the same lazy calculation. - Library search lower-cases each track once per library change, not on every keystroke, and typing more letters only narrows the tracks already shown. - Waveform analysis for smart fading reads files at their own sample rate instead of resampling them first. - Playlist tabs only add up a tab's length when its tooltip is wanted, and hour lines compare hours without building a string per row. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018AqiVGDcxKHnYv84gryaby --- src/app.rs | 64 ++++++++++---------- src/fade.rs | 46 ++++++++++++++- src/panes.rs | 129 +++++++++++++++++++++-------------------- src/playout.rs | 151 +++++++++++++++++++++++++++++++++++++----------- src/plview.rs | 14 +++-- src/serverui.rs | 3 + 6 files changed, 273 insertions(+), 134 deletions(-) diff --git a/src/app.rs b/src/app.rs index f10454c..f758de5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -222,6 +222,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) @@ -273,6 +276,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 @@ -553,6 +558,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, @@ -605,6 +611,7 @@ impl App { icon_tex: None, view_pl: usize::MAX, renaming: None, + tab_hovered: None, tab_ops: vec![], scroll_to: None, followed: None, @@ -1563,20 +1570,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; @@ -1636,11 +1631,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) { @@ -2111,23 +2106,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 847c76f..e7cfada 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)) } @@ -293,6 +325,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 d80722c..d7f1e85 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); - 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); + 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 a632ccf..072b92d 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -196,6 +196,68 @@ 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`. + 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), @@ -1229,30 +1291,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 = Local::now(); if n == 0 { - return out; + return Starts { p: self, out, next: 0, since: 0, t: now }; } - let now = Local::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) } @@ -1260,29 +1329,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. @@ -1313,7 +1360,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 = @@ -1403,12 +1450,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 - Local::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 - Local::now()).num_seconds().max(0)) } /// Fill in a speech row's text: the time placeholders plus {next}, {next_artist}, {next_title}, @@ -2895,6 +2939,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 63787d0..bc7d6df 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); 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) { From f3823e5db12ece09a86267d286e6183916364667 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:37:48 +0000 Subject: [PATCH 2/2] Say to reuse one Starts for all the rows of a frame Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018AqiVGDcxKHnYv84gryaby --- src/playout.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/playout.rs b/src/playout.rs index d94954e..c196acd 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -214,7 +214,8 @@ pub struct Starts<'a> { 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`. + /// 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()