CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a bar sized for a bar they are a few characters of text, which is not a touch target — and explore-link holds its navigation for one double-click interval and drops it if a second click arrives, a gesture that exists so double-clicking a row can play it and that means nothing on touch. Below the shell's phone breakpoint the three render as plain text. The words are unchanged: the source line still says where the queue came from, because dropping the link is the change and dropping the information would be a different and worse one. The cover art already carries the phone-only button that opens the full-screen Now Playing view, which is where the links live. This is in JS rather than in the stylesheet because what changes is the content, not its appearance — no CSS rule takes a click handler off an element. matchMedia is read in connectedCallback for the reason the reduce-motion query beside it already is, so a test can answer it first. Two smaller things. PHONE_QUERY moves out of track-list.ts into utils/breakpoints.ts: it was a private const when one component needed it, and a second reader is where a copy starts drifting from index.css. And `phone` joins geometryKey(), because crossing the breakpoint swaps a link for a bare string and the marquee travels a distance read from measuring it — the words being identical either side is not the same as the box measuring the same. Closes #61
2446 lines
77 KiB
TypeScript
2446 lines
77 KiB
TypeScript
import * as library from '@go/library/models.js';
|
|
import { LitElement, html, svg, css, nothing } from 'lit';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { srOnly } from '../../styles/sr-only.css';
|
|
import {
|
|
customElement,
|
|
property,
|
|
state,
|
|
query,
|
|
} from 'lit/decorators.js';
|
|
import { SelectionController } from '@utils/selection-controller';
|
|
import type { SelectionHost } from '@utils/selection-controller';
|
|
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
|
import { PHONE_QUERY } from '@utils/breakpoints';
|
|
import {
|
|
ContextMenuController,
|
|
contextMenuStyles,
|
|
isContextMenuKey,
|
|
} from '@utils/context-menu-controller.js';
|
|
|
|
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
|
import { PlayerController } from '@store/controllers/player-controller';
|
|
import { SearchController } from '@store/controllers/search-controller';
|
|
import '@components/page-header/page-header';
|
|
import type { SortOption } from '@components/page-header/page-header';
|
|
import { TrackListController } from '@store/controllers/tracklist-controller';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { queueStore } from '@store/queue-store';
|
|
import { creditStore } from '@store/credit-store';
|
|
import type { QueueSource } from '@store/queue-store';
|
|
import { LibraryController } from '@store/controllers/library-controller';
|
|
import {
|
|
COLUMN_DEFS,
|
|
DEFAULT_COLUMN_IDS,
|
|
PHONE_COLUMN_IDS,
|
|
} from './columns';
|
|
import type { ColumnDef } from './columns';
|
|
import { classMap } from 'lit/directives/class-map.js';
|
|
import {
|
|
rankTracks,
|
|
highlightText,
|
|
} from './search-ranking';
|
|
import {
|
|
artistLink,
|
|
creditLink,
|
|
albumLink,
|
|
trackLink,
|
|
exploreLinkStyles,
|
|
} from '@utils/explore-link';
|
|
import {
|
|
setDragPayload,
|
|
emitDragActive,
|
|
} from '@utils/drag-controller';
|
|
import {
|
|
createDragImage,
|
|
createTrackCardDragImage,
|
|
removeDragImage,
|
|
} from '@utils/drag-image';
|
|
import '@lit-labs/virtualizer';
|
|
import type {
|
|
LitVirtualizer,
|
|
VisibilityChangedEvent,
|
|
} from '@lit-labs/virtualizer';
|
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import { describeError } from '@utils/describe-error';
|
|
import { notificationStore } from '@store/notification-store';
|
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
|
import { RemoveFromLibrary } from '@go/library/library.js';
|
|
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
|
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
|
import '@components/playlist-picker/playlist-picker.js';
|
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
import {
|
|
ICON_PLAYLIST,
|
|
ICON_QUEUE,
|
|
} from '@utils/icon-language';
|
|
|
|
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
|
const SORT_FIELD_KEY = 'track-list-sort-field';
|
|
const SORT_DIR_KEY = 'track-list-sort-direction';
|
|
const MIN_COLUMN_WIDTH = 50;
|
|
const DEFAULT_FIXED_WIDTH = 80;
|
|
|
|
/**
|
|
* Chrome the resizable columns cannot use: the fixed favourite column
|
|
* and the row's own horizontal padding. Both numbers also appear in
|
|
* the CSS below — the `24px` first track of `--grid-cols` and
|
|
* `.track-row`/`.header-row`'s `padding: 8px` — so they live here and
|
|
* are read from both places rather than written out four times.
|
|
*/
|
|
const FAV_COL_WIDTH = 24;
|
|
const ROW_PADDING_X = 8;
|
|
const ROW_CHROME_WIDTH =
|
|
FAV_COL_WIDTH + ROW_PADDING_X * 2;
|
|
|
|
/**
|
|
* Row heights, in the same relationship as the widths above: the number
|
|
* is read by the CSS *and* by the virtualizer's layout, so they cannot
|
|
* disagree. A phone row is two lines (title over artist).
|
|
*/
|
|
const ROW_HEIGHT = 33;
|
|
const PHONE_ROW_HEIGHT = 52;
|
|
|
|
|
|
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
|
|
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
|
|
const FAV_ICONS = {
|
|
heart: {
|
|
viewBox: '0 0 512 512',
|
|
regular: 'M225.8 468.2l-2.5-2.3L48.1 303.2C17.4 274.7 0 234.7 0 192.8v-3.3c0-70.4 50-130.8 119.2-144C158.6 37.9 198.9 47 231 69.6c9 6.3 17.3 13.5 25 21.5c7.7-8 16-15.2 25-21.5c32.1-22.6 72.4-31.7 111.8-24.2C461.5 59.6 512 124.2 512 192.8v3.3c0 41.9-17.4 81.9-48.1 110.4L288.7 465.9l-2.5 2.3c-8.2 7.6-19 11.9-30.2 11.9s-22-4.2-30.2-11.9z',
|
|
solid: 'M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z',
|
|
},
|
|
star: {
|
|
viewBox: '0 0 576 512',
|
|
regular: 'M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L434.8 326.7l26.2 155.6c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7L288 439.6 149.7 507.5c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l26.2-155.6L31.1 218.2c-6.5-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L266.3 13.5C270.4 5.2 278.7 0 287.9 0z',
|
|
solid: 'M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 108.4 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3L288.1 439.8 420.9 508.3c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L437.7 329 542 225.9c8.6-8.4 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L380.7 150.3 316.9 18z',
|
|
},
|
|
} as const;
|
|
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
|
@customElement('track-list')
|
|
export class TrackList
|
|
extends ViewLifecycleMixin(LitElement)
|
|
implements SelectionHost, ContextMenuHost
|
|
{
|
|
/* Claimed while this list is the view on screen, which is what makes
|
|
* the `tracklist.*` bindings resolve at all: `data-shortcut-scope`
|
|
* was read by the shortcut service and set by nobody, so Enter and
|
|
* Delete were dead shortcuts the Settings page still advertised. */
|
|
protected override shortcutScope = 'tracklist';
|
|
|
|
/**
|
|
* When set, the list displays these tracks instead of
|
|
* fetching all tracks from the library store. The
|
|
* parent is responsible for reloading when data changes.
|
|
*/
|
|
@property({ type: Array, attribute: false })
|
|
externalTracks?: library.Track[];
|
|
|
|
/**
|
|
* What a host embedding this list (e.g. `genre-details`) should say
|
|
* a queue built from it came from. Unset when this list is showing
|
|
* the whole library — the one case with no host to ask, and where
|
|
* `effectiveQueueSource` supplies "All Tracks" itself.
|
|
*/
|
|
@property({ attribute: false })
|
|
queueSource?: QueueSource;
|
|
|
|
/**
|
|
* The library's own top-level Tracks view has no host to name a
|
|
* source — it *is* the source. Anything embedding this list with
|
|
* `externalTracks` is expected to set `queueSource` itself; if it
|
|
* doesn't, the queue is left undescribed rather than mislabeled.
|
|
*/
|
|
private get effectiveQueueSource(): QueueSource | undefined {
|
|
if (this.queueSource) return this.queueSource;
|
|
|
|
return this.externalTracks
|
|
? undefined
|
|
: { type: 'tracks', id: 0, label: 'All Tracks' };
|
|
}
|
|
|
|
/**
|
|
* Loading, empty and failed are three different things, and this
|
|
* list used to render all three as a permanent “Loading tracks…”
|
|
* — including on the first screen a new user ever sees, behind the
|
|
* first-run wizard (errors.M2, H-12). `home-view` is the model.
|
|
*/
|
|
@state() private loadingTracks = false;
|
|
@state() private loadError = '';
|
|
|
|
private player = new PlayerController(this);
|
|
private libraryCtrl = new LibraryController(this);
|
|
private searchCtrl = new SearchController(this);
|
|
private trackListCtrl = new TrackListController(this);
|
|
private favCtrl = new FavoritesController(this);
|
|
private selection = new SelectionController(this);
|
|
private ctxMenu = new ContextMenuController(this);
|
|
private lastSearchTerm = '';
|
|
|
|
/** Tracks the store's cached array reference to detect refreshes. */
|
|
private lastTracksRef: library.Track[] | null =
|
|
null;
|
|
|
|
/**
|
|
* Resolved column definitions for the currently configured
|
|
* column IDs. Falls back to defaults for any unknown ID.
|
|
*/
|
|
/**
|
|
* The columns the user has chosen — what a desktop draws, and what
|
|
* *anything* may be sorted by.
|
|
*
|
|
* This is deliberately separate from `activeColumns`: "which columns
|
|
* are drawn" and "what can I sort by" are different questions, and
|
|
* the phone is exactly where they diverge. Building the sort list
|
|
* from the drawn columns would silently take sort-by-artist and
|
|
* sort-by-album away from the phone, which has no other route to
|
|
* them since it has no column headers either.
|
|
*/
|
|
private get configuredColumns(): ColumnDef[] {
|
|
const ids = this.trackListCtrl.columnIds;
|
|
const chosen = !ids || ids.length === 0 ? DEFAULT_COLUMN_IDS : ids;
|
|
|
|
return chosen
|
|
.map((id) => COLUMN_DEFS[id])
|
|
.filter(
|
|
(d): d is ColumnDef =>
|
|
d !== undefined,
|
|
);
|
|
}
|
|
|
|
/** The columns actually drawn: two stacked lines on a phone. */
|
|
private get activeColumns(): ColumnDef[] {
|
|
if (!this.phone) return this.configuredColumns;
|
|
|
|
return PHONE_COLUMN_IDS
|
|
.map((id) => COLUMN_DEFS[id])
|
|
.filter(
|
|
(d): d is ColumnDef =>
|
|
d !== undefined,
|
|
);
|
|
}
|
|
|
|
@state()
|
|
private tracks: library.Track[] = [];
|
|
|
|
@query('#context-menu')
|
|
private contextMenuPopup!: WaPopup;
|
|
|
|
@query('#playlist-submenu')
|
|
private playlistSubmenuPopup!: WaPopup;
|
|
|
|
// -- ContextMenuHost interface --
|
|
|
|
getContextMenuPopup(): WaPopup | undefined {
|
|
return this.contextMenuPopup;
|
|
}
|
|
|
|
getPlaylistSubmenuPopup(): WaPopup | undefined {
|
|
return this.playlistSubmenuPopup;
|
|
}
|
|
|
|
@query('track-details')
|
|
private trackDetailsDialog!: TrackDetails;
|
|
|
|
@query('lit-virtualizer')
|
|
private virtualizer!: LitVirtualizer;
|
|
|
|
private lastActiveTrackPath: string | null = null;
|
|
|
|
// -- Memoisation caches for filtered / sorted tracks --
|
|
private cachedFilteredTracks: library.Track[] = [];
|
|
private cachedSortedTracks: library.Track[] = [];
|
|
private cachedRelevanceScores = new Map<
|
|
string,
|
|
number
|
|
>();
|
|
private prevFilterTracks: library.Track[] = [];
|
|
private prevFilterTerm = '';
|
|
private prevFilterColIds = '';
|
|
private prevSortFiltered: library.Track[] = [];
|
|
private prevSortField: string | null = null;
|
|
private prevSortDir: SortDirection = 'asc';
|
|
|
|
/** The row that holds the list's single tab stop.
|
|
*
|
|
* A grid of ten thousand rows must not be ten thousand tab stops,
|
|
* so one row is focusable at a time and the arrows move which — the
|
|
* standard roving tabindex. Before this the list had no keyboard
|
|
* path into it at all (H-5). */
|
|
@state() private focusedIndex = 0;
|
|
|
|
private handleSelectAll = (): void => {
|
|
this.selection.selectAll();
|
|
};
|
|
|
|
/** Arrow/Home/End move the focused row; Enter plays it.
|
|
*
|
|
* Enter is not handled here — it is the `tracklist.play` binding,
|
|
* which resolves because the list claims the `tracklist` shortcut
|
|
* scope while it is on screen. */
|
|
private onListKeydown = (e: KeyboardEvent): void => {
|
|
const last = this.cachedSortedTracks.length - 1;
|
|
|
|
if (last < 0) return;
|
|
|
|
let next = this.focusedIndex;
|
|
|
|
// The menu is most of what a row can do, and it was reachable
|
|
// only by right-click (a11y.3).
|
|
if (isContextMenuKey(e)) {
|
|
const track = this.cachedSortedTracks[this.focusedIndex];
|
|
const row = e.target instanceof HTMLElement
|
|
? e.target.closest<HTMLElement>('[role="row"]')
|
|
: null;
|
|
|
|
if (!track || !row) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.selection.handleContextMenu(track.FilePath);
|
|
this.ctxMenu.openFrom(row);
|
|
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
next = Math.min(this.focusedIndex + 1, last);
|
|
break;
|
|
case 'ArrowUp':
|
|
next = Math.max(this.focusedIndex - 1, 0);
|
|
break;
|
|
case 'Home':
|
|
next = 0;
|
|
break;
|
|
case 'End':
|
|
next = last;
|
|
break;
|
|
case ' ':
|
|
case 'Enter':
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.focusRow(next, { select: e.shiftKey || !e.ctrlKey });
|
|
};
|
|
|
|
/** Move the roving tab stop, bringing the row into view and
|
|
* selecting it so Enter and the context menu have a subject. */
|
|
private focusRow(
|
|
index: number,
|
|
opts: { select?: boolean } = {},
|
|
): void {
|
|
const track = this.cachedSortedTracks[index];
|
|
|
|
if (!track) return;
|
|
|
|
this.focusedIndex = index;
|
|
|
|
if (opts.select) {
|
|
this.selection.clear();
|
|
this.selection.handleItemClick(
|
|
new MouseEvent('click'),
|
|
track.FilePath,
|
|
index,
|
|
);
|
|
}
|
|
|
|
this.virtualizer?.scrollToIndex(index, 'nearest');
|
|
void this.updateComplete.then(() => {
|
|
this.shadowRoot
|
|
?.querySelector<HTMLElement>(
|
|
`.track-row[data-index="${index}"]`,
|
|
)
|
|
?.focus();
|
|
});
|
|
}
|
|
|
|
private clearSelectionHandler = (e: MouseEvent) => {
|
|
const path = e.composedPath();
|
|
const isTrackClick = path.some(
|
|
(el) =>
|
|
el instanceof HTMLElement &&
|
|
el.classList.contains('track-row') &&
|
|
this.shadowRoot?.contains(el),
|
|
);
|
|
|
|
if (!isTrackClick) {
|
|
this.selection.clear();
|
|
}
|
|
};
|
|
|
|
private dragImageEl: HTMLElement | null = null;
|
|
|
|
@state()
|
|
private columnWidths: number[] = [];
|
|
|
|
/** Column ID to sort by, or null for default order. */
|
|
@state()
|
|
private sortField: string | null = null;
|
|
|
|
/** Current sort direction. */
|
|
@state()
|
|
private sortDirection: SortDirection = 'asc';
|
|
|
|
/** Whether delegated event handlers have been attached to the virtualizer. */
|
|
private delegationAttached = false;
|
|
|
|
private resizingColumn: number | null = null;
|
|
private resizeStartX = 0;
|
|
private resizeStartWidths: number[] = [];
|
|
private resizeObserver: ResizeObserver | null =
|
|
null;
|
|
|
|
// _itemSize matches the fixed .track-row height (33px) so lit-virtualizer
|
|
// doesn't need to measure items. Without this hint, the default 100px
|
|
// estimate causes constant scroll error correction (scrollTo() calls)
|
|
// that produce visible jumping/skipping during scroll.
|
|
/**
|
|
* The virtualizer's item size and the CSS row height are the same
|
|
* number in two places, and they must agree: the layout positions
|
|
* rows from this figure, so a row that is really taller overlaps its
|
|
* neighbour and a shorter one leaves a gap. Both come from here.
|
|
*/
|
|
private flowLayout = flow({
|
|
_itemSize: { width: 100, height: ROW_HEIGHT },
|
|
} as Parameters<typeof flow>[0]);
|
|
|
|
private phoneFlowLayout = flow({
|
|
_itemSize: { width: 100, height: PHONE_ROW_HEIGHT },
|
|
} as Parameters<typeof flow>[0]);
|
|
|
|
private get rowLayout(): Parameters<typeof flow>[0] {
|
|
return this.phone ? this.phoneFlowLayout : this.flowLayout;
|
|
}
|
|
|
|
/**
|
|
* Phone width, from the shell's own breakpoint.
|
|
*
|
|
* A media query *inside* a shadow root is answered by the viewport,
|
|
* which is what lets every other component state what it drops at
|
|
* phone width in its own stylesheet. This list cannot: its grid is
|
|
* computed in JS from the host width, so the same threshold has to
|
|
* be readable from JS as well. One breakpoint, two expressions of
|
|
* it, and the reason is written here rather than inferred.
|
|
*/
|
|
@state()
|
|
private phone = matchMedia(PHONE_QUERY).matches;
|
|
|
|
private phoneQuery = matchMedia(PHONE_QUERY);
|
|
|
|
private onPhoneChange = (e: MediaQueryListEvent): void => {
|
|
this.phone = e.matches;
|
|
};
|
|
private hasRestoredScroll = false;
|
|
private scrollSaveRAFId: number | null = null;
|
|
|
|
// =================================================================
|
|
// Filtered / sorted tracks (memoised)
|
|
// =================================================================
|
|
|
|
/**
|
|
* Recompute the filtered and sorted track caches when
|
|
* their inputs have changed. Called from willUpdate()
|
|
* so the caches are ready before render().
|
|
*/
|
|
private recomputeTrackCaches() {
|
|
const term = this.searchCtrl.term;
|
|
const colIds =
|
|
this.trackListCtrl.columnIds.join(',');
|
|
|
|
if (
|
|
this.tracks !== this.prevFilterTracks ||
|
|
term !== this.prevFilterTerm ||
|
|
colIds !== this.prevFilterColIds
|
|
) {
|
|
this.prevFilterTracks = this.tracks;
|
|
this.prevFilterTerm = term;
|
|
this.prevFilterColIds = colIds;
|
|
this.cachedFilteredTracks =
|
|
this.computeFilteredTracks();
|
|
}
|
|
|
|
if (
|
|
this.cachedFilteredTracks !==
|
|
this.prevSortFiltered ||
|
|
this.sortField !== this.prevSortField ||
|
|
this.sortDirection !== this.prevSortDir
|
|
) {
|
|
this.prevSortFiltered =
|
|
this.cachedFilteredTracks;
|
|
this.prevSortField = this.sortField;
|
|
this.prevSortDir = this.sortDirection;
|
|
this.cachedSortedTracks =
|
|
this.computeSortedTracks();
|
|
}
|
|
}
|
|
|
|
private computeFilteredTracks(): library.Track[] {
|
|
const term = this.searchCtrl.term;
|
|
|
|
if (!term) {
|
|
this.cachedRelevanceScores.clear();
|
|
|
|
return this.tracks;
|
|
}
|
|
|
|
const result = rankTracks(
|
|
this.tracks,
|
|
term,
|
|
this.activeColumns,
|
|
);
|
|
|
|
this.cachedRelevanceScores = result.scores;
|
|
|
|
return result.tracks;
|
|
}
|
|
|
|
private computeSortedTracks(): library.Track[] {
|
|
const tracks = this.cachedFilteredTracks;
|
|
const hasSearch =
|
|
this.cachedRelevanceScores.size > 0;
|
|
const col = this.sortField
|
|
? COLUMN_DEFS[this.sortField]
|
|
: undefined;
|
|
const hasColSort = col?.comparator != null;
|
|
|
|
// No search, no column sort — default order.
|
|
if (!hasSearch && !hasColSort) return tracks;
|
|
|
|
// No search, column sort only — sort by column.
|
|
if (!hasSearch && hasColSort) {
|
|
const dir =
|
|
this.sortDirection === 'asc' ? 1 : -1;
|
|
|
|
return [...tracks].sort(
|
|
(a, b) =>
|
|
dir * col!.comparator!(a, b),
|
|
);
|
|
}
|
|
|
|
// Search active — relevance is primary sort,
|
|
// column sort (if any) is the tiebreaker.
|
|
const scores = this.cachedRelevanceScores;
|
|
const dir =
|
|
this.sortDirection === 'asc' ? 1 : -1;
|
|
|
|
return [...tracks].sort((a, b) => {
|
|
const sa = scores.get(a.FilePath) ?? 0;
|
|
const sb = scores.get(b.FilePath) ?? 0;
|
|
|
|
if (sa !== sb) return sb - sa;
|
|
|
|
if (hasColSort) {
|
|
return dir * col!.comparator!(a, b);
|
|
}
|
|
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
// =================================================================
|
|
// SelectionHost interface
|
|
// =================================================================
|
|
|
|
getItemKey(index: number): string | undefined {
|
|
return this.cachedSortedTracks[index]?.FilePath;
|
|
}
|
|
|
|
getItemCount(): number {
|
|
return this.cachedSortedTracks.length;
|
|
}
|
|
|
|
onSelectionChanged(): void {
|
|
this.virtualizer?.requestUpdate();
|
|
}
|
|
|
|
private get gridTemplateColumns(): string {
|
|
const cols = this.activeColumns;
|
|
const favCol = `${FAV_COL_WIDTH}px`;
|
|
|
|
if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
|
|
return (
|
|
favCol +
|
|
' ' +
|
|
cols
|
|
.map((c) => c.defaultWidth)
|
|
.join(' ')
|
|
);
|
|
}
|
|
|
|
return (
|
|
favCol +
|
|
' ' +
|
|
this.columnWidths
|
|
.map((w) => `${w}px`)
|
|
.join(' ')
|
|
);
|
|
}
|
|
|
|
private get colBoundaryPositions(): number[] {
|
|
if (this.columnWidths.length === 0) return [];
|
|
|
|
const positions: number[] = [];
|
|
let cumulative =
|
|
ROW_PADDING_X + FAV_COL_WIDTH;
|
|
|
|
for (
|
|
let i = 0;
|
|
i < this.columnWidths.length - 1;
|
|
i++
|
|
) {
|
|
cumulative += this.columnWidths[i] ?? 0;
|
|
positions.push(cumulative);
|
|
}
|
|
|
|
return positions;
|
|
}
|
|
|
|
private initColumnWidths() {
|
|
// A phone's widths are never the saved ones. `loadColumnWidths`
|
|
// is keyed by column *id* and fills a gap with
|
|
// `MIN_COLUMN_WIDTH`, so the phone's stacked column -- which
|
|
// nothing has ever saved a width for, there being no handles to
|
|
// drag -- came out at the minimum while the duration column
|
|
// inherited a width saved for a four-column desktop row. Found
|
|
// on the device: `24px 148px 236px`, the duration column with
|
|
// 55% of a phone's row.
|
|
if (this.phone) {
|
|
this.computeDefaultWidths();
|
|
|
|
return;
|
|
}
|
|
|
|
const saved = this.loadColumnWidths();
|
|
const cols = this.activeColumns;
|
|
|
|
if (saved && saved.length === cols.length) {
|
|
this.columnWidths = saved;
|
|
|
|
return;
|
|
}
|
|
|
|
this.computeDefaultWidths();
|
|
}
|
|
|
|
/**
|
|
* Width the resizable columns may share. H-7: this was the raw
|
|
* `clientWidth`, which the columns then summed to exactly — so
|
|
* every row was `ROW_CHROME_WIDTH` (40 px) wider than the box it
|
|
* had to fit in and the last column was always clipped.
|
|
*/
|
|
private get availableColumnWidth(): number {
|
|
return this.clientWidth - ROW_CHROME_WIDTH;
|
|
}
|
|
|
|
private computeDefaultWidths() {
|
|
const totalWidth = this.availableColumnWidth;
|
|
|
|
if (totalWidth <= 0) return;
|
|
|
|
const cols = this.activeColumns;
|
|
|
|
if (cols.length === 0) return;
|
|
|
|
// Fixed-width columns use their pixel default;
|
|
// flex columns share the remainder equally.
|
|
const fixedTotal = cols.reduce((sum, c) => {
|
|
if (c.defaultWidth.endsWith('px')) {
|
|
return (
|
|
sum +
|
|
parseInt(c.defaultWidth, 10)
|
|
);
|
|
}
|
|
|
|
return sum;
|
|
}, 0);
|
|
|
|
const flexCols = cols.filter(
|
|
(c) => !c.defaultWidth.endsWith('px'),
|
|
);
|
|
|
|
const remaining = Math.max(
|
|
0,
|
|
totalWidth - fixedTotal,
|
|
);
|
|
|
|
const perFlex =
|
|
flexCols.length > 0
|
|
? Math.floor(
|
|
remaining / flexCols.length,
|
|
)
|
|
: DEFAULT_FIXED_WIDTH;
|
|
|
|
const raw = cols.map((c) => {
|
|
if (c.defaultWidth.endsWith('px')) {
|
|
return parseInt(c.defaultWidth, 10);
|
|
}
|
|
|
|
return Math.max(
|
|
MIN_COLUMN_WIDTH,
|
|
perFlex,
|
|
);
|
|
});
|
|
|
|
this.columnWidths = this.normalizeWidths(raw);
|
|
}
|
|
|
|
private loadColumnWidths(): number[] | null {
|
|
try {
|
|
const raw = localStorage.getItem(
|
|
COLUMN_STORAGE_KEY,
|
|
);
|
|
|
|
if (!raw) return null;
|
|
|
|
const parsed: unknown = JSON.parse(raw);
|
|
|
|
// Support new id-keyed format: Record<string, number>.
|
|
if (
|
|
parsed !== null &&
|
|
typeof parsed === 'object' &&
|
|
!Array.isArray(parsed)
|
|
) {
|
|
const map = parsed as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const cols = this.activeColumns;
|
|
|
|
const widths = cols.map((c) => {
|
|
const w = map[c.id];
|
|
|
|
if (
|
|
typeof w === 'number' &&
|
|
w >= MIN_COLUMN_WIDTH
|
|
) {
|
|
return w;
|
|
}
|
|
|
|
// Fallback for columns without saved width.
|
|
if (
|
|
c.defaultWidth.endsWith('px')
|
|
) {
|
|
return parseInt(
|
|
c.defaultWidth,
|
|
10,
|
|
);
|
|
}
|
|
|
|
return MIN_COLUMN_WIDTH;
|
|
});
|
|
|
|
return this.normalizeWidths(widths);
|
|
}
|
|
|
|
// Legacy array format — discard on column count mismatch.
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private saveColumnWidths() {
|
|
// And a phone's widths are never *saved*: they are computed from
|
|
// a column set the user did not choose, and writing them would
|
|
// overwrite the width they dragged for the same column on a
|
|
// desktop. Nothing on a phone can resize a column anyway, so
|
|
// this is only reachable by a window crossing the breakpoint.
|
|
if (this.phone) return;
|
|
|
|
try {
|
|
const cols = this.activeColumns;
|
|
|
|
const map: Record<string, number> = {};
|
|
|
|
cols.forEach((c, i) => {
|
|
map[c.id] =
|
|
this.columnWidths[i] ??
|
|
MIN_COLUMN_WIDTH;
|
|
});
|
|
|
|
localStorage.setItem(
|
|
COLUMN_STORAGE_KEY,
|
|
JSON.stringify(map),
|
|
);
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scale widths so they sum to exactly the width available to the
|
|
* columns (the host minus the row chrome).
|
|
* Every column is guaranteed at least MIN_COLUMN_WIDTH.
|
|
*/
|
|
private normalizeWidths(
|
|
widths: number[],
|
|
): number[] {
|
|
const container = this.availableColumnWidth;
|
|
|
|
if (container <= 0 || widths.length === 0) {
|
|
return widths;
|
|
}
|
|
|
|
const minTotal =
|
|
widths.length * MIN_COLUMN_WIDTH;
|
|
|
|
// If the container can't even fit minimums,
|
|
// give every column the minimum.
|
|
if (container <= minTotal) {
|
|
return widths.map(
|
|
() => MIN_COLUMN_WIDTH,
|
|
);
|
|
}
|
|
|
|
const sum = widths.reduce(
|
|
(a, b) => a + b,
|
|
0,
|
|
);
|
|
|
|
if (sum <= 0) {
|
|
const even = Math.floor(
|
|
container / widths.length,
|
|
);
|
|
|
|
return widths.map(() =>
|
|
Math.max(MIN_COLUMN_WIDTH, even),
|
|
);
|
|
}
|
|
|
|
// Scale proportionally.
|
|
const scale = container / sum;
|
|
|
|
const scaled = widths.map((w) =>
|
|
Math.max(
|
|
MIN_COLUMN_WIDTH,
|
|
Math.round(w * scale),
|
|
),
|
|
);
|
|
|
|
// Fix rounding remainder so the total is
|
|
// exactly containerWidth.
|
|
const scaledSum = scaled.reduce(
|
|
(a, b) => a + b,
|
|
0,
|
|
);
|
|
|
|
const diff = container - scaledSum;
|
|
|
|
if (diff !== 0) {
|
|
// Apply remainder to the widest column.
|
|
let maxIdx = 0;
|
|
|
|
for (let i = 1; i < scaled.length; i++) {
|
|
if (
|
|
(scaled[i] ?? 0) >
|
|
(scaled[maxIdx] ?? 0)
|
|
) {
|
|
maxIdx = i;
|
|
}
|
|
}
|
|
|
|
scaled[maxIdx] =
|
|
(scaled[maxIdx] ?? 0) + diff;
|
|
}
|
|
|
|
return scaled;
|
|
}
|
|
|
|
/** perf.m4: the drag's document listeners exist while it is dragging
|
|
* and not before, which is the standard pattern and stops every
|
|
* pointer move in the app calling a handler that guards and
|
|
* returns. */
|
|
private attachColResizeListeners(on: boolean): void {
|
|
const fn = on ? 'addEventListener' : 'removeEventListener';
|
|
|
|
document[fn]('mousemove', this.onColResizeMove as EventListener);
|
|
document[fn]('mouseup', this.onColResizeEnd as EventListener);
|
|
}
|
|
|
|
private onColResizeStart = (e: MouseEvent, columnIndex: number) => {
|
|
e.preventDefault();
|
|
this.resizingColumn = columnIndex;
|
|
this.resizeStartX = e.clientX;
|
|
this.resizeStartWidths = [...this.columnWidths];
|
|
this.attachColResizeListeners(true);
|
|
this.requestUpdate();
|
|
};
|
|
|
|
private onColResizeMove = (e: MouseEvent) => {
|
|
if (this.resizingColumn === null) return;
|
|
|
|
const container = this.availableColumnWidth;
|
|
|
|
if (container <= 0) return;
|
|
|
|
const delta = e.clientX - this.resizeStartX;
|
|
const col = this.resizingColumn;
|
|
const starts = this.resizeStartWidths;
|
|
|
|
// Sum of columns to the left (unchanged).
|
|
let leftSum = 0;
|
|
|
|
for (let i = 0; i < col; i++) {
|
|
leftSum += starts[i] ?? 0;
|
|
}
|
|
|
|
// Count and sum of columns to the right.
|
|
const rightCount =
|
|
starts.length - col - 1;
|
|
|
|
let rightSum = 0;
|
|
|
|
for (
|
|
let i = col + 1;
|
|
i < starts.length;
|
|
i++
|
|
) {
|
|
rightSum += starts[i] ?? 0;
|
|
}
|
|
|
|
// Clamp dragged column: leave at least
|
|
// MIN_COLUMN_WIDTH for each right column.
|
|
const maxWidth =
|
|
container -
|
|
leftSum -
|
|
rightCount * MIN_COLUMN_WIDTH;
|
|
|
|
let newWidth = Math.max(
|
|
MIN_COLUMN_WIDTH,
|
|
Math.min(
|
|
maxWidth,
|
|
(starts[col] ?? 0) + delta,
|
|
),
|
|
);
|
|
|
|
const updated: number[] = new Array(
|
|
starts.length,
|
|
);
|
|
|
|
// Left columns keep starting widths.
|
|
for (let i = 0; i < col; i++) {
|
|
updated[i] = starts[i] ?? 0;
|
|
}
|
|
|
|
updated[col] = newWidth;
|
|
|
|
// Right columns always fill remaining space
|
|
// proportionally (handles both grow & shrink).
|
|
const availableForRight =
|
|
container - leftSum - newWidth;
|
|
|
|
if (rightCount === 0 || rightSum <= 0) {
|
|
// Nothing to distribute.
|
|
} else {
|
|
const scale =
|
|
availableForRight / rightSum;
|
|
|
|
let roundedSum = 0;
|
|
let maxIdx = -1;
|
|
let maxVal = 0;
|
|
|
|
for (
|
|
let i = col + 1;
|
|
i < starts.length;
|
|
i++
|
|
) {
|
|
const scaled = Math.max(
|
|
MIN_COLUMN_WIDTH,
|
|
Math.round(
|
|
(starts[i] ?? 0) * scale,
|
|
),
|
|
);
|
|
|
|
updated[i] = scaled;
|
|
roundedSum += scaled;
|
|
|
|
if (scaled > maxVal) {
|
|
maxVal = scaled;
|
|
maxIdx = i;
|
|
}
|
|
}
|
|
|
|
// Fix rounding remainder on the widest
|
|
// right column.
|
|
const diff =
|
|
availableForRight - roundedSum;
|
|
|
|
if (diff !== 0 && maxIdx >= 0) {
|
|
updated[maxIdx] =
|
|
(updated[maxIdx] ?? 0) + diff;
|
|
}
|
|
|
|
// Re-derive dragged width so total is
|
|
// exactly container.
|
|
newWidth =
|
|
container -
|
|
leftSum -
|
|
roundedSum -
|
|
diff;
|
|
|
|
if (newWidth < MIN_COLUMN_WIDTH) {
|
|
newWidth = MIN_COLUMN_WIDTH;
|
|
}
|
|
|
|
updated[col] = newWidth;
|
|
}
|
|
|
|
this.columnWidths = updated;
|
|
};
|
|
|
|
private onColResizeEnd = () => {
|
|
this.attachColResizeListeners(false);
|
|
|
|
if (this.resizingColumn === null) return;
|
|
|
|
this.resizingColumn = null;
|
|
this.saveColumnWidths();
|
|
this.requestUpdate();
|
|
};
|
|
|
|
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
height: 100%;
|
|
contain: layout style;
|
|
}
|
|
|
|
.table-container {
|
|
position: relative;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ---- Header row ---- */
|
|
|
|
.header-row {
|
|
display: grid;
|
|
grid-template-columns: var(--grid-cols);
|
|
padding: 8px;
|
|
font-weight: bold;
|
|
color: var(--yj-text-primary, #fff);
|
|
border-bottom: 1px solid
|
|
var(--yj-text-tertiary, #666);
|
|
flex-shrink: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header-cell {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.header-cell:hover {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.sort-arrow {
|
|
/* a11y.34. Was 10px, below the type scale's own floor, with a
|
|
comment acknowledging it. The finding's stated harm — "the sort
|
|
direction is a 10px glyph or nothing" — is half closed already:
|
|
Phase 1 gave these cells an aria-sort, so it is announced. What
|
|
is left is a sighted user reading it, and nothing in the header
|
|
needs it to be smaller than the smallest text in the app. */
|
|
font-size: var(--yj-text-xs, 11px);
|
|
flex-shrink: 0;
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.resize-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 2;
|
|
}
|
|
|
|
.col-resize-handle {
|
|
position: absolute;
|
|
top: 0;
|
|
height: 100%;
|
|
width: 1px;
|
|
cursor: col-resize;
|
|
pointer-events: auto;
|
|
background-color: var(--yj-border, #444);
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.col-resize-handle::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: -3px;
|
|
width: 7px;
|
|
height: 100%;
|
|
}
|
|
|
|
.col-resize-handle:hover,
|
|
.col-resize-handle.active {
|
|
background-color: var(--yj-text-tertiary, #6c757d);
|
|
}
|
|
|
|
.no-results {
|
|
padding: 24px 16px;
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
font-size: var(--yj-text-md);
|
|
}
|
|
|
|
lit-virtualizer {
|
|
flex: 1;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
user-select: none;
|
|
contain: paint;
|
|
overflow-anchor: none;
|
|
}
|
|
|
|
.track-row {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: var(--grid-cols);
|
|
font-size: var(--yj-text-sm);
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
|
align-items: center;
|
|
width: 100%;
|
|
cursor: default;
|
|
user-select: none;
|
|
overflow: hidden;
|
|
height: 33px;
|
|
box-sizing: border-box;
|
|
contain: strict;
|
|
}
|
|
|
|
/* A phone row is two lines, and this height must equal
|
|
PHONE_ROW_HEIGHT: the virtualizer positions rows from that number,
|
|
so a taller row overlaps its neighbour and a shorter one gaps. */
|
|
@media (max-width: 599px) {
|
|
.track-row {
|
|
height: 52px;
|
|
}
|
|
}
|
|
|
|
.stacked {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.stacked-title,
|
|
.stacked-sub {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.stacked-sub {
|
|
font-size: var(--yj-text-xs);
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
}
|
|
|
|
.track-row > * {
|
|
min-width: 0;
|
|
}
|
|
|
|
.header-row > :not(:first-child),
|
|
.track-row > :not(:first-child) {
|
|
padding-left: 6px;
|
|
}
|
|
|
|
.track-row:hover {
|
|
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
|
}
|
|
|
|
.track-row.selected {
|
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
|
}
|
|
|
|
.track-row.active {
|
|
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
|
|
}
|
|
|
|
.track-row.active {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
/* a11y.22: the playing row was a background tint and a text colour
|
|
and nothing else, so a colour-blind user could not find it
|
|
(WCAG 1.4.1). A triangle drawn in the row's own 8px left padding
|
|
is a *shape* that is present or absent, and it costs no layout:
|
|
the grid columns are computed from the host width and every one
|
|
of them would have had to move for a marker in the flow. Sized
|
|
to that padding rather than to the glyph a font would give. */
|
|
.track-row.active::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 1px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
border-left: 5px solid currentColor;
|
|
border-top: 4px solid transparent;
|
|
border-bottom: 4px solid transparent;
|
|
}
|
|
|
|
.track-row.selected.active {
|
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
|
}
|
|
|
|
.cell {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
cursor: default;
|
|
user-select: none;
|
|
}
|
|
|
|
.cell-right {
|
|
text-align: right;
|
|
}
|
|
|
|
.cell-center {
|
|
text-align: center;
|
|
}
|
|
|
|
.fav-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
flex-shrink: 0;
|
|
cursor: pointer;
|
|
color: var(--yj-text-tertiary, #666);
|
|
font-size: var(--yj-text-sm);
|
|
}
|
|
|
|
.fav-icon:hover {
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
|
|
.fav-icon.favorited {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.fav-icon.favorited:hover {
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.search-match {
|
|
background-color: rgba(255, 212, 59, 0.15);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.list-placeholder {
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
padding: 1em;
|
|
}
|
|
|
|
.placeholder-action {
|
|
background: none;
|
|
border: 1px solid var(--yj-border, #495057);
|
|
border-radius: 4px;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
margin-top: 0.5em;
|
|
padding: 4px 10px;
|
|
}
|
|
|
|
`];
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.restoreSortPreferences();
|
|
|
|
if (this.externalTracks) {
|
|
this.tracks = this.externalTracks;
|
|
} else {
|
|
this.loadTracks();
|
|
}
|
|
this.resizeObserver = new ResizeObserver(
|
|
() => {
|
|
this.onHostResize();
|
|
},
|
|
);
|
|
|
|
this.resizeObserver.observe(this);
|
|
|
|
// Connection, not the view lifecycle: this only sets state, so
|
|
// it is harmless (and wanted) while the list is off screen -- a
|
|
// rotation on another view must not leave this one laid out for
|
|
// the wrong width when the user comes back to it.
|
|
this.phoneQuery.addEventListener('change', this.onPhoneChange);
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
this.phoneQuery.removeEventListener('change', this.onPhoneChange);
|
|
|
|
// Remove delegated event handlers from virtualizer.
|
|
const virt = this.virtualizer;
|
|
if (virt) {
|
|
virt.removeEventListener('click', this.onDelegatedClick);
|
|
virt.removeEventListener('dblclick', this.onDelegatedDblClick);
|
|
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
|
|
virt.removeEventListener('dragstart', this.onDelegatedDragStart);
|
|
virt.removeEventListener('dragend', this.onTrackDragEnd);
|
|
}
|
|
this.delegationAttached = false;
|
|
|
|
this.virtualizer?.removeEventListener(
|
|
'visibilityChanged',
|
|
this.onVisibilityChanged,
|
|
);
|
|
this.hasRestoredScroll = false;
|
|
// A drag interrupted by the list going away still has to clean
|
|
// up after itself; these are no longer registered with the view
|
|
// lifecycle, so nothing else would.
|
|
this.attachColResizeListeners(false);
|
|
super.disconnectedCallback();
|
|
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
}
|
|
|
|
/** Document-level listeners belong to the *visible* list. A cached
|
|
* list is never disconnected, so this is the only place they can be
|
|
* taken down again. */
|
|
protected override onViewActivate(): void {
|
|
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
|
|
this.listenWhileActive(
|
|
document,
|
|
'shortcut:select-all',
|
|
this.handleSelectAll,
|
|
);
|
|
this.listenWhileActive(
|
|
document,
|
|
'shortcut:tracklist-play',
|
|
this.handleShortcutPlay,
|
|
);
|
|
this.listenWhileActive(
|
|
document,
|
|
'shortcut:tracklist-delete',
|
|
this.handleShortcutDelete,
|
|
);
|
|
|
|
// Credits arrive after the rows that asked for them. The
|
|
// virtualizer produces its rows from its *own* properties, so a
|
|
// host re-render alone repaints nothing — the same reason a
|
|
// selection change pushes requestUpdate() into it.
|
|
this.whileActive(
|
|
creditStore.subscribe(() => {
|
|
this.requestUpdate();
|
|
this.virtualizer?.requestUpdate();
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete opens the confirmation and does nothing else.
|
|
*
|
|
* That is the whole design of the binding: one keystroke from a
|
|
* focused row, a key that *asks* is defensible and a key that
|
|
* *acts* is not — so this is the same dialog the menu command
|
|
* opens, reached by a different route.
|
|
*/
|
|
private handleShortcutDelete = (): void => {
|
|
const filePaths = this.selection.getSelectedKeysOrdered();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
void this.removeFromLibrary(filePaths);
|
|
};
|
|
|
|
/** Enter plays the selection — the `tracklist.play` binding, which
|
|
* has existed in the defaults and in Settings since it was written
|
|
* and has never had anything on the other end of it. */
|
|
private handleShortcutPlay = (): void => {
|
|
this.playSelection(this.selection.getSelectedKeysOrdered());
|
|
};
|
|
|
|
/**
|
|
* "Play" means the same thing from the menu and from Enter, and it
|
|
* asks how much the user selected. One row is a position in the
|
|
* list - it queues the list from there, exactly as double-clicking
|
|
* does. Several rows are an explicit choice of *those* tracks, so
|
|
* they become the queue on their own (and `shuffleStart` applies,
|
|
* since no one row was named as the place to start).
|
|
*/
|
|
private playSelection(filePaths: string[]): void {
|
|
if (filePaths.length === 0) return;
|
|
|
|
if (filePaths.length === 1) {
|
|
const index = this.displayIndexOf(filePaths[0]!);
|
|
|
|
if (index >= 0) {
|
|
this.playFromRow(index);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
|
|
}
|
|
|
|
override willUpdate(
|
|
changed: Map<PropertyKey, unknown>,
|
|
) {
|
|
super.willUpdate(changed);
|
|
|
|
// When the parent provides a new external track
|
|
// list, update local tracks and reset selection.
|
|
if (
|
|
changed.has('externalTracks') &&
|
|
this.externalTracks
|
|
) {
|
|
this.tracks = this.externalTracks;
|
|
this.selection.clear();
|
|
}
|
|
|
|
this.recomputeTrackCaches();
|
|
}
|
|
|
|
override firstUpdated() {
|
|
this.initColumnWidths();
|
|
this.attachDelegation();
|
|
}
|
|
|
|
/**
|
|
* Attach delegated event handlers to the virtualizer.
|
|
* The virtualizer may not exist on first render (tracks
|
|
* still loading), so this is called from both
|
|
* firstUpdated() and updated() — guarded by a flag.
|
|
*/
|
|
private attachDelegation() {
|
|
if (this.delegationAttached) return;
|
|
|
|
const virt = this.virtualizer;
|
|
|
|
if (!virt) return;
|
|
|
|
virt.addEventListener('click', this.onDelegatedClick);
|
|
virt.addEventListener('dblclick', this.onDelegatedDblClick);
|
|
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
|
|
virt.addEventListener('dragstart', this.onDelegatedDragStart);
|
|
virt.addEventListener('dragend', this.onTrackDragEnd);
|
|
this.delegationAttached = true;
|
|
}
|
|
|
|
override updated(changed: Map<string, unknown>) {
|
|
// The virtualizer may not exist on first render
|
|
// (tracks still loading). Retry delegation here.
|
|
this.attachDelegation();
|
|
|
|
// Recompute widths when the column config changes.
|
|
const colKey = this.trackListCtrl.columnIds.join(
|
|
',',
|
|
);
|
|
|
|
if (colKey !== this.previousColumnIds) {
|
|
this.previousColumnIds = colKey;
|
|
this.initColumnWidths();
|
|
}
|
|
|
|
if (changed.has('columnWidths')) {
|
|
this.style.setProperty(
|
|
'--grid-cols',
|
|
this.gridTemplateColumns,
|
|
);
|
|
}
|
|
|
|
const currentPath =
|
|
this.player.currentTrack?.filePath ?? null;
|
|
|
|
if (currentPath !== this.lastActiveTrackPath) {
|
|
this.lastActiveTrackPath = currentPath;
|
|
this.virtualizer?.requestUpdate();
|
|
}
|
|
|
|
// Clear selection when search term changes.
|
|
const currentTerm = this.searchCtrl.term;
|
|
|
|
if (currentTerm !== this.lastSearchTerm) {
|
|
this.lastSearchTerm = currentTerm;
|
|
this.selection.clear();
|
|
}
|
|
|
|
// Re-fetch when the store delivers fresh
|
|
// data after eager refetch on invalidation.
|
|
if (!this.externalTracks) {
|
|
const cached =
|
|
this.libraryCtrl.cachedTracks;
|
|
|
|
if (
|
|
cached !== null &&
|
|
cached !== this.lastTracksRef
|
|
) {
|
|
this.lastTracksRef = cached;
|
|
this.loadTracks();
|
|
}
|
|
}
|
|
}
|
|
|
|
private previousHostWidth = 0;
|
|
private previousColumnIds = '';
|
|
|
|
private onHostResize() {
|
|
const newWidth = this.clientWidth;
|
|
|
|
if (
|
|
newWidth <= 0 ||
|
|
this.columnWidths.length === 0 ||
|
|
this.resizingColumn !== null
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (this.previousHostWidth === 0) {
|
|
this.previousHostWidth = newWidth;
|
|
|
|
return;
|
|
}
|
|
|
|
this.columnWidths = this.normalizeWidths(
|
|
this.columnWidths,
|
|
);
|
|
|
|
this.previousHostWidth = newWidth;
|
|
this.saveColumnWidths();
|
|
}
|
|
|
|
async loadTracks() {
|
|
this.loadingTracks = this.tracks.length === 0;
|
|
this.loadError = '';
|
|
|
|
try {
|
|
const tracks = await this.libraryCtrl.getTracks();
|
|
this.tracks = tracks;
|
|
|
|
// Keep what is still there. A refetch is not a
|
|
// deselection: this used to clear, so every finished track
|
|
// wiped the user's selection while music played (perf.C2).
|
|
// The keys are file paths, which survive a refetch.
|
|
const present = new Set(tracks.map((t) => t.FilePath));
|
|
this.selection.retain((key) => present.has(key));
|
|
|
|
await this.updateComplete;
|
|
|
|
if (this.isConnected && this.virtualizer) {
|
|
this.virtualizer.addEventListener(
|
|
'visibilityChanged',
|
|
this.onVisibilityChanged,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading tracks:', error);
|
|
this.loadError = describeError(
|
|
error,
|
|
'Your tracks could not be loaded.',
|
|
);
|
|
} finally {
|
|
this.loadingTracks = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What a screen reader is told about this list, in a sentence.
|
|
*
|
|
* Loading, failed, empty and "n results for a search" were all
|
|
* silent — the list said them in text nobody was watching (a11y.12).
|
|
*/
|
|
private liveStatus(visible: number): string {
|
|
if (this.loadError) return this.loadError;
|
|
|
|
if (this.loadingTracks) return 'Loading tracks…';
|
|
|
|
if (this.tracks.length === 0) return 'No tracks.';
|
|
|
|
const term = this.searchCtrl.term.trim();
|
|
|
|
if (term === '') return '';
|
|
|
|
return visible === 0
|
|
? `No tracks match “${term}”.`
|
|
: `${visible} track${visible === 1 ? '' : 's'} match “${term}”.`;
|
|
}
|
|
|
|
/** Loading / failed / genuinely empty, said apart. */
|
|
private renderPlaceholder() {
|
|
if (this.loadError) {
|
|
return html`
|
|
<div class="list-placeholder" data-testid="track-list-error">
|
|
<p>${this.loadError}</p>
|
|
<button
|
|
type="button"
|
|
class="placeholder-action"
|
|
@click=${() => void this.loadTracks()}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (this.loadingTracks) {
|
|
return html`<p class="list-placeholder" data-testid="track-list-loading">
|
|
Loading tracks…
|
|
</p>`;
|
|
}
|
|
|
|
return html`<p class="list-placeholder" data-testid="track-list-empty">
|
|
${this.externalTracks
|
|
? 'Nothing here yet.'
|
|
: 'No tracks yet — add a folder in Settings to get started.'}
|
|
</p>`;
|
|
}
|
|
|
|
private onVisibilityChanged = (e: Event) => {
|
|
const { first } = e as VisibilityChangedEvent;
|
|
|
|
if (!this.hasRestoredScroll) {
|
|
this.hasRestoredScroll = true;
|
|
|
|
const savedIndex =
|
|
this.libraryCtrl.getScrollPosition('tracks');
|
|
|
|
if (savedIndex > 0) {
|
|
requestAnimationFrame(() => {
|
|
this.virtualizer?.scrollToIndex(
|
|
savedIndex,
|
|
'start',
|
|
);
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// RAF-throttle scroll position saves — at most once per frame.
|
|
// Without this, every visibilityChanged (fired per-item during
|
|
// scroll) writes to the store synchronously, adding main-thread
|
|
// work during the scroll hot path.
|
|
if (this.scrollSaveRAFId === null) {
|
|
this.scrollSaveRAFId = requestAnimationFrame(() => {
|
|
this.scrollSaveRAFId = null;
|
|
this.libraryCtrl.setScrollPosition('tracks', first);
|
|
});
|
|
}
|
|
};
|
|
|
|
// =================================================================
|
|
// Delegated event handlers (stable references, zero per-item closures)
|
|
// =================================================================
|
|
|
|
/**
|
|
* Walk up from the event target to find the nearest
|
|
* `.track-row` and extract track + index via `data-index`.
|
|
*/
|
|
private resolveTrackFromEvent(
|
|
e: Event,
|
|
): { track: library.Track; index: number } | null {
|
|
const row = (e.target as HTMLElement).closest(
|
|
'.track-row',
|
|
) as HTMLElement | null;
|
|
|
|
if (!row) return null;
|
|
|
|
const idx = Number(row.dataset.index);
|
|
const track = this.cachedSortedTracks[idx];
|
|
|
|
if (!track) return null;
|
|
|
|
return { track, index: idx };
|
|
}
|
|
|
|
private onDelegatedClick = (e: MouseEvent) => {
|
|
const hit = this.resolveTrackFromEvent(e);
|
|
|
|
if (!hit) return;
|
|
|
|
// Check if click was on fav-icon
|
|
const favEl = (e.target as HTMLElement).closest(
|
|
'.fav-icon',
|
|
);
|
|
|
|
if (favEl) {
|
|
e.stopPropagation();
|
|
void this.favCtrl.toggleFavorite(
|
|
hit.track.FilePath,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
this.onTrackRowClick(e, hit.track, hit.index);
|
|
};
|
|
|
|
private onDelegatedDblClick = (e: MouseEvent) => {
|
|
const hit = this.resolveTrackFromEvent(e);
|
|
|
|
if (hit) this.onTrackRowDblClick(hit.track, hit.index);
|
|
};
|
|
|
|
private onDelegatedContextMenu = (e: MouseEvent) => {
|
|
const hit = this.resolveTrackFromEvent(e);
|
|
|
|
if (hit) this.onTrackContextMenu(e, hit.track);
|
|
};
|
|
|
|
private onDelegatedDragStart = (e: DragEvent) => {
|
|
const hit = this.resolveTrackFromEvent(e);
|
|
|
|
if (hit) this.onTrackDragStart(e, hit.track);
|
|
};
|
|
|
|
// =================================================================
|
|
// Original handlers (still used internally)
|
|
// =================================================================
|
|
|
|
private onTrackRowClick(
|
|
e: MouseEvent,
|
|
track: library.Track,
|
|
index: number,
|
|
) {
|
|
// Clicking is also how the keyboard's starting point is chosen:
|
|
// tabbing back into the list should land where the user was.
|
|
this.focusedIndex = index;
|
|
this.selection.handleItemClick(e, track.FilePath, index);
|
|
}
|
|
|
|
private onTrackRowDblClick(_track: library.Track, index: number) {
|
|
this.selection.clear();
|
|
this.playFromRow(index);
|
|
}
|
|
|
|
/**
|
|
* Activating one row plays the list that row is in, from that row -
|
|
* the library, the artist or the genre the user is looking at, not
|
|
* a queue of one. The paths come from `cachedSortedTracks`, so it
|
|
* is the list as *displayed*: whatever the current sort, search and
|
|
* library filter have made of it, which is the only order the user
|
|
* can see and therefore the only one they can mean.
|
|
*/
|
|
private playFromRow(index: number) {
|
|
const filePaths = this.cachedSortedTracks.map(
|
|
(t) => t.FilePath,
|
|
);
|
|
|
|
if (filePaths.length === 0 || index < 0) return;
|
|
|
|
queueStore.setQueue(
|
|
filePaths,
|
|
index,
|
|
false,
|
|
this.effectiveQueueSource,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Where a "play this" command lands in the displayed list, or -1.
|
|
*
|
|
* Selection keys are file paths, which survive the re-sorts and
|
|
* refetches an index does not - so the index is looked up at the
|
|
* moment it is used rather than remembered.
|
|
*/
|
|
private displayIndexOf(filePath: string): number {
|
|
return this.cachedSortedTracks.findIndex(
|
|
(t) => t.FilePath === filePath,
|
|
);
|
|
}
|
|
|
|
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.selection.handleContextMenu(track.FilePath);
|
|
this.ctxMenu.openAt(e.clientX, e.clientY);
|
|
}
|
|
|
|
// =================================================================
|
|
// Drag source
|
|
// =================================================================
|
|
|
|
private onTrackDragStart = (
|
|
e: DragEvent,
|
|
track: library.Track,
|
|
) => {
|
|
// Gather file paths: all selected if this track is selected,
|
|
// otherwise just the dragged track.
|
|
let filePaths: string[];
|
|
|
|
if (this.selection.isSelected(track.FilePath)) {
|
|
filePaths =
|
|
this.selection.getSelectedKeysOrdered();
|
|
} else {
|
|
filePaths = [track.FilePath];
|
|
}
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
setDragPayload(e, {
|
|
filePaths,
|
|
source: 'track-list',
|
|
});
|
|
|
|
// Custom drag image.
|
|
this.dragImageEl =
|
|
filePaths.length === 1
|
|
? createTrackCardDragImage(
|
|
track.TrackName,
|
|
track.ArtistName,
|
|
track.FilePath,
|
|
)
|
|
: createDragImage(filePaths.length);
|
|
e.dataTransfer?.setDragImage(
|
|
this.dragImageEl,
|
|
0,
|
|
0,
|
|
);
|
|
|
|
emitDragActive(true);
|
|
};
|
|
|
|
private onTrackDragEnd = () => {
|
|
if (this.dragImageEl) {
|
|
removeDragImage(this.dragImageEl);
|
|
this.dragImageEl = null;
|
|
}
|
|
|
|
emitDragActive(false);
|
|
};
|
|
|
|
private onContextMenuAction(action: string) {
|
|
const filePaths =
|
|
this.selection.getSelectedKeysOrdered();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
switch (action) {
|
|
case 'play':
|
|
this.playSelection(filePaths);
|
|
break;
|
|
case 'add-to-queue':
|
|
queueStore.addTracksToQueue(filePaths);
|
|
break;
|
|
case 'play-next':
|
|
queueStore.playTracksNext(filePaths);
|
|
break;
|
|
case 'track-details':
|
|
if (filePaths.length === 1) {
|
|
void this.openTrackDetails(filePaths[0]!);
|
|
} else {
|
|
void this.openBatchTrackDetails(filePaths);
|
|
}
|
|
break;
|
|
case 'remove-from-library':
|
|
// The only destructive command in this menu: it asks
|
|
// first, and it keeps the selection until the user has
|
|
// answered — the dialog names a count, and clearing the
|
|
// selection under it would make that count a claim
|
|
// about nothing.
|
|
this.ctxMenu.close();
|
|
void this.removeFromLibrary(filePaths);
|
|
|
|
return;
|
|
}
|
|
|
|
this.selection.clear();
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
/**
|
|
* "Remove from library", behind a confirmation that says what it
|
|
* does *and* what it does not.
|
|
*
|
|
* The second half is the point. This deletes the database rows and
|
|
* stops the scanner importing those paths again; the audio files
|
|
* are left exactly where they are. A user who reads "remove" as
|
|
* "delete" and finds their music gone would have been failed by the
|
|
* copy, not by the operation — so the copy says so in the impact
|
|
* line, where the consequence of every other destructive action in
|
|
* the app is written.
|
|
*/
|
|
private async removeFromLibrary(filePaths: string[]) {
|
|
const count = filePaths.length;
|
|
const only =
|
|
count === 1
|
|
? tracksByFilePath(this.tracks).get(filePaths[0]!)
|
|
: undefined;
|
|
|
|
const ok = await confirmAction({
|
|
title:
|
|
count === 1
|
|
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
|
|
: `Remove ${count.toLocaleString()} tracks from the library?`,
|
|
message:
|
|
count === 1
|
|
? 'It is removed from YellowJacket and will not be added' +
|
|
' back by a future scan.'
|
|
: 'They are removed from YellowJacket and will not be' +
|
|
' added back by a future scan.',
|
|
impact:
|
|
count === 1
|
|
? 'The file is not deleted — it stays on disk exactly' +
|
|
' where it is. A full rescan brings it back.'
|
|
: 'The files are not deleted — they stay on disk exactly' +
|
|
' where they are. A full rescan brings them back.',
|
|
confirmLabel:
|
|
count === 1
|
|
? 'Remove track'
|
|
: `Remove ${count.toLocaleString()} tracks`,
|
|
danger: true,
|
|
});
|
|
|
|
if (!ok) return;
|
|
|
|
try {
|
|
await RemoveFromLibrary(filePaths);
|
|
this.selection.clear();
|
|
} catch (error) {
|
|
console.error('Error removing tracks from library:', error);
|
|
notificationStore.persistent({
|
|
title: 'Could not remove from library',
|
|
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private onContextMenuFavoriteToggle() {
|
|
const filePaths =
|
|
this.selection.getSelectedKeysOrdered();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
if (this.favCtrl.allFavorited(filePaths)) {
|
|
void this.favCtrl.removeFromFavorites(
|
|
filePaths,
|
|
);
|
|
} else {
|
|
void this.favCtrl.addToFavorites(
|
|
filePaths,
|
|
);
|
|
}
|
|
|
|
this.selection.clear();
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
private async openTrackDetails(filePath: string) {
|
|
const track = tracksByFilePath(this.tracks).get(
|
|
filePath,
|
|
);
|
|
|
|
if (!track) return;
|
|
|
|
const ready = await loadTrackDetails(
|
|
() => void this.openTrackDetails(filePath),
|
|
);
|
|
|
|
if (!ready) return;
|
|
|
|
const coverArt = track.CoverArtPath
|
|
? {
|
|
coverArtPath: track.CoverArtPath,
|
|
coverArtSmall: track.CoverArtSmall,
|
|
coverArtMedium: track.CoverArtMedium,
|
|
coverArtLarge: track.CoverArtLarge,
|
|
}
|
|
: undefined;
|
|
|
|
this.trackDetailsDialog?.show(
|
|
track,
|
|
coverArt,
|
|
);
|
|
}
|
|
|
|
private async openBatchTrackDetails(
|
|
filePaths: string[],
|
|
) {
|
|
const tracks = tracksForPaths(
|
|
this.tracks,
|
|
filePaths,
|
|
);
|
|
|
|
if (tracks.length === 0) return;
|
|
|
|
const ready = await loadTrackDetails(
|
|
() => void this.openBatchTrackDetails(filePaths),
|
|
);
|
|
|
|
if (!ready) return;
|
|
|
|
// Use cover art from the first track. If all tracks share
|
|
// the same album, they share the same art.
|
|
const first = tracks[0]!;
|
|
let coverArt: CoverArtUrls | null = null;
|
|
let coverArtMixed = false;
|
|
|
|
const albumNames = new Set(tracks.map((t) => t.Album));
|
|
|
|
if (albumNames.size === 1 && first.CoverArtPath) {
|
|
coverArt = {
|
|
coverArtPath: first.CoverArtPath,
|
|
coverArtSmall: first.CoverArtSmall,
|
|
coverArtMedium: first.CoverArtMedium,
|
|
coverArtLarge: first.CoverArtLarge,
|
|
};
|
|
} else if (albumNames.size > 1) {
|
|
coverArtMixed = true;
|
|
}
|
|
|
|
this.trackDetailsDialog?.showBatch(
|
|
tracks,
|
|
coverArt,
|
|
coverArtMixed,
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// Sort controls
|
|
// =================================================================
|
|
|
|
/** Restore sort preferences from localStorage. */
|
|
private restoreSortPreferences() {
|
|
try {
|
|
const field =
|
|
localStorage.getItem(SORT_FIELD_KEY);
|
|
const dir =
|
|
localStorage.getItem(SORT_DIR_KEY);
|
|
|
|
if (
|
|
field &&
|
|
COLUMN_DEFS[field]?.comparator
|
|
) {
|
|
this.sortField = field;
|
|
}
|
|
|
|
if (dir === 'asc' || dir === 'desc') {
|
|
this.sortDirection = dir;
|
|
}
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
/** Persist sort preferences to localStorage. */
|
|
private saveSortPreferences() {
|
|
try {
|
|
if (this.sortField) {
|
|
localStorage.setItem(
|
|
SORT_FIELD_KEY,
|
|
this.sortField,
|
|
);
|
|
} else {
|
|
localStorage.removeItem(
|
|
SORT_FIELD_KEY,
|
|
);
|
|
}
|
|
|
|
localStorage.setItem(
|
|
SORT_DIR_KEY,
|
|
this.sortDirection,
|
|
);
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a click on a column header to toggle sorting.
|
|
* First click: sort ascending. Second: descending.
|
|
* Third: clear sort (back to default order).
|
|
*/
|
|
private onHeaderCellClick(colId: string) {
|
|
const col = COLUMN_DEFS[colId];
|
|
|
|
if (!col?.comparator) return;
|
|
|
|
if (this.sortField === colId) {
|
|
if (this.sortDirection === 'asc') {
|
|
this.sortDirection = 'desc';
|
|
} else {
|
|
this.sortField = null;
|
|
this.sortDirection = 'asc';
|
|
}
|
|
} else {
|
|
this.sortField = colId;
|
|
this.sortDirection = 'asc';
|
|
}
|
|
|
|
this.saveSortPreferences();
|
|
}
|
|
|
|
private isActiveTrack(track: library.Track): boolean {
|
|
const currentTrack = this.player.currentTrack;
|
|
|
|
if (!currentTrack) return false;
|
|
|
|
return currentTrack.filePath === track.FilePath;
|
|
}
|
|
|
|
private renderTrackRow = (
|
|
track: library.Track,
|
|
index: number,
|
|
): unknown => {
|
|
const active = this.isActiveTrack(track);
|
|
const selected = this.selection.isSelected(
|
|
track.FilePath,
|
|
);
|
|
|
|
const cols = this.activeColumns;
|
|
|
|
const isFav = this.favCtrl.isFavorited(
|
|
track.FilePath,
|
|
);
|
|
|
|
// Inline SVG instead of wa-icon — eliminates a shadow DOM tree
|
|
// per visible row (~30-50 during scroll).
|
|
const iconDef = FAV_ICONS[this.favCtrl.iconStyle === 'star' ? 'star' : 'heart'];
|
|
const iconPath = isFav ? iconDef.solid : iconDef.regular;
|
|
|
|
// No inline closures — all events delegated via data-index
|
|
// on the virtualizer element (see firstUpdated).
|
|
const term = this.searchCtrl.term;
|
|
return html`
|
|
<div
|
|
class=${classMap({
|
|
'track-row': true,
|
|
active,
|
|
selected,
|
|
})}
|
|
role="row"
|
|
aria-rowindex=${index + 1}
|
|
aria-selected=${selected}
|
|
aria-current=${active ? 'true' : 'false'}
|
|
tabindex=${index === this.focusedIndex ? 0 : -1}
|
|
draggable="true"
|
|
data-index=${index}
|
|
data-testid="track-row"
|
|
data-file-path=${track.FilePath}
|
|
>
|
|
<div
|
|
role="gridcell"
|
|
class=${classMap({
|
|
'fav-icon': true,
|
|
favorited: isFav,
|
|
})}
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${iconDef.viewBox.split(' ').slice(2).join(' ')}" width="14" height="14">
|
|
${svg`<path fill="currentColor" d="${iconPath}"/>`}
|
|
</svg>
|
|
</div>
|
|
${cols.map((col) => {
|
|
const customCell = col.renderCell?.(track, term);
|
|
if (customCell !== undefined && customCell !== nothing) {
|
|
return html`<div role="gridcell" class="cell">${customCell}</div>`;
|
|
}
|
|
const val = col.accessor(track);
|
|
const centered = val === '\u2014';
|
|
let display: unknown = term
|
|
? highlightText(val, term)
|
|
: val;
|
|
|
|
// Wrap artist/album/track values in explore links.
|
|
if (col.id === 'trackName') {
|
|
display = trackLink(track.TrackName, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, display as any, track.ArtistName);
|
|
} else if (col.id === 'artistName') {
|
|
// A search term highlights the *flat* credit string,
|
|
// and mapping those spans onto decomposed parts is a
|
|
// different problem from rendering the credit. While
|
|
// filtering, the single link is the honest answer.
|
|
creditStore.request(track.RecordingMBID);
|
|
const parts = term ? undefined : creditStore.get(track.RecordingMBID);
|
|
display = parts && parts.length > 1
|
|
? creditLink(parts, track.ArtistName, track.ArtistMBID)
|
|
: artistLink(track.ArtistName, track.ArtistMBID, display as any);
|
|
} else if (col.id === 'album') {
|
|
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any, track.ArtistName);
|
|
}
|
|
|
|
// `title` on the cell rather than on whatever is inside
|
|
// it: the value may be a link, a highlighted match or
|
|
// plain text, and a tooltip is inherited by descendants
|
|
// either way (a11y.24).
|
|
return html`
|
|
<div role="gridcell" title=${val} class=${classMap({
|
|
cell: true,
|
|
'cell-center': centered,
|
|
'cell-right': !centered && col.align === 'right',
|
|
})}>
|
|
${display}
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
/**
|
|
* The page header, which carries what used to be a hand-rolled
|
|
* sort toolbar written out twice (here and in `cover-grid`).
|
|
*
|
|
* `externalTracks` means this list is a section of some other page
|
|
* — the genre and playlist details — which has a heading already,
|
|
* so the header keeps the count and the sort and drops the title.
|
|
*/
|
|
private renderPageHeader() {
|
|
const options: SortOption[] = [
|
|
{ id: '', label: 'Default' },
|
|
...this.configuredColumns
|
|
.filter((c) => c.comparator)
|
|
.map((c) => ({ id: c.id, label: c.label })),
|
|
];
|
|
|
|
return html`
|
|
<page-header
|
|
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
|
|
.count=${this.loadingTracks
|
|
? null
|
|
: this.cachedSortedTracks.length}
|
|
count-noun="track"
|
|
.sortOptions=${options}
|
|
sort-field=${this.sortField ?? ''}
|
|
sort-direction=${this.sortDirection}
|
|
search-term=${this.searchCtrl.term}
|
|
@sort-change=${this.onPageHeaderSort}
|
|
></page-header>
|
|
`;
|
|
}
|
|
|
|
private onPageHeaderSort = (
|
|
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
|
) => {
|
|
this.sortField =
|
|
e.detail.field === '' ? null : e.detail.field;
|
|
this.sortDirection = e.detail.direction;
|
|
this.saveSortPreferences();
|
|
};
|
|
|
|
override render() {
|
|
const visibleTracks = this.cachedSortedTracks;
|
|
const cols = this.activeColumns;
|
|
|
|
return html`
|
|
${this.renderPageHeader()}
|
|
<div class="sr-only" role="status" aria-live="polite">
|
|
${this.liveStatus(visibleTracks.length)}
|
|
</div>
|
|
${this.tracks.length === 0
|
|
? this.renderPlaceholder()
|
|
: html`
|
|
<div
|
|
class="table-container"
|
|
role="grid"
|
|
aria-label="Tracks"
|
|
aria-rowcount=${visibleTracks.length}
|
|
aria-busy=${this.loadingTracks}
|
|
@keydown=${this.onListKeydown}
|
|
>
|
|
${this.phone ? nothing : html`<div class="header-row" role="row">
|
|
<div role="columnheader" aria-label="Favourite"></div>
|
|
${cols.map(
|
|
(col) => html`
|
|
<div
|
|
role="columnheader"
|
|
tabindex="0"
|
|
aria-sort=${this.sortField === col.id
|
|
? (this.sortDirection === 'asc' ? 'ascending' : 'descending')
|
|
: 'none'}
|
|
class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
|
|
@click=${() =>
|
|
this.onHeaderCellClick(
|
|
col.id,
|
|
)}
|
|
@keydown=${(e: KeyboardEvent) => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.onHeaderCellClick(col.id);
|
|
}}
|
|
>
|
|
<span>${col.label}</span>
|
|
${this.sortField === col.id
|
|
? html`<span
|
|
class="sort-arrow"
|
|
>
|
|
${this.sortDirection === 'asc' ? '\u25B2' : '\u25BC'}
|
|
</span>`
|
|
: nothing}
|
|
</div>
|
|
`,
|
|
)}
|
|
</div>`}
|
|
${visibleTracks.length === 0
|
|
? html`<p class="no-results">
|
|
No tracks match your search.
|
|
</p>`
|
|
: html`
|
|
<lit-virtualizer
|
|
scroller
|
|
.items=${visibleTracks}
|
|
.renderItem=${this.renderTrackRow}
|
|
.keyFunction=${(track: library.Track) => track.FilePath}
|
|
.layout=${this.rowLayout}
|
|
></lit-virtualizer>
|
|
`}
|
|
|
|
<!-- Resizing is a pointer gesture with no touch equivalent, and
|
|
the phone's two columns are not the user's to arrange. -->
|
|
<div class="resize-overlay">
|
|
${(this.phone ? [] : this.colBoundaryPositions).map(
|
|
(pos, i) => html`
|
|
<div
|
|
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
|
style="left: ${pos}px"
|
|
@mousedown=${(e: MouseEvent) =>
|
|
this.onColResizeStart(e, i)}
|
|
></div>
|
|
`,
|
|
)}
|
|
</div>
|
|
</div>
|
|
`}
|
|
|
|
<wa-popup
|
|
id="context-menu"
|
|
placement="bottom-start"
|
|
flip
|
|
shift
|
|
.active=${this.ctxMenu.contextMenuOpen}
|
|
>
|
|
${this.ctxMenu.contextMenuOpen
|
|
? html`
|
|
<div class="context-menu-panel" role="menu" aria-label="Track actions">
|
|
<wa-dropdown-item
|
|
@click=${() => this.onContextMenuAction('play')}
|
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon slot="icon" name="play"></wa-icon>
|
|
Play
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() => this.onContextMenuAction('add-to-queue')}
|
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
|
|
Add to Queue
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() => this.onContextMenuAction('play-next')}
|
|
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon slot="icon" name="forward-step"></wa-icon>
|
|
Play Next
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
class="submenu-item"
|
|
@mouseenter=${() => {
|
|
this.ctxMenu.clearSubmenuCloseTimer();
|
|
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
|
}}
|
|
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
|
@click=${(e: Event) => {
|
|
e.stopPropagation();
|
|
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
|
|
}}
|
|
>
|
|
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
|
|
Add to Playlist
|
|
<span class="submenu-arrow">▶</span>
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuFavoriteToggle()}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name=${this.favCtrl.iconName}
|
|
></wa-icon>
|
|
${this.favCtrl.allFavorited(this.selection.getSelectedKeysOrdered()) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'track-details',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="circle-info"
|
|
></wa-icon>
|
|
Track Details
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'remove-from-library',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="trash"
|
|
></wa-icon>
|
|
Remove from Library
|
|
</wa-dropdown-item>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
|
|
<wa-popup
|
|
id="playlist-submenu"
|
|
placement="right-start"
|
|
flip
|
|
shift
|
|
.active=${this.ctxMenu.playlistSubmenuOpen}
|
|
>
|
|
${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection
|
|
? html`
|
|
<div
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.clearSubmenuCloseTimer()}
|
|
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
|
|
>
|
|
<playlist-picker
|
|
.filePaths=${this.ctxMenu.playlistFilePaths}
|
|
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
|
|
@click=${(e: Event) => e.stopPropagation()}
|
|
></playlist-picker>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
|
|
<track-details></track-details>
|
|
`;
|
|
}
|
|
}
|