added multi-select to artists and genres views

This commit is contained in:
2026-02-23 11:46:44 -05:00
parent 588acf09ef
commit 1dfbfd9595
2 changed files with 420 additions and 75 deletions
@@ -47,6 +47,7 @@ export class ArtistsView extends LitElement {
private searchCtrl = new SearchController(this);
private cancelScanComplete?: () => void;
private wheelListenerAttached = false;
private lastSearchTerm = '';
@state()
private artists: library.Artist[] = [];
@@ -57,6 +58,14 @@ export class ArtistsView extends LitElement {
@state()
private cardSize: number = CARD_SIZE_DEFAULT;
// ----- Multi-select state -----
@state()
private selectedArtists: Set<number> = new Set();
private lastSelectedArtistIndex: number | null =
null;
// ----- Context menu state -----
@state()
@@ -68,10 +77,6 @@ export class ArtistsView extends LitElement {
@state()
private playlistFilePaths: string[] = [];
/** The artist targeted by the current context menu. */
private contextMenuArtist: library.Artist | null =
null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@@ -213,6 +218,20 @@ export class ArtistsView extends LitElement {
transform: scale(0.97);
}
.artist-card.selected {
outline: 2px solid
var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.artist-card.selected .avatar-container {
scale: 0.95;
}
.artist-card.selected .artist-name {
scale: 0.95;
}
.avatar-container {
width: var(--avatar-size);
height: var(--avatar-size);
@@ -397,6 +416,14 @@ export class ArtistsView extends LitElement {
this.updateSizeProperties();
this.ensureWheelListener();
this.updateGridLayout();
// Clear selection when search term changes.
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.clearSelection();
}
}
/* ================================================================
@@ -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
* ================================================================ */
private onArtistClick(
e: MouseEvent,
artist: library.Artist,
index: number,
) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'artist-details',
artistId: artist.ID,
artistName: artist.Name,
},
}),
);
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (
isShift &&
this.lastSelectedArtistIndex !== null
) {
const range = this.selectArtistRange(
this.lastSelectedArtistIndex,
index,
);
const next = new Set(
this.selectedArtists,
);
for (const id of range) {
next.add(id);
}
this.selectedArtists = next;
} else if (isCtrl) {
const next = new Set(
this.selectedArtists,
);
if (next.has(artist.ID)) {
next.delete(artist.ID);
} else {
next.add(artist.ID);
}
this.selectedArtists = next;
this.lastSelectedArtistIndex = index;
} else {
// Plain click: navigate to details.
this.clearSelection();
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'artist-details',
artistId: artist.ID,
artistName: artist.Name,
},
}),
);
}
}
/* ================================================================
@@ -613,7 +736,22 @@ export class ArtistsView extends LitElement {
e.preventDefault();
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(
e.clientX,
e.clientY,
@@ -655,7 +793,6 @@ export class ArtistsView extends LitElement {
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
this.contextMenuArtist = null;
const popup = this.contextMenuPopup;
@@ -667,12 +804,10 @@ export class ArtistsView extends LitElement {
private async onContextMenuAction(
action: string,
) {
const artist = this.contextMenuArtist;
if (!artist) return;
if (this.selectedArtists.size === 0) return;
const filePaths =
await this.getArtistFilePaths(artist);
await this.getSelectedArtistFilePaths();
if (filePaths.length === 0) return;
@@ -719,12 +854,10 @@ export class ArtistsView extends LitElement {
if (this.playlistSubmenuOpen) return;
const artist = this.contextMenuArtist;
if (!artist) return;
if (this.selectedArtists.size === 0) return;
this.playlistFilePaths =
await this.getArtistFilePaths(artist);
await this.getSelectedArtistFilePaths();
this.playlistSubmenuOpen = true;
@@ -822,24 +955,33 @@ export class ArtistsView extends LitElement {
* ================================================================ */
private renderArtistCard(entry: ArtistEntry) {
const { artist } = entry;
const { artist, index } = entry;
const imgSize = this.imageSize;
const placeholderFont = Math.round(
imgSize * 0.38,
);
const isSelected =
this.selectedArtists.has(artist.ID);
return html`
<div
class="artist-card"
class="artist-card${isSelected
? ' selected'
: ''}"
tabindex="0"
role="button"
aria-label="${artist.Name}"
aria-selected="${isSelected}"
style="
--avatar-size: ${imgSize}px;
--placeholder-font: ${placeholderFont}px;
"
@click=${() =>
this.onArtistClick(artist)}
@click=${(e: MouseEvent) =>
this.onArtistClick(
e,
artist,
index,
)}
@contextmenu=${(e: MouseEvent) =>
this.onArtistContextMenu(
e,
@@ -851,7 +993,23 @@ export class ArtistsView extends LitElement {
e.key === ' '
) {
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}&rdquo;
</div>`
: nothing}
<div class="grid-scroll-container">
<div
class="grid-scroll-container"
@click=${this.onGridClick}
>
<lit-virtualizer
.items=${entries}
.renderItem=${(
@@ -1037,4 +1198,22 @@ export class ArtistsView extends LitElement {
${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 cancelScanComplete?: () => void;
private wheelListenerAttached = false;
private lastSearchTerm = '';
/** All tracks from the library (used to derive genres). */
private allTracks: library.Track[] = [];
@@ -60,6 +61,14 @@ export class GenresView extends LitElement {
@state()
private cardSize: number = CARD_SIZE_DEFAULT;
// ----- Multi-select state -----
@state()
private selectedGenres: Set<string> = new Set();
private lastSelectedGenreIndex: number | null =
null;
// ----- Context menu state -----
@state()
@@ -71,9 +80,6 @@ export class GenresView extends LitElement {
@state()
private playlistFilePaths: string[] = [];
/** The genre targeted by the current context menu. */
private contextMenuGenre: Genre | null = null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@@ -215,6 +221,20 @@ export class GenresView extends LitElement {
transform: scale(0.97);
}
.genre-card.selected {
outline: 2px solid
var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.genre-card.selected .avatar-container {
scale: 0.95;
}
.genre-card.selected .genre-name {
scale: 0.95;
}
.avatar-container {
width: var(--avatar-size);
height: var(--avatar-size);
@@ -402,6 +422,14 @@ export class GenresView extends LitElement {
this.updateSizeProperties();
this.ensureWheelListener();
this.updateGridLayout();
// Clear selection when search term changes.
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.clearSelection();
}
}
/* ================================================================
@@ -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
* ================================================================ */
private onGenreClick(genre: Genre) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'genre-details',
genreName: genre.name,
},
}),
);
private onGenreClick(
e: MouseEvent,
genre: Genre,
index: number,
) {
const isCtrl = e.ctrlKey || e.metaKey;
const isShift = e.shiftKey;
if (
isShift &&
this.lastSelectedGenreIndex !== null
) {
const range = this.selectGenreRange(
this.lastSelectedGenreIndex,
index,
);
const next = new Set(
this.selectedGenres,
);
for (const name of range) {
next.add(name);
}
this.selectedGenres = next;
} else if (isCtrl) {
const next = new Set(
this.selectedGenres,
);
if (next.has(genre.name)) {
next.delete(genre.name);
} else {
next.add(genre.name);
}
this.selectedGenres = next;
this.lastSelectedGenreIndex = index;
} else {
// Plain click: navigate to details.
this.clearSelection();
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'genre-details',
genreName: genre.name,
},
}),
);
}
}
/* ================================================================
@@ -653,7 +781,20 @@ export class GenresView extends LitElement {
e.preventDefault();
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(
e.clientX,
e.clientY,
@@ -695,7 +836,6 @@ export class GenresView extends LitElement {
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
this.contextMenuGenre = null;
const popup = this.contextMenuPopup;
@@ -705,12 +845,10 @@ export class GenresView extends LitElement {
}
private onContextMenuAction(action: string) {
const genre = this.contextMenuGenre;
if (!genre) return;
if (this.selectedGenres.size === 0) return;
const filePaths =
this.getGenreFilePaths(genre.name);
this.getSelectedGenreFilePaths();
if (filePaths.length === 0) return;
@@ -757,12 +895,10 @@ export class GenresView extends LitElement {
if (this.playlistSubmenuOpen) return;
const genre = this.contextMenuGenre;
if (!genre) return;
if (this.selectedGenres.size === 0) return;
this.playlistFilePaths =
this.getGenreFilePaths(genre.name);
this.getSelectedGenreFilePaths();
this.playlistSubmenuOpen = true;
@@ -811,20 +947,6 @@ export class GenresView extends LitElement {
* 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
* ================================================================ */
@@ -840,24 +962,33 @@ export class GenresView extends LitElement {
* ================================================================ */
private renderGenreCard(entry: GenreEntry) {
const { genre } = entry;
const { genre, index } = entry;
const imgSize = this.imageSize;
const placeholderFont = Math.round(
imgSize * 0.38,
);
const isSelected =
this.selectedGenres.has(genre.name);
return html`
<div
class="genre-card"
class="genre-card${isSelected
? ' selected'
: ''}"
tabindex="0"
role="button"
aria-label="${genre.name}"
aria-selected="${isSelected}"
style="
--avatar-size: ${imgSize}px;
--placeholder-font: ${placeholderFont}px;
"
@click=${() =>
this.onGenreClick(genre)}
@click=${(e: MouseEvent) =>
this.onGenreClick(
e,
genre,
index,
)}
@contextmenu=${(e: MouseEvent) =>
this.onGenreContextMenu(
e,
@@ -869,7 +1000,21 @@ export class GenresView extends LitElement {
e.key === ' '
) {
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}&rdquo;
</div>`
: nothing}
<div class="grid-scroll-container">
<div
class="grid-scroll-container"
@click=${this.onGridClick}
>
<lit-virtualizer
.items=${entries}
.renderItem=${(
@@ -1053,4 +1201,22 @@ export class GenresView extends LitElement {
${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();
}
};
}