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 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}&rdquo; .term}&rdquo;
</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}&rdquo; .term}&rdquo;
</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();
}
};
} }