ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git
commit d4e77dde3a0dd010e63847bd941a9b748e6e336a
parent bb40b5bd6c6dbda79cc1a3e82422c74e6ea1571d
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 16:57:11 +0900

Precompute library views instead of rebuilding them every frame

The derived views — artists, albums, genres, formats, directories — were
recomputed from scratch on every access, each walking all tracks and cloning
strings, and the render loop asks for several of them per frame. Measured on
a synthetic library at 160x48:

tracks before after
1,000 0.815 ms 0.409 ms
10,000 1.981 ms 0.313 ms
50,000 7.966 ms 0.337 ms

Frame cost is now flat in library size rather than linear: the work happens
once when the library is built, and lookups are a hash and a slice.

path_to_index was a linear scan, so restoring a 1,000-entry playlist against
a 10,000-track library cost ten million path comparisons; it is a map now.
Extension search became an index lookup (1.594 ms to 0.008 ms at 50k), and
the general search no longer lowercases five fields of every track per
keystroke (4.62 ms to 1.98 ms). The queue pane asked
playlists.iter().any(contains(..)) per visible row per frame; App keeps a
membership set.

Track::from_path had lofty decode and allocate every embedded cover — around
a megabyte a track — only to drop it, since album art is read separately when
the view needs it. Skipping it takes 50 real FLACs from 3.8 ms to 0.6 ms.

The event loop redrew on its 50 ms timeout whether or not anything had
changed, and drew between individual mouse-motion events so the unbounded
channel grew faster than it drained. It now redraws only when an event
changed something and the queue is empty, with the splash exempt because it
animates. Event::LibraryReady is boxed so the channel element is not sized to
a whole library with its index.

Diffstat:
Msrc/app/handler.rs | 6+++---
Msrc/app/mod.rs | 22+++++++++++++++++++++-
Msrc/event/mod.rs | 5++++-
Msrc/library/mod.rs | 453++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/library/track.rs | 12+++++++++++-
Msrc/library/watcher.rs | 2+-
Msrc/main.rs | 37+++++++++++++++++++++++++++++--------
Msrc/ui/mod.rs | 2+-
Msrc/ui/panes/library_pane.rs | 17+++++------------
Msrc/ui/panes/list_pane.rs | 65++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/ui/panes/queue_pane.rs | 2+-
Msrc/ui/widgets/status_bar.rs | 4+---
12 files changed, 420 insertion(+), 207 deletion(-)

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 {