diff --git a/src/app/handler.rs b/src/app/handler.rs
@@ -695,7 +695,7 @@ fn library_row_count(app: &App, ui: &Ui) -> usize {
match app.tab {
Tab::Queue => ui.library_pane.row_count(app),
Tab::Directories => ui.dir_browser_pane.entries.len(),
- tab => list_pane::rows_for(app, tab).len(),
+ tab => list_pane::row_count(app, tab),
}
}
@@ -705,7 +705,7 @@ fn library_key(key: KeyEvent, app: &App, ui: &mut Ui) -> Option<AppAction> {
Tab::Queue => ui.library_pane.handle_key(key, app),
Tab::Directories => ui.dir_browser_pane.handle_key(key, app),
tab => {
- let count = list_pane::rows_for(app, tab).len();
+ let count = list_pane::row_count(app, tab);
let pane = ui.list_pane_mut(tab);
pane.handle_key(key, count)
.then(|| list_pane::activate(app, tab, pane.selected))
@@ -720,7 +720,7 @@ fn library_mouse(mouse: MouseEvent, area: Rect, app: &App, ui: &mut Ui) -> Optio
Tab::Queue => ui.library_pane.handle_mouse(mouse, area, app),
Tab::Directories => ui.dir_browser_pane.handle_mouse(mouse, area, app),
tab => {
- let count = list_pane::rows_for(app, tab).len();
+ let count = list_pane::row_count(app, tab);
ui.list_pane_mut(tab).handle_mouse(mouse, area, count);
None
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
@@ -70,6 +70,12 @@ pub struct App {
/// Undecodable files in a row. Bounded so a queue of broken files stops
/// instead of spinning through them forever.
consecutive_failures: u32,
+ /// Every track index that appears in some playlist.
+ ///
+ /// The queue pane draws a star per row and asked
+ /// `playlists.iter().any(|pl| pl.tracks.contains(..))` for each visible
+ /// row — a linear scan of every playlist, per row, per frame.
+ playlist_members: std::collections::HashSet<usize>,
audio_engine: Option<AudioEngine>,
event_tx: Option<Sender<Event>>,
}
@@ -89,6 +95,7 @@ impl App {
initial_scan_complete: false,
audio_error: None,
consecutive_failures: 0,
+ playlist_members: std::collections::HashSet::new(),
audio_engine: None,
event_tx: None,
}
@@ -280,11 +287,13 @@ impl App {
pl.tracks.push(track_idx);
}
}
+ self.rebuild_playlist_members();
}
AppAction::RemoveFromPlaylist { playlist_idx, track_idx } => {
if let Some(pl) = self.playlists.get_mut(playlist_idx) {
pl.tracks.retain(|&t| t != track_idx);
}
+ self.rebuild_playlist_members();
}
AppAction::CreatePlaylist(name) => {
self.playlists.push(state::Playlist::new(name));
@@ -292,6 +301,7 @@ impl App {
AppAction::DeletePlaylist(idx) => {
if idx < self.playlists.len() {
self.playlists.remove(idx);
+ self.rebuild_playlist_members();
}
}
AppAction::RenamePlaylist { idx, name } => {
@@ -309,7 +319,7 @@ impl App {
let tx = tx.clone();
std::thread::spawn(move || {
let lib = Library::scan(&dir);
- let _ = tx.send(Event::LibraryReady(lib));
+ let _ = tx.send(Event::LibraryReady(Box::new(lib)));
});
}
}
@@ -393,6 +403,7 @@ impl App {
self.library = new_lib;
self.sync_state = SyncState::Idle;
self.resync_shuffle_order(true);
+ self.rebuild_playlist_members();
}
/// Start the track at queue position `qi`, the single place playback is
@@ -565,6 +576,15 @@ impl App {
}
}
+ /// Is this track starred in any playlist? O(1).
+ pub fn is_in_playlist(&self, track_idx: usize) -> bool {
+ self.playlist_members.contains(&track_idx)
+ }
+
+ pub fn rebuild_playlist_members(&mut self) {
+ self.playlist_members = self.playlists.iter().flat_map(|pl| pl.tracks.iter().copied()).collect();
+ }
+
/// The engine confirmed audio is running, so the failure streak is over.
pub fn note_playback_started(&mut self) {
self.consecutive_failures = 0;
diff --git a/src/event/mod.rs b/src/event/mod.rs
@@ -8,7 +8,10 @@ pub enum Event {
Resize,
Tick,
Audio(AudioEvent),
- LibraryReady(crate::library::Library),
+ /// Boxed: every event in the channel is sized to the largest variant, and
+ /// the library carries its whole derived index. Mouse motion alone puts
+ /// hundreds of events a second through here.
+ LibraryReady(Box<crate::library::Library>),
}
#[derive(Debug, Clone)]
diff --git a/src/library/mod.rs b/src/library/mod.rs
@@ -2,150 +2,123 @@ pub mod scanner;
pub mod track;
pub mod watcher;
-use std::collections::BTreeSet;
-use std::path::Path;
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+use std::path::{Path, PathBuf};
use track::Track;
+/// Tracks with no artist tag are grouped under this name.
+pub const UNKNOWN_ARTIST: &str = "Unknown Artist";
+
+/// Views derived from `tracks`, built once when the library is constructed.
+///
+/// Every one of these used to be recomputed on each access — `get_albums` alone
+/// walked all tracks and cloned two strings each — and the render loop calls
+/// several of them per frame at up to 20 frames a second.
+#[derive(Debug, Default)]
+struct Index {
+ artists: Vec<String>,
+ albums: Vec<(String, String)>,
+ genres: Vec<String>,
+ /// Format extension with how many tracks use it.
+ formats: Vec<(String, usize)>,
+ /// Basenames of every directory containing a track.
+ directories: Vec<String>,
+ by_artist: HashMap<String, Vec<usize>>,
+ by_album: HashMap<(String, String), Vec<usize>>,
+ by_genre: HashMap<String, Vec<usize>>,
+ by_format: HashMap<String, Vec<usize>>,
+ by_path: HashMap<PathBuf, usize>,
+}
+
#[derive(Debug)]
pub struct Library {
pub tracks: Vec<Track>,
+ index: Index,
}
impl Library {
pub fn new() -> Self {
- Self {
- tracks: Vec::new(),
- }
+ Self { tracks: Vec::new(), index: Index::default() }
+ }
+
+ pub fn from_tracks(tracks: Vec<Track>) -> Self {
+ let index = Index::build(&tracks);
+ Self { tracks, index }
}
pub fn scan(path: &Path) -> Self {
- let tracks = scanner::scan_directory(path);
- Self { tracks }
- }
-
- pub fn get_artists(&self) -> Vec<String> {
- let mut set = BTreeSet::new();
- let mut has_unknown = false;
- for t in &self.tracks {
- if t.artist.is_empty() {
- has_unknown = true;
- } else {
- set.insert(t.artist.clone());
- }
- }
- let mut result: Vec<String> = set.into_iter().collect();
- if has_unknown {
- result.push("Unknown Artist".to_string());
- }
- result
+ Self::from_tracks(scanner::scan_directory(path))
}
- pub fn get_genres(&self) -> Vec<String> {
- let mut set = BTreeSet::new();
- for t in &self.tracks {
- if !t.genre.is_empty() {
- set.insert(t.genre.clone());
- }
- }
- set.into_iter().collect()
+ // ── Derived views: O(1), the work happened at construction ───────────
+
+ pub fn artists(&self) -> &[String] {
+ &self.index.artists
}
- pub fn get_albums(&self) -> Vec<(String, String)> {
- let mut set = BTreeSet::new();
- for t in &self.tracks {
- if !t.album.is_empty() {
- set.insert((t.album.clone(), self.album_artist_of(t)));
- }
- }
- set.into_iter().collect()
+ pub fn albums(&self) -> &[(String, String)] {
+ &self.index.albums
}
- pub fn get_tracks_by_artist(&self, artist: &str) -> Vec<usize> {
- self.tracks
- .iter()
- .enumerate()
- .filter(|(_, t)| {
- if artist == "Unknown Artist" {
- t.artist.is_empty()
- } else {
- t.artist == artist
- }
- })
- .map(|(i, _)| i)
- .collect()
+ pub fn genres(&self) -> &[String] {
+ &self.index.genres
}
- /// Tracks on `album` by `artist`. `get_albums` pairs every album with its
- /// artist, so matching on the title alone merged two different records that
- /// happen to share a name — picking either "Greatest Hits" row queued both.
- pub fn get_tracks_by_album(&self, album: &str, artist: &str) -> Vec<usize> {
- self.tracks
- .iter()
- .enumerate()
- .filter(|(_, t)| t.album == album && self.album_artist_of(t) == artist)
- .map(|(i, _)| i)
- .collect()
+ /// Format extensions paired with their track counts.
+ pub fn formats(&self) -> &[(String, usize)] {
+ &self.index.formats
}
- /// The artist an album is filed under: the album artist when tagged,
- /// otherwise the track artist. `get_albums` groups by the same rule.
- fn album_artist_of(&self, t: &track::Track) -> String {
- if t.album_artist.is_empty() {
- t.artist.clone()
- } else {
- t.album_artist.clone()
- }
+ pub fn directories(&self) -> &[String] {
+ &self.index.directories
}
- /// Every format with how many tracks use it, in one pass.
- pub fn format_counts(&self) -> Vec<(String, usize)> {
- let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
- for t in &self.tracks {
- let ext = t
- .path
- .extension()
- .map(|e| e.to_string_lossy().to_lowercase())
- .unwrap_or_default();
- if !ext.is_empty() {
- *counts.entry(ext).or_default() += 1;
- }
- }
- counts.into_iter().collect()
+ // ── Lookups: one hash, then a slice ─────────────────────────────────
+
+ pub fn tracks_by_artist(&self, artist: &str) -> &[usize] {
+ Self::slice(self.index.by_artist.get(artist))
}
- pub fn get_tracks_by_genre(&self, genre: &str) -> Vec<usize> {
- self.tracks
- .iter()
- .enumerate()
- .filter(|(_, t)| t.genre == genre)
- .map(|(i, _)| i)
- .collect()
+ /// Tracks on `album` by `artist`. Matching on the title alone merged two
+ /// different records that happen to share a name, since `albums()` pairs
+ /// each album with its artist.
+ pub fn tracks_by_album(&self, album: &str, artist: &str) -> &[usize] {
+ Self::slice(self.index.by_album.get(&(album.to_string(), artist.to_string())))
}
- pub fn get_formats(&self) -> Vec<String> {
- let mut set = BTreeSet::new();
- for t in &self.tracks {
- let ext = t.path.extension()
- .map(|e| e.to_string_lossy().to_lowercase())
- .unwrap_or_default();
- if !ext.is_empty() {
- set.insert(ext);
- }
+ pub fn tracks_by_genre(&self, genre: &str) -> &[usize] {
+ Self::slice(self.index.by_genre.get(genre))
+ }
+
+ pub fn tracks_by_format(&self, format: &str) -> &[usize] {
+ Self::slice(self.index.by_format.get(format))
+ }
+
+ fn slice(v: Option<&Vec<usize>>) -> &[usize] {
+ v.map(Vec::as_slice).unwrap_or(&[])
+ }
+
+ /// Was a linear scan over every path, so restoring a 1,000-entry playlist
+ /// against a 10,000-track library cost 10 million path comparisons.
+ pub fn path_to_index(&self, path: &Path) -> Option<usize> {
+ self.index.by_path.get(path).copied()
+ }
+
+ /// The artist an album is filed under: the album artist when tagged,
+ /// otherwise the track artist.
+ fn album_artist_of(t: &Track) -> &str {
+ if t.album_artist.is_empty() {
+ &t.artist
+ } else {
+ &t.album_artist
}
- set.into_iter().collect()
}
- pub fn get_tracks_by_format(&self, format: &str) -> Vec<usize> {
- self.tracks
- .iter()
- .enumerate()
- .filter(|(_, t)| {
- t.path.extension()
- .map(|e| e.to_string_lossy().to_lowercase() == format)
- .unwrap_or(false)
- })
- .map(|(i, _)| i)
- .collect()
+ fn extension_of(t: &Track) -> String {
+ t.path
+ .extension()
+ .map(|e| e.to_string_lossy().to_lowercase())
+ .unwrap_or_default()
}
pub fn get_directory_entries(&self, dir: &Path) -> (Vec<String>, Vec<usize>) {
@@ -167,26 +140,14 @@ impl Library {
(subdirs.into_iter().collect(), tracks)
}
- pub fn path_to_index(&self, path: &Path) -> Option<usize> {
- self.tracks.iter().position(|t| t.path == path)
- }
-
pub fn search(&self, query: &str) -> Vec<usize> {
if query.is_empty() {
return Vec::new();
}
- // Extension filter: *.flac, *.mp3, etc.
+ // Extension filter: *.flac, *.mp3, etc. — now an index lookup.
if let Some(ext) = query.strip_prefix("*.") {
- let ext = ext.to_lowercase();
- return self.tracks.iter().enumerate()
- .filter(|(_, t)| {
- t.path.extension()
- .map(|e| e.to_string_lossy().to_lowercase() == ext)
- .unwrap_or(false)
- })
- .map(|(i, _)| i)
- .collect();
+ return self.tracks_by_format(&ext.to_lowercase()).to_vec();
}
// Field-specific filter: artist:, album:, genre:, title:
@@ -194,27 +155,19 @@ impl Library {
let field = prefix.trim().to_lowercase();
let v = value.trim().to_lowercase();
if !v.is_empty() {
+ let pick = |f: fn(&Track) -> &str| {
+ self.tracks
+ .iter()
+ .enumerate()
+ .filter(|(_, t)| contains_ci(f(t), &v))
+ .map(|(i, _)| i)
+ .collect::<Vec<_>>()
+ };
match field.as_str() {
- "artist" => {
- return self.tracks.iter().enumerate()
- .filter(|(_, t)| t.artist.to_lowercase().contains(&v))
- .map(|(i, _)| i).collect();
- }
- "album" => {
- return self.tracks.iter().enumerate()
- .filter(|(_, t)| t.album.to_lowercase().contains(&v))
- .map(|(i, _)| i).collect();
- }
- "genre" => {
- return self.tracks.iter().enumerate()
- .filter(|(_, t)| t.genre.to_lowercase().contains(&v))
- .map(|(i, _)| i).collect();
- }
- "title" => {
- return self.tracks.iter().enumerate()
- .filter(|(_, t)| t.title.to_lowercase().contains(&v))
- .map(|(i, _)| i).collect();
- }
+ "artist" => return pick(|t| &t.artist),
+ "album" => return pick(|t| &t.album),
+ "genre" => return pick(|t| &t.genre),
+ "title" => return pick(|t| &t.title),
_ => {} // unknown prefix, fall through to general search
}
}
@@ -226,15 +179,207 @@ impl Library {
.iter()
.enumerate()
.filter(|(_, t)| {
- t.title.to_lowercase().contains(&q)
- || t.artist.to_lowercase().contains(&q)
- || t.album.to_lowercase().contains(&q)
- || t.genre.to_lowercase().contains(&q)
- || t.path.file_name()
- .map(|f| f.to_string_lossy().to_lowercase().contains(&q))
- .unwrap_or(false)
+ contains_ci(&t.title, &q)
+ || contains_ci(&t.artist, &q)
+ || contains_ci(&t.album, &q)
+ || contains_ci(&t.genre, &q)
+ || t.path
+ .file_name()
+ .is_some_and(|f| contains_ci(&f.to_string_lossy(), &q))
})
.map(|(i, _)| i)
.collect()
}
}
+
+/// Case-insensitive substring test that does not allocate for ASCII text.
+/// Search ran `to_lowercase()` on five fields of every track for every
+/// keystroke, which is five heap allocations per track per character typed.
+fn contains_ci(haystack: &str, needle_lower: &str) -> bool {
+ if needle_lower.is_empty() {
+ return true;
+ }
+ if haystack.is_ascii() {
+ let hay = haystack.as_bytes();
+ let ned = needle_lower.as_bytes();
+ return ned.len() <= hay.len()
+ && hay.windows(ned.len()).any(|w| w.eq_ignore_ascii_case(ned));
+ }
+ haystack.to_lowercase().contains(needle_lower)
+}
+
+impl Index {
+ fn build(tracks: &[Track]) -> Self {
+ let mut by_artist: HashMap<String, Vec<usize>> = HashMap::new();
+ let mut by_album: HashMap<(String, String), Vec<usize>> = HashMap::new();
+ let mut by_genre: HashMap<String, Vec<usize>> = HashMap::new();
+ let mut by_format: HashMap<String, Vec<usize>> = HashMap::new();
+ let mut by_path: HashMap<PathBuf, usize> = HashMap::with_capacity(tracks.len());
+ let mut directories: BTreeSet<String> = BTreeSet::new();
+
+ for (i, t) in tracks.iter().enumerate() {
+ let artist = if t.artist.is_empty() { UNKNOWN_ARTIST } else { &t.artist };
+ by_artist.entry(artist.to_string()).or_default().push(i);
+
+ if !t.album.is_empty() {
+ let key = (t.album.clone(), Library::album_artist_of(t).to_string());
+ by_album.entry(key).or_default().push(i);
+ }
+ if !t.genre.is_empty() {
+ by_genre.entry(t.genre.clone()).or_default().push(i);
+ }
+ let ext = Library::extension_of(t);
+ if !ext.is_empty() {
+ by_format.entry(ext).or_default().push(i);
+ }
+ // First path wins, matching the old `position()` behaviour when a
+ // symlink makes the same file appear under two paths.
+ by_path.entry(t.path.clone()).or_insert(i);
+
+ if let Some(name) = t
+ .path
+ .parent()
+ .and_then(|p| p.file_name())
+ .and_then(|n| n.to_str())
+ {
+ directories.insert(name.to_string());
+ }
+ }
+
+ // "Unknown Artist" sorts last, as it did when it was appended after the
+ // sorted set.
+ let mut artists: Vec<String> = by_artist.keys().cloned().collect();
+ artists.sort_by(|a, b| match (a == UNKNOWN_ARTIST, b == UNKNOWN_ARTIST) {
+ (true, false) => std::cmp::Ordering::Greater,
+ (false, true) => std::cmp::Ordering::Less,
+ _ => a.cmp(b),
+ });
+
+ let albums: Vec<(String, String)> =
+ by_album.keys().cloned().collect::<BTreeSet<_>>().into_iter().collect();
+ let genres: Vec<String> =
+ by_genre.keys().cloned().collect::<BTreeSet<_>>().into_iter().collect();
+ let formats: Vec<(String, usize)> = by_format
+ .iter()
+ .map(|(k, v)| (k.clone(), v.len()))
+ .collect::<BTreeMap<_, _>>()
+ .into_iter()
+ .collect();
+
+ Self {
+ artists,
+ albums,
+ genres,
+ formats,
+ directories: directories.into_iter().collect(),
+ by_artist,
+ by_album,
+ by_genre,
+ by_format,
+ by_path,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::Duration;
+
+ fn t(artist: &str, album_artist: &str, album: &str, genre: &str, path: &str) -> Track {
+ Track {
+ path: PathBuf::from(path),
+ title: path.to_string(),
+ artist: artist.to_string(),
+ album: album.to_string(),
+ album_artist: album_artist.to_string(),
+ genre: genre.to_string(),
+ year: None,
+ track_number: None,
+ track_total: None,
+ disc_number: None,
+ disc_total: None,
+ comment: None,
+ duration: Duration::ZERO,
+ bitrate: None,
+ sample_rate: None,
+ bit_depth: None,
+ channels: None,
+ }
+ }
+
+ fn library() -> Library {
+ Library::from_tracks(vec![
+ t("b", "", "Hits", "rock", "/m/b/1.flac"),
+ t("a", "", "Hits", "pop", "/m/a/2.mp3"),
+ t("", "", "Loose", "", "/m/c/3.flac"),
+ t("a", "", "Hits", "pop", "/m/a/4.flac"),
+ ])
+ }
+
+ #[test]
+ fn artists_are_sorted_with_unknown_last() {
+ assert_eq!(library().artists(), ["a", "b", UNKNOWN_ARTIST]);
+ }
+
+ #[test]
+ fn albums_keep_their_artist_so_same_named_records_stay_separate() {
+ let lib = library();
+ // "Hits" exists under both a and b and must not be merged.
+ assert_eq!(
+ lib.albums(),
+ [
+ ("Hits".into(), "a".into()),
+ ("Hits".into(), "b".into()),
+ ("Loose".into(), "".into()),
+ ]
+ );
+ assert_eq!(lib.tracks_by_album("Hits", "a"), [1, 3]);
+ assert_eq!(lib.tracks_by_album("Hits", "b"), [0]);
+ }
+
+ #[test]
+ fn untagged_tracks_group_under_unknown_artist() {
+ assert_eq!(library().tracks_by_artist(UNKNOWN_ARTIST), [2]);
+ assert_eq!(library().tracks_by_artist("a"), [1, 3]);
+ assert!(library().tracks_by_artist("nobody").is_empty());
+ }
+
+ #[test]
+ fn formats_carry_their_counts_and_index_their_tracks() {
+ let lib = library();
+ assert_eq!(lib.formats(), [("flac".into(), 3usize), ("mp3".into(), 1)]);
+ assert_eq!(lib.tracks_by_format("flac"), [0, 2, 3]);
+ }
+
+ #[test]
+ fn lookups_resolve_by_path_and_directory() {
+ let lib = library();
+ assert_eq!(lib.path_to_index(Path::new("/m/a/4.flac")), Some(3));
+ assert_eq!(lib.path_to_index(Path::new("/m/nope.flac")), None);
+ assert_eq!(lib.directories(), ["a", "b", "c"]);
+ }
+
+ #[test]
+ fn extension_search_uses_the_index_and_matches_the_scan() {
+ assert_eq!(library().search("*.flac"), vec![0, 2, 3]);
+ }
+
+ #[test]
+ fn case_insensitive_match_agrees_with_the_allocating_version() {
+ for (hay, needle) in [
+ ("Ghost In A Flower", "flower"),
+ ("Ghost In A Flower", "GHOST"),
+ ("肺", "肺"),
+ ("ヨルシカ Elma", "elma"),
+ ("short", "much longer needle"),
+ ("anything", ""),
+ ] {
+ assert_eq!(
+ contains_ci(hay, &needle.to_lowercase()),
+ hay.to_lowercase().contains(&needle.to_lowercase()),
+ "{hay:?} contains {needle:?}"
+ );
+ }
+ }
+}
diff --git a/src/library/track.rs b/src/library/track.rs
@@ -1,5 +1,7 @@
+use lofty::config::ParseOptions;
use lofty::file::AudioFile;
use lofty::prelude::*;
+use lofty::probe::Probe;
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -26,7 +28,15 @@ pub struct Track {
impl Track {
pub fn from_path(path: &Path) -> Option<Self> {
- let tagged_file = lofty::read_from_path(path).ok()?;
+ // Skip cover art: scanning never keeps it, and the album art view reads
+ // the picture straight from the file when it actually needs one. lofty
+ // otherwise decodes and allocates every embedded image — around a
+ // megabyte per track — only to have it dropped.
+ let tagged_file = Probe::open(path)
+ .ok()?
+ .options(ParseOptions::new().read_cover_art(false))
+ .read()
+ .ok()?;
let tag = tagged_file
.primary_tag()
diff --git a/src/library/watcher.rs b/src/library/watcher.rs
@@ -59,7 +59,7 @@ pub fn spawn_watcher(music_dir: &Path, event_tx: Sender<Event>) -> Option<Recomm
pending = None;
let lib = Library::scan(&dir);
- let _ = event_tx.send(Event::LibraryReady(lib));
+ let _ = event_tx.send(Event::LibraryReady(Box::new(lib)));
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
}
diff --git a/src/main.rs b/src/main.rs
@@ -119,6 +119,8 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
// A backend write error must not skip the save below, so the loop records it
// and breaks instead of returning early.
let mut draw_error: Option<io::Error> = None;
+ // Set by anything that can change what is on screen.
+ let mut dirty = true;
loop {
// Check if library scan is done
@@ -167,10 +169,12 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
playlists.push(app::state::Playlist::new("Bookmarks"));
}
app.playlists = playlists;
+ app.rebuild_playlist_members();
}
scan_done = true;
app.initial_scan_complete = true;
+ dirty = true;
_watcher = library::watcher::spawn_watcher(&music_dir, event_tx.clone());
}
Err(_) => {
@@ -183,6 +187,12 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
}
}
+ // The splash cross-fades over two seconds and needs frames of its own;
+ // everything else only redraws when an event changed something.
+ if ui.show_splash {
+ dirty = true;
+ }
+
// Process events
match event_rx.recv_timeout(Duration::from_millis(50)) {
Ok(event) => {
@@ -250,7 +260,7 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
vec![]
}
Event::LibraryReady(new_lib) => {
- app.replace_library(new_lib);
+ app.replace_library(*new_lib);
ui.refresh_dir_browser(&app);
ui.clamp_selections(&app);
// The search modal caches raw library indices, which
@@ -299,21 +309,32 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()>
if app.should_quit {
break;
}
+ dirty = true;
}
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
- // Just re-render
+ // Nothing happened, so nothing can have changed.
}
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
break;
}
}
- // Render
- if let Err(e) = terminal.draw(|frame| {
- ui.render(frame, &app);
- }) {
- draw_error = Some(e);
- break;
+ // Render only when something changed, and only once the queue is drained.
+ //
+ // Redrawing on the 50ms timeout burned a full frame 20 times a second
+ // with nothing happening; the ticks and position updates that do change
+ // the display arrive on their own. Mouse motion tracking emits hundreds
+ // of events a second, and drawing between each one let the unbounded
+ // channel grow faster than it drained, so the UI replayed stale pointer
+ // positions for seconds after the mouse stopped.
+ if dirty && event_rx.is_empty() {
+ dirty = false;
+ if let Err(e) = terminal.draw(|frame| {
+ ui.render(frame, &app);
+ }) {
+ draw_error = Some(e);
+ break;
+ }
}
}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
@@ -256,7 +256,7 @@ impl Ui {
pub fn clamp_selections(&mut self, app: &App) {
for tab in [Tab::Artists, Tab::Albums, Tab::Genre, Tab::Format, Tab::Playlists] {
- let len = list_pane::rows_for(app, tab).len();
+ let len = list_pane::row_count(app, tab);
let pane = &mut self.list_panes[list_slot(tab)];
pane.selected = pane.selected.min(len.saturating_sub(1));
pane.scroll_offset = pane.scroll_offset.min(len.saturating_sub(1));
diff --git a/src/ui/panes/library_pane.rs b/src/ui/panes/library_pane.rs
@@ -63,32 +63,25 @@ impl LibraryPane {
entries.push(LibraryEntry::Separator);
// --- Directories ---
- let mut dirs = std::collections::BTreeSet::new();
- for t in &app.library.tracks {
- if let Some(parent) = t.path.parent() {
- if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
- dirs.insert(name.to_string());
- }
- }
- }
+ let dirs = app.library.directories();
entries.push(LibraryEntry::SectionHeader(format!(
"\u{F054} Directories ({})",
dirs.len()
)));
- for d in &dirs {
+ for d in dirs {
entries.push(LibraryEntry::FavoriteDir(d.clone()));
}
entries.push(LibraryEntry::Separator);
// --- Albums ---
- let albums = app.library.get_albums();
+ let albums = app.library.albums();
entries.push(LibraryEntry::SectionHeader(format!(
"\u{F054} Albums ({})",
albums.len()
)));
for (album, artist) in albums {
- entries.push(LibraryEntry::Album { name: album, artist });
+ entries.push(LibraryEntry::Album { name: album.clone(), artist: artist.clone() });
}
entries
@@ -354,7 +347,7 @@ impl Pane for LibraryPane {
}
}
LibraryEntry::Album { name, artist } => {
- let tracks = app.library.get_tracks_by_album(name, artist);
+ let tracks = app.library.tracks_by_album(name, artist).to_vec();
if !tracks.is_empty() {
Some(AppAction::AddToQueue(tracks))
} else {
diff --git a/src/ui/panes/list_pane.rs b/src/ui/panes/list_pane.rs
@@ -230,27 +230,48 @@ impl ListPane {
}
}
+/// How many rows `tab` will produce, without building any of them.
+///
+/// Mouse handling needs only the count; it used to get it by materialising the
+/// whole row list, so every motion event rebuilt the artist or album view.
+pub fn row_count(app: &App, tab: Tab) -> usize {
+ match tab {
+ Tab::Artists => app.library.artists().len(),
+ Tab::Albums => app.library.albums().len(),
+ Tab::Genre => app.library.genres().len(),
+ Tab::Format => app.library.formats().len(),
+ Tab::Playlists => app.playlists.len(),
+ Tab::Queue | Tab::Directories => 0,
+ }
+}
+
/// Rows for whichever of the five list tabs is showing.
pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
match tab {
- Tab::Artists => app.library.get_artists().into_iter().map(ListRow::plain).collect(),
+ Tab::Artists => app
+ .library
+ .artists()
+ .iter()
+ .map(|a| ListRow::plain(a.clone()))
+ .collect(),
Tab::Albums => app
.library
- .get_albums()
- .into_iter()
- .map(|(album, artist)| ListRow::with_detail(album, artist))
+ .albums()
+ .iter()
+ .map(|(album, artist)| ListRow::with_detail(album.clone(), artist.clone()))
+ .collect(),
+ Tab::Genre => app
+ .library
+ .genres()
+ .iter()
+ .map(|g| ListRow::plain(g.clone()))
+ .collect(),
+ Tab::Format => app
+ .library
+ .formats()
+ .iter()
+ .map(|(fmt, n)| ListRow::with_detail(fmt.clone(), format!("({n})")))
.collect(),
- Tab::Genre => app.library.get_genres().into_iter().map(ListRow::plain).collect(),
- Tab::Format => {
- // One pass over the library instead of one per visible row: the old
- // format pane called get_tracks_by_format inside the render closure,
- // rescanning and lowercasing every track for every row, every frame.
- let counts = app.library.format_counts();
- counts
- .into_iter()
- .map(|(fmt, n)| ListRow::with_detail(fmt, format!("({n})")))
- .collect()
- }
Tab::Playlists => app
.playlists
.iter()
@@ -267,14 +288,16 @@ pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
/// The action for activating row `selected` of `tab`.
pub fn activate(app: &App, tab: Tab, selected: usize) -> Option<AppAction> {
let tracks = match tab {
- Tab::Artists => app.library.get_tracks_by_artist(app.library.get_artists().get(selected)?),
+ Tab::Artists => app.library.tracks_by_artist(app.library.artists().get(selected)?).to_vec(),
Tab::Albums => {
- let albums = app.library.get_albums();
- let (album, artist) = albums.get(selected)?;
- app.library.get_tracks_by_album(album, artist)
+ let (album, artist) = app.library.albums().get(selected)?;
+ app.library.tracks_by_album(album, artist).to_vec()
}
- Tab::Genre => app.library.get_tracks_by_genre(app.library.get_genres().get(selected)?),
- Tab::Format => app.library.get_tracks_by_format(app.library.get_formats().get(selected)?),
+ Tab::Genre => app.library.tracks_by_genre(app.library.genres().get(selected)?).to_vec(),
+ Tab::Format => app
+ .library
+ .tracks_by_format(&app.library.formats().get(selected)?.0)
+ .to_vec(),
Tab::Playlists => app.playlists.get(selected)?.tracks.clone(),
Tab::Queue | Tab::Directories => return None,
};
diff --git a/src/ui/panes/queue_pane.rs b/src/ui/panes/queue_pane.rs
@@ -146,7 +146,7 @@ impl Pane for QueuePane {
)
};
- let in_playlist = app.playlists.iter().any(|pl| pl.tracks.contains(&track_idx));
+ let in_playlist = app.is_in_playlist(track_idx);
let prefix = if is_current { "\u{F04B} " } else { " " }; // nf-fa-play
// Star integrated into title text so it stays next to the title
diff --git a/src/ui/widgets/status_bar.rs b/src/ui/widgets/status_bar.rs
@@ -155,9 +155,7 @@ pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme
let is_bookmarked = app.queue.current_index
.and_then(|qi| app.queue.tracks.get(qi))
- .is_some_and(|&ti| {
- app.playlists.iter().any(|pl| pl.tracks.contains(&ti))
- });
+ .is_some_and(|&ti| app.is_in_playlist(ti));
let bookmark_style = if is_bookmarked {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
|