Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 35 additions & 29 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ pub struct App {
lib_filter: String,
lib_filtered: Vec<usize>,
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<usize>),
/// selected library tracks (library indexes)
Expand Down Expand Up @@ -264,6 +267,8 @@ pub struct App {
icon_tex: Option<egui::TextureHandle>,
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<usize>,
tab_ops: Vec<plview::TabOp>,
scroll_to: Option<usize>,
/// (tab, on-air row) the playlist last scrolled to follow
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment thread
epell-development marked this conversation as resolved.
self.lib_filtered = if narrow { shown.into_iter().filter(hit).collect() } else { (0..text.len()).filter(hit).collect() };
self.lib_key = key;
}
}
Expand Down
46 changes: 43 additions & 3 deletions src/fade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,6 +43,37 @@ impl<I: Iterator<Item = f32>> Iterator for Gentle<I> {
}
}

/// Interleaved samples with any number of channels as left/right pairs: mono is doubled, channels
/// after the first two are left out.
struct Stereo<I> {
inner: I,
channels: usize,
/// position in the current frame
k: usize,
/// a mono sample still to be given as the right channel
held: Option<f32>,
}

impl<I: Iterator<Item = f32>> Iterator for Stereo<I> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
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;

Expand All @@ -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<Waveform> {
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::<f32>(), 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::<f32>(), 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))
}
Expand Down Expand Up @@ -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<f32>, channels| Stereo { inner: v.into_iter(), channels, k: 0, held: None }.collect::<Vec<_>>();
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;
Expand Down
129 changes: 66 additions & 63 deletions src/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::playout::Start>>, Option<usize>) =
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<usize> = 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));
Expand Down
Loading
Loading