cover grid refactor

-split component into several files
This commit is contained in:
2026-02-24 09:49:34 -05:00
parent 352fa9da61
commit 1221a403cf
13 changed files with 3055 additions and 3193 deletions
@@ -19,12 +19,16 @@ import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store';
import {
ContextMenuController,
contextMenuStyles,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { Events } from '../../events';
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. */
const ZOOM_STEP = 16;
@@ -49,9 +53,13 @@ interface ArtistEntry {
}
@customElement('artists-view')
export class ArtistsView extends LitElement {
export class ArtistsView
extends LitElement
implements ContextMenuHost
{
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private ctxMenu = new ContextMenuController(this);
private cancelScanComplete?: () => void;
private wheelListenerAttached = false;
private lastSearchTerm = '';
@@ -81,9 +89,6 @@ export class ArtistsView extends LitElement {
// ----- Context menu state -----
@state()
private contextMenuOpen = false;
/**
* Artist ID that was right-clicked to open the
* context menu. Used as fallback when the
@@ -92,39 +97,25 @@ export class ArtistsView extends LitElement {
*/
private contextMenuArtistId: number | null = null;
@state()
private playlistSubmenuOpen = false;
@state()
private playlistFilePaths: string[] = [];
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
getContextMenuPopup(): HTMLElement | undefined {
return this.contextMenuPopup;
}
// ----- Close handlers -----
getPlaylistSubmenuPopup():
| HTMLElement
| undefined {
return this.playlistSubmenuPopup;
}
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();
};
onContextMenuClose(): void {
this.contextMenuArtistId = null;
}
// ----- Grid spacing constants -----
@@ -219,198 +210,156 @@ export class ArtistsView extends LitElement {
);
}
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
static override styles = [
contextMenuStyles,
css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.grid-scroll-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.grid-scroll-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
lit-virtualizer {
width: 100%;
min-height: 100%;
}
lit-virtualizer {
width: 100%;
min-height: 100%;
}
.artist-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
overflow: hidden;
}
.artist-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 5px;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.15s ease,
transform 0.15s ease;
overflow: hidden;
}
.artist-card:hover {
background-color: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
}
.artist-card:hover {
background-color: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
}
.artist-card:active {
transform: scale(0.97);
}
.artist-card:active {
transform: scale(0.97);
}
.artist-card.selected {
outline: 2px solid
var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.artist-card.selected {
outline: 2px solid
var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.artist-card.selected .avatar-container {
scale: 0.95;
}
.artist-card.selected
.avatar-container {
scale: 0.95;
}
.artist-card.selected .artist-name {
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-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-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;
}
.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;
}
.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-indicator {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
pointer-events: none;
background: var(
--yj-bg-overlay,
#495057
);
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 12px;
padding: 4px 14px;
border-radius: 12px;
border: 1px solid
var(--yj-border-subtle, #555);
white-space: nowrap;
opacity: 0.92;
}
.search-indicator {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
pointer-events: none;
background: var(
--yj-bg-overlay,
#495057
);
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 12px;
padding: 4px 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;
}
/* ====================================
* 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;
}
`;
.loading-message,
.empty-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 14px;
}
`,
];
/* ================================================================
* Lifecycle
@@ -431,18 +380,6 @@ export class ArtistsView extends LitElement {
Events.LibraryScanComplete,
() => this.loadArtists(),
);
document.addEventListener(
'click',
this.closeHandler,
);
document.addEventListener(
'contextmenu',
this.closeHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
override disconnectedCallback() {
@@ -453,19 +390,6 @@ export class ArtistsView extends LitElement {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
document.removeEventListener(
'click',
this.closeHandler,
);
document.removeEventListener(
'contextmenu',
this.closeHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
override updated() {
@@ -905,56 +829,12 @@ export class ArtistsView extends LitElement {
this.contextMenuArtistId = artist.ID;
this.openContextMenuAt(
this.ctxMenu.openAt(
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.contextMenuArtistId = null;
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async onContextMenuAction(
action: string,
) {
@@ -965,7 +845,11 @@ export class ArtistsView extends LitElement {
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(
filePaths,
0,
true,
);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(
@@ -979,81 +863,20 @@ export class ArtistsView extends LitElement {
break;
}
this.closeContextMenu();
this.ctxMenu.close();
}
/* ================================================================
* Playlist submenu
* ================================================================ */
private clearSubmenuCloseTimer() {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
private scheduleSubmenuClose = () => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, 150);
};
private async showPlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
this.playlistFilePaths =
/**
* Resolve artist file paths and show the
* playlist submenu.
*/
private async handleShowPlaylistSubmenu() {
const paths =
await this.getContextMenuArtistFilePaths();
if (this.playlistFilePaths.length === 0) {
return;
}
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();
void this.ctxMenu.showPlaylistSubmenu(paths);
}
private closePlaylistSubmenu() {
this.clearSubmenuCloseTimer();
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
* ================================================================ */
@@ -1191,9 +1014,10 @@ export class ArtistsView extends LitElement {
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
.active=${this.ctxMenu
.contextMenuOpen}
>
${this.contextMenuOpen
${this.ctxMenu.contextMenuOpen
? html`
<div
class="context-menu-panel"
@@ -1204,7 +1028,7 @@ export class ArtistsView extends LitElement {
'play',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1218,7 +1042,7 @@ export class ArtistsView extends LitElement {
'add-to-queue',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1232,7 +1056,7 @@ export class ArtistsView extends LitElement {
'play-next',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1243,16 +1067,17 @@ export class ArtistsView extends LitElement {
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.clearSubmenuCloseTimer();
void this.showPlaylistSubmenu();
this.ctxMenu.clearSubmenuCloseTimer();
void this.handleShowPlaylistSubmenu();
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(
e: Event,
) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
void this.handleShowPlaylistSubmenu();
}}
>
<wa-icon
@@ -1275,21 +1100,24 @@ export class ArtistsView extends LitElement {
placement="right-start"
flip
shift
.active=${this
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
${this.ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() =>
this.clearSubmenuCloseTimer()}
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this
.ctxMenu
.playlistFilePaths}
@playlist-action-complete=${this
.ctxMenu
.onPlaylistActionComplete}
@click=${(
e: Event,
@@ -0,0 +1,354 @@
import { GetAlbumTracks } from '@go/library/Library';
import type { library } from '@go/models';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
/**
* Manages album and track selection, file-path resolution,
* and the drag-cache for the cover grid.
*
* This is a plain helper class (not a ReactiveController)
* because selection state is owned by the component's
* `@state()` properties — the manager only computes
* derived data (file paths, ranges, cache entries).
*/
export class AlbumSelectionManager {
/**
* Map from album ID to Album for O(1) lookups.
* Rebuilt via `setAlbums()` when the album list changes.
*/
private albumById = new Map<number, library.Album>();
/**
* Pre-resolved file paths for selected albums, keyed by album ID.
* Populated asynchronously when albums are selected so that
* dragstart can read them synchronously.
*/
private albumFilePathCache = new Map<
number,
string[]
>();
/**
* Update the album-by-ID index. Call this whenever
* the full album list changes (initial load, library
* rescan, external album prop change).
*
* Also clears the file-path cache since album IDs may
* have shifted after a rescan.
*/
setAlbums(albums: library.Album[]): void {
this.albumById = new Map(
albums.map((a) => [a.ID, a]),
);
this.albumFilePathCache.clear();
}
// ================================================================
// Album selection helpers
// ================================================================
/**
* Return the set of album IDs in the range
* [from, to] (inclusive, order-independent)
* within the filtered album list.
*/
selectAlbumRange(
from: number,
to: number,
filteredAlbums: library.Album[],
): Set<number> {
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 album = filteredAlbums[i];
if (album) {
ids.add(album.ID);
}
}
return ids;
}
/**
* Fetch file paths for all albums in the given
* selection set. Uses the albumById index for
* O(1) lookups instead of filtering the full list.
*/
async getSelectedAlbumFilePaths(
selectedAlbums: Set<number>,
): Promise<string[]> {
const allPaths: string[] = [];
for (const id of selectedAlbums) {
const album = this.albumById.get(id);
if (!album) continue;
const paths =
await this.getAlbumFilePaths(album);
allPaths.push(...paths);
}
return allPaths;
}
/**
* Return file paths for the context menu target.
* If the right-clicked album is part of the current
* selection, return paths for all selected albums.
* Otherwise return paths for the right-clicked
* album only.
*/
async getContextMenuAlbumFilePaths(
contextMenuAlbumId: number | null,
selectedAlbums: Set<number>,
): Promise<string[]> {
if (
contextMenuAlbumId !== null &&
!selectedAlbums.has(contextMenuAlbumId)
) {
const album = this.albumById.get(
contextMenuAlbumId,
);
if (album) {
return this.getAlbumFilePaths(album);
}
return [];
}
return this.getSelectedAlbumFilePaths(
selectedAlbums,
);
}
/**
* Fetch file paths for a single album by loading
* its tracks from the backend.
*/
async getAlbumFilePaths(
album: library.Album,
): Promise<string[]> {
try {
const tracks = await GetAlbumTracks(
album.ID,
);
return tracks.map((t) => t.FilePath);
} catch (error) {
console.error(
'Error loading album tracks:',
error,
);
return [];
}
}
// ================================================================
// Drag file-path cache
// ================================================================
/**
* Pre-resolve file paths for all selected albums so
* that dragstart can read them synchronously. Called
* fire-and-forget whenever the album selection changes.
*
* After warming, prunes entries whose album ID is no
* longer in the selection to prevent unbounded growth.
*/
async warmCache(
selectedAlbums: Set<number>,
): Promise<void> {
for (const id of selectedAlbums) {
if (this.albumFilePathCache.has(id)) {
continue;
}
const album = this.albumById.get(id);
if (!album) continue;
try {
const tracks = await GetAlbumTracks(
album.ID,
);
// Only store if still selected.
if (selectedAlbums.has(album.ID)) {
this.albumFilePathCache.set(
album.ID,
tracks.map((t) => t.FilePath),
);
}
} catch {
// Silently skip — drag will just not
// include this album's paths.
}
}
// Prune stale entries (6h).
for (const id of this.albumFilePathCache.keys()) {
if (!selectedAlbums.has(id)) {
this.albumFilePathCache.delete(id);
}
}
}
/**
* Read cached file paths for the current album
* selection. Returns concatenated paths (may be
* incomplete if some albums haven't been cached yet).
*/
getCachedSelectedPaths(
selectedAlbums: Set<number>,
): string[] {
const result: string[] = [];
for (const id of selectedAlbums) {
const paths =
this.albumFilePathCache.get(id);
if (paths) {
result.push(...paths);
}
}
return result;
}
/**
* Check whether a single album's paths are in the
* cache, and return them if so.
*/
getCachedAlbumPaths(
albumId: number,
): string[] | undefined {
return this.albumFilePathCache.get(albumId);
}
/**
* Warm a single album's cache entry (used by
* pointerdown before a potential dragstart).
*/
async warmSingleAlbum(
album: library.Album,
): Promise<void> {
if (this.albumFilePathCache.has(album.ID)) {
return;
}
const paths = await this.getAlbumFilePaths(
album,
);
if (paths.length > 0) {
this.albumFilePathCache.set(
album.ID,
paths,
);
}
}
// ================================================================
// Track selection helpers
// ================================================================
/**
* Return the set of track file paths in the range
* [from, to] (inclusive, order-independent).
*/
selectTrackRange(
from: number,
to: number,
expandedTracks: library.Track[],
): Set<string> {
const start = Math.min(from, to);
const end = Math.max(from, to);
const paths = new Set<string>();
for (let i = start; i <= end; i++) {
const track = expandedTracks[i];
if (track) {
paths.add(track.FilePath);
}
}
return paths;
}
/**
* Return selected track file paths in their
* original track order.
*/
getSelectedTrackFilePaths(
selectedTracks: Set<string>,
expandedTracks: library.Track[],
): string[] {
return expandedTracks
.filter((t) =>
selectedTracks.has(t.FilePath),
)
.map((t) => t.FilePath);
}
// ================================================================
// Cover art resolution
// ================================================================
/**
* Resolve cover art URLs for a track's album.
* Uses the albumById index with the expanded album ID
* for an O(1) lookup instead of a name-based O(n) scan.
*
* Falls back to name-based search if the expanded album
* doesn't match (defensive).
*/
resolveTrackCoverArt(
albumName: string,
expandedAlbumId: number | null,
): CoverArtUrls | null {
if (!albumName) return null;
// Prefer the expanded album (we know the track
// belongs to it) for an O(1) lookup.
if (expandedAlbumId !== null) {
const album = this.albumById.get(
expandedAlbumId,
);
if (album?.CoverArtPath) {
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium:
album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
}
// Fallback: name-based search across all albums.
for (const album of this.albumById.values()) {
if (
album.Name === albumName &&
album.CoverArtPath
) {
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium:
album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
}
return null;
}
}
@@ -0,0 +1,282 @@
import { css } from 'lit';
import { contextMenuStyles } from '@utils/context-menu-controller.js';
/** Component-specific styles for the cover grid. */
const gridStyles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* ========================================
* Sort toolbar
* ======================================== */
.sort-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(
--yj-text-secondary,
#b3b3b3
);
border-bottom: 1px solid
var(--yj-border-subtle, #333);
flex-shrink: 0;
user-select: none;
}
.sort-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
border: none;
color: inherit;
font: inherit;
}
.sort-anchor:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.sort-anchor .sort-label {
color: var(--yj-text-primary, #fff);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
border-radius: 4px;
background: transparent;
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 12px;
padding: 0;
}
.sort-dir-btn:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
color: var(--yj-text-primary, #fff);
}
.sort-dropdown-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: 140px;
}
.sort-dropdown-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: 13px;
}
.sort-dropdown-panel
wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.sort-dropdown-panel
wa-dropdown-item.active-sort {
color: var(--yj-accent, #ffd43b);
--wa-color-text-normal: var(
--yj-accent,
#ffd43b
);
}
#sort-dropdown {
z-index: 200;
}
.grid-scroll-container {
flex: 1;
position: relative;
overflow-y: auto;
}
/* ========================================
* Album card
* ======================================== */
.album-card {
display: flex;
flex-direction: column;
cursor: pointer;
border-radius: 8px;
padding: 5px;
transition:
background-color 0.2s ease,
transform 0.15s ease;
box-sizing: border-box;
width: var(--card-width, 176px);
}
.album-card:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.1));
}
.album-card.selected {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.album-card:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.cover-container {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background-color: var(--yj-bg-surface, #282828);
transition: scale 0.15s ease;
}
.album-card.selected .cover-container {
scale: 0.95;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
-webkit-user-drag: none;
}
.placeholder-cover {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--placeholder-font, 48px);
}
.album-info {
margin-top: 4px;
min-width: 0;
text-align: center;
transition: scale 0.15s ease;
}
.album-card.selected .album-info {
scale: 0.95;
}
.album-name {
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name {
font-size: var(--artist-name-font, 12px);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.album-year {
color: var(--yj-text-tertiary, #888);
}
/* ========================================
* Shared states
* ======================================== */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: var(--yj-text-secondary, #b3b3b3);
}
.search-indicator {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
pointer-events: none;
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-secondary, #b3b3b3);
font-size: 12px;
padding: 4px 14px;
border-radius: 12px;
border: 1px solid
var(--yj-border-subtle, #555);
white-space: nowrap;
opacity: 0.92;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px;
color: var(--yj-text-secondary, #b3b3b3);
text-align: center;
}
.empty-state p {
margin: 8px 0;
}
`;
/** Combined styles for the cover grid component. */
export const coverGridStyles = [
gridStyles,
contextMenuStyles,
];
@@ -0,0 +1,88 @@
import type { library } from '@go/models';
/**
* Discriminated context menu target so we know whether the
* context-menu is operating on albums or on tracks inside the
* dropdown.
*/
export type ContextMenuTarget =
| { kind: 'album' }
| { kind: 'track' };
/**
* Item for the virtualized grid.
* Carries the original album and its index in the filtered
* album list.
*/
export interface GridEntry {
album: library.Album;
albumIndex: number;
}
/** Milliseconds to debounce visibility-changed saves. */
export const SCROLL_DEBOUNCE_MS = 100;
/** Pixels to change card width per scroll tick. */
export const ZOOM_STEP = 16;
/** localStorage keys for sort preferences. */
export const SORT_FIELD_KEY = 'cover-grid-sort-field';
export const SORT_DIR_KEY = 'cover-grid-sort-direction';
/** Available sort fields for the album grid. */
export type AlbumSortField = 'name' | 'artist' | 'year';
/** Sort option definition for the dropdown. */
export interface AlbumSortOption {
id: AlbumSortField;
label: string;
comparator: (
a: library.Album,
b: library.Album,
) => number;
}
/** All available sort options for albums. */
export const ALBUM_SORT_OPTIONS: AlbumSortOption[] = [
{
id: 'name',
label: 'Name',
comparator: (a, b) =>
a.Name.localeCompare(b.Name),
},
{
id: 'artist',
label: 'Artist',
comparator: (a, b) => {
const cmp = a.ArtistName.localeCompare(
b.ArtistName,
);
if (cmp !== 0) return cmp;
return a.Name.localeCompare(b.Name);
},
},
{
id: 'year',
label: 'Year',
comparator: (a, b) => {
// Albums without a year sort last.
if (!a.Year && !b.Year) {
return a.Name.localeCompare(b.Name);
}
if (!a.Year) return 1;
if (!b.Year) return -1;
const cmp = a.Year - b.Year;
if (cmp !== 0) return cmp;
return a.Name.localeCompare(b.Name);
},
},
];
/** Sort direction for the album grid. */
export type SortDirection = 'asc' | 'desc';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,907 @@
import type { LitElement } from 'lit';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import type { library } from '@go/models';
import type { LibraryController } from '@store/controllers/library-controller';
import {
SCROLL_DEBOUNCE_MS,
} from './cover-grid-types.js';
import type { GridEntry } from './cover-grid-types.js';
/**
* Grid spacing constants shared between the scroll
* manager and the host component.
*/
export interface GridConstants {
readonly GRID_GAP: number;
readonly GRID_PADDING: number;
}
/**
* Read-only interface into the cover-grid component
* that the scroll manager needs.
*/
export interface ScrollManagerHost extends LitElement {
readonly libraryCtrl: LibraryController;
readonly cachedFilteredAlbums: library.Album[];
readonly expandedAlbumId: number | null;
readonly expandedTracks: library.Track[];
readonly splitMode: boolean;
readonly splitIndex: number;
readonly cardWidth: number;
readonly cardHeight: number;
}
/**
* Manages scroll position persistence, resize-aware
* scroll preservation, transition overlays, and
* split/single mode geometry for the cover grid.
*
* This is a plain class (not a ReactiveController)
* because scroll management is imperative and async,
* not reactive.
*/
export class ScrollManager {
private host: ScrollManagerHost;
private gc: GridConstants;
// Scroll position debounce.
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
// Resize-aware scroll preservation.
private resizeObserver: ResizeObserver | null = null;
private resizeDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
private pendingFocus: {
albumIndex: number;
viewportOffset: number;
} | null = null;
private currentColumnCount = 0;
/** True while a resize reflow is in progress. */
isResizing = false;
// Scroll restoration across single/split mode
// transitions.
savedScrollTop = 0;
needsScrollRestore = false;
showDropdownAfterRestore = false;
/**
* Monotonically increasing counter used to cancel
* stale scroll-restore async blocks.
*/
private scrollRestoreGeneration = 0;
/**
* Set to the generation value when an async
* scroll-restore block finishes or is cancelled.
*/
private scrollRestoreResolved = 0;
/**
* When switching albums, the pixel distance from
* the newly-expanded album's top edge to the
* viewport top.
*/
savedAlbumViewportOffset: number | null = null;
/** Overlay element showing the old grid state
* while a mode transition is in flight. */
private transitionOverlay: HTMLDivElement | null =
null;
/** Cached index of the expanded album in the
* filtered list. -1 when no album is expanded
* or the album isn't in the filtered list. */
private expandedAlbumIndex = -1;
/** The expanded album ID that corresponds to the
* cached index. Used to detect invalidation. */
private expandedAlbumIndexId: number | null = null;
/** The filtered-albums reference used to compute
* the cached index. Used to detect invalidation. */
private expandedAlbumIndexAlbums:
library.Album[] = [];
constructor(
host: ScrollManagerHost,
gc: GridConstants,
) {
this.host = host;
this.gc = gc;
}
// ================================================================
// Expanded album index cache (improvement 6c)
// ================================================================
/**
* Return the index of the expanded album in the
* filtered list. Cached and invalidated when
* `expandedAlbumId` or `cachedFilteredAlbums`
* changes.
*/
getExpandedAlbumIndex(): number {
const id = this.host.expandedAlbumId;
const albums = this.host.cachedFilteredAlbums;
if (
id === this.expandedAlbumIndexId &&
albums === this.expandedAlbumIndexAlbums
) {
return this.expandedAlbumIndex;
}
this.expandedAlbumIndexId = id;
this.expandedAlbumIndexAlbums = albums;
if (id === null) {
this.expandedAlbumIndex = -1;
} else {
this.expandedAlbumIndex = albums.findIndex(
(a) => a.ID === id,
);
}
return this.expandedAlbumIndex;
}
// ================================================================
// Lifecycle
// ================================================================
/** Clean up timers and observers. */
teardown(): void {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
if (this.resizeDebounceTimer !== null) {
clearTimeout(this.resizeDebounceTimer);
}
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.removeOverlay();
}
// ================================================================
// Scroll position (index-based)
// ================================================================
/**
* Restore scroll position from the library store
* after initial album load.
*/
restoreScrollPosition(
virtualizer: LitVirtualizer | undefined,
): void {
const saved =
this.host.libraryCtrl.getScrollPosition(
'albums',
);
if (saved <= 0 || !virtualizer) return;
const safeIndex = Math.min(
saved,
this.host.cachedFilteredAlbums.length - 1,
);
if (safeIndex <= 0) return;
virtualizer.scrollToIndex(safeIndex, 'start');
}
/**
* Save scroll position from the first visible album.
* In split mode we use the before-entries; in single
* mode we use the full grid entries.
*/
onVisibilityChanged(
first: number,
getEntries: () => GridEntry[],
): void {
if (this.isResizing) return;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => {
const entries = getEntries();
const entry = entries[first];
if (entry) {
this.host.libraryCtrl.setScrollPosition(
'albums',
entry.albumIndex,
);
}
}, SCROLL_DEBOUNCE_MS);
}
// ================================================================
// Resize-aware scroll preservation
// ================================================================
/**
* Set up a ResizeObserver on the scroll container
* to preserve scroll position across width changes.
*/
setupResizeObserver(
container: HTMLElement,
onSplitResize: () => Promise<void>,
): void {
// Guard against stacked observers.
this.resizeObserver?.disconnect();
this.currentColumnCount =
this.getColumnCount(container);
const restoreScroll = () => {
const pending = this.pendingFocus;
this.pendingFocus = null;
this.isResizing = false;
if (!pending) return;
const newColumns =
this.getColumnCount(container);
this.currentColumnCount = newColumns;
// If a dropdown is open, delegate to the
// host for split recomputation.
if (
this.host.splitMode &&
this.host.expandedAlbumId !== null
) {
void onSplitResize();
return;
}
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const rowStep =
this.host.cardHeight + gap;
const newRow = Math.floor(
pending.albumIndex / newColumns,
);
const newY = pad + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
};
this.resizeObserver = new ResizeObserver(
() => {
const rowStep =
this.host.cardHeight +
this.gc.GRID_GAP;
if (this.pendingFocus === null) {
this.isResizing = true;
this.captureFocusPoint(
container,
rowStep,
);
}
const newColumns =
this.getColumnCount(container);
if (
newColumns !==
this.currentColumnCount
) {
if (
this.resizeDebounceTimer !==
null
) {
clearTimeout(
this.resizeDebounceTimer,
);
this.resizeDebounceTimer =
null;
}
restoreScroll();
return;
}
if (
this.resizeDebounceTimer !== null
) {
clearTimeout(
this.resizeDebounceTimer,
);
}
this.resizeDebounceTimer = setTimeout(
restoreScroll,
100,
);
},
);
this.resizeObserver.observe(container);
}
/**
* Determine the focus point for scroll restoration.
*/
private captureFocusPoint(
container: HTMLElement,
rowStep: number,
): void {
const pad = this.gc.GRID_PADDING;
const cols = this.currentColumnCount;
const filtered =
this.host.cachedFilteredAlbums;
// Prefer the expanded album as focus.
if (this.host.expandedAlbumId !== null) {
const idx = this.getExpandedAlbumIndex();
if (idx >= 0) {
const albumRow = Math.floor(
idx / cols,
);
const albumY =
pad + albumRow * rowStep;
this.pendingFocus = {
albumIndex: idx,
viewportOffset:
albumY - container.scrollTop,
};
return;
}
}
const centerY =
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - pad) / rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
Math.max(0, filtered.length - 1),
);
const albumY = pad + centerRow * rowStep;
this.pendingFocus = {
albumIndex,
viewportOffset:
albumY - container.scrollTop,
};
}
// ================================================================
// Column count / geometry helpers
// ================================================================
/**
* Compute the number of columns that fit in the
* given container.
*/
getColumnCount(
container?: HTMLElement,
): number {
if (!container) return 1;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const availableWidth =
container.clientWidth - pad * 2;
return Math.max(
1,
Math.floor(
(availableWidth + gap) /
(this.host.cardWidth + gap),
),
);
}
/** Container width in pixels. */
getContainerWidth(
container?: HTMLElement,
): number {
return container?.clientWidth ?? 800;
}
/**
* Width of the album row (left of leftmost card to
* right of rightmost card).
*/
getGridRowWidth(
container?: HTMLElement,
): number {
const cols = this.getColumnCount(container);
const gap = this.gc.GRID_GAP;
return (
cols * this.host.cardWidth +
(cols - 1) * gap
);
}
/**
* Horizontal offset of the carat so it points at
* the center of the expanded album card.
*/
getCaratOffset(
container?: HTMLElement,
): number {
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return 0;
const cols = this.getColumnCount(container);
const colIndex = idx % cols;
const gap = this.gc.GRID_GAP;
return (
colIndex *
(this.host.cardWidth + gap) +
this.host.cardWidth / 2
);
}
// ================================================================
// Split-mode helpers
// ================================================================
/**
* Compute the split point and return it. The
* component assigns this to its `splitIndex` state.
*/
computeSplitIndex(
container?: HTMLElement,
): number {
const filtered =
this.host.cachedFilteredAlbums;
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return filtered.length;
const columns =
this.getColumnCount(container);
return Math.min(
(Math.floor(idx / columns) + 1) * columns,
filtered.length,
);
}
// ================================================================
// Transition overlay
// ================================================================
/**
* Capture the current scroll container as a static
* overlay.
*/
captureOverlay(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): void {
if (!container || this.transitionOverlay) {
return;
}
const scrollY = container.scrollTop;
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute;inset:0;z-index:10;' +
'overflow:hidden;pointer-events:none;';
const inner = document.createElement('div');
inner.style.cssText =
'position:relative;height:100%;' +
'pointer-events:none;';
for (const child of Array.from(
container.childNodes,
)) {
inner.appendChild(child.cloneNode(true));
}
inner.style.transform =
`translateY(-${scrollY}px)`;
overlay.appendChild(inner);
shadowRoot?.appendChild(overlay);
this.transitionOverlay = overlay;
container.style.visibility = 'hidden';
}
/**
* Remove the snapshot overlay and reveal the real
* scroll container.
*/
removeOverlay(): void {
if (this.transitionOverlay) {
this.transitionOverlay.remove();
this.transitionOverlay = null;
}
}
/**
* Reveal the real scroll container (call separately
* when the overlay has already been removed or was
* never created).
*/
revealContainer(
container: HTMLElement | undefined,
): void {
if (container) {
container.style.visibility = '';
}
}
// ================================================================
// Dropdown scroll positioning
// ================================================================
/**
* Wait for the "before" virtualizer to finish its
* layout pass.
*/
async awaitBeforeLayout(
shadowRoot: ShadowRoot | null,
): Promise<void> {
const virt = shadowRoot?.querySelector(
'#grid-before',
) as LitVirtualizer | null;
await virt?.layoutComplete;
}
/**
* Return the current scrollTop converted to
* single-mode (dropdown-free) coordinates.
*/
computeAdjustedScrollTop(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): number {
if (!container) return 0;
const raw = container.scrollTop;
if (!this.host.splitMode) return raw;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const beforeRows = Math.ceil(
this.host.splitIndex / columns,
);
const dropdownTop =
pad + beforeRows * rowStep;
if (raw <= dropdownTop) return raw;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
const dropdownHeight =
(dropdown as HTMLElement)?.offsetHeight ??
0;
return raw - dropdownHeight;
}
/**
* Set scrollTop on the scroll container with
* retry logic for virtualizer expansion.
*/
async restoreScrollTop(
container: HTMLElement | undefined,
target: number,
): Promise<void> {
if (!container) return;
const maxAttempts = 10;
for (let i = 0; i < maxAttempts; i++) {
container.scrollTop = target;
if (
container.scrollTop >= target ||
target <= 0
) {
return;
}
await new Promise<void>((r) =>
requestAnimationFrame(() => r()),
);
}
console.warn(
'[restoreScrollTop] gave up after max attempts',
{
target,
actual: container.scrollTop,
scrollHeight: container.scrollHeight,
},
);
}
/**
* Scroll the container so the expanded album card
* and its dropdown are visible with minimal movement.
*/
async scrollToShowDropdown(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): Promise<void> {
if (
!container ||
this.host.expandedAlbumId === null
) {
return;
}
const expandedIndex =
this.getExpandedAlbumIndex();
if (expandedIndex < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const columns =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const albumRow = Math.floor(
expandedIndex / columns,
);
const albumTop =
pad + albumRow * rowStep - gap / 2;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
if (!dropdown) return;
await (dropdown as LitElement).updateComplete;
const beforeRows = Math.ceil(
this.host.splitIndex / columns,
);
const dropdownTop =
pad + beforeRows * rowStep;
const dropdownBottom =
dropdownTop +
(dropdown as HTMLElement).offsetHeight;
const viewTop = container.scrollTop;
const viewHeight = container.clientHeight;
const minScroll = dropdownBottom - viewHeight;
const maxScroll = albumTop;
let newScrollTop: number;
if (minScroll <= maxScroll) {
newScrollTop = Math.max(
minScroll,
Math.min(viewTop, maxScroll),
);
} else {
newScrollTop = albumTop;
}
if (newScrollTop !== viewTop) {
await this.restoreScrollTop(
container,
newScrollTop,
);
}
}
// ================================================================
// willUpdate / updated helpers
//
// Called from the component's lifecycle methods to
// compute scroll-related state transitions.
// ================================================================
/**
* Check whether a scroll-restore async block is
* currently in flight.
*/
get restoreInFlight(): boolean {
return (
this.scrollRestoreGeneration >
this.scrollRestoreResolved
);
}
/**
* Prepare the anchor capture for an exit-split
* transition when switching albums (not closing).
* Records the viewport offset of the newly-expanded
* album in the old split layout.
*/
captureAnchorOffset(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
): void {
if (this.host.expandedAlbumId === null) {
this.savedAlbumViewportOffset = null;
return;
}
const rawScrollTop =
container?.scrollTop ?? 0;
const idx = this.getExpandedAlbumIndex();
if (idx < 0) return;
const gap = this.gc.GRID_GAP;
const pad = this.gc.GRID_PADDING;
const cols =
this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap;
const row = Math.floor(idx / cols);
const albumY = pad + row * rowStep;
const oldBeforeRows = Math.ceil(
this.host.splitIndex / cols,
);
const oldDropdownTop =
pad + oldBeforeRows * rowStep;
const dropdown = shadowRoot?.querySelector(
'album-dropdown',
);
const oldDropdownHeight =
(dropdown as HTMLElement)?.offsetHeight ??
0;
const albumYOldSplit =
albumY >= oldDropdownTop
? albumY + oldDropdownHeight
: albumY;
this.savedAlbumViewportOffset =
albumYOldSplit - rawScrollTop;
}
/**
* Run the async scroll-restore sequence from the
* component's `updated()` callback.
*/
runScrollRestore(
container: HTMLElement | undefined,
shadowRoot: ShadowRoot | null,
expandedAlbumId: number | null,
updateComplete: Promise<boolean>,
): void {
this.needsScrollRestore = false;
const saved = this.savedScrollTop;
const showDropdown =
this.showDropdownAfterRestore;
const switching =
!showDropdown &&
expandedAlbumId !== null;
const gen = ++this.scrollRestoreGeneration;
void (async () => {
await updateComplete;
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
await this.restoreScrollTop(
container,
saved,
);
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
if (showDropdown) {
if (
this.savedAlbumViewportOffset !==
null &&
expandedAlbumId !== null
) {
const idx =
this.getExpandedAlbumIndex();
if (idx >= 0) {
const gap = this.gc.GRID_GAP;
const pad =
this.gc.GRID_PADDING;
const cols =
this.getColumnCount(
container,
);
const rowStep =
this.host.cardHeight +
gap;
const row = Math.floor(
idx / cols,
);
const albumY =
pad + row * rowStep;
const anchor =
albumY -
this
.savedAlbumViewportOffset!;
await this.restoreScrollTop(
container,
anchor,
);
}
this.savedAlbumViewportOffset =
null;
}
if (
gen !==
this.scrollRestoreGeneration
) {
this.scrollRestoreResolved = gen;
return;
}
await this.scrollToShowDropdown(
container,
shadowRoot,
);
}
if (gen !== this.scrollRestoreGeneration) {
this.scrollRestoreResolved = gen;
return;
}
if (!switching) {
this.removeOverlay();
this.revealContainer(container);
}
this.scrollRestoreResolved = gen;
})();
}
}
@@ -16,11 +16,15 @@ import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store';
import { Events } from '../../events';
import {
ContextMenuController,
contextMenuStyles,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.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. */
const ZOOM_STEP = 16;
@@ -49,9 +53,13 @@ interface GenreEntry {
}
@customElement('genres-view')
export class GenresView extends LitElement {
export class GenresView
extends LitElement
implements ContextMenuHost
{
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private ctxMenu = new ContextMenuController(this);
private cancelScanComplete?: () => void;
private wheelListenerAttached = false;
private lastSearchTerm = '';
@@ -84,9 +92,6 @@ export class GenresView extends LitElement {
// ----- Context menu state -----
@state()
private contextMenuOpen = false;
/**
* Genre name that was right-clicked to open the
* context menu. Used as fallback when the
@@ -95,39 +100,27 @@ export class GenresView extends LitElement {
*/
private contextMenuGenreName: string | null = null;
@state()
private playlistSubmenuOpen = false;
@state()
private playlistFilePaths: string[] = [];
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
// ----- ContextMenuHost interface -----
// ----- Close handlers -----
getContextMenuPopup(): HTMLElement | undefined {
return this.contextMenuPopup;
}
private closeHandler = () =>
this.closeContextMenu();
getPlaylistSubmenuPopup():
| HTMLElement
| undefined {
return this.playlistSubmenuPopup;
}
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();
};
onContextMenuClose(): void {
this.contextMenuGenreName = null;
}
// ----- Grid spacing constants -----
@@ -221,7 +214,9 @@ export class GenresView extends LitElement {
);
}
static override styles = css`
static override styles = [
contextMenuStyles,
css`
:host {
display: flex;
flex-direction: column;
@@ -363,59 +358,8 @@ export class GenresView extends LitElement {
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
@@ -436,18 +380,6 @@ export class GenresView extends LitElement {
Events.LibraryScanComplete,
() => this.loadGenres(),
);
document.addEventListener(
'click',
this.closeHandler,
);
document.addEventListener(
'contextmenu',
this.closeHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
override disconnectedCallback() {
@@ -458,19 +390,6 @@ export class GenresView extends LitElement {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
document.removeEventListener(
'click',
this.closeHandler,
);
document.removeEventListener(
'contextmenu',
this.closeHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
);
}
override updated() {
@@ -954,56 +873,12 @@ export class GenresView extends LitElement {
this.contextMenuGenreName = genre.name;
this.openContextMenuAt(
this.ctxMenu.openAt(
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.contextMenuGenreName = null;
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private onContextMenuAction(action: string) {
const filePaths =
this.getContextMenuGenreFilePaths();
@@ -1026,87 +901,9 @@ export class GenresView extends LitElement {
break;
}
this.closeContextMenu();
this.ctxMenu.close();
}
/* ================================================================
* Playlist submenu
* ================================================================ */
private clearSubmenuCloseTimer() {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
private scheduleSubmenuClose = () => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, 150);
};
private showPlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
this.playlistFilePaths =
this.getContextMenuGenreFilePaths();
if (this.playlistFilePaths.length === 0) {
return;
}
this.playlistSubmenuOpen = true;
void this.updateComplete.then(() => {
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() {
this.clearSubmenuCloseTimer();
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
* ================================================================ */
/* ================================================================
* Helpers
* ================================================================ */
@@ -1202,9 +999,10 @@ export class GenresView extends LitElement {
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
.active=${this.ctxMenu
.contextMenuOpen}
>
${this.contextMenuOpen
${this.ctxMenu.contextMenuOpen
? html`
<div
class="context-menu-panel"
@@ -1215,7 +1013,7 @@ export class GenresView extends LitElement {
'play',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1229,7 +1027,7 @@ export class GenresView extends LitElement {
'add-to-queue',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1243,7 +1041,7 @@ export class GenresView extends LitElement {
'play-next',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1254,16 +1052,21 @@ export class GenresView extends LitElement {
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.clearSubmenuCloseTimer();
this.showPlaylistSubmenu();
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(
this.getContextMenuGenreFilePaths(),
);
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(
e: Event,
) => {
e.stopPropagation();
this.showPlaylistSubmenu();
void this.ctxMenu.showPlaylistSubmenu(
this.getContextMenuGenreFilePaths(),
);
}}
>
<wa-icon
@@ -1286,21 +1089,24 @@ export class GenresView extends LitElement {
placement="right-start"
flip
shift
.active=${this
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
${this.ctxMenu.playlistSubmenuOpen
? html`
<div
@mouseenter=${() =>
this.clearSubmenuCloseTimer()}
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this
.ctxMenu
.playlistFilePaths}
@playlist-action-complete=${this
.ctxMenu
.onPlaylistActionComplete}
@click=${(
e: Event,
@@ -24,7 +24,6 @@ import { PlaylistController } from '@store/controllers/playlist-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/track-info/track-info';
import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -41,6 +40,9 @@ import {
removeDragImage,
} from '@utils/drag-image';
import { libraryStore } from '@store/library-store';
import { ContextMenuController } from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { contextMenuStyles } from '@utils/context-menu-controller.js';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
@@ -56,12 +58,23 @@ interface PlaylistEntry {
@customElement('playlist-view')
export class PlaylistView
extends LitElement
implements SelectionHost
implements SelectionHost, ContextMenuHost
{
private player = new PlayerController(this);
private playlistCtrl = new PlaylistController(this);
private searchCtrl = new SearchController(this);
private selection = new SelectionController(this);
private ctxMenu = new ContextMenuController(this);
getContextMenuPopup(): HTMLElement | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| HTMLElement
| undefined {
return this.playlistSubmenuPopup;
}
private cancelScanComplete?: () => void;
private scrollDebounceTimer: ReturnType<
typeof setTimeout
@@ -162,8 +175,6 @@ export class PlaylistView
@state() private refreshing = false;
@state() private creating = false;
@state() private newPlaylistName = '';
@state() private contextMenuOpen = false;
@state() private playlistSubmenuOpen = false;
@state() private playlistContextMenuOpen = false;
@state() private playlistContextMenuIndex = -1;
@state() private renamingPlaylistIndex = -1;
@@ -198,31 +209,23 @@ export class PlaylistView
@query('track-details')
private trackDetailsDialog!: TrackDetails;
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
private closePlaylistCtxMenuHandler =
() => this.closePlaylistContextMenu();
private closeContextMenuHandler = () => {
this.closeContextMenu();
this.closePlaylistContextMenu();
};
private playlistCtxMenuMousedownHandler =
(e: MouseEvent) => {
const plPopup =
this.playlistContextMenuPopup;
private mousedownCloseHandler = (
e: MouseEvent,
) => {
const path = e.composedPath();
const popup = this.contextMenuPopup;
const submenu = this.playlistSubmenuPopup;
const plPopup =
this.playlistContextMenuPopup;
if (
plPopup &&
e.composedPath().includes(plPopup)
) {
return;
}
if (popup && path.includes(popup)) return;
if (submenu && path.includes(submenu)) return;
if (plPopup && path.includes(plPopup)) return;
this.closeContextMenu();
this.closePlaylistContextMenu();
};
this.closePlaylistContextMenu();
};
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
@@ -321,7 +324,9 @@ export class PlaylistView
}
}
static override styles = css`
static override styles = [
contextMenuStyles,
css`
:host {
display: flex;
flex-direction: column;
@@ -705,43 +710,6 @@ export class PlaylistView
display: flex;
}
#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: 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;
}
#playlist-context-menu {
z-index: 200;
}
@@ -777,7 +745,7 @@ export class PlaylistView
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent, #ffd43b);
}
`;
`];
override connectedCallback() {
super.connectedCallback();
@@ -788,15 +756,15 @@ export class PlaylistView
);
document.addEventListener(
'click',
this.closeContextMenuHandler,
this.closePlaylistCtxMenuHandler,
);
document.addEventListener(
'contextmenu',
this.closeContextMenuHandler,
this.closePlaylistCtxMenuHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
this.playlistCtxMenuMousedownHandler,
);
document.addEventListener(
'click',
@@ -815,15 +783,15 @@ export class PlaylistView
document.removeEventListener(
'click',
this.closeContextMenuHandler,
this.closePlaylistCtxMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closeContextMenuHandler,
this.closePlaylistCtxMenuHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
this.playlistCtxMenuMousedownHandler,
);
document.removeEventListener(
'click',
@@ -1040,30 +1008,7 @@ export class PlaylistView
this.selection.handleContextMenu(
String(trackIndex),
);
this.contextMenuOpen = true;
// Position at mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: string) {
@@ -1092,7 +1037,8 @@ export class PlaylistView
break;
}
this.closeContextMenu(true);
this.selection.clear();
this.ctxMenu.close();
}
private openTrackDetails(filePath: string) {
@@ -1451,82 +1397,7 @@ export class PlaylistView
this.onEmptyZoneDrop(e);
};
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private clearSubmenuCloseTimer() {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
private scheduleSubmenuClose = () => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, 150);
};
private async showPlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
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() {
this.clearSubmenuCloseTimer();
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
private isActiveTrack(
track: playlist.Track,
@@ -1549,7 +1420,7 @@ export class PlaylistView
e.preventDefault();
e.stopPropagation();
this.closeContextMenu();
this.ctxMenu.close();
this.playlistContextMenuIndex = index;
this.playlistContextMenuOpen = true;
@@ -1854,9 +1725,10 @@ export class PlaylistView
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
.active=${this.ctxMenu
.contextMenuOpen}
>
${this.contextMenuOpen
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@@ -1865,7 +1737,7 @@ export class PlaylistView
'play',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1879,7 +1751,7 @@ export class PlaylistView
'add-to-queue',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1893,7 +1765,7 @@ export class PlaylistView
'play-next',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1907,7 +1779,7 @@ export class PlaylistView
'remove',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1918,14 +1790,15 @@ export class PlaylistView
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.clearSubmenuCloseTimer();
void this.showPlaylistSubmenu();
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
@@ -1948,7 +1821,7 @@ export class PlaylistView
'track-details',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1969,20 +1842,23 @@ export class PlaylistView
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
${this.ctxMenu.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<div
@mouseenter=${() =>
this.clearSubmenuCloseTimer()}
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
@playlist-action-complete=${this
.ctxMenu
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
@@ -17,6 +17,11 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import type { QueueTrack } from '@store/queue-store';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
ContextMenuController,
contextMenuStyles,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import {
hasTrackPayload,
getDragPayload,
@@ -41,10 +46,11 @@ const DEFAULT_WIDTH = 320;
@customElement('queue-panel')
export class QueuePanel
extends LitElement
implements SelectionHost
implements SelectionHost, ContextMenuHost
{
private queue = new QueueController(this);
private selection = new SelectionController(this);
private ctxMenu = new ContextMenuController(this);
@property({ type: Boolean, reflect: true })
open = false;
@@ -55,12 +61,6 @@ export class QueuePanel
@state()
private playlistPickerOpen = false;
@state()
private contextMenuOpen = false;
@state()
private playlistSubmenuOpen = false;
private dragOver = false;
private dragEnterCount = 0;
@@ -103,24 +103,6 @@ export class QueuePanel
}
};
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
private closeContextMenuHandler = () =>
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();
};
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
@@ -172,7 +154,19 @@ export class QueuePanel
this.virtualizer?.requestUpdate();
}
static override styles = css`
// =================================================================
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): HTMLElement | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): HTMLElement | undefined {
return this.playlistSubmenuPopup;
}
static override styles = [contextMenuStyles, css`
:host {
flex-shrink: 0;
width: 0;
@@ -445,43 +439,7 @@ export class QueuePanel
display: none;
}
#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: 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;
}
`;
`];
override connectedCallback() {
super.connectedCallback();
@@ -501,18 +459,6 @@ export class QueuePanel
'click',
this.closePickerHandler,
);
document.addEventListener(
'click',
this.closeContextMenuHandler,
);
document.addEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.addEventListener(
'mousedown',
this.mousedownCloseHandler,
);
document.addEventListener(
'click',
this.clearSelectionHandler,
@@ -537,18 +483,6 @@ export class QueuePanel
'click',
this.closePickerHandler,
);
document.removeEventListener(
'click',
this.closeContextMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.removeEventListener(
'mousedown',
this.mousedownCloseHandler,
);
document.removeEventListener(
'click',
this.clearSelectionHandler,
@@ -660,30 +594,7 @@ export class QueuePanel
e.stopPropagation();
this.selection.handleContextMenu(String(index));
this.contextMenuOpen = true;
// Position at mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: string) {
@@ -706,7 +617,8 @@ export class QueuePanel
break;
}
this.closeContextMenu(true);
this.selection.clear();
this.ctxMenu.close();
}
private openTrackDetails(index: number) {
@@ -759,77 +671,11 @@ export class QueuePanel
};
}
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private clearSubmenuCloseTimer() {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
private scheduleSubmenuClose = () => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, 150);
private onContextPlaylistActionComplete = () => {
this.selection.clear();
this.ctxMenu.close();
};
private async showPlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
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(
'#context-playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
/**
* Derive file paths from selected indices for
* operations that need file paths (e.g. Add to Playlist).
@@ -842,10 +688,6 @@ export class QueuePanel
.map((i) => tracks[i]!.filePath);
}
private onContextPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
// =================================================================
// Drop target (tracks dropped into queue)
// =================================================================
@@ -1430,9 +1272,9 @@ export class QueuePanel
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
.active=${this.ctxMenu.contextMenuOpen}
>
${this.contextMenuOpen
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@@ -1441,7 +1283,7 @@ export class QueuePanel
'play',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1455,7 +1297,7 @@ export class QueuePanel
'remove',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1466,14 +1308,14 @@ export class QueuePanel
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.clearSubmenuCloseTimer();
void this.showPlaylistSubmenu();
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.scheduleSubmenuClose}
.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
@@ -1496,7 +1338,7 @@ export class QueuePanel
'track-details',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1517,20 +1359,20 @@ export class QueuePanel
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
${this.ctxMenu.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<div
@mouseenter=${() =>
this.clearSubmenuCloseTimer()}
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this
.scheduleSubmenuClose}
.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
id="context-playlist-picker"
.filePaths=${this.getSelectedFilePaths()}
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this
.onContextPlaylistActionComplete}
@click=${(e: Event) =>
+39 -190
View File
@@ -9,6 +9,11 @@ import {
import { EventsOn } from '@runtime/runtime';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
ContextMenuController,
contextMenuStyles,
} 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 { TrackListController } from '@store/controllers/tracklist-controller';
@@ -39,7 +44,6 @@ import '@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 '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import '@components/track-details/track-details.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
@@ -53,7 +57,7 @@ const DEFAULT_FIXED_WIDTH = 80;
type SortDirection = 'asc' | 'desc';
@customElement('track-list')
export class TrackList extends LitElement implements SelectionHost {
export class TrackList extends LitElement implements SelectionHost, ContextMenuHost {
/**
* When set, the list displays these tracks instead of
* fetching all tracks from the library store. The
@@ -68,6 +72,7 @@ export class TrackList extends LitElement implements SelectionHost {
private searchCtrl = new SearchController(this);
private trackListCtrl = new TrackListController(this);
private selection = new SelectionController(this);
private ctxMenu = new ContextMenuController(this);
private cancelScanComplete?: () => void;
private lastSearchTerm = '';
@@ -98,18 +103,22 @@ export class TrackList extends LitElement implements SelectionHost {
@state()
private tracks: library.Track[] = [];
@state()
private contextMenuOpen = false;
@state()
private playlistSubmenuOpen = false;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
// -- ContextMenuHost interface --
getContextMenuPopup(): HTMLElement | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): HTMLElement | undefined {
return this.playlistSubmenuPopup;
}
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@@ -118,10 +127,6 @@ export class TrackList extends LitElement implements SelectionHost {
private lastActiveTrackPath: string | null = null;
private submenuCloseTimer: ReturnType<
typeof setTimeout
> | null = null;
// -- Memoisation caches for filtered / sorted tracks --
private cachedFilteredTracks: library.Track[] = [];
private cachedSortedTracks: library.Track[] = [];
@@ -132,21 +137,6 @@ export class TrackList extends LitElement implements SelectionHost {
private prevSortField: string | null = null;
private prevSortDir: SortDirection = 'asc';
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();
};
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
@@ -671,7 +661,7 @@ export class TrackList extends LitElement implements SelectionHost {
this.requestUpdate();
};
static override styles = css`
static override styles = [contextMenuStyles, css`
:host {
display: flex;
flex-direction: column;
@@ -942,46 +932,7 @@ export class TrackList extends LitElement implements SelectionHost {
text-align: center;
}
#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;
}
.context-menu-panel wa-dropdown-item {
--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;
}
`;
`];
override connectedCallback() {
super.connectedCallback();
@@ -996,9 +947,6 @@ export class TrackList extends LitElement implements SelectionHost {
() => this.loadTracks(),
);
}
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
document.addEventListener('mousedown', this.mousedownCloseHandler);
document.addEventListener('mousedown', this.sortDropdownCloseHandler);
document.addEventListener('click', this.clearSelectionHandler);
document.addEventListener('mousemove', this.onColResizeMove);
@@ -1021,9 +969,6 @@ export class TrackList extends LitElement implements SelectionHost {
this.hasRestoredScroll = false;
super.disconnectedCallback();
this.cancelScanComplete?.();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
document.removeEventListener('mousedown', this.mousedownCloseHandler);
document.removeEventListener('mousedown', this.sortDropdownCloseHandler);
document.removeEventListener('click', this.clearSelectionHandler);
document.removeEventListener('mousemove', this.onColResizeMove);
@@ -1178,30 +1123,7 @@ export class TrackList extends LitElement implements SelectionHost {
e.stopPropagation();
this.selection.handleContextMenu(track.FilePath);
this.contextMenuOpen = true;
// Position the popup at the mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
this.ctxMenu.openAt(e.clientX, e.clientY);
}
// =================================================================
@@ -1278,7 +1200,8 @@ export class TrackList extends LitElement implements SelectionHost {
break;
}
this.closeContextMenu(true);
this.selection.clear();
this.ctxMenu.close();
}
private openTrackDetails(filePath: string) {
@@ -1320,80 +1243,6 @@ export class TrackList extends LitElement implements SelectionHost {
};
}
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private clearSubmenuCloseTimer() {
if (this.submenuCloseTimer !== null) {
clearTimeout(this.submenuCloseTimer);
this.submenuCloseTimer = null;
}
}
private scheduleSubmenuClose = () => {
this.clearSubmenuCloseTimer();
this.submenuCloseTimer = setTimeout(() => {
this.submenuCloseTimer = null;
this.closePlaylistSubmenu();
}, 150);
};
private async showPlaylistSubmenu() {
this.clearSubmenuCloseTimer();
if (this.playlistSubmenuOpen) return;
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() {
this.clearSubmenuCloseTimer();
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
// =================================================================
// Sort controls
// =================================================================
@@ -1781,28 +1630,28 @@ export class TrackList extends LitElement implements SelectionHost {
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
.active=${this.ctxMenu.contextMenuOpen}
>
${this.contextMenuOpen
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
@mouseenter=${() => this.closePlaylistSubmenu()}
@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.closePlaylistSubmenu()}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
@mouseenter=${() => this.closePlaylistSubmenu()}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
@@ -1810,13 +1659,13 @@ export class TrackList extends LitElement implements SelectionHost {
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.clearSubmenuCloseTimer();
void this.showPlaylistSubmenu();
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
}}
@mouseleave=${this.scheduleSubmenuClose}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
@@ -1830,8 +1679,8 @@ export class TrackList extends LitElement implements SelectionHost {
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.closePlaylistSubmenu()}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
@@ -1851,18 +1700,18 @@ export class TrackList extends LitElement implements SelectionHost {
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen && this.selection.hasSelection
${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection
? html`
<div
@mouseenter=${() =>
this.clearSubmenuCloseTimer()}
@mouseleave=${this.scheduleSubmenuClose}
this.ctxMenu.clearSubmenuCloseTimer()}
@mouseleave=${this.ctxMenu.scheduleSubmenuClose}
>
<playlist-picker
.filePaths=${this.selection.getSelectedKeysOrdered()}
@playlist-action-complete=${this.onPlaylistActionComplete}
.filePaths=${this.ctxMenu.playlistFilePaths}
@playlist-action-complete=${this.ctxMenu.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
</div>