library: three views of the collection, and what every file actually is
Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because the dominant format needs the path's extension counted per album, which SQLite cannot do without string gymnastics, and a library is a few thousand rows. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still.
This commit is contained in:
1 parent
10e4c75b33
commit
f55052506a
8 files changed
+2762
-404
No files matched your search
@@ -58,6 +58,25 @@ pub struct PlaylistTrack {
|
||||
pub art_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-album aggregates of what the files carry — the Library page's numbers.
|
||||
/// Everything here is derived from the tracks table plus the plays table;
|
||||
/// nothing is stored, so a rescan or a new play is reflected on the next call.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AlbumStats {
|
||||
pub album_id: i64,
|
||||
/// Total running time of the album's tracks, seconds.
|
||||
pub secs: i64,
|
||||
/// Total size on disk, bytes.
|
||||
pub bytes: i64,
|
||||
pub avg_kbps: Option<u32>,
|
||||
pub bit_depth: Option<u32>,
|
||||
pub sample_rate: Option<u32>,
|
||||
/// File extensions present, dominant first ("flac", "mp3", ...).
|
||||
pub formats: Vec<String>,
|
||||
/// Newest play that met the caller's seconds floor. None = never played.
|
||||
pub last_played_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// One followed artist. Name and portrait are stored alongside the id so the
|
||||
/// future home page can render "artists you follow" without paying a catalog
|
||||
/// request per artist — the row alone is enough to draw a card.
|
||||
@@ -579,6 +598,116 @@ impl Library {
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// Per-album aggregates for the Library page: what the FILES add up to.
|
||||
/// One call for the whole library, because the page derives its stat row,
|
||||
/// its Format grouping and four of its sorts (length, size, bitrate,
|
||||
/// format) from these — asking per album would be N invokes on open.
|
||||
///
|
||||
/// `min_secs` is the same play floor the recents list uses (the frontend
|
||||
/// passes its RECENTS_FLOOR_SECS): under it a play is a skip, and a page
|
||||
/// that sorts by "never played first" must not count skips as listening.
|
||||
pub fn album_stats(&self, min_secs: u32) -> Result<Vec<AlbumStats>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Aggregate in Rust off one pass over tracks rather than in SQL:
|
||||
// the dominant FORMAT needs the path's extension counted per album,
|
||||
// which SQLite cannot do without string gymnastics, and a library is
|
||||
// a few thousand rows — one iteration is nothing.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT album_id, duration_secs, size, bitrate_kbps, bit_depth, sample_rate, path
|
||||
FROM tracks",
|
||||
)?;
|
||||
struct Acc {
|
||||
secs: i64,
|
||||
bytes: i64,
|
||||
kbps_sum: i64,
|
||||
kbps_n: i64,
|
||||
depth: Option<u32>,
|
||||
rate: Option<u32>,
|
||||
exts: Vec<(String, u32)>,
|
||||
}
|
||||
let mut by_album: std::collections::HashMap<i64, Acc> = std::collections::HashMap::new();
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, Option<i64>>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
r.get::<_, Option<i64>>(3)?,
|
||||
r.get::<_, Option<u32>>(4)?,
|
||||
r.get::<_, Option<u32>>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let Ok((album_id, secs, size, kbps, depth, rate, path)) = row else { continue };
|
||||
let acc = by_album.entry(album_id).or_insert(Acc {
|
||||
secs: 0,
|
||||
bytes: 0,
|
||||
kbps_sum: 0,
|
||||
kbps_n: 0,
|
||||
depth: None,
|
||||
rate: None,
|
||||
exts: Vec::new(),
|
||||
});
|
||||
acc.secs += secs.unwrap_or(0);
|
||||
acc.bytes += size.unwrap_or(0);
|
||||
if let Some(k) = kbps {
|
||||
acc.kbps_sum += k;
|
||||
acc.kbps_n += 1;
|
||||
}
|
||||
acc.depth = acc.depth.max(depth);
|
||||
acc.rate = acc.rate.max(rate);
|
||||
let ext = std::path::Path::new(&path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if !ext.is_empty() {
|
||||
match acc.exts.iter_mut().find(|(e, _)| *e == ext) {
|
||||
Some((_, n)) => *n += 1,
|
||||
None => acc.exts.push((ext, 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Newest qualifying play per album. Exact album_id anchor only — a play
|
||||
// written before the album_id column existed simply does not count,
|
||||
// exactly as the recents list treats it.
|
||||
let mut plays = conn.prepare(
|
||||
"SELECT album_id, MAX(started_at) FROM plays
|
||||
WHERE album_id IS NOT NULL AND seconds >= ?1
|
||||
GROUP BY album_id",
|
||||
)?;
|
||||
let mut last_played: std::collections::HashMap<i64, i64> = std::collections::HashMap::new();
|
||||
let play_rows = plays.query_map([min_secs], |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for row in play_rows {
|
||||
if let Ok((id, at)) = row {
|
||||
last_played.insert(id, at);
|
||||
}
|
||||
}
|
||||
Ok(by_album
|
||||
.into_iter()
|
||||
.map(|(album_id, mut acc)| {
|
||||
// Dominant format first, so `formats[0]` is the album's chip.
|
||||
acc.exts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
AlbumStats {
|
||||
album_id,
|
||||
secs: acc.secs,
|
||||
bytes: acc.bytes,
|
||||
avg_kbps: if acc.kbps_n > 0 {
|
||||
Some((acc.kbps_sum / acc.kbps_n) as u32)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
bit_depth: acc.depth,
|
||||
sample_rate: acc.rate,
|
||||
formats: acc.exts.into_iter().map(|(e, _)| e).collect(),
|
||||
last_played_at: last_played.get(&album_id).copied(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
|
||||
Reference in new issue
Block a user