added multi-select to artists and genres views
This commit is contained in:
@@ -47,6 +47,7 @@ export class ArtistsView extends LitElement {
|
|||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
private wheelListenerAttached = false;
|
private wheelListenerAttached = false;
|
||||||
|
private lastSearchTerm = '';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private artists: library.Artist[] = [];
|
private artists: library.Artist[] = [];
|
||||||
@@ -57,6 +58,14 @@ export class ArtistsView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private cardSize: number = CARD_SIZE_DEFAULT;
|
private cardSize: number = CARD_SIZE_DEFAULT;
|
||||||
|
|
||||||
|
// ----- Multi-select state -----
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private selectedArtists: Set<number> = new Set();
|
||||||
|
|
||||||
|
private lastSelectedArtistIndex: number | null =
|
||||||
|
null;
|
||||||
|
|
||||||
// ----- Context menu state -----
|
// ----- Context menu state -----
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -68,10 +77,6 @@ export class ArtistsView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private playlistFilePaths: string[] = [];
|
private playlistFilePaths: string[] = [];
|
||||||
|
|
||||||
/** The artist targeted by the current context menu. */
|
|
||||||
private contextMenuArtist: library.Artist | null =
|
|
||||||
null;
|
|
||||||
|
|
||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: HTMLElement;
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
@@ -213,6 +218,20 @@ export class ArtistsView extends LitElement {
|
|||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artist-card.selected {
|
||||||
|
outline: 2px solid
|
||||||
|
var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card.selected .avatar-container {
|
||||||
|
scale: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card.selected .artist-name {
|
||||||
|
scale: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
width: var(--avatar-size);
|
width: var(--avatar-size);
|
||||||
height: var(--avatar-size);
|
height: var(--avatar-size);
|
||||||
@@ -397,6 +416,14 @@ export class ArtistsView extends LitElement {
|
|||||||
this.updateSizeProperties();
|
this.updateSizeProperties();
|
||||||
this.ensureWheelListener();
|
this.ensureWheelListener();
|
||||||
this.updateGridLayout();
|
this.updateGridLayout();
|
||||||
|
|
||||||
|
// Clear selection when search term changes.
|
||||||
|
const currentTerm = this.searchCtrl.term;
|
||||||
|
|
||||||
|
if (currentTerm !== this.lastSearchTerm) {
|
||||||
|
this.lastSearchTerm = currentTerm;
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -582,24 +609,120 @@ export class ArtistsView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Artist selection helpers
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a contiguous range of artist IDs
|
||||||
|
* between two indices in filteredArtists.
|
||||||
|
*/
|
||||||
|
private selectArtistRange(
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): Set<number> {
|
||||||
|
const filtered = this.filteredArtists;
|
||||||
|
const start = Math.min(from, to);
|
||||||
|
const end = Math.max(from, to);
|
||||||
|
const ids = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const artist = filtered[i];
|
||||||
|
|
||||||
|
if (artist) {
|
||||||
|
ids.add(artist.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all file paths for every selected
|
||||||
|
* artist.
|
||||||
|
*/
|
||||||
|
private async getSelectedArtistFilePaths(): Promise<
|
||||||
|
string[]
|
||||||
|
> {
|
||||||
|
const selected = this.artists.filter((a) =>
|
||||||
|
this.selectedArtists.has(a.ID),
|
||||||
|
);
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
|
||||||
|
for (const artist of selected) {
|
||||||
|
const paths =
|
||||||
|
await this.getArtistFilePaths(
|
||||||
|
artist,
|
||||||
|
);
|
||||||
|
allPaths.push(...paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the current artist selection. */
|
||||||
|
private clearSelection() {
|
||||||
|
this.selectedArtists = new Set();
|
||||||
|
this.lastSelectedArtistIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Artist card click
|
* Artist card click
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
private onArtistClick(
|
private onArtistClick(
|
||||||
|
e: MouseEvent,
|
||||||
artist: library.Artist,
|
artist: library.Artist,
|
||||||
|
index: number,
|
||||||
) {
|
) {
|
||||||
this.dispatchEvent(
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
new CustomEvent('navigate', {
|
const isShift = e.shiftKey;
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
if (
|
||||||
detail: {
|
isShift &&
|
||||||
view: 'artist-details',
|
this.lastSelectedArtistIndex !== null
|
||||||
artistId: artist.ID,
|
) {
|
||||||
artistName: artist.Name,
|
const range = this.selectArtistRange(
|
||||||
},
|
this.lastSelectedArtistIndex,
|
||||||
}),
|
index,
|
||||||
);
|
);
|
||||||
|
const next = new Set(
|
||||||
|
this.selectedArtists,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const id of range) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedArtists = next;
|
||||||
|
} else if (isCtrl) {
|
||||||
|
const next = new Set(
|
||||||
|
this.selectedArtists,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next.has(artist.ID)) {
|
||||||
|
next.delete(artist.ID);
|
||||||
|
} else {
|
||||||
|
next.add(artist.ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedArtists = next;
|
||||||
|
this.lastSelectedArtistIndex = index;
|
||||||
|
} else {
|
||||||
|
// Plain click: navigate to details.
|
||||||
|
this.clearSelection();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
view: 'artist-details',
|
||||||
|
artistId: artist.ID,
|
||||||
|
artistName: artist.Name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -613,7 +736,22 @@ export class ArtistsView extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
this.contextMenuArtist = artist;
|
// If right-clicked artist is not in the
|
||||||
|
// current selection, replace the selection
|
||||||
|
// with just this artist.
|
||||||
|
if (
|
||||||
|
!this.selectedArtists.has(artist.ID)
|
||||||
|
) {
|
||||||
|
const idx =
|
||||||
|
this.filteredArtists.indexOf(artist);
|
||||||
|
|
||||||
|
this.selectedArtists = new Set([
|
||||||
|
artist.ID,
|
||||||
|
]);
|
||||||
|
this.lastSelectedArtistIndex =
|
||||||
|
idx >= 0 ? idx : null;
|
||||||
|
}
|
||||||
|
|
||||||
this.openContextMenuAt(
|
this.openContextMenuAt(
|
||||||
e.clientX,
|
e.clientX,
|
||||||
e.clientY,
|
e.clientY,
|
||||||
@@ -655,7 +793,6 @@ export class ArtistsView extends LitElement {
|
|||||||
this.closePlaylistSubmenu();
|
this.closePlaylistSubmenu();
|
||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
this.playlistFilePaths = [];
|
this.playlistFilePaths = [];
|
||||||
this.contextMenuArtist = null;
|
|
||||||
|
|
||||||
const popup = this.contextMenuPopup;
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
@@ -667,12 +804,10 @@ export class ArtistsView extends LitElement {
|
|||||||
private async onContextMenuAction(
|
private async onContextMenuAction(
|
||||||
action: string,
|
action: string,
|
||||||
) {
|
) {
|
||||||
const artist = this.contextMenuArtist;
|
if (this.selectedArtists.size === 0) return;
|
||||||
|
|
||||||
if (!artist) return;
|
|
||||||
|
|
||||||
const filePaths =
|
const filePaths =
|
||||||
await this.getArtistFilePaths(artist);
|
await this.getSelectedArtistFilePaths();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -719,12 +854,10 @@ export class ArtistsView extends LitElement {
|
|||||||
|
|
||||||
if (this.playlistSubmenuOpen) return;
|
if (this.playlistSubmenuOpen) return;
|
||||||
|
|
||||||
const artist = this.contextMenuArtist;
|
if (this.selectedArtists.size === 0) return;
|
||||||
|
|
||||||
if (!artist) return;
|
|
||||||
|
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
await this.getArtistFilePaths(artist);
|
await this.getSelectedArtistFilePaths();
|
||||||
|
|
||||||
this.playlistSubmenuOpen = true;
|
this.playlistSubmenuOpen = true;
|
||||||
|
|
||||||
@@ -822,24 +955,33 @@ export class ArtistsView extends LitElement {
|
|||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
private renderArtistCard(entry: ArtistEntry) {
|
private renderArtistCard(entry: ArtistEntry) {
|
||||||
const { artist } = entry;
|
const { artist, index } = entry;
|
||||||
const imgSize = this.imageSize;
|
const imgSize = this.imageSize;
|
||||||
const placeholderFont = Math.round(
|
const placeholderFont = Math.round(
|
||||||
imgSize * 0.38,
|
imgSize * 0.38,
|
||||||
);
|
);
|
||||||
|
const isSelected =
|
||||||
|
this.selectedArtists.has(artist.ID);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="artist-card"
|
class="artist-card${isSelected
|
||||||
|
? ' selected'
|
||||||
|
: ''}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="${artist.Name}"
|
aria-label="${artist.Name}"
|
||||||
|
aria-selected="${isSelected}"
|
||||||
style="
|
style="
|
||||||
--avatar-size: ${imgSize}px;
|
--avatar-size: ${imgSize}px;
|
||||||
--placeholder-font: ${placeholderFont}px;
|
--placeholder-font: ${placeholderFont}px;
|
||||||
"
|
"
|
||||||
@click=${() =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onArtistClick(artist)}
|
this.onArtistClick(
|
||||||
|
e,
|
||||||
|
artist,
|
||||||
|
index,
|
||||||
|
)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onArtistContextMenu(
|
this.onArtistContextMenu(
|
||||||
e,
|
e,
|
||||||
@@ -851,7 +993,23 @@ export class ArtistsView extends LitElement {
|
|||||||
e.key === ' '
|
e.key === ' '
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.onArtistClick(artist);
|
this.clearSelection();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(
|
||||||
|
'navigate',
|
||||||
|
{
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
view: 'artist-details',
|
||||||
|
artistId:
|
||||||
|
artist.ID,
|
||||||
|
artistName:
|
||||||
|
artist.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1022,7 +1180,10 @@ export class ArtistsView extends LitElement {
|
|||||||
.term}”
|
.term}”
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="grid-scroll-container">
|
<div
|
||||||
|
class="grid-scroll-container"
|
||||||
|
@click=${this.onGridClick}
|
||||||
|
>
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
.items=${entries}
|
.items=${entries}
|
||||||
.renderItem=${(
|
.renderItem=${(
|
||||||
@@ -1037,4 +1198,22 @@ export class ArtistsView extends LitElement {
|
|||||||
${this.renderContextMenu()}
|
${this.renderContextMenu()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on empty area of the grid clears the
|
||||||
|
* selection.
|
||||||
|
*/
|
||||||
|
private onGridClick = (e: MouseEvent) => {
|
||||||
|
const path = e.composedPath();
|
||||||
|
|
||||||
|
const clickedCard = path.some(
|
||||||
|
(el) =>
|
||||||
|
el instanceof HTMLElement &&
|
||||||
|
el.classList.contains('artist-card'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!clickedCard) {
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export class GenresView extends LitElement {
|
|||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
private wheelListenerAttached = false;
|
private wheelListenerAttached = false;
|
||||||
|
private lastSearchTerm = '';
|
||||||
|
|
||||||
/** All tracks from the library (used to derive genres). */
|
/** All tracks from the library (used to derive genres). */
|
||||||
private allTracks: library.Track[] = [];
|
private allTracks: library.Track[] = [];
|
||||||
@@ -60,6 +61,14 @@ export class GenresView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private cardSize: number = CARD_SIZE_DEFAULT;
|
private cardSize: number = CARD_SIZE_DEFAULT;
|
||||||
|
|
||||||
|
// ----- Multi-select state -----
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private selectedGenres: Set<string> = new Set();
|
||||||
|
|
||||||
|
private lastSelectedGenreIndex: number | null =
|
||||||
|
null;
|
||||||
|
|
||||||
// ----- Context menu state -----
|
// ----- Context menu state -----
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -71,9 +80,6 @@ export class GenresView extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private playlistFilePaths: string[] = [];
|
private playlistFilePaths: string[] = [];
|
||||||
|
|
||||||
/** The genre targeted by the current context menu. */
|
|
||||||
private contextMenuGenre: Genre | null = null;
|
|
||||||
|
|
||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: HTMLElement;
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
@@ -215,6 +221,20 @@ export class GenresView extends LitElement {
|
|||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.genre-card.selected {
|
||||||
|
outline: 2px solid
|
||||||
|
var(--yj-accent, #ffd43b);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card.selected .avatar-container {
|
||||||
|
scale: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-card.selected .genre-name {
|
||||||
|
scale: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
width: var(--avatar-size);
|
width: var(--avatar-size);
|
||||||
height: var(--avatar-size);
|
height: var(--avatar-size);
|
||||||
@@ -402,6 +422,14 @@ export class GenresView extends LitElement {
|
|||||||
this.updateSizeProperties();
|
this.updateSizeProperties();
|
||||||
this.ensureWheelListener();
|
this.ensureWheelListener();
|
||||||
this.updateGridLayout();
|
this.updateGridLayout();
|
||||||
|
|
||||||
|
// Clear selection when search term changes.
|
||||||
|
const currentTerm = this.searchCtrl.term;
|
||||||
|
|
||||||
|
if (currentTerm !== this.lastSearchTerm) {
|
||||||
|
this.lastSearchTerm = currentTerm;
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -625,21 +653,121 @@ export class GenresView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
* Genre selection helpers
|
||||||
|
* ================================================================ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a contiguous range of genre names
|
||||||
|
* between two indices in filteredGenres.
|
||||||
|
*/
|
||||||
|
private selectGenreRange(
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
): Set<string> {
|
||||||
|
const filtered = this.filteredGenres;
|
||||||
|
const start = Math.min(from, to);
|
||||||
|
const end = Math.max(from, to);
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const genre = filtered[i];
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
names.add(genre.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all file paths for every selected
|
||||||
|
* genre.
|
||||||
|
*/
|
||||||
|
private getSelectedGenreFilePaths(): string[] {
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const track of this.allTracks) {
|
||||||
|
if (seen.has(track.FilePath)) continue;
|
||||||
|
|
||||||
|
const genres = track.Genre ?? [];
|
||||||
|
const match = genres.some((g) =>
|
||||||
|
this.selectedGenres.has(g),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
allPaths.push(track.FilePath);
|
||||||
|
seen.add(track.FilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the current genre selection. */
|
||||||
|
private clearSelection() {
|
||||||
|
this.selectedGenres = new Set();
|
||||||
|
this.lastSelectedGenreIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Genre card click
|
* Genre card click
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
private onGenreClick(genre: Genre) {
|
private onGenreClick(
|
||||||
this.dispatchEvent(
|
e: MouseEvent,
|
||||||
new CustomEvent('navigate', {
|
genre: Genre,
|
||||||
bubbles: true,
|
index: number,
|
||||||
composed: true,
|
) {
|
||||||
detail: {
|
const isCtrl = e.ctrlKey || e.metaKey;
|
||||||
view: 'genre-details',
|
const isShift = e.shiftKey;
|
||||||
genreName: genre.name,
|
|
||||||
},
|
if (
|
||||||
}),
|
isShift &&
|
||||||
);
|
this.lastSelectedGenreIndex !== null
|
||||||
|
) {
|
||||||
|
const range = this.selectGenreRange(
|
||||||
|
this.lastSelectedGenreIndex,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
const next = new Set(
|
||||||
|
this.selectedGenres,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of range) {
|
||||||
|
next.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedGenres = next;
|
||||||
|
} else if (isCtrl) {
|
||||||
|
const next = new Set(
|
||||||
|
this.selectedGenres,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (next.has(genre.name)) {
|
||||||
|
next.delete(genre.name);
|
||||||
|
} else {
|
||||||
|
next.add(genre.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedGenres = next;
|
||||||
|
this.lastSelectedGenreIndex = index;
|
||||||
|
} else {
|
||||||
|
// Plain click: navigate to details.
|
||||||
|
this.clearSelection();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('navigate', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
view: 'genre-details',
|
||||||
|
genreName: genre.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -653,7 +781,20 @@ export class GenresView extends LitElement {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
this.contextMenuGenre = genre;
|
// If right-clicked genre is not in the
|
||||||
|
// current selection, replace the selection
|
||||||
|
// with just this genre.
|
||||||
|
if (!this.selectedGenres.has(genre.name)) {
|
||||||
|
const idx =
|
||||||
|
this.filteredGenres.indexOf(genre);
|
||||||
|
|
||||||
|
this.selectedGenres = new Set([
|
||||||
|
genre.name,
|
||||||
|
]);
|
||||||
|
this.lastSelectedGenreIndex =
|
||||||
|
idx >= 0 ? idx : null;
|
||||||
|
}
|
||||||
|
|
||||||
this.openContextMenuAt(
|
this.openContextMenuAt(
|
||||||
e.clientX,
|
e.clientX,
|
||||||
e.clientY,
|
e.clientY,
|
||||||
@@ -695,7 +836,6 @@ export class GenresView extends LitElement {
|
|||||||
this.closePlaylistSubmenu();
|
this.closePlaylistSubmenu();
|
||||||
this.contextMenuOpen = false;
|
this.contextMenuOpen = false;
|
||||||
this.playlistFilePaths = [];
|
this.playlistFilePaths = [];
|
||||||
this.contextMenuGenre = null;
|
|
||||||
|
|
||||||
const popup = this.contextMenuPopup;
|
const popup = this.contextMenuPopup;
|
||||||
|
|
||||||
@@ -705,12 +845,10 @@ export class GenresView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onContextMenuAction(action: string) {
|
private onContextMenuAction(action: string) {
|
||||||
const genre = this.contextMenuGenre;
|
if (this.selectedGenres.size === 0) return;
|
||||||
|
|
||||||
if (!genre) return;
|
|
||||||
|
|
||||||
const filePaths =
|
const filePaths =
|
||||||
this.getGenreFilePaths(genre.name);
|
this.getSelectedGenreFilePaths();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -757,12 +895,10 @@ export class GenresView extends LitElement {
|
|||||||
|
|
||||||
if (this.playlistSubmenuOpen) return;
|
if (this.playlistSubmenuOpen) return;
|
||||||
|
|
||||||
const genre = this.contextMenuGenre;
|
if (this.selectedGenres.size === 0) return;
|
||||||
|
|
||||||
if (!genre) return;
|
|
||||||
|
|
||||||
this.playlistFilePaths =
|
this.playlistFilePaths =
|
||||||
this.getGenreFilePaths(genre.name);
|
this.getSelectedGenreFilePaths();
|
||||||
|
|
||||||
this.playlistSubmenuOpen = true;
|
this.playlistSubmenuOpen = true;
|
||||||
|
|
||||||
@@ -811,20 +947,6 @@ export class GenresView extends LitElement {
|
|||||||
* File path resolution
|
* File path resolution
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all file paths for tracks that
|
|
||||||
* contain the given genre name.
|
|
||||||
*/
|
|
||||||
private getGenreFilePaths(
|
|
||||||
genreName: string,
|
|
||||||
): string[] {
|
|
||||||
return this.allTracks
|
|
||||||
.filter((t) =>
|
|
||||||
(t.Genre ?? []).includes(genreName),
|
|
||||||
)
|
|
||||||
.map((t) => t.FilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Helpers
|
* Helpers
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@@ -840,24 +962,33 @@ export class GenresView extends LitElement {
|
|||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
private renderGenreCard(entry: GenreEntry) {
|
private renderGenreCard(entry: GenreEntry) {
|
||||||
const { genre } = entry;
|
const { genre, index } = entry;
|
||||||
const imgSize = this.imageSize;
|
const imgSize = this.imageSize;
|
||||||
const placeholderFont = Math.round(
|
const placeholderFont = Math.round(
|
||||||
imgSize * 0.38,
|
imgSize * 0.38,
|
||||||
);
|
);
|
||||||
|
const isSelected =
|
||||||
|
this.selectedGenres.has(genre.name);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="genre-card"
|
class="genre-card${isSelected
|
||||||
|
? ' selected'
|
||||||
|
: ''}"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
aria-label="${genre.name}"
|
aria-label="${genre.name}"
|
||||||
|
aria-selected="${isSelected}"
|
||||||
style="
|
style="
|
||||||
--avatar-size: ${imgSize}px;
|
--avatar-size: ${imgSize}px;
|
||||||
--placeholder-font: ${placeholderFont}px;
|
--placeholder-font: ${placeholderFont}px;
|
||||||
"
|
"
|
||||||
@click=${() =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onGenreClick(genre)}
|
this.onGenreClick(
|
||||||
|
e,
|
||||||
|
genre,
|
||||||
|
index,
|
||||||
|
)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onGenreContextMenu(
|
this.onGenreContextMenu(
|
||||||
e,
|
e,
|
||||||
@@ -869,7 +1000,21 @@ export class GenresView extends LitElement {
|
|||||||
e.key === ' '
|
e.key === ' '
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.onGenreClick(genre);
|
this.clearSelection();
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(
|
||||||
|
'navigate',
|
||||||
|
{
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
view: 'genre-details',
|
||||||
|
genreName:
|
||||||
|
genre.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1040,7 +1185,10 @@ export class GenresView extends LitElement {
|
|||||||
.term}”
|
.term}”
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
<div class="grid-scroll-container">
|
<div
|
||||||
|
class="grid-scroll-container"
|
||||||
|
@click=${this.onGridClick}
|
||||||
|
>
|
||||||
<lit-virtualizer
|
<lit-virtualizer
|
||||||
.items=${entries}
|
.items=${entries}
|
||||||
.renderItem=${(
|
.renderItem=${(
|
||||||
@@ -1053,4 +1201,22 @@ export class GenresView extends LitElement {
|
|||||||
${this.renderContextMenu()}
|
${this.renderContextMenu()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click on empty area of the grid clears the
|
||||||
|
* selection.
|
||||||
|
*/
|
||||||
|
private onGridClick = (e: MouseEvent) => {
|
||||||
|
const path = e.composedPath();
|
||||||
|
|
||||||
|
const clickedCard = path.some(
|
||||||
|
(el) =>
|
||||||
|
el instanceof HTMLElement &&
|
||||||
|
el.classList.contains('genre-card'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!clickedCard) {
|
||||||
|
this.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user