A list pays per row, and only while scrolling — and none of this is visible to any test tier: nothing renders differently and nothing fails, the app is just slower. - The track list's Art column rendered `CoverArtPath`, the original artwork, into a 24 px box while `CoverArtSmall` sat unused on the same model, with no `loading="lazy"`. 26 of 26 image requests asked for the full-size tier; now 0. - `artists-view`'s avatar fallback linear-scanned every cached album per card per frame, lowercasing two strings per comparison, inside the virtualizer's renderItem — the common case, since a locally tagged library has no artist images at all. Measured at 5 000 albums and 24 visible cards: 1.46 ms/frame -> 0.01 ms/frame. - Five components resolved selected file paths back to tracks with `tracks.find(...)`; they share `utils/track-index.ts` now. "Select all -> Edit tags" at 50 000 tracks: 3 051-6 298 ms -> 68 ms. - "Play this artist", "play these albums" and the album drag cache resolve paths in one call instead of one per album. - The column-resize drag registers its document listeners on mousedown. Two things here are load-bearing and read as sloppiness. The per-render arrow functions in `artists-view` and `genres-view` are the *only* thing changing a property of their virtualizer on a host update, and therefore the only thing repainting the cards: hoisting them to stable fields takes a selection from 1 highlighted card to 0. And a row inside a virtualizer needs `width: 100%`, because the virtualizer positions its children absolutely and a grid row otherwise shrinks to fit its content and stops lining up with the header above it.
258 lines
7.5 KiB
TypeScript
258 lines
7.5 KiB
TypeScript
import type { library } from '@go/models';
|
||
import {
|
||
formatSampleRate,
|
||
formatBitDepth,
|
||
formatChannels,
|
||
formatBitrate,
|
||
formatFileSize,
|
||
} from '@utils/format';
|
||
import { formatMilliseconds } from '@utils/time';
|
||
import { html, nothing } from 'lit';
|
||
|
||
/** Compares two strings using locale-aware ordering. */
|
||
const compareStr = (
|
||
a: string,
|
||
b: string,
|
||
): number => a.localeCompare(b);
|
||
|
||
/** Compares two numbers, treating 0 as "empty" (sorted last). */
|
||
const compareNum = (a: number, b: number): number => {
|
||
if (!a && !b) return 0;
|
||
if (!a) return 1;
|
||
if (!b) return -1;
|
||
|
||
return a - b;
|
||
};
|
||
|
||
/** Definition for a single displayable column. */
|
||
export interface ColumnDef {
|
||
/** Unique identifier matching the backend ColumnID. */
|
||
id: string;
|
||
/** Human-readable header label. */
|
||
label: string;
|
||
/** Extracts the display value from a track. */
|
||
accessor: (track: library.Track) => string;
|
||
/** Default CSS width (used when no saved width exists). */
|
||
defaultWidth: string;
|
||
/** Text alignment. Defaults to left. */
|
||
align?: 'left' | 'right';
|
||
/** Optional custom render function returning an HTML template. */
|
||
renderCell?: (track: library.Track) => unknown;
|
||
/**
|
||
* Comparison function for sorting two tracks by this column.
|
||
* Returns negative if a < b, positive if a > b, zero if equal.
|
||
* If omitted the column is not sortable.
|
||
*/
|
||
comparator?: (
|
||
a: library.Track,
|
||
b: library.Track,
|
||
) => number;
|
||
}
|
||
|
||
/** Registry of every available column keyed by ID. */
|
||
export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||
albumArt: {
|
||
id: 'albumArt',
|
||
label: 'Art',
|
||
accessor: () => '',
|
||
defaultWidth: '36px',
|
||
renderCell: (track: library.Track) => {
|
||
// `perf.M3`. This rendered `CoverArtPath` — the *original*
|
||
// embedded artwork, commonly 1500×1500 and several hundred
|
||
// kB — scaled by CSS into a 24 px box, while the 100 px
|
||
// `CoverArtSmall` sat unused on the same model. Every row
|
||
// the virtualizer recycled into view decoded a full
|
||
// resolution JPEG on the main thread to draw 576 pixels.
|
||
//
|
||
// `cover-grid.getCoverUrl()` has picked the right tier all
|
||
// along; this is the same rule for a much smaller box, with
|
||
// the two attributes that keep the decode off the scroll
|
||
// path.
|
||
const src = track.CoverArtSmall
|
||
|| track.CoverArtMedium
|
||
|| track.CoverArtPath;
|
||
|
||
if (!src) return nothing;
|
||
|
||
return html`<img
|
||
src="${src}"
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
width="24"
|
||
height="24"
|
||
style="width:24px;height:24px;border-radius:3px;object-fit:cover;display:block;"
|
||
/>`;
|
||
},
|
||
},
|
||
trackName: {
|
||
id: 'trackName',
|
||
label: 'Track Name',
|
||
accessor: (t) => t.TrackName,
|
||
defaultWidth: '1fr',
|
||
comparator: (a, b) =>
|
||
compareStr(a.TrackName, b.TrackName),
|
||
},
|
||
artistName: {
|
||
id: 'artistName',
|
||
label: 'Artist',
|
||
accessor: (t) => t.ArtistName,
|
||
defaultWidth: '1fr',
|
||
comparator: (a, b) =>
|
||
compareStr(a.ArtistName, b.ArtistName),
|
||
},
|
||
trackLength: {
|
||
id: 'trackLength',
|
||
label: 'Duration',
|
||
accessor: (t) => formatMilliseconds(t.TrackLength),
|
||
defaultWidth: '80px',
|
||
comparator: (a, b) =>
|
||
Number(a.TrackLength) - Number(b.TrackLength),
|
||
},
|
||
album: {
|
||
id: 'album',
|
||
label: 'Album',
|
||
accessor: (t) => t.Album,
|
||
defaultWidth: '1fr',
|
||
comparator: (a, b) =>
|
||
compareStr(a.Album, b.Album),
|
||
},
|
||
genre: {
|
||
id: 'genre',
|
||
label: 'Genre',
|
||
accessor: (t) => (t.Genre ?? []).join(', '),
|
||
defaultWidth: '120px',
|
||
comparator: (a, b) =>
|
||
compareStr(
|
||
(a.Genre ?? []).join(', '),
|
||
(b.Genre ?? []).join(', '),
|
||
),
|
||
},
|
||
year: {
|
||
id: 'year',
|
||
label: 'Year',
|
||
accessor: (t) =>
|
||
t.Year ? String(t.Year) : '',
|
||
defaultWidth: '60px',
|
||
comparator: (a, b) =>
|
||
compareNum(a.Year, b.Year),
|
||
},
|
||
composer: {
|
||
id: 'composer',
|
||
label: 'Composer',
|
||
accessor: (t) => t.Composer,
|
||
defaultWidth: '1fr',
|
||
comparator: (a, b) =>
|
||
compareStr(a.Composer, b.Composer),
|
||
},
|
||
trackNumber: {
|
||
id: 'trackNumber',
|
||
label: 'Track #',
|
||
accessor: (t) =>
|
||
t.TrackNumber ? String(t.TrackNumber) : '',
|
||
defaultWidth: '60px',
|
||
comparator: (a, b) =>
|
||
compareNum(a.TrackNumber, b.TrackNumber),
|
||
},
|
||
discNumber: {
|
||
id: 'discNumber',
|
||
label: 'Disc #',
|
||
accessor: (t) =>
|
||
t.DiscNumber ? String(t.DiscNumber) : '',
|
||
defaultWidth: '60px',
|
||
comparator: (a, b) =>
|
||
compareNum(a.DiscNumber, b.DiscNumber),
|
||
},
|
||
filePath: {
|
||
id: 'filePath',
|
||
label: 'File Path',
|
||
accessor: (t) => t.FilePath,
|
||
defaultWidth: '1fr',
|
||
comparator: (a, b) =>
|
||
compareStr(a.FilePath, b.FilePath),
|
||
},
|
||
fileType: {
|
||
id: 'fileType',
|
||
label: 'File Type',
|
||
accessor: (t) => t.FileType,
|
||
defaultWidth: '80px',
|
||
comparator: (a, b) =>
|
||
compareStr(a.FileType, b.FileType),
|
||
},
|
||
sampleRate: {
|
||
id: 'sampleRate',
|
||
label: 'Sample Rate',
|
||
accessor: (t) => formatSampleRate(t.SampleRate),
|
||
defaultWidth: '100px',
|
||
align: 'right',
|
||
comparator: (a, b) =>
|
||
compareNum(a.SampleRate, b.SampleRate),
|
||
},
|
||
bitDepth: {
|
||
id: 'bitDepth',
|
||
label: 'Bit Depth',
|
||
accessor: (t) => formatBitDepth(t.BitDepth),
|
||
defaultWidth: '80px',
|
||
align: 'right',
|
||
comparator: (a, b) =>
|
||
compareNum(a.BitDepth, b.BitDepth),
|
||
},
|
||
channels: {
|
||
id: 'channels',
|
||
label: 'Channels',
|
||
accessor: (t) => formatChannels(t.Channels),
|
||
defaultWidth: '80px',
|
||
comparator: (a, b) =>
|
||
compareNum(a.Channels, b.Channels),
|
||
},
|
||
bitrate: {
|
||
id: 'bitrate',
|
||
label: 'Bitrate',
|
||
accessor: (t) => formatBitrate(t.Bitrate),
|
||
defaultWidth: '100px',
|
||
align: 'right',
|
||
comparator: (a, b) =>
|
||
compareNum(a.Bitrate, b.Bitrate),
|
||
},
|
||
fileSize: {
|
||
id: 'fileSize',
|
||
label: 'File Size',
|
||
accessor: (t) => formatFileSize(t.FileSize),
|
||
defaultWidth: '80px',
|
||
align: 'right',
|
||
comparator: (a, b) =>
|
||
compareNum(a.FileSize, b.FileSize),
|
||
},
|
||
playCount: {
|
||
id: 'playCount',
|
||
label: 'Play Count',
|
||
accessor: (t) => t.PlayCount > 0 ? `${t.PlayCount}` : '0',
|
||
defaultWidth: '80px',
|
||
comparator: (a, b) =>
|
||
compareNum(a.PlayCount, b.PlayCount),
|
||
},
|
||
};
|
||
|
||
/**
|
||
* All column IDs in default display order.
|
||
* Used by the settings UI to list available columns.
|
||
*/
|
||
export const ALL_COLUMN_IDS: string[] = Object.keys(COLUMN_DEFS);
|
||
|
||
/**
|
||
* Column IDs that are always searched regardless of visibility.
|
||
* These represent the most common search targets.
|
||
*/
|
||
export const CORE_SEARCH_COLUMN_IDS: string[] = [
|
||
'trackName',
|
||
'artistName',
|
||
'album',
|
||
];
|
||
|
||
/** Default column IDs matching the original hardcoded layout. */
|
||
export const DEFAULT_COLUMN_IDS: string[] = [
|
||
'trackName',
|
||
'artistName',
|
||
'trackLength',
|
||
];
|