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
2 changes: 1 addition & 1 deletion docs/architecture/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ Wider topology: [audio architecture](audio.md).

`TrackTable` uses `@tanstack/react-virtual` for 6000+ track performance. Virtualized tables consume `usePageScroll()` for the scroll element instead of nesting their own `overflow-y-auto` — that drives a single Spotify-style page scrollbar.

**Where the line is.** "Everywhere" means every list whose length has no natural bound: the library's tracks, albums, artists and playlist grids, and `PlaylistView` — a playlist can hold six thousand tracks because someone put them there. A **detail view of one entity** is bounded by that entity and is not virtualized: `AlbumDetailView`, `ArtistDetailView` and `GenreDetailView` all render their lists directly, and an album is a dozen or two tracks by nature. The distinction is worth stating because the cost is not free — a virtualizer needs a measured scroll element, a `scrollMargin` recomputed against the page scroller, and a re-measure on every column change, which is real machinery to carry for a list that fits on a screen.
**Where the line is.** "Everywhere" means every list whose length has no natural bound: the library's tracks, albums, artists and playlist grids, `PlaylistView` — a playlist can hold six thousand tracks because someone put them there — the liked tracks, the play history, and the track tables of a genre, a year and an artist. Only a list its entity bounds by nature renders directly: `AlbumDetailView`, because an album is a dozen or two tracks. The line used to be drawn at "a detail view of one entity", which left `GenreDetailView`, `YearDetailView` and `ArtistDetailView` mapping every row, though a genre can be a third of the library and a prolific artist runs to hundreds of tracks. The machinery a page-driven list needs — the scroll element from `usePageScroll()`, a `scrollMargin` re-measured against the page scroller whenever the list, the scroller or the content above it changes size — is one hook, [`usePageVirtualizer`](../../src/hooks/usePageVirtualizer.ts), so a new list takes it rather than copying it (the library tables and `PlaylistView` predate it and still carry their own copy). A grouped list keeps its groups whole: the history virtualizes **days**, each a measured `<section>` whose sticky header still docks under the TopBar and is pushed off by the next day's, and a day is bounded by how much one person listens in one.

The **sidebar** is the deliberate exception on the other side: it is a fixed column with its own scroller, so it neither uses `usePageScroll()` nor virtualizes. Pointing a virtualizer at its nested `overflow-y-auto` would introduce, inside the one component that legitimately has one, exactly the pattern this invariant exists to keep out of the page.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ Track tables themselves are **borderless** — no `rounded-2xl border bg-white`

## Performance

- **Virtual scroll** — `@tanstack/react-virtual` on every long list (tracks, queue, playlist contents, statistics rows). Tables share the page-level scroller via [`usePageScroll()`](../../src/hooks/usePageScroll.ts) and compute `scrollMargin` from the parent's offset so the virtualiser knows where its content begins. Single Spotify-style scrollbar, no nested overflow.
- **Virtual scroll** — `@tanstack/react-virtual` on every long list (tracks, queue, playlist contents, liked tracks, play history, the genre / year / artist track tables). Tables share the page-level scroller via [`usePageScroll()`](../../src/hooks/usePageScroll.ts) and compute `scrollMargin` from the parent's offset so the virtualiser knows where its content begins — packaged for new lists as [`usePageVirtualizer`](../../src/hooks/usePageVirtualizer.ts). The history virtualizes whole days, so its sticky day headers behave as before. An album's track list is the one track list left in flow: an album is short by nature, and the statistics top lists are ten rows. One page scrollbar, no nested overflow. Where the line sits and why: [invariants § Virtual scroll everywhere](../architecture/invariants.md#virtual-scroll-everywhere).
- **Image cache** — in-memory LRU (`lib/imageCache.ts`) for `convertFileSrc` results so the same artwork URL isn't recomputed on every render.
- **Thumbnails** — 1× and 2× covers generated by [`thumbnails.rs`](../../src-tauri/crates/core/src/artwork/thumbnails.rs) with `fast_image_resize` (SIMD AVX/SSE/NEON depending on host) and served via the asset protocol.

Expand Down
44 changes: 38 additions & 6 deletions src/components/views/ArtistDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ShareImageButton } from "../common/ShareImageButton";
import { renderCollectionCard } from "../../lib/collectionCard";
import { useTrackContextMenu } from "../../hooks/useTrackContextMenu";
import { useTrackUpdated } from "../../hooks/useTrackUpdated";
import { usePageVirtualizer } from "../../hooks/usePageVirtualizer";
import { useArtistBioCollapsed } from "../../hooks/useArtistBioCollapsed";
import { useArtistHero } from "../../hooks/useArtistHero";
import {
Expand Down Expand Up @@ -922,6 +923,13 @@ export function ArtistDetailView({

// ── Track table ─────────────────────────────────────────────────────

/** Row pitch, border included — the 56 px the playlist and library rows
* use, and what a 40 px cover with its padding needed in flow. */
const ARTIST_ROW_HEIGHT = 56;

/** Every track of the artist, virtualized against the page scroller: an
* album is small by nature, a discography is not — a prolific composer
* or a long-running act runs to hundreds of rows. */
function ArtistTrackTable({
tracks,
isLoading,
Expand All @@ -947,7 +955,13 @@ function ArtistTrackTable({
onRowMenuKey: (event: React.KeyboardEvent, track: Track) => boolean;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
"use no memo";
const gridCols = "grid-cols-[3rem_2.75rem_1fr_1fr_5rem_2rem]";
const { parentRef, virtualizer, scrollMargin } =
usePageVirtualizer<HTMLUListElement>({
count: tracks.length,
estimateSize: () => ARTIST_ROW_HEIGHT,
});
return (
<div className="rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-800/40 overflow-hidden">
<div
Expand All @@ -967,11 +981,17 @@ function ArtistTrackTable({
</div>

<ul
className={`divide-y divide-zinc-100 dark:divide-zinc-800/60 ${
isLoading ? "opacity-50" : ""
}`}
ref={parentRef}
className={isLoading ? "opacity-50" : ""}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{tracks.map((track, index) => {
{virtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const track = tracks[index];
if (!track) return null;
const isCurrent = track.id === currentTrackId;
return (
// Row can't be a <button> because it contains action
Expand All @@ -985,7 +1005,7 @@ function ArtistTrackTable({
onKeyDown={(e) => {
// Only play when the row itself is focused — see LibraryView.
if (e.target !== e.currentTarget) return;
if (onRowMenuKey(e, tracks[index])) return;
if (onRowMenuKey(e, track)) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPlayTrack(index);
Expand All @@ -996,7 +1016,19 @@ function ArtistTrackTable({
if (e.key === " ") e.preventDefault();
}}
onContextMenu={(e) => onContextMenuRow(e, track)}
className={`grid ${gridCols} gap-4 px-5 py-2 items-center select-none transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 ${
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
className={`grid ${gridCols} gap-4 px-5 items-center select-none transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 ${
index < tracks.length - 1
? "border-b border-zinc-100 dark:border-zinc-800/60"
: ""
} ${
isCurrent
? "bg-emerald-50 dark:bg-emerald-900/20"
: "hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
Expand Down
45 changes: 38 additions & 7 deletions src/components/views/GenreDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { PlayingIndicator } from "../common/PlayingIndicator";
import { usePlayer } from "../../hooks/usePlayer";
import { useTrackContextMenu } from "../../hooks/useTrackContextMenu";
import { useTrackUpdated } from "../../hooks/useTrackUpdated";
import { usePageVirtualizer } from "../../hooks/usePageVirtualizer";
import { getGenreDetail, type GenreDetail } from "../../lib/tauri/detail";
import {
formatDuration,
Expand Down Expand Up @@ -263,8 +264,14 @@ export function GenreDetailView({

// ── Track table ─────────────────────────────────────────────────────

/** Row pitch, border included — the 56 px the playlist and library rows
* use, and what a 40 px cover with its padding needed in flow. */
const DETAIL_ROW_HEIGHT = 56;

/** The track table of a detail page that lists tracks across albums: a
* genre, a year. */
* genre, a year. Virtualized against the page scroller, because unlike
* an album neither is small by nature — a genre can be a third of the
* library. */
export function DetailTrackTable({
tracks,
isLoading,
Expand Down Expand Up @@ -294,7 +301,13 @@ export function DetailTrackTable({
onRowMenuKey: (event: React.KeyboardEvent, track: Track) => boolean;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
"use no memo";
const gridCols = "grid-cols-[3rem_2.75rem_1.5fr_1fr_1fr_5rem_2rem]";
const { parentRef, virtualizer, scrollMargin } =
usePageVirtualizer<HTMLUListElement>({
count: tracks.length,
estimateSize: () => DETAIL_ROW_HEIGHT,
});
return (
<div className="rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-800/40 overflow-hidden">
<div
Expand All @@ -315,11 +328,17 @@ export function DetailTrackTable({
</div>

<ul
className={`divide-y divide-zinc-100 dark:divide-zinc-800/60 ${
isLoading ? "opacity-50" : ""
}`}
ref={parentRef}
className={isLoading ? "opacity-50" : ""}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{tracks.map((track, index) => {
{virtualizer.getVirtualItems().map((virtualRow) => {
const index = virtualRow.index;
const track = tracks[index];
if (!track) return null;
const isCurrent = track.id === currentTrackId;
return (
// Row can't be a <button> because it contains action
Expand All @@ -333,7 +352,7 @@ export function DetailTrackTable({
onKeyDown={(e) => {
// Only play when the row itself is focused — see LibraryView.
if (e.target !== e.currentTarget) return;
if (onRowMenuKey(e, tracks[index])) return;
if (onRowMenuKey(e, track)) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPlayTrack(index);
Expand All @@ -344,7 +363,19 @@ export function DetailTrackTable({
if (e.key === " ") e.preventDefault();
}}
onContextMenu={(e) => onContextMenuRow(e, track)}
className={`grid ${gridCols} gap-4 px-5 py-2 items-center select-none transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 ${
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
className={`grid ${gridCols} gap-4 px-5 items-center select-none transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-500 ${
index < tracks.length - 1
? "border-b border-zinc-100 dark:border-zinc-800/60"
: ""
} ${
isCurrent
? "bg-emerald-50 dark:bg-emerald-900/20"
: "hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
Expand Down
Loading
Loading