B2 phase 4, and the last of it. Measured on the device: at 424 CSS px the four configured columns fit the row *exactly* -- `--grid-cols` came out `24px 102px 101px 101px 80px` -- and not one of them fit its content, with "Duration" too narrow for its own header. The columns were never too wide; there were too many of them. So a phone draws `titleArtist` (the title with the artist under it, across the row's whole width) plus the duration, and drops the column headers and the resize handles, which are a click-to-sort and a drag with no touch equivalent. It is a **column set, not a second row template**: the row, its delegated events, the selection semantics, the playing marker and the virtualizer never learn anything changed, because from their side only the number of columns did. Three rules come with it. The row height is in two places (`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the virtualizer positions rows from that number and a taller row overlaps its neighbour. What is drawn and what can be sorted are different questions, so the sort list is built from `configuredColumns` -- a phone has no headers either, and building it from the drawn columns would leave it able to sort by title and duration alone. And a phone's column widths are neither loaded nor saved. That third rule is the bug the device found with the arrangement already passing five component tests and five e2e specs at the phone's own viewport. `loadColumnWidths` is keyed by column *id* and fills a gap with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever have saved a width for -- came out at 148px beside a duration column of 236. The mirror image was worse and unreachable from a phone at all: saving would have written those widths back under the same ids, replacing the width the user dragged on a desktop. The specs asserted shape, and the fault depended on what `localStorage` held for a different column set; the unit test now carries that map as a fixture. Verified: 809 component tests, 112 e2e specs, and on the phone at 424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow. One full e2e run of three saw an unrelated autotag keypress spec flake and pass on retry.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
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<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;"
|
||
/>`;
|
||
},
|
||
},
|
||
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`
|
||
<div class="stacked">
|
||
<span class="stacked-title"
|
||
>${term ? highlightText(t.TrackName, term) : t.TrackName}</span
|
||
>
|
||
<span class="stacked-sub"
|
||
>${term ? highlightText(t.ArtistName, term) : t.ArtistName}</span
|
||
>
|
||
</div>
|
||
`,
|
||
},
|
||
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',
|
||
];
|