diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index f76003b..10134d0 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -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 = 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 { + const filtered = this.filteredArtists; + const start = Math.min(from, to); + const end = Math.max(from, to); + const ids = new Set(); + + 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`
- 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}”
` : nothing} -
+
{ + const path = e.composedPath(); + + const clickedCard = path.some( + (el) => + el instanceof HTMLElement && + el.classList.contains('artist-card'), + ); + + if (!clickedCard) { + this.clearSelection(); + } + }; } diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 5b74f70..2793219 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -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 = 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 { + const filtered = this.filteredGenres; + const start = Math.min(from, to); + const end = Math.max(from, to); + const names = new Set(); + + 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(); + + 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`
- 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}”
` : nothing} -
+
{ + const path = e.composedPath(); + + const clickedCard = path.some( + (el) => + el instanceof HTMLElement && + el.classList.contains('genre-card'), + ); + + if (!clickedCard) { + this.clearSelection(); + } + }; }