diff --git a/docs/architecture/invariants.md b/docs/architecture/invariants.md index d9b64b7d..5c136a80 100644 --- a/docs/architecture/invariants.md +++ b/docs/architecture/invariants.md @@ -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 `
` 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. diff --git a/docs/features/ui.md b/docs/features/ui.md index 91196f8b..9006c8e1 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -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. diff --git a/src/components/views/ArtistDetailView.tsx b/src/components/views/ArtistDetailView.tsx index c7e4e219..21b44378 100644 --- a/src/components/views/ArtistDetailView.tsx +++ b/src/components/views/ArtistDetailView.tsx @@ -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 { @@ -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, @@ -947,7 +955,13 @@ function ArtistTrackTable({ onRowMenuKey: (event: React.KeyboardEvent, track: Track) => boolean; t: (key: string, opts?: Record) => string; }) { + "use no memo"; const gridCols = "grid-cols-[3rem_2.75rem_1fr_1fr_5rem_2rem]"; + const { parentRef, virtualizer, scrollMargin } = + usePageVirtualizer({ + count: tracks.length, + estimateSize: () => ARTIST_ROW_HEIGHT, + }); return (
    - {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 -
- - ); - })} - + + playTracks(tracks, index, { type: "liked", id: null }) + } + onUnlike={handleUnlike} + onNavigateToArtist={onNavigateToArtist} + onContextMenuRow={trackContextMenu.open} + onRowMenuKey={trackContextMenu.openFromKeyboard} + unknownLabel={unknown} + unlikeLabel={t("liked.unlike")} + />
) : ( void; + onUnlike: (trackId: number) => void; + onNavigateToArtist: (artistId: number) => void; + onContextMenuRow: (event: React.MouseEvent, track: Track) => void; + /** Keyboard equivalent (Menu / Shift+F10). Returns `true` when it + * opened the menu, so the row's own key handling can stand down. */ + onRowMenuKey: (event: React.KeyboardEvent, track: Track) => boolean; + unknownLabel: string; + unlikeLabel: string; +} + +/** The liked rows, virtualized against the page scroller: nothing bounds + * how many tracks someone likes, and every row carries artwork. */ +function LikedTrackList({ + tracks, + isLoading, + currentTrackId, + onPlayTrack, + onUnlike, + onNavigateToArtist, + onContextMenuRow, + onRowMenuKey, + unknownLabel, + unlikeLabel, +}: LikedTrackListProps) { + "use no memo"; + const { parentRef, virtualizer, scrollMargin } = + usePageVirtualizer({ + count: tracks.length, + estimateSize: () => LIKED_ROW_HEIGHT, + }); + + return ( +
    + {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 + + + ); + })} +
+ ); +} + interface LikedSkeletonProps { t: (key: string, options?: Record) => string; } diff --git a/src/components/views/settings/PluginStoreCard.tsx b/src/components/views/settings/PluginStoreCard.tsx index b8834d29..fef3b600 100644 --- a/src/components/views/settings/PluginStoreCard.tsx +++ b/src/components/views/settings/PluginStoreCard.tsx @@ -20,6 +20,7 @@ import { } from "../../../lib/tauri/plugins"; import { PLUGIN_AVAILABILITY_EVENT } from "../../../hooks/usePluginAvailability"; import { useLocalizedText } from "../../../hooks/useLocalizedText"; +import { PluginWorldBadge } from "./PluginWorldBadge"; /** * Settings → Plugins → Store (Phase 2). Browses the curated registry @@ -217,7 +218,7 @@ function StoreRow({ v{entry.version} - + {entry.official && (