import type * as library from '@go/library/models.js'; import { formatSampleRate, formatBitDepth, formatChannels, formatBitrate, formatFileSize, } from '@utils/format'; import { formatMilliseconds } from '@utils/time'; import { html, nothing } from 'lit'; import { highlightText } from './search-ranking'; /** 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. * * `term` is the active search term, for a cell that wants to * highlight its own text: the default path applies `highlightText` * to `accessor`'s value, and a cell that renders itself has to do * that itself or silently lose the highlight. Only `titleArtist` * needs it, which is why it is optional rather than a second * required parameter on all of them. */ renderCell?: (track: library.Track, term?: string) => 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 = { 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``; }, }, titleArtist: { id: 'titleArtist', // Named for what it sorts by, since that is the only place the // label is user-visible: the phone has no column headers, and // the page header's sort list is built from the *configured* // columns rather than the drawn ones. label: 'Track Name', accessor: (t) => t.TrackName, defaultWidth: '1fr', comparator: (a, b) => compareStr(a.TrackName, b.TrackName), renderCell: (t, term) => html`
${term ? highlightText(t.TrackName, term) : t.TrackName} ${term ? highlightText(t.ArtistName, term) : t.ArtistName}
`, }, 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', ]; /** * The one column a phone shows, and it is two lines. * * At 424 CSS px -- the width of the phone this was measured on -- four * columns fit the row exactly and none of them fits its *content*: * `--grid-cols` came out `24px 102px 101px 101px 80px`, so "Duration" * did not fit its own header and a title had ~20 characters. The * columns were never too wide; there were too many of them. * * So the phone gets the title with the artist under it, which is the * shape every phone music list has, and the full row width to put them * in. It is a *column definition* rather than a second row template on * purpose: the row, the delegated events, the selection semantics, the * playing marker and the virtualizer all keep working, because from * their side nothing has changed except how many columns there are. */ export const PHONE_COLUMN_IDS: string[] = ['titleArtist', 'trackLength']; /** * Default column IDs. Album is in them (H-15): without it, the three * `Tideline / Aurora Fields / 00:06` rows in this app's own fixture * library are indistinguishable, in an app that has duplicate * detection. Must match `tracklist.DefaultColumns` in Go, which is what * a fresh install actually persists. */ export const DEFAULT_COLUMN_IDS: string[] = [ 'trackName', 'artistName', 'album', 'trackLength', ];