added context menu to artist grid

This commit is contained in:
2026-02-22 19:52:46 -05:00
parent 9dabfb81c5
commit 3c06277820
@@ -1,13 +1,26 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import {
customElement,
state,
query,
} from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime'; import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer'; import '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import {
GetAlbumsByArtist,
GetAlbumTracks,
} from '@go/library/Library';
import { library } from '@go/models'; import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller'; import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store';
import { Events } from '../../events'; import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@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 type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
/** Pixels to change card width per scroll tick. */ /** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16; const ZOOM_STEP = 16;
@@ -44,13 +57,56 @@ export class ArtistsView extends LitElement {
@state() @state()
private cardSize: number = CARD_SIZE_DEFAULT; private cardSize: number = CARD_SIZE_DEFAULT;
// Fixed grid spacing constants. // ----- Context menu state -----
@state()
private contextMenuOpen = false;
@state()
private playlistSubmenuOpen = false;
@state()
private playlistFilePaths: string[] = [];
/** The artist targeted by the current context menu. */
private contextMenuArtist: library.Artist | null =
null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
// ----- Close handlers -----
private closeHandler = () =>
this.closeContextMenu();
private mousedownCloseHandler = (
e: MouseEvent,
) => {
const path = e.composedPath();
const popup = this.contextMenuPopup;
const submenu = this.playlistSubmenuPopup;
if (popup && path.includes(popup)) return;
if (submenu && path.includes(submenu)) return;
this.closeContextMenu();
};
// ----- Grid spacing constants -----
private static readonly GRID_GAP = 8; private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8; private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5; private static readonly CARD_PADDING = 5;
private get imageSize(): number { private get imageSize(): number {
return this.cardSize - ArtistsView.CARD_PADDING * 2; return (
this.cardSize -
ArtistsView.CARD_PADDING * 2
);
} }
private get cardTextHeight(): number { private get cardTextHeight(): number {
@@ -184,13 +240,17 @@ export class ArtistsView extends LitElement {
.artist-name { .artist-name {
width: 100%; width: 100%;
text-align: center; text-align: center;
font-size: var(--artist-name-font, 14px); font-size: var(
--artist-name-font,
14px
);
font-weight: 500; font-weight: 500;
color: var(--yj-text-primary, #fff); color: var(--yj-text-primary, #fff);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: var(--artist-name-pad, 6px) 2px 0; padding: var(--artist-name-pad, 6px) 2px
0;
line-height: 1.3; line-height: 1.3;
} }
@@ -206,8 +266,65 @@ export class ArtistsView extends LitElement {
); );
font-size: 14px; font-size: 14px;
} }
/* ====================================
* Context menu
* ==================================== */
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: var(
--yj-bg-elevated,
#343a40
);
border: 1px solid
var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px
rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: 13px;
}
.context-menu-panel
wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`; `;
/* ================================================================
* Lifecycle
* ================================================================ */
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.loadCardSize(); this.loadCardSize();
@@ -216,12 +333,36 @@ export class ArtistsView extends LitElement {
Events.LibraryScanComplete, Events.LibraryScanComplete,
() => this.loadArtists(), () => this.loadArtists(),
); );
document.addEventListener(
'click',
this.closeHandler,
);
document.addEventListener(
'contextmenu',
this.closeHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
);
} }
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.cancelScanComplete?.(); this.cancelScanComplete?.();
this.detachWheelListener(); this.detachWheelListener();
document.removeEventListener(
'click',
this.closeHandler,
);
document.removeEventListener(
'contextmenu',
this.closeHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
);
} }
override updated() { override updated() {
@@ -321,14 +462,19 @@ export class ArtistsView extends LitElement {
} }
private ensureWheelListener() { private ensureWheelListener() {
const container = this.shadowRoot?.querySelector( const container =
'.grid-scroll-container', this.shadowRoot?.querySelector(
); '.grid-scroll-container',
);
if (container && !this.wheelListenerAttached) { if (
container &&
!this.wheelListenerAttached
) {
container.addEventListener( container.addEventListener(
'wheel', 'wheel',
this.wheelHandler as EventListener, this
.wheelHandler as EventListener,
{ passive: false }, { passive: false },
); );
this.wheelListenerAttached = true; this.wheelListenerAttached = true;
@@ -336,14 +482,19 @@ export class ArtistsView extends LitElement {
} }
private detachWheelListener() { private detachWheelListener() {
const container = this.shadowRoot?.querySelector( const container =
'.grid-scroll-container', this.shadowRoot?.querySelector(
); '.grid-scroll-container',
);
if (container && this.wheelListenerAttached) { if (
container &&
this.wheelListenerAttached
) {
container.removeEventListener( container.removeEventListener(
'wheel', 'wheel',
this.wheelHandler as EventListener, this
.wheelHandler as EventListener,
); );
this.wheelListenerAttached = false; this.wheelListenerAttached = false;
} }
@@ -356,7 +507,9 @@ export class ArtistsView extends LitElement {
private lastLayoutWidth = 0; private lastLayoutWidth = 0;
private updateGridLayout() { private updateGridLayout() {
if (this.cardSize === this.lastLayoutWidth) { if (
this.cardSize === this.lastLayoutWidth
) {
return; return;
} }
@@ -405,7 +558,9 @@ export class ArtistsView extends LitElement {
* Artist card click * Artist card click
* ================================================================ */ * ================================================================ */
private onArtistClick(artist: library.Artist) { private onArtistClick(
artist: library.Artist,
) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('navigate', { new CustomEvent('navigate', {
bubbles: true, bubbles: true,
@@ -419,11 +574,197 @@ export class ArtistsView extends LitElement {
); );
} }
/* ================================================================
* Context menu
* ================================================================ */
private onArtistContextMenu = (
e: MouseEvent,
artist: library.Artist,
) => {
e.preventDefault();
e.stopPropagation();
this.contextMenuArtist = artist;
this.openContextMenuAt(
e.clientX,
e.clientY,
);
};
private openContextMenuAt(
clientX: number,
clientY: number,
) {
this.contextMenuOpen = true;
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: clientX,
y: clientY,
top: clientY,
left: clientX,
right: clientX,
bottom: clientY,
};
},
};
(popup as any).active = true;
}
});
}
private closeContextMenu() {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
this.contextMenuArtist = null;
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async onContextMenuAction(
action: string,
) {
const artist = this.contextMenuArtist;
if (!artist) return;
const filePaths =
await this.getArtistFilePaths(artist);
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(
filePaths,
);
break;
case 'play-next':
queueStore.playTracksNext(
filePaths,
);
break;
}
this.closeContextMenu();
}
/* ================================================================
* Playlist submenu
* ================================================================ */
private async showPlaylistSubmenu() {
if (this.playlistSubmenuOpen) return;
const artist = this.contextMenuArtist;
if (!artist) return;
this.playlistFilePaths =
await this.getArtistFilePaths(artist);
this.playlistSubmenuOpen = true;
await this.updateComplete;
const submenu = this.playlistSubmenuPopup;
const trigger =
this.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
(submenu as any).anchor = trigger;
(submenu as any).active = true;
}
const picker =
this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu();
};
/* ================================================================
* File path resolution
* ================================================================ */
/**
* Fetches all file paths for an artist by
* loading their albums, then each album's
* tracks.
*/
private async getArtistFilePaths(
artist: library.Artist,
): Promise<string[]> {
try {
const albums =
await GetAlbumsByArtist(artist.ID);
const allPaths: string[] = [];
for (const album of albums) {
const tracks =
await GetAlbumTracks(album.ID);
for (const t of tracks) {
allPaths.push(t.FilePath);
}
}
return allPaths;
} catch (error) {
console.error(
'Error loading artist tracks:',
error,
);
return [];
}
}
/* ================================================================ /* ================================================================
* Helpers * Helpers
* ================================================================ */ * ================================================================ */
private getArtistInitial(name: string): string { private getArtistInitial(
name: string,
): string {
if (!name) return '?'; if (!name) return '?';
return name.charAt(0).toUpperCase(); return name.charAt(0).toUpperCase();
@@ -433,9 +774,7 @@ export class ArtistsView extends LitElement {
* Rendering * Rendering
* ================================================================ */ * ================================================================ */
private renderArtistCard( private renderArtistCard(entry: ArtistEntry) {
entry: ArtistEntry,
) {
const { artist } = entry; const { artist } = entry;
const imgSize = this.imageSize; const imgSize = this.imageSize;
const placeholderFont = Math.round( const placeholderFont = Math.round(
@@ -454,6 +793,11 @@ export class ArtistsView extends LitElement {
" "
@click=${() => @click=${() =>
this.onArtistClick(artist)} this.onArtistClick(artist)}
@contextmenu=${(e: MouseEvent) =>
this.onArtistContextMenu(
e,
artist,
)}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if ( if (
e.key === 'Enter' || e.key === 'Enter' ||
@@ -481,6 +825,108 @@ export class ArtistsView extends LitElement {
`; `;
} }
private renderContextMenu() {
return html`
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div
class="context-menu-panel"
>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
@click=${(
e: Event,
) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>&#9654;</span
>
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this
.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
? html`
<playlist-picker
.filePaths=${this
.playlistFilePaths}
@playlist-action-complete=${this
.onPlaylistActionComplete}
@click=${(
e: Event,
) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
`;
}
override render() { override render() {
if (this.loading) { if (this.loading) {
return html` return html`
@@ -509,10 +955,13 @@ export class ArtistsView extends LitElement {
.renderItem=${( .renderItem=${(
entry: ArtistEntry, entry: ArtistEntry,
) => ) =>
this.renderArtistCard(entry)} this.renderArtistCard(
entry,
)}
.layout=${this.gridLayout} .layout=${this.gridLayout}
></lit-virtualizer> ></lit-virtualizer>
</div> </div>
${this.renderContextMenu()}
`; `;
} }
} }