`plus` meant "add to the queue", "add to a playlist", "make a new playlist" and "you do not own this" -- the first two adjacent in the same context menu, so two neighbouring items were the same glyph doing different things. `list` meant the queue (the button that opens it), the Playlists destination, and adding to the queue in `queue-panel` alone. Two icons carrying seven meanings is not a vocabulary, and nothing catches it: a wrong-but-real icon renders perfectly. `utils/icon-language.ts` is the table, beside `library-status.ts` as the issue suggested. The rule it is built on is that an icon names the **noun** it acts on, not the verb: "add to queue" and "add to playlist" are one verb on two nouns, so the noun is what differs -- which is why adding to a playlist wears the Playlists destination's own icon, and why the queue took `bars-staggered` and stopped wearing Playlists'. `plus` keeps the one meaning it is unambiguous about, making something that is not there yet, which covers New Playlist and the drop zones. `bars-staggered` is the only new glyph, vendored through names.txt and fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1. Two things this found rather than changed: - The request toggle's outline/solid pair was already in the app and already right -- `explore-album-details`'s "Request this" button has used `regular/bookmark` -> `solid/bookmark` since it was written -- while the badge forty pixels away showed a **plus** for the same state. That is `utils/library-status.ts`'s fault one layer down: it made the two surfaces agree on what wanting *means* and left them disagreeing on what it looks like. - `explore-artist-details`'s Follow button was `bookmark-check`, which is Font Awesome **Pro** and has never been bundled, so it has drawn the missing-icon fallback -- a circled question mark -- for every followed artist since it was written. `requested-badge.spec.ts` was written for exactly this bug on the album button and says so in its docstring; this is the same bug one component over, still live, because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec had ever followed an artist. So the test does what reaching the state cannot. `icon-language.test.ts` reads every `src/**/*.ts` as raw text and fails on a governed name written outside the table, and separately asserts every `ICON_*` is a *bundled* name -- which is what makes a Pro name a failing test rather than a runtime report from a state something has to reach first. Its first assertion is that it read any source at all, because a sweep over an empty glob passes. `chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned the badge's glyphs against the vocabulary they were meant to follow; it names them from the table now, and keeps the assertion that the three differ, which is the property the states actually need. Downloads keeps the solid bookmark on purpose. That is one word twice, not two words: the badge says the entity is on your list and the nav item is that list. Closes #34
1411 lines
42 KiB
TypeScript
1411 lines
42 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 {
|
|
GetFilePathsByGenres,
|
|
} from '@go/library/library.js';
|
|
import type * as library from '@go/library/models.js';
|
|
import { LibraryController } from '@store/controllers/library-controller';
|
|
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 } 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 WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
|
import '@components/playlist-picker/playlist-picker.js';
|
|
import { dictByName } 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 genre card size. */
|
|
const CARD_SIZE_KEY = 'genres-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;
|
|
|
|
/** A genre extracted from the track library. */
|
|
const GENRE_SORT_KEY = 'genres-view-sort';
|
|
|
|
const GENRE_SORT_OPTIONS = [
|
|
{ id: 'name', label: 'Name' },
|
|
{ id: 'tracks', label: 'Tracks' },
|
|
];
|
|
|
|
interface Genre {
|
|
name: string;
|
|
trackCount: number;
|
|
}
|
|
|
|
/** Grid entry for the virtualized genre grid. */
|
|
interface GenreEntry {
|
|
genre: Genre;
|
|
index: number;
|
|
}
|
|
|
|
@customElement('genres-view')
|
|
export class GenresView
|
|
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 = '';
|
|
|
|
/** See artists-view: one tab stop for the grid, moved with the
|
|
* arrow keys. */
|
|
private roving = new RovingGridController(this, {
|
|
cardSelector: '.genre-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 lastGenresRef:
|
|
| library.GenreWithCount[]
|
|
| null = null;
|
|
|
|
private scrollDebounceTimer: ReturnType<
|
|
typeof setTimeout
|
|
> | null = null;
|
|
|
|
@state()
|
|
private genres: Genre[] = [];
|
|
|
|
@state()
|
|
private loading = true;
|
|
|
|
@state()
|
|
private restoringScroll = false;
|
|
|
|
@state()
|
|
private cardSize: number = CARD_SIZE_DEFAULT;
|
|
|
|
// ----- Multi-select state -----
|
|
|
|
@state()
|
|
private selectedGenres: Set<string> = new Set();
|
|
|
|
private lastSelectedGenreIndex: number | null =
|
|
null;
|
|
|
|
// ----- Context menu state -----
|
|
|
|
/**
|
|
* Genre name that was right-clicked to open the
|
|
* context menu. Used as fallback when the
|
|
* right-clicked genre is not in the current
|
|
* visual selection.
|
|
*/
|
|
private contextMenuGenreName: string | null = null;
|
|
|
|
@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;
|
|
}
|
|
|
|
onContextMenuClose(): void {
|
|
this.contextMenuGenreName = null;
|
|
}
|
|
|
|
// ----- Grid spacing constants -----
|
|
|
|
private static readonly CARD_PADDING = 5;
|
|
|
|
private get imageSize(): number {
|
|
return (
|
|
this.cardSize -
|
|
GenresView.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 key and direction for the genre grid (H-19: it had none). */
|
|
@state()
|
|
private sortField: 'name' | 'tracks' = 'name';
|
|
|
|
@state()
|
|
private sortDirection: 'asc' | 'desc' = 'asc';
|
|
|
|
// -- Memoisation caches for filtered genres --
|
|
private cachedFilteredGenres: Genre[] = [];
|
|
private cachedGridEntries: GenreEntry[] = [];
|
|
private prevFilterGenres: Genre[] = [];
|
|
private prevFilterTerm = '';
|
|
private prevFilterSort = '';
|
|
|
|
/**
|
|
* Recompute the filtered-genres and grid-entries
|
|
* caches when their inputs have changed. Called
|
|
* from willUpdate() so the caches are ready
|
|
* before render().
|
|
*/
|
|
private recomputeGenreCaches() {
|
|
const term = this.searchCtrl.term;
|
|
|
|
const sortKey = `${this.sortField}:${this.sortDirection}`;
|
|
|
|
if (
|
|
this.genres !== this.prevFilterGenres ||
|
|
term !== this.prevFilterTerm ||
|
|
sortKey !== this.prevFilterSort
|
|
) {
|
|
this.prevFilterGenres = this.genres;
|
|
this.prevFilterTerm = term;
|
|
this.prevFilterSort = sortKey;
|
|
this.cachedFilteredGenres =
|
|
this.computeFilteredGenres();
|
|
this.cachedGridEntries =
|
|
this.cachedFilteredGenres.map(
|
|
(genre, index) => ({
|
|
genre,
|
|
index,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
private computeFilteredGenres(): Genre[] {
|
|
const term =
|
|
this.searchCtrl.term.toLowerCase();
|
|
|
|
const matching = term
|
|
? this.genres.filter((g) =>
|
|
g.name.toLowerCase().includes(term),
|
|
)
|
|
: this.genres;
|
|
|
|
// The default order is the backend's, and the array's identity
|
|
// is what makes the virtualizer repaint — so leave it alone
|
|
// unless the user asked for something else.
|
|
if (this.sortField === 'name' && this.sortDirection === 'asc') {
|
|
return matching;
|
|
}
|
|
|
|
const dir = this.sortDirection === 'asc' ? 1 : -1;
|
|
|
|
return [...matching].sort((a, b) =>
|
|
this.sortField === 'tracks'
|
|
? dir * (a.trackCount - b.trackCount)
|
|
: dir * a.name.localeCompare(b.name),
|
|
);
|
|
}
|
|
|
|
private onPageHeaderSort = (
|
|
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
|
) => {
|
|
this.sortField =
|
|
e.detail.field === 'tracks' ? 'tracks' : 'name';
|
|
this.sortDirection = e.detail.direction;
|
|
|
|
try {
|
|
localStorage.setItem(
|
|
GENRE_SORT_KEY,
|
|
`${this.sortField}:${this.sortDirection}`,
|
|
);
|
|
} catch {
|
|
// Ignore storage errors.
|
|
}
|
|
};
|
|
|
|
private loadSortPreferences() {
|
|
try {
|
|
const saved = localStorage.getItem(GENRE_SORT_KEY);
|
|
const [field, dir] = (saved ?? '').split(':');
|
|
|
|
if (field === 'name' || field === 'tracks') {
|
|
this.sortField = field;
|
|
}
|
|
|
|
if (dir === 'asc' || dir === 'desc') {
|
|
this.sortDirection = dir;
|
|
}
|
|
} 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%;
|
|
}
|
|
|
|
.genre-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;
|
|
}
|
|
|
|
.genre-card:hover {
|
|
background-color: var(
|
|
--yj-bg-overlay,
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
}
|
|
|
|
.genre-card:active {
|
|
transform: scale(0.97);
|
|
}
|
|
|
|
.genre-card.selected {
|
|
outline: 2px solid
|
|
var(--yj-accent, #ffd43b);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.genre-card.selected .avatar-container {
|
|
scale: 0.95;
|
|
}
|
|
|
|
.genre-card.selected .genre-name {
|
|
scale: 0.95;
|
|
}
|
|
|
|
.avatar-container {
|
|
width: var(--avatar-size);
|
|
height: var(--avatar-size);
|
|
border-radius: 8px;
|
|
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-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;
|
|
}
|
|
|
|
.genre-name {
|
|
width: 100%;
|
|
text-align: center;
|
|
font-size: var(
|
|
--genre-name-font,
|
|
14px
|
|
);
|
|
font-weight: 500;
|
|
color: var(--yj-text-primary, #fff);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
padding: var(--genre-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.recomputeGenreCaches();
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
this.loadCardSize();
|
|
this.loadSortPreferences();
|
|
this.loadGenres();
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.detachWheelListener();
|
|
this.gridResizeObserver?.disconnect();
|
|
this.gridResizeObserver = null;
|
|
}
|
|
|
|
/** See artists-view: off-screen the grid cannot be scrolled, and
|
|
* being cached it 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.cachedGenres;
|
|
|
|
if (
|
|
cached !== null &&
|
|
cached !== this.lastGenresRef
|
|
) {
|
|
this.lastGenresRef = cached;
|
|
this.loadGenres();
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Data loading
|
|
* ================================================================ */
|
|
|
|
private async loadGenres() {
|
|
try {
|
|
this.loading = true;
|
|
|
|
const rows =
|
|
await this.libraryCtrl.getGenres();
|
|
|
|
this.genres = (rows ?? []).map((r) => ({
|
|
name: r.Name,
|
|
trackCount: r.TrackCount,
|
|
}));
|
|
} catch (error) {
|
|
console.error(
|
|
'Error loading genres:',
|
|
error,
|
|
);
|
|
this.genres = [];
|
|
} finally {
|
|
const saved =
|
|
this.libraryCtrl.getScrollPosition(
|
|
'genres',
|
|
);
|
|
|
|
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(
|
|
'genres',
|
|
e.first,
|
|
);
|
|
},
|
|
SCROLL_DEBOUNCE_MS,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Restore scroll position from the store.
|
|
*/
|
|
private restoreScrollPosition(): void {
|
|
const saved =
|
|
this.libraryCtrl.getScrollPosition(
|
|
'genres',
|
|
);
|
|
|
|
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.cachedFilteredGenres.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(
|
|
'--genre-name-font',
|
|
'12px',
|
|
);
|
|
this.style.setProperty(
|
|
'--genre-name-pad',
|
|
'4px',
|
|
);
|
|
} else if (w > 250) {
|
|
this.style.setProperty(
|
|
'--genre-name-font',
|
|
'15px',
|
|
);
|
|
this.style.setProperty(
|
|
'--genre-name-pad',
|
|
'8px',
|
|
);
|
|
} else {
|
|
this.style.setProperty(
|
|
'--genre-name-font',
|
|
'14px',
|
|
);
|
|
this.style.setProperty(
|
|
'--genre-name-pad',
|
|
'6px',
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Genre selection helpers
|
|
* ================================================================ */
|
|
|
|
/**
|
|
* Select a contiguous range of genre names
|
|
* between two indices in filteredGenres.
|
|
*/
|
|
private selectGenreRange(
|
|
from: number,
|
|
to: number,
|
|
): Set<string> {
|
|
const filtered = this.cachedFilteredGenres;
|
|
const start = Math.min(from, to);
|
|
const end = Math.max(from, to);
|
|
const names = new Set<string>();
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
const genre = filtered[i];
|
|
|
|
if (genre) {
|
|
names.add(genre.name);
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Fetch file paths for a set of genre names by
|
|
* querying the backend for each genre.
|
|
* Respects the active library filter.
|
|
*/
|
|
private async getFilePathsForGenres(
|
|
genreNames: Iterable<string>,
|
|
): Promise<string[]> {
|
|
const seen = new Set<string>();
|
|
const allPaths: string[] = [];
|
|
const libId =
|
|
this.libraryCtrl.selectedLibraryId;
|
|
|
|
// perf.m2: one call per genre, each returning
|
|
// whole track rows so the file path could be
|
|
// read off them — 6 MB over the IPC for five
|
|
// genres of a 50 000-track library.
|
|
const names = Array.from(genreNames);
|
|
const byGenre = await dictByName(
|
|
GetFilePathsByGenres(names, libId ?? 0),
|
|
);
|
|
|
|
// Still de-duplicated here: a track with two of
|
|
// the selected genres appears under both, and
|
|
// the caller owns the order.
|
|
for (const name of names) {
|
|
for (const path of byGenre[name] ?? []) {
|
|
if (!seen.has(path)) {
|
|
seen.add(path);
|
|
allPaths.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
return allPaths;
|
|
}
|
|
|
|
/**
|
|
* Return file paths for the context menu target.
|
|
* If the right-clicked genre is part of the
|
|
* current selection, return paths for all selected
|
|
* genres. Otherwise return paths for the
|
|
* right-clicked genre only.
|
|
*/
|
|
private async getContextMenuGenreFilePaths(): Promise<
|
|
string[]
|
|
> {
|
|
if (
|
|
this.contextMenuGenreName !== null &&
|
|
!this.selectedGenres.has(
|
|
this.contextMenuGenreName,
|
|
)
|
|
) {
|
|
return this.getFilePathsForGenres([
|
|
this.contextMenuGenreName,
|
|
]);
|
|
}
|
|
|
|
return this.getFilePathsForGenres(
|
|
this.selectedGenres,
|
|
);
|
|
}
|
|
|
|
/** Clear the current genre selection. */
|
|
private clearSelection() {
|
|
this.selectedGenres = new Set();
|
|
this.lastSelectedGenreIndex = null;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Genre card click
|
|
* ================================================================ */
|
|
|
|
private onGenreClick(
|
|
e: MouseEvent,
|
|
genre: Genre,
|
|
index: number,
|
|
) {
|
|
const isCtrl = e.ctrlKey || e.metaKey;
|
|
const isShift = e.shiftKey;
|
|
|
|
if (
|
|
isShift &&
|
|
this.lastSelectedGenreIndex !== null
|
|
) {
|
|
const range = this.selectGenreRange(
|
|
this.lastSelectedGenreIndex,
|
|
index,
|
|
);
|
|
const next = new Set(
|
|
this.selectedGenres,
|
|
);
|
|
|
|
for (const name of range) {
|
|
next.add(name);
|
|
}
|
|
|
|
this.selectedGenres = next;
|
|
} else if (isCtrl) {
|
|
const next = new Set(
|
|
this.selectedGenres,
|
|
);
|
|
|
|
if (next.has(genre.name)) {
|
|
next.delete(genre.name);
|
|
} else {
|
|
next.add(genre.name);
|
|
}
|
|
|
|
this.selectedGenres = next;
|
|
this.lastSelectedGenreIndex = index;
|
|
} else {
|
|
// Plain click: navigate to details.
|
|
this.clearSelection();
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
view: 'genre-details',
|
|
genreName: genre.name,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Context menu
|
|
* ================================================================ */
|
|
|
|
private onGenreContextMenu = (
|
|
e: MouseEvent,
|
|
genre: Genre,
|
|
) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.contextMenuGenreName = genre.name;
|
|
|
|
this.ctxMenu.openAt(
|
|
e.clientX,
|
|
e.clientY,
|
|
);
|
|
};
|
|
|
|
/** Shift+F10 / ContextMenu on a focused card. */
|
|
private openGenreMenuFromKey(
|
|
e: KeyboardEvent,
|
|
genre: Genre,
|
|
): void {
|
|
const card = e.currentTarget as HTMLElement | null;
|
|
|
|
if (!card) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.contextMenuGenreName = genre.name;
|
|
this.ctxMenu.openFrom(card);
|
|
}
|
|
|
|
private async onContextMenuAction(
|
|
action: string,
|
|
) {
|
|
const filePaths =
|
|
await this.getContextMenuGenreFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
switch (action) {
|
|
case 'play':
|
|
queueStore.setQueue(filePaths, 0, true, {
|
|
type: 'genre',
|
|
id: 0,
|
|
label: this.contextMenuGenreName ?? '',
|
|
});
|
|
break;
|
|
case 'add-to-queue':
|
|
queueStore.addTracksToQueue(
|
|
filePaths,
|
|
);
|
|
break;
|
|
case 'play-next':
|
|
queueStore.playTracksNext(
|
|
filePaths,
|
|
);
|
|
break;
|
|
}
|
|
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
private async onContextMenuFavoriteToggle() {
|
|
const filePaths =
|
|
await this.getContextMenuGenreFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
if (this.favCtrl.allFavorited(filePaths)) {
|
|
void this.favCtrl.removeFromFavorites(
|
|
filePaths,
|
|
);
|
|
} else {
|
|
void this.favCtrl.addToFavorites(
|
|
filePaths,
|
|
);
|
|
}
|
|
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Helpers
|
|
* ================================================================ */
|
|
|
|
private getGenreInitial(name: string): string {
|
|
if (!name) return '?';
|
|
|
|
return name.charAt(0).toUpperCase();
|
|
}
|
|
|
|
/* ================================================================
|
|
* Rendering
|
|
* ================================================================ */
|
|
|
|
private renderGenreCard(entry: GenreEntry) {
|
|
const { genre, index } = entry;
|
|
const imgSize = this.imageSize;
|
|
const placeholderFont = Math.round(
|
|
imgSize * 0.38,
|
|
);
|
|
const isSelected =
|
|
this.selectedGenres.has(genre.name);
|
|
|
|
return html`
|
|
<div
|
|
class="genre-card${isSelected
|
|
? ' selected'
|
|
: ''}"
|
|
data-index=${index}
|
|
tabindex=${this.roving.tabIndexFor(index)}
|
|
@focus=${() => this.roving.noteFocus(index)}
|
|
role="option"
|
|
aria-label="${genre.name}"
|
|
aria-selected="${isSelected}"
|
|
style="
|
|
--avatar-size: ${imgSize}px;
|
|
--placeholder-font: ${placeholderFont}px;
|
|
"
|
|
@click=${(e: MouseEvent) =>
|
|
this.onGenreClick(
|
|
e,
|
|
genre,
|
|
index,
|
|
)}
|
|
@contextmenu=${(e: MouseEvent) =>
|
|
this.onGenreContextMenu(
|
|
e,
|
|
genre,
|
|
)}
|
|
@keydown=${(e: KeyboardEvent) => {
|
|
if (isContextMenuKey(e)) {
|
|
this.openGenreMenuFromKey(
|
|
e,
|
|
genre,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if (
|
|
e.key === 'Enter' ||
|
|
e.key === ' '
|
|
) {
|
|
e.preventDefault();
|
|
this.clearSelection();
|
|
this.dispatchEvent(
|
|
new CustomEvent(
|
|
'navigate',
|
|
{
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
view: 'genre-details',
|
|
genreName:
|
|
genre.name,
|
|
},
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div class="avatar-container">
|
|
<span class="avatar-placeholder">
|
|
${this.getGenreInitial(
|
|
genre.name,
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="genre-name"
|
|
title="${genre.name}"
|
|
>
|
|
${genre.name}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderContextMenu() {
|
|
return html`
|
|
<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="Genre 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.getContextMenuGenreFilePaths().then(
|
|
(paths) =>
|
|
this.ctxMenu.showPlaylistSubmenu(
|
|
paths,
|
|
),
|
|
);
|
|
}}
|
|
@mouseleave=${this
|
|
.ctxMenu
|
|
.scheduleSubmenuClose}
|
|
@click=${(
|
|
e: Event,
|
|
) => {
|
|
e.stopPropagation();
|
|
void this.getContextMenuGenreFilePaths().then(
|
|
(paths) =>
|
|
this.ctxMenu.showPlaylistSubmenu(
|
|
paths,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
<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}
|
|
</wa-popup>
|
|
|
|
<wa-popup
|
|
id="playlist-submenu"
|
|
placement="right-start"
|
|
flip
|
|
shift
|
|
.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}
|
|
</wa-popup>
|
|
`;
|
|
}
|
|
|
|
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="Genres"
|
|
.sortOptions=${GENRE_SORT_OPTIONS}
|
|
sort-field=${this.sortField}
|
|
sort-direction=${this.sortDirection}
|
|
></page-header>
|
|
<div class="loading-message">
|
|
Loading genres...
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const entries = this.cachedGridEntries;
|
|
const searchBar = html`
|
|
<page-header
|
|
heading="Genres"
|
|
.count=${entries.length}
|
|
count-noun="genre"
|
|
.sortOptions=${GENRE_SORT_OPTIONS}
|
|
sort-field=${this.sortField}
|
|
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 genres match your search.'
|
|
: 'No genres 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="Genres"
|
|
aria-multiselectable="true"
|
|
.items=${entries}
|
|
.renderItem=${(entry: GenreEntry) => this.renderGenreCard(entry)}
|
|
.keyFunction=${(entry: GenreEntry) => entry.genre.name}
|
|
.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('genre-card'),
|
|
);
|
|
|
|
if (!clickedCard) {
|
|
this.clearSelection();
|
|
}
|
|
};
|
|
}
|