Fourteen call sites, one tag name each and nothing else -- which is what menu-surface's shape buys: the host's panel is slotted into whichever presentation is up, so no item model, no keyboard model and no styling moved. The 48px rows come from contextMenuStyles, the one stylesheet every one of these hosts already includes, because the panel is the host's own light DOM and only the host's stylesheet can reach it. Two of the fourteen were found by the source sweep rather than by the conversion: queue-panel's add-to-playlist popup, which is a real menu. now-playing's cover preview is allowlisted instead -- it is a hover affordance in the bottom bar, so a touch device never opens it and nothing clips it. The playlist submenu had to come too, and that is the one place this change made something worse before it made it better. It is a placement="right-start" flyout anchored to its row, and making the menu full-width moved that anchor to x=0 -- so the flip put the picker at x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at all. Before the change the row started at x~245 and the same flip landed on screen. It is a sheet now and stacks over the first, which is also why menu-shown does not re-assert focus while it is open. The three hosts that do not use ContextMenuController -- page-header's overflow menu, playlist-view's hand-rolled menu, queue-panel's picker -- bind menu-dismiss themselves, or Escape would close the sheet and leave their own open flag set. page-header is included deliberately: the clipping does not bite there, since it opens downward from the top of a full-height view, but on a phone every action of an overflowing page lives in that menu at wa-dropdown-item defaults. One surface, so there is no second answer to what a menu looks like.
1558 lines
47 KiB
TypeScript
1558 lines
47 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import {
|
|
customElement,
|
|
state,
|
|
query,
|
|
} from 'lit/decorators.js';
|
|
import '@lit-labs/virtualizer';
|
|
import type {
|
|
LitVirtualizer,
|
|
VisibilityChangedEvent,
|
|
} from '@lit-labs/virtualizer';
|
|
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
|
import { gridSpacingFor } from '@utils/grid-spacing';
|
|
import {
|
|
GetAlbumsByArtist,
|
|
GetFilePathsByAlbums,
|
|
} from '@go/library/library.js';
|
|
import * as library from '@go/library/models.js';
|
|
import { LibraryController } from '@store/controllers/library-controller';
|
|
import { libraryStore } from '@store/library-store';
|
|
import { SearchController } from '@store/controllers/search-controller';
|
|
import '@components/page-header/page-header';
|
|
import { queueStore } from '@store/queue-store';
|
|
import {
|
|
ContextMenuController,
|
|
contextMenuStyles,
|
|
isContextMenuKey,
|
|
} from '@utils/context-menu-controller.js';
|
|
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
|
import { RovingGridController } from '@utils/roving-grid';
|
|
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import type { MenuSurface } from '../menu-surface/menu-surface';
|
|
import '../menu-surface/menu-surface';
|
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
|
import '@components/playlist-picker/playlist-picker.js';
|
|
import { dict, list } from '@utils/binding';
|
|
import {
|
|
ICON_PLAYLIST,
|
|
ICON_QUEUE,
|
|
} from '@utils/icon-language';
|
|
|
|
/** Pixels to change card width per scroll tick. */
|
|
const ZOOM_STEP = 16;
|
|
|
|
/** localStorage key for persisted artist card size. */
|
|
const CARD_SIZE_KEY = 'artists-view-card-size';
|
|
|
|
/** Card size limits. */
|
|
const CARD_SIZE_MIN = 100;
|
|
const CARD_SIZE_MAX = 350;
|
|
const CARD_SIZE_DEFAULT = 176;
|
|
|
|
/** Debounce delay for saving scroll position. */
|
|
const SCROLL_DEBOUNCE_MS = 100;
|
|
|
|
/**
|
|
* Grid entry for the virtualized artist grid.
|
|
*/
|
|
const ARTIST_SORT_DIR_KEY = 'artists-view-sort-dir';
|
|
|
|
/** One key, so the header renders a label and a direction button. */
|
|
const ARTIST_SORT_OPTIONS = [{ id: 'name', label: 'Name' }];
|
|
|
|
interface ArtistEntry {
|
|
artist: library.Artist;
|
|
index: number;
|
|
}
|
|
|
|
@customElement('artists-view')
|
|
export class ArtistsView
|
|
extends ViewLifecycleMixin(LitElement)
|
|
implements ContextMenuHost
|
|
{
|
|
private libraryCtrl = new LibraryController(this);
|
|
private searchCtrl = new SearchController(this);
|
|
private ctxMenu = new ContextMenuController(this);
|
|
private favCtrl = new FavoritesController(this);
|
|
private wheelListenerAttached = false;
|
|
private lastSearchTerm = '';
|
|
|
|
/** One tab stop for the whole grid, moved with the arrows — a card
|
|
* per tab stop makes a library-length tab sequence (H-5). */
|
|
private roving = new RovingGridController(this, {
|
|
cardSelector: '.artist-card',
|
|
count: () => this.cachedGridEntries.length,
|
|
scrollToIndex: (index) => {
|
|
this.shadowRoot
|
|
?.querySelector<LitVirtualizer>('lit-virtualizer')
|
|
?.scrollToIndex(index, 'nearest');
|
|
},
|
|
});
|
|
|
|
/** Tracks the store's cached array reference to detect refreshes. */
|
|
private lastArtistsRef: library.Artist[] | null =
|
|
null;
|
|
private scrollDebounceTimer: ReturnType<
|
|
typeof setTimeout
|
|
> | null = null;
|
|
|
|
@state()
|
|
private artists: library.Artist[] = [];
|
|
|
|
@state()
|
|
private loading = true;
|
|
|
|
@state()
|
|
private restoringScroll = false;
|
|
|
|
@state()
|
|
private cardSize: number = CARD_SIZE_DEFAULT;
|
|
|
|
// ----- Multi-select state -----
|
|
|
|
@state()
|
|
private selectedArtists: Set<number> = new Set();
|
|
|
|
private lastSelectedArtistIndex: number | null =
|
|
null;
|
|
|
|
// ----- Context menu state -----
|
|
|
|
/**
|
|
* Artist ID that was right-clicked to open the
|
|
* context menu. Used as fallback when the
|
|
* right-clicked artist is not in the current
|
|
* visual selection.
|
|
*/
|
|
private contextMenuArtistId: number | null = null;
|
|
|
|
@query('#context-menu')
|
|
private contextMenuPopup!: MenuSurface;
|
|
|
|
@query('#playlist-submenu')
|
|
private playlistSubmenuPopup!: MenuSurface;
|
|
|
|
getContextMenuPopup(): MenuTarget | undefined {
|
|
return this.contextMenuPopup;
|
|
}
|
|
|
|
getPlaylistSubmenuPopup():
|
|
| MenuTarget
|
|
| undefined {
|
|
return this.playlistSubmenuPopup;
|
|
}
|
|
|
|
onContextMenuClose(): void {
|
|
this.contextMenuArtistId = null;
|
|
}
|
|
|
|
// ----- Grid spacing constants -----
|
|
|
|
private static readonly CARD_PADDING = 5;
|
|
|
|
private get imageSize(): number {
|
|
return (
|
|
this.cardSize -
|
|
ArtistsView.CARD_PADDING * 2
|
|
);
|
|
}
|
|
|
|
private get cardTextHeight(): number {
|
|
const w = this.cardSize;
|
|
|
|
if (w < 160) return 30;
|
|
if (w > 250) return 42;
|
|
|
|
return 36;
|
|
}
|
|
|
|
/** Wheel handler reference for add/remove. */
|
|
private wheelHandler = (e: WheelEvent) => {
|
|
this.onWheel(e);
|
|
};
|
|
|
|
private gridLayout = this.createGridLayout();
|
|
|
|
private createGridLayout() {
|
|
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
|
|
const h = w + this.cardTextHeight;
|
|
|
|
// One number for the gap, the row gap and the padding: whatever
|
|
// a row could not spend on another card, shared out equally, so
|
|
// the outside is never wider than the inside. See
|
|
// `utils/grid-spacing.ts`.
|
|
const spacing = this.spacingFor(this.containerWidth);
|
|
|
|
this.lastLayoutSpacing = spacing;
|
|
|
|
return grid({
|
|
itemSize: {
|
|
width: `${w}px`,
|
|
height: `${h}px`,
|
|
},
|
|
gap: `${spacing}px`,
|
|
padding: `${spacing}px`,
|
|
justify: 'start',
|
|
});
|
|
}
|
|
|
|
/** The width the grid lays itself out in. */
|
|
private get containerWidth(): number {
|
|
return (
|
|
this.renderRoot?.querySelector<HTMLElement>(
|
|
'.grid-scroll-container',
|
|
)?.clientWidth ||
|
|
this.clientWidth ||
|
|
0
|
|
);
|
|
}
|
|
|
|
private spacingFor(width: number): number {
|
|
return gridSpacingFor(width, this.cardSize);
|
|
}
|
|
|
|
/** Sort direction for the artist grid.
|
|
*
|
|
* There is only one key to sort by: `library.Artist` carries a
|
|
* name, an MBID and three image URLs, and nothing countable — so
|
|
* the header shows "Sort: Name" and this button, rather than a
|
|
* select with one option in it (H-19). */
|
|
@state()
|
|
private sortDirection: 'asc' | 'desc' = 'asc';
|
|
|
|
// -- Memoisation caches for filtered artists --
|
|
private cachedFilteredArtists: library.Artist[] =
|
|
[];
|
|
private cachedGridEntries: ArtistEntry[] = [];
|
|
private prevFilterArtists: library.Artist[] = [];
|
|
private prevFilterTerm = '';
|
|
private prevFilterDir: 'asc' | 'desc' | '' = '';
|
|
|
|
/**
|
|
* Recompute the filtered-artists and grid-entries
|
|
* caches when their inputs have changed. Called
|
|
* from willUpdate() so the caches are ready
|
|
* before render().
|
|
*/
|
|
private recomputeArtistCaches() {
|
|
const term = this.searchCtrl.term;
|
|
|
|
if (
|
|
this.artists !== this.prevFilterArtists ||
|
|
term !== this.prevFilterTerm ||
|
|
this.sortDirection !== this.prevFilterDir
|
|
) {
|
|
this.prevFilterArtists = this.artists;
|
|
this.prevFilterTerm = term;
|
|
this.prevFilterDir = this.sortDirection;
|
|
this.cachedFilteredArtists =
|
|
this.computeFilteredArtists();
|
|
this.cachedGridEntries =
|
|
this.cachedFilteredArtists.map(
|
|
(artist, index) => ({
|
|
artist,
|
|
index,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private computeFilteredArtists(): library.Artist[] {
|
|
const term =
|
|
this.searchCtrl.term.toLowerCase();
|
|
|
|
const matching = term
|
|
? this.artists.filter((a) =>
|
|
a.Name.toLowerCase().includes(term),
|
|
)
|
|
: this.artists;
|
|
|
|
// Descending is the only reordering available, so ascending
|
|
// keeps the backend's order rather than re-sorting it: the
|
|
// array's identity is what tells the virtualizer to repaint,
|
|
// and copying it every pass would repaint on every keystroke.
|
|
if (this.sortDirection === 'asc') return matching;
|
|
|
|
return [...matching].sort((a, b) =>
|
|
b.Name.localeCompare(a.Name),
|
|
);
|
|
}
|
|
|
|
private onPageHeaderSort = (
|
|
e: CustomEvent<{ direction: 'asc' | 'desc' }>,
|
|
) => {
|
|
this.sortDirection = e.detail.direction;
|
|
|
|
try {
|
|
localStorage.setItem(
|
|
ARTIST_SORT_DIR_KEY,
|
|
this.sortDirection,
|
|
);
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
};
|
|
|
|
static override styles = [
|
|
contextMenuStyles,
|
|
css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
height: 100%;
|
|
position: relative;
|
|
contain: layout style;
|
|
}
|
|
|
|
.grid-scroll-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
contain: paint;
|
|
}
|
|
|
|
lit-virtualizer {
|
|
width: 100%;
|
|
min-height: 100%;
|
|
}
|
|
|
|
.artist-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 5px;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
/* transitions removed — software rendering repaints per frame */
|
|
overflow: hidden;
|
|
}
|
|
|
|
.artist-card:hover {
|
|
background-color: var(
|
|
--yj-bg-overlay,
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
}
|
|
|
|
.artist-card:active {
|
|
transform: scale(0.97);
|
|
}
|
|
|
|
.artist-card.selected {
|
|
outline: 2px solid
|
|
var(--yj-accent, #ffd43b);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.artist-card.selected
|
|
.avatar-container {
|
|
scale: 0.95;
|
|
}
|
|
|
|
.artist-card.selected .artist-name {
|
|
scale: 0.95;
|
|
}
|
|
|
|
.avatar-container {
|
|
width: var(--avatar-size);
|
|
height: var(--avatar-size);
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
background: linear-gradient(
|
|
135deg,
|
|
var(--yj-bg-overlay, #404040) 0%,
|
|
var(--yj-bg-surface, #282828)
|
|
100%
|
|
);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.avatar-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.avatar-placeholder {
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
font-size: var(
|
|
--placeholder-font,
|
|
48px
|
|
);
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
user-select: none;
|
|
line-height: 1;
|
|
}
|
|
|
|
.artist-name {
|
|
width: 100%;
|
|
text-align: center;
|
|
font-size: var(
|
|
--artist-name-font,
|
|
14px
|
|
);
|
|
font-weight: 500;
|
|
color: var(
|
|
--yj-text-primary,
|
|
#fff
|
|
);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
padding: var(--artist-name-pad, 6px)
|
|
2px 0;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.search-bar-row {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 30px;
|
|
border-bottom: 1px solid
|
|
var(--yj-border-subtle, #333);
|
|
flex-shrink: 0;
|
|
user-select: none;
|
|
}
|
|
|
|
.search-indicator {
|
|
position: absolute;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
pointer-events: none;
|
|
background: var(
|
|
--yj-bg-overlay,
|
|
#495057
|
|
);
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
font-size: 12px;
|
|
padding: 2px 14px;
|
|
border-radius: 12px;
|
|
border: 1px solid
|
|
var(--yj-border-subtle, #555);
|
|
white-space: nowrap;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.loading-message,
|
|
.empty-message {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
font-size: 14px;
|
|
}
|
|
|
|
`,
|
|
];
|
|
|
|
/* ================================================================
|
|
* Lifecycle
|
|
* ================================================================ */
|
|
|
|
override willUpdate(
|
|
changed: Map<PropertyKey, unknown>,
|
|
) {
|
|
super.willUpdate(changed);
|
|
this.recomputeArtistCaches();
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.loadCardSize();
|
|
this.loadSortDirection();
|
|
this.loadArtists();
|
|
}
|
|
|
|
private loadSortDirection() {
|
|
try {
|
|
const saved = localStorage.getItem(
|
|
ARTIST_SORT_DIR_KEY,
|
|
);
|
|
|
|
if (saved === 'asc' || saved === 'desc') {
|
|
this.sortDirection = saved;
|
|
}
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.detachWheelListener();
|
|
this.gridResizeObserver?.disconnect();
|
|
this.gridResizeObserver = null;
|
|
}
|
|
|
|
/** The wheel listener and the scroll debounce belong to the grid
|
|
* while it is on screen; off-screen it cannot be scrolled, and a
|
|
* cached view is never disconnected. */
|
|
protected override onViewDeactivate(): void {
|
|
this.detachWheelListener();
|
|
|
|
if (this.scrollDebounceTimer !== null) {
|
|
clearTimeout(this.scrollDebounceTimer);
|
|
this.scrollDebounceTimer = null;
|
|
}
|
|
}
|
|
|
|
override updated() {
|
|
this.updateSizeProperties();
|
|
this.ensureWheelListener();
|
|
this.updateGridLayout();
|
|
|
|
// Clear selection when search term changes.
|
|
const currentTerm = this.searchCtrl.term;
|
|
|
|
if (currentTerm !== this.lastSearchTerm) {
|
|
this.lastSearchTerm = currentTerm;
|
|
this.clearSelection();
|
|
}
|
|
|
|
// Re-fetch when the store delivers fresh
|
|
// data after eager refetch on invalidation.
|
|
const cached =
|
|
this.libraryCtrl.cachedArtists;
|
|
|
|
if (
|
|
cached !== null &&
|
|
cached !== this.lastArtistsRef
|
|
) {
|
|
this.lastArtistsRef = cached;
|
|
this.loadArtists();
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Data loading
|
|
* ================================================================ */
|
|
|
|
private async loadArtists() {
|
|
try {
|
|
this.loading = true;
|
|
|
|
const artists =
|
|
await this.libraryCtrl.getArtists();
|
|
|
|
this.artists = artists ?? [];
|
|
} catch (error) {
|
|
console.error(
|
|
'Error loading artists:',
|
|
error,
|
|
);
|
|
this.artists = [];
|
|
} finally {
|
|
const saved =
|
|
this.libraryCtrl.getScrollPosition(
|
|
'artists',
|
|
);
|
|
|
|
this.restoringScroll = saved > 0;
|
|
this.loading = false;
|
|
}
|
|
|
|
await this.updateComplete;
|
|
this.restoreScrollPosition();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Scroll position persistence
|
|
* ================================================================ */
|
|
|
|
/**
|
|
* Save the first visible item index on scroll.
|
|
*/
|
|
private onVisibilityChanged = (
|
|
e: VisibilityChangedEvent,
|
|
) => {
|
|
if (this.restoringScroll) return;
|
|
|
|
if (this.scrollDebounceTimer !== null) {
|
|
clearTimeout(this.scrollDebounceTimer);
|
|
}
|
|
|
|
this.scrollDebounceTimer = setTimeout(
|
|
() => {
|
|
this.libraryCtrl.setScrollPosition(
|
|
'artists',
|
|
e.first,
|
|
);
|
|
},
|
|
SCROLL_DEBOUNCE_MS,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Restore scroll position from the store.
|
|
*/
|
|
private restoreScrollPosition(): void {
|
|
const saved =
|
|
this.libraryCtrl.getScrollPosition(
|
|
'artists',
|
|
);
|
|
|
|
if (saved <= 0) {
|
|
this.restoringScroll = false;
|
|
|
|
return;
|
|
}
|
|
|
|
const virt =
|
|
this.shadowRoot?.querySelector(
|
|
'lit-virtualizer',
|
|
) as LitVirtualizer | null;
|
|
|
|
if (!virt) {
|
|
this.restoringScroll = false;
|
|
|
|
return;
|
|
}
|
|
|
|
const safeIndex = Math.min(
|
|
saved,
|
|
this.cachedFilteredArtists.length - 1,
|
|
);
|
|
|
|
if (safeIndex <= 0) {
|
|
this.restoringScroll = false;
|
|
|
|
return;
|
|
}
|
|
|
|
virt.scrollToIndex(safeIndex, 'start');
|
|
this.restoringScroll = false;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Card size (zoom)
|
|
* ================================================================ */
|
|
|
|
private loadCardSize(): void {
|
|
try {
|
|
const stored =
|
|
localStorage.getItem(CARD_SIZE_KEY);
|
|
|
|
if (stored !== null) {
|
|
const parsed = parseInt(stored, 10);
|
|
|
|
if (!Number.isNaN(parsed)) {
|
|
this.cardSize = Math.max(
|
|
CARD_SIZE_MIN,
|
|
Math.min(
|
|
CARD_SIZE_MAX,
|
|
parsed,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// localStorage may be unavailable.
|
|
}
|
|
}
|
|
|
|
private saveCardSize(): void {
|
|
try {
|
|
localStorage.setItem(
|
|
CARD_SIZE_KEY,
|
|
String(this.cardSize),
|
|
);
|
|
} catch {
|
|
// localStorage may be unavailable.
|
|
}
|
|
}
|
|
|
|
private setCardSize(size: number): void {
|
|
const clamped = Math.round(
|
|
Math.max(
|
|
CARD_SIZE_MIN,
|
|
Math.min(CARD_SIZE_MAX, size),
|
|
),
|
|
);
|
|
|
|
if (clamped === this.cardSize) return;
|
|
|
|
this.cardSize = clamped;
|
|
this.saveCardSize();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Wheel zoom (Ctrl+scroll)
|
|
* ================================================================ */
|
|
|
|
private onWheel(e: WheelEvent) {
|
|
if (!e.ctrlKey) return;
|
|
|
|
e.preventDefault();
|
|
|
|
const delta =
|
|
e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
|
|
|
|
this.setCardSize(this.cardSize + delta);
|
|
}
|
|
|
|
private ensureWheelListener() {
|
|
const container =
|
|
this.shadowRoot?.querySelector(
|
|
'.grid-scroll-container',
|
|
);
|
|
|
|
if (
|
|
container &&
|
|
!this.wheelListenerAttached
|
|
) {
|
|
container.addEventListener(
|
|
'wheel',
|
|
this
|
|
.wheelHandler as EventListener,
|
|
{ passive: false },
|
|
);
|
|
this.wheelListenerAttached = true;
|
|
}
|
|
}
|
|
|
|
private detachWheelListener() {
|
|
const container =
|
|
this.shadowRoot?.querySelector(
|
|
'.grid-scroll-container',
|
|
);
|
|
|
|
if (
|
|
container &&
|
|
this.wheelListenerAttached
|
|
) {
|
|
container.removeEventListener(
|
|
'wheel',
|
|
this
|
|
.wheelHandler as EventListener,
|
|
);
|
|
this.wheelListenerAttached = false;
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Grid layout
|
|
* ================================================================ */
|
|
|
|
private lastLayoutWidth = 0;
|
|
private lastLayoutSpacing = 0;
|
|
|
|
/** Watches the scroller so a window resize rebuilds the layout:
|
|
* the spacing is derived from its width, and nothing else asks
|
|
* this view to update when only that changes. */
|
|
private gridResizeObserver: ResizeObserver | null = null;
|
|
|
|
private observeGridWidth() {
|
|
const container =
|
|
this.renderRoot?.querySelector<HTMLElement>(
|
|
'.grid-scroll-container',
|
|
);
|
|
|
|
if (!container || this.gridResizeObserver) return;
|
|
|
|
this.gridResizeObserver = new ResizeObserver(() =>
|
|
this.requestUpdate(),
|
|
);
|
|
this.gridResizeObserver.observe(container);
|
|
}
|
|
|
|
private updateGridLayout() {
|
|
this.observeGridWidth();
|
|
|
|
if (
|
|
this.cardSize === this.lastLayoutWidth &&
|
|
this.lastLayoutSpacing ===
|
|
this.spacingFor(this.containerWidth)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.lastLayoutWidth = this.cardSize;
|
|
this.gridLayout = this.createGridLayout();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Dynamic size properties
|
|
* ================================================================ */
|
|
|
|
private updateSizeProperties() {
|
|
const w = this.cardSize;
|
|
|
|
if (w < 160) {
|
|
this.style.setProperty(
|
|
'--artist-name-font',
|
|
'12px',
|
|
);
|
|
this.style.setProperty(
|
|
'--artist-name-pad',
|
|
'4px',
|
|
);
|
|
} else if (w > 250) {
|
|
this.style.setProperty(
|
|
'--artist-name-font',
|
|
'15px',
|
|
);
|
|
this.style.setProperty(
|
|
'--artist-name-pad',
|
|
'8px',
|
|
);
|
|
} else {
|
|
this.style.setProperty(
|
|
'--artist-name-font',
|
|
'14px',
|
|
);
|
|
this.style.setProperty(
|
|
'--artist-name-pad',
|
|
'6px',
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Artist selection helpers
|
|
* ================================================================ */
|
|
|
|
/**
|
|
* Select a contiguous range of artist IDs
|
|
* between two indices in filteredArtists.
|
|
*/
|
|
private selectArtistRange(
|
|
from: number,
|
|
to: number,
|
|
): Set<number> {
|
|
const filtered = this.cachedFilteredArtists;
|
|
const start = Math.min(from, to);
|
|
const end = Math.max(from, to);
|
|
const ids = new Set<number>();
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
const artist = filtered[i];
|
|
|
|
if (artist) {
|
|
ids.add(artist.ID);
|
|
}
|
|
}
|
|
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Fetches all file paths for every selected
|
|
* artist.
|
|
*/
|
|
private async getSelectedArtistFilePaths(): Promise<
|
|
string[]
|
|
> {
|
|
const selected = this.artists.filter((a) =>
|
|
this.selectedArtists.has(a.ID),
|
|
);
|
|
const allPaths: string[] = [];
|
|
|
|
for (const artist of selected) {
|
|
const paths =
|
|
await this.getArtistFilePaths(
|
|
artist,
|
|
);
|
|
allPaths.push(...paths);
|
|
}
|
|
|
|
return allPaths;
|
|
}
|
|
|
|
/**
|
|
* Return file paths for the context menu target.
|
|
* If the right-clicked artist is part of the
|
|
* current selection, return paths for all selected
|
|
* artists. Otherwise return paths for the
|
|
* right-clicked artist only.
|
|
*/
|
|
private async getContextMenuArtistFilePaths(): Promise<
|
|
string[]
|
|
> {
|
|
if (
|
|
this.contextMenuArtistId !== null &&
|
|
!this.selectedArtists.has(
|
|
this.contextMenuArtistId,
|
|
)
|
|
) {
|
|
const artist = this.artists.find(
|
|
(a) =>
|
|
a.ID ===
|
|
this.contextMenuArtistId,
|
|
);
|
|
|
|
if (artist) {
|
|
return this.getArtistFilePaths(
|
|
artist,
|
|
);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
return this.getSelectedArtistFilePaths();
|
|
}
|
|
|
|
/** Clear the current artist selection. */
|
|
private clearSelection() {
|
|
this.selectedArtists = new Set();
|
|
this.lastSelectedArtistIndex = null;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Artist card click
|
|
* ================================================================ */
|
|
|
|
private onArtistClick(
|
|
e: MouseEvent,
|
|
artist: library.Artist,
|
|
index: number,
|
|
) {
|
|
const isCtrl = e.ctrlKey || e.metaKey;
|
|
const isShift = e.shiftKey;
|
|
|
|
if (
|
|
isShift &&
|
|
this.lastSelectedArtistIndex !== null
|
|
) {
|
|
const range = this.selectArtistRange(
|
|
this.lastSelectedArtistIndex,
|
|
index,
|
|
);
|
|
const next = new Set(
|
|
this.selectedArtists,
|
|
);
|
|
|
|
for (const id of range) {
|
|
next.add(id);
|
|
}
|
|
|
|
this.selectedArtists = next;
|
|
} else if (isCtrl) {
|
|
const next = new Set(
|
|
this.selectedArtists,
|
|
);
|
|
|
|
if (next.has(artist.ID)) {
|
|
next.delete(artist.ID);
|
|
} else {
|
|
next.add(artist.ID);
|
|
}
|
|
|
|
this.selectedArtists = next;
|
|
this.lastSelectedArtistIndex = index;
|
|
} else {
|
|
// Plain click: navigate to details.
|
|
this.clearSelection();
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
view: 'explore-artist-details',
|
|
artistMBID: artist.MBID || '',
|
|
artistName: artist.Name,
|
|
localArtistId: artist.ID,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Context menu
|
|
* ================================================================ */
|
|
|
|
private onArtistContextMenu = (
|
|
e: MouseEvent,
|
|
artist: library.Artist,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.contextMenuArtistId = artist.ID;
|
|
|
|
this.ctxMenu.openAt(
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
};
|
|
|
|
/** Shift+F10 / ContextMenu on a focused card, anchored to the card
|
|
* so the menu appears where the artist is and focus goes back
|
|
* there when it closes. */
|
|
private openArtistMenuFromKey(
|
|
e: KeyboardEvent,
|
|
artist: library.Artist,
|
|
): void {
|
|
const card = e.currentTarget as HTMLElement | null;
|
|
|
|
if (!card) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.contextMenuArtistId = artist.ID;
|
|
this.ctxMenu.openFrom(card);
|
|
}
|
|
|
|
private async onContextMenuAction(
|
|
action: string,
|
|
) {
|
|
const filePaths =
|
|
await this.getContextMenuArtistFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
const artist = this.artists.find(
|
|
(a) => a.ID === this.contextMenuArtistId,
|
|
);
|
|
|
|
switch (action) {
|
|
case 'play':
|
|
queueStore.setQueue(
|
|
filePaths,
|
|
0,
|
|
true,
|
|
artist
|
|
? { type: 'artist', id: artist.ID, label: artist.Name }
|
|
: undefined,
|
|
);
|
|
break;
|
|
case 'add-to-queue':
|
|
queueStore.addTracksToQueue(
|
|
filePaths,
|
|
);
|
|
break;
|
|
case 'play-next':
|
|
queueStore.playTracksNext(
|
|
filePaths,
|
|
);
|
|
break;
|
|
}
|
|
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
/**
|
|
* Resolve artist file paths and show the
|
|
* playlist submenu.
|
|
*/
|
|
private async handleShowPlaylistSubmenu() {
|
|
const paths =
|
|
await this.getContextMenuArtistFilePaths();
|
|
|
|
void this.ctxMenu.showPlaylistSubmenu(paths);
|
|
}
|
|
|
|
private async onContextMenuFavoriteToggle() {
|
|
const filePaths =
|
|
await this.getContextMenuArtistFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
if (this.favCtrl.allFavorited(filePaths)) {
|
|
void this.favCtrl.removeFromFavorites(
|
|
filePaths,
|
|
);
|
|
} else {
|
|
void this.favCtrl.addToFavorites(
|
|
filePaths,
|
|
);
|
|
}
|
|
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
/* ================================================================
|
|
* File path resolution
|
|
* ================================================================ */
|
|
|
|
/**
|
|
* Fetches all file paths for an artist by
|
|
* loading their albums, then every album's paths
|
|
* in one call. Respects the active library filter.
|
|
*
|
|
* perf.m2: this was a `for await` over the albums,
|
|
* so a 16-album artist was 17 sequential round
|
|
* trips returning whole track rows to read one
|
|
* field off each.
|
|
*/
|
|
private async getArtistFilePaths(
|
|
artist: library.Artist,
|
|
): Promise<string[]> {
|
|
try {
|
|
const libId =
|
|
this.libraryCtrl.selectedLibraryId;
|
|
|
|
const albums = await list(
|
|
GetAlbumsByArtist(artist.Name, libId ?? 0),
|
|
);
|
|
|
|
const byAlbum = await dict(
|
|
GetFilePathsByAlbums(
|
|
albums.map((a) => a.ID),
|
|
libId ?? 0,
|
|
),
|
|
);
|
|
|
|
const allPaths: string[] = [];
|
|
|
|
// The album order is the caller's, which is
|
|
// why the backend groups rather than
|
|
// flattens. Keys arrive as strings, JSON
|
|
// having no integer keys.
|
|
for (const album of albums) {
|
|
const paths =
|
|
byAlbum[album.ID] ?? [];
|
|
|
|
allPaths.push(...paths);
|
|
}
|
|
|
|
return allPaths;
|
|
} catch (error) {
|
|
console.error(
|
|
'Error loading artist tracks:',
|
|
error,
|
|
);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Helpers
|
|
* ================================================================ */
|
|
|
|
private renderArtistAvatar(artist: library.Artist) {
|
|
const needed = (this.imageSize ?? 176) * window.devicePixelRatio;
|
|
let imageURL = '';
|
|
|
|
if (needed <= 100) {
|
|
imageURL = artist.ImageSmall || artist.ImageMedium || artist.ImageLarge || '';
|
|
} else if (needed <= 200) {
|
|
imageURL = artist.ImageMedium || artist.ImageLarge || '';
|
|
} else {
|
|
imageURL = artist.ImageLarge || '';
|
|
}
|
|
|
|
// Fallback: use album cover art if no artist image.
|
|
//
|
|
// `perf.M4`. This was a linear scan of every cached album,
|
|
// lowercasing two strings per comparison, run from inside the
|
|
// virtualizer's `renderItem` — so per card, per frame. It is
|
|
// also the *common* case rather than an edge one: a
|
|
// locally-tagged library has no artist images at all (the bulk
|
|
// measurement seed has 440 artists, 4 988 albums and zero
|
|
// images), so every visible card paid it on every pass.
|
|
if (!imageURL) {
|
|
imageURL = this.albumArtByArtist().get(
|
|
artist.Name.toLowerCase(),
|
|
) ?? '';
|
|
}
|
|
|
|
if (imageURL) {
|
|
return html`<img
|
|
class="avatar-image"
|
|
src="${imageURL}"
|
|
alt="${artist.Name}"
|
|
loading="lazy"
|
|
/>`;
|
|
}
|
|
|
|
return html`<span class="avatar-placeholder">
|
|
${this.getArtistInitial(artist.Name)}
|
|
</span>`;
|
|
}
|
|
|
|
/**
|
|
* Lowercased artist name → that artist's cover art, built once per
|
|
* identity of the album cache rather than per card per frame.
|
|
*
|
|
* Keyed on the array identity because that is exactly what
|
|
* `library-store` gives it: the store replaces the array when its
|
|
* contents change and shares the unchanged members, which is the
|
|
* same signal every memoized cache in `track-list` keys on. The
|
|
* size hint is included so a *tier* change (the grid resizing past
|
|
* one of `getCoverUrl`'s breakpoints) also rebuilds.
|
|
*/
|
|
private albumArtCache?: {
|
|
albums: readonly library.Album[];
|
|
needed: number;
|
|
map: Map<string, string>;
|
|
};
|
|
|
|
private albumArtByArtist(): Map<string, string> {
|
|
const albums = libraryStore.cachedAlbums;
|
|
const needed = (this.imageSize ?? 176) * window.devicePixelRatio;
|
|
|
|
if (!albums) return new Map();
|
|
|
|
if (
|
|
this.albumArtCache
|
|
&& this.albumArtCache.albums === albums
|
|
&& this.albumArtCache.needed === needed
|
|
) {
|
|
return this.albumArtCache.map;
|
|
}
|
|
|
|
const map = new Map<string, string>();
|
|
|
|
for (const a of albums) {
|
|
const key = a.ArtistName.toLowerCase();
|
|
|
|
// First album wins, which is what the original scan did by
|
|
// breaking on its first match.
|
|
if (map.has(key)) continue;
|
|
|
|
const url = needed <= 100
|
|
? a.CoverArtSmall || a.CoverArtMedium || ''
|
|
: a.CoverArtMedium || a.CoverArtLarge || '';
|
|
|
|
if (url) map.set(key, url);
|
|
}
|
|
|
|
this.albumArtCache = { albums, needed, map };
|
|
|
|
return map;
|
|
}
|
|
|
|
private getArtistInitial(
|
|
name: string,
|
|
): string {
|
|
if (!name) return '?';
|
|
|
|
return name.charAt(0).toUpperCase();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Rendering
|
|
* ================================================================ */
|
|
|
|
private renderArtistCard(entry: ArtistEntry) {
|
|
const { artist, index } = entry;
|
|
const imgSize = this.imageSize;
|
|
const placeholderFont = Math.round(
|
|
imgSize * 0.38,
|
|
);
|
|
const isSelected =
|
|
this.selectedArtists.has(artist.ID);
|
|
|
|
return html`
|
|
<div
|
|
class="artist-card${isSelected
|
|
? ' selected'
|
|
: ''}"
|
|
data-index=${index}
|
|
tabindex=${this.roving.tabIndexFor(index)}
|
|
@focus=${() => this.roving.noteFocus(index)}
|
|
role="option"
|
|
aria-label="${artist.Name}"
|
|
aria-selected="${isSelected}"
|
|
style="
|
|
--avatar-size: ${imgSize}px;
|
|
--placeholder-font: ${placeholderFont}px;
|
|
"
|
|
@click=${(e: MouseEvent) =>
|
|
this.onArtistClick(
|
|
e,
|
|
artist,
|
|
index,
|
|
)}
|
|
@contextmenu=${(e: MouseEvent) =>
|
|
this.onArtistContextMenu(
|
|
e,
|
|
artist,
|
|
)}
|
|
@keydown=${(e: KeyboardEvent) => {
|
|
if (isContextMenuKey(e)) {
|
|
this.openArtistMenuFromKey(
|
|
e,
|
|
artist,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
e.key === 'Enter' ||
|
|
e.key === ' '
|
|
) {
|
|
e.preventDefault();
|
|
this.clearSelection();
|
|
this.dispatchEvent(
|
|
new CustomEvent(
|
|
'navigate',
|
|
{
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
view: 'explore-artist-details',
|
|
artistMBID:
|
|
artist.MBID || '',
|
|
artistName:
|
|
artist.Name,
|
|
localArtistId:
|
|
artist.ID,
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div class="avatar-container">
|
|
${this.renderArtistAvatar(artist)}
|
|
</div>
|
|
<div
|
|
class="artist-name"
|
|
title="${artist.Name}"
|
|
>
|
|
${artist.Name}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderContextMenu() {
|
|
return html`
|
|
<menu-surface
|
|
id="context-menu"
|
|
.active=${this.ctxMenu
|
|
.contextMenuOpen}
|
|
>
|
|
${this.ctxMenu.contextMenuOpen
|
|
? html`
|
|
<div
|
|
class="context-menu-panel"
|
|
role="menu"
|
|
aria-label="Artist 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.handleShowPlaylistSubmenu();
|
|
}}
|
|
@mouseleave=${this
|
|
.ctxMenu
|
|
.scheduleSubmenuClose}
|
|
@click=${(
|
|
e: Event,
|
|
) => {
|
|
e.stopPropagation();
|
|
void this.handleShowPlaylistSubmenu();
|
|
}}
|
|
>
|
|
<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.ctxMenu.playlistFilePaths) ? `Remove from ${this.favCtrl.playlistName}` : `Add to ${this.favCtrl.playlistName}`}
|
|
</wa-dropdown-item>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</menu-surface>
|
|
|
|
<menu-surface
|
|
id="playlist-submenu"
|
|
label="Add to playlist"
|
|
placement="right-start"
|
|
.active=${this.ctxMenu
|
|
.playlistSubmenuOpen}
|
|
>
|
|
${this.ctxMenu.playlistSubmenuOpen
|
|
? 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}
|
|
</menu-surface>
|
|
`;
|
|
}
|
|
|
|
override render() {
|
|
if (this.loading) {
|
|
// The header keeps its place while the view loads: a
|
|
// heading that appears only once the data does is the
|
|
// shifting layout this component exists to stop.
|
|
return html`
|
|
<page-header
|
|
heading="Artists"
|
|
.sortOptions=${ARTIST_SORT_OPTIONS}
|
|
sort-field="name"
|
|
sort-direction=${this.sortDirection}
|
|
></page-header>
|
|
<div class="loading-message">
|
|
Loading artists...
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const entries = this.cachedGridEntries;
|
|
const searchBar = html`
|
|
<page-header
|
|
heading="Artists"
|
|
.count=${entries.length}
|
|
count-noun="artist"
|
|
.sortOptions=${ARTIST_SORT_OPTIONS}
|
|
sort-field="name"
|
|
sort-direction=${this.sortDirection}
|
|
search-term=${this.searchCtrl.term}
|
|
@sort-change=${this.onPageHeaderSort}
|
|
></page-header>
|
|
`;
|
|
|
|
if (entries.length === 0) {
|
|
return html`
|
|
${searchBar}
|
|
<div class="empty-message">
|
|
${this.searchCtrl.term
|
|
? 'No artists match your search.'
|
|
: 'No artists in library.'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
${searchBar}
|
|
<div
|
|
class="grid-scroll-container"
|
|
style=${this.restoringScroll
|
|
? 'visibility: hidden'
|
|
: ''}
|
|
@click=${this.onGridClick}
|
|
@keydown=${this.roving.handleKeydown}
|
|
>
|
|
<lit-virtualizer
|
|
role="listbox"
|
|
aria-label="Artists"
|
|
aria-multiselectable="true"
|
|
.items=${entries}
|
|
.renderItem=${(entry: ArtistEntry) => this.renderArtistCard(entry)}
|
|
.keyFunction=${(entry: ArtistEntry) => entry.artist.ID}
|
|
.layout=${this.gridLayout}
|
|
@visibilityChanged=${this.onVisibilityChanged}
|
|
></lit-virtualizer>
|
|
</div>
|
|
${this.renderContextMenu()}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Click on empty area of the grid clears the
|
|
* selection.
|
|
*/
|
|
private onGridClick = (e: MouseEvent) => {
|
|
const path = e.composedPath();
|
|
|
|
const clickedCard = path.some(
|
|
(el) =>
|
|
el instanceof HTMLElement &&
|
|
el.classList.contains('artist-card'),
|
|
);
|
|
|
|
if (!clickedCard) {
|
|
this.clearSelection();
|
|
}
|
|
};
|
|
}
|