added multi-select to cover grid, added shift select to track list
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import { EventsEmit } from '@runtime/runtime';
|
||||
import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library';
|
||||
import { library } from '@go/models';
|
||||
import { QueueController } from '@store/controllers/queue-controller';
|
||||
@@ -16,6 +15,8 @@ import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker
|
||||
export class CoverGrid extends LitElement {
|
||||
private queue = new QueueController(this);
|
||||
|
||||
private lastSelectedIndex: number | null = null;
|
||||
|
||||
private closeHandler = () => this.closeContextMenu();
|
||||
|
||||
static override styles = css`
|
||||
@@ -44,8 +45,13 @@ export class CoverGrid extends LitElement {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.album-card:focus {
|
||||
outline: 2px solid #1db954;
|
||||
.album-card.selected {
|
||||
outline: 2px solid #ffd43b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.album-card:focus-visible {
|
||||
outline: 2px solid #ffd43b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -171,7 +177,7 @@ export class CoverGrid extends LitElement {
|
||||
private contextMenuOpen = false;
|
||||
|
||||
@state()
|
||||
private contextMenuAlbum: library.Album | null = null;
|
||||
private selectedAlbums: Set<number> = new Set();
|
||||
|
||||
@state()
|
||||
private playlistSubmenuOpen = false;
|
||||
@@ -203,6 +209,8 @@ export class CoverGrid extends LitElement {
|
||||
this.loading = true;
|
||||
const albums = await GetAllAlbums();
|
||||
this.albums = albums ?? [];
|
||||
this.selectedAlbums = new Set();
|
||||
this.lastSelectedIndex = null;
|
||||
} catch (error) {
|
||||
console.error("Error loading albums:", error);
|
||||
this.albums = [];
|
||||
@@ -211,6 +219,39 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private selectRange(
|
||||
from: number,
|
||||
to: number,
|
||||
): 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 = this.albums[i];
|
||||
|
||||
if (album) {
|
||||
ids.add(album.ID);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async getSelectedFilePaths(): Promise<string[]> {
|
||||
const selected = this.albums.filter(
|
||||
(a) => this.selectedAlbums.has(a.ID),
|
||||
);
|
||||
const allPaths: string[] = [];
|
||||
|
||||
for (const album of selected) {
|
||||
const paths = await this.getAlbumFilePaths(album);
|
||||
allPaths.push(...paths);
|
||||
}
|
||||
|
||||
return allPaths;
|
||||
}
|
||||
|
||||
private async getAlbumFilePaths(album: library.Album): Promise<string[]> {
|
||||
try {
|
||||
const tracks = await GetAlbumTracks(album.ID);
|
||||
@@ -227,7 +268,10 @@ export class CoverGrid extends LitElement {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.contextMenuAlbum = album;
|
||||
if (!this.selectedAlbums.has(album.ID)) {
|
||||
this.selectedAlbums = new Set([album.ID]);
|
||||
}
|
||||
|
||||
this.contextMenuOpen = true;
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
@@ -254,9 +298,9 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
private async onContextMenuAction(action: string) {
|
||||
if (!this.contextMenuAlbum) return;
|
||||
if (this.selectedAlbums.size === 0) return;
|
||||
|
||||
const filePaths = await this.getAlbumFilePaths(this.contextMenuAlbum);
|
||||
const filePaths = await this.getSelectedFilePaths();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
@@ -272,17 +316,20 @@ export class CoverGrid extends LitElement {
|
||||
break;
|
||||
}
|
||||
|
||||
this.closeContextMenu();
|
||||
this.closeContextMenu(true);
|
||||
}
|
||||
|
||||
private closeContextMenu() {
|
||||
private closeContextMenu(clearSelection = false) {
|
||||
if (!this.contextMenuOpen) return;
|
||||
|
||||
this.closePlaylistSubmenu();
|
||||
this.contextMenuOpen = false;
|
||||
this.contextMenuAlbum = null;
|
||||
this.playlistFilePaths = [];
|
||||
|
||||
if (clearSelection) {
|
||||
this.selectedAlbums = new Set();
|
||||
}
|
||||
|
||||
const popup = this.contextMenuPopup;
|
||||
|
||||
if (popup) {
|
||||
@@ -293,10 +340,9 @@ export class CoverGrid extends LitElement {
|
||||
private async showPlaylistSubmenu() {
|
||||
if (this.playlistSubmenuOpen) return;
|
||||
|
||||
if (this.contextMenuAlbum) {
|
||||
this.playlistFilePaths = await this.getAlbumFilePaths(
|
||||
this.contextMenuAlbum,
|
||||
);
|
||||
if (this.selectedAlbums.size > 0) {
|
||||
this.playlistFilePaths =
|
||||
await this.getSelectedFilePaths();
|
||||
}
|
||||
|
||||
this.playlistSubmenuOpen = true;
|
||||
@@ -336,16 +382,31 @@ export class CoverGrid extends LitElement {
|
||||
this.closeContextMenu();
|
||||
};
|
||||
|
||||
private renderAlbumCard = (album: library.Album): unknown => {
|
||||
private renderAlbumCard = (
|
||||
album: library.Album,
|
||||
index: number,
|
||||
): unknown => {
|
||||
const selected = this.selectedAlbums.has(album.ID);
|
||||
|
||||
const classes = [
|
||||
'album-card',
|
||||
selected ? 'selected' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="album-card"
|
||||
class=${classes}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="${album.Name} by ${album.ArtistName}"
|
||||
@click=${() => this.onAlbumClick(album)}
|
||||
@keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)}
|
||||
@contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)}
|
||||
@click=${(e: MouseEvent) =>
|
||||
this.onAlbumClick(e, album, index)}
|
||||
@keydown=${(e: KeyboardEvent) =>
|
||||
this.onAlbumKeydown(e, album, index)}
|
||||
@contextmenu=${(e: MouseEvent) =>
|
||||
this.onAlbumContextMenu(e, album)}
|
||||
>
|
||||
<div class="cover-container">
|
||||
${album.CoverArtPath
|
||||
@@ -379,21 +440,65 @@ export class CoverGrid extends LitElement {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
private onAlbumClick(album: library.Album) {
|
||||
EventsEmit('AlbumSelected', album);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('album-selected', {
|
||||
detail: album,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
private onGridClick(e: MouseEvent) {
|
||||
const clickedCard = e.composedPath().some(
|
||||
(el) =>
|
||||
el instanceof HTMLElement &&
|
||||
el.classList.contains('album-card'),
|
||||
);
|
||||
|
||||
if (!clickedCard) {
|
||||
this.selectedAlbums = new Set();
|
||||
this.lastSelectedIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onAlbumKeydown(e: KeyboardEvent, album: library.Album) {
|
||||
private onAlbumClick(
|
||||
e: MouseEvent,
|
||||
album: library.Album,
|
||||
index: number,
|
||||
) {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isShift = e.shiftKey;
|
||||
|
||||
if (isShift && this.lastSelectedIndex !== null) {
|
||||
const range = this.selectRange(
|
||||
this.lastSelectedIndex,
|
||||
index,
|
||||
);
|
||||
const next = new Set(this.selectedAlbums);
|
||||
|
||||
for (const id of range) {
|
||||
next.add(id);
|
||||
}
|
||||
|
||||
this.selectedAlbums = next;
|
||||
} else if (isCtrl) {
|
||||
const next = new Set(this.selectedAlbums);
|
||||
|
||||
if (next.has(album.ID)) {
|
||||
next.delete(album.ID);
|
||||
} else {
|
||||
next.add(album.ID);
|
||||
}
|
||||
|
||||
this.selectedAlbums = next;
|
||||
this.lastSelectedIndex = index;
|
||||
} else {
|
||||
this.selectedAlbums = new Set([album.ID]);
|
||||
this.lastSelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
private onAlbumKeydown(
|
||||
e: KeyboardEvent,
|
||||
album: library.Album,
|
||||
index: number,
|
||||
) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.onAlbumClick(album);
|
||||
this.selectedAlbums = new Set([album.ID]);
|
||||
this.lastSelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +521,7 @@ export class CoverGrid extends LitElement {
|
||||
scroller
|
||||
.items=${this.albums}
|
||||
.renderItem=${this.renderAlbumCard}
|
||||
@click=${(e: MouseEvent) => this.onGridClick(e)}
|
||||
.layout=${grid({
|
||||
itemSize: { width: '176px', height: '230px' },
|
||||
gap: '16px',
|
||||
|
||||
@@ -37,6 +37,8 @@ export class TrackList extends LitElement {
|
||||
@query('#playlist-submenu')
|
||||
private playlistSubmenuPopup!: HTMLElement;
|
||||
|
||||
private lastSelectedIndex: number | null = null;
|
||||
|
||||
private closeHandler = () => this.closeContextMenu();
|
||||
|
||||
static override styles = css`
|
||||
@@ -171,6 +173,7 @@ export class TrackList extends LitElement {
|
||||
const tracks = await GetAllTracks();
|
||||
this.tracks = tracks;
|
||||
this.selectedTracks = new Set();
|
||||
this.lastSelectedIndex = null;
|
||||
|
||||
if (tracks[0]) {
|
||||
LogPrint(tracks[0].TrackName);
|
||||
@@ -186,10 +189,59 @@ export class TrackList extends LitElement {
|
||||
.map((t) => t.FilePath);
|
||||
}
|
||||
|
||||
private onTrackRowClick(e: MouseEvent, track: library.Track) {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
private selectRange(from: number, to: number): Set<string> {
|
||||
const start = Math.min(from, to);
|
||||
const end = Math.max(from, to);
|
||||
const paths = new Set<string>();
|
||||
|
||||
if (isCtrl) {
|
||||
for (let i = start; i <= end; i++) {
|
||||
const track = this.tracks[i];
|
||||
|
||||
if (track) {
|
||||
paths.add(track.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private onTrackRowClick(
|
||||
e: MouseEvent,
|
||||
track: library.Track,
|
||||
index: number,
|
||||
) {
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isShift = e.shiftKey;
|
||||
|
||||
if (isShift && this.lastSelectedIndex !== null) {
|
||||
const range = this.selectRange(
|
||||
this.lastSelectedIndex,
|
||||
index,
|
||||
);
|
||||
|
||||
if (isCtrl) {
|
||||
// Ctrl+Shift: add range to existing selection.
|
||||
const next = new Set(this.selectedTracks);
|
||||
|
||||
for (const path of range) {
|
||||
next.add(path);
|
||||
}
|
||||
|
||||
this.selectedTracks = next;
|
||||
} else {
|
||||
// Shift only: add range to existing selection.
|
||||
const next = new Set(this.selectedTracks);
|
||||
|
||||
for (const path of range) {
|
||||
next.add(path);
|
||||
}
|
||||
|
||||
this.selectedTracks = next;
|
||||
}
|
||||
|
||||
// Don't update anchor on shift-click so user can
|
||||
// adjust the range endpoint with another shift-click.
|
||||
} else if (isCtrl) {
|
||||
const next = new Set(this.selectedTracks);
|
||||
|
||||
if (next.has(track.FilePath)) {
|
||||
@@ -199,8 +251,10 @@ export class TrackList extends LitElement {
|
||||
}
|
||||
|
||||
this.selectedTracks = next;
|
||||
this.lastSelectedIndex = index;
|
||||
} else {
|
||||
this.selectedTracks = new Set([track.FilePath]);
|
||||
this.lastSelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +380,10 @@ export class TrackList extends LitElement {
|
||||
return currentTrack.filePath === track.FilePath;
|
||||
}
|
||||
|
||||
private renderTrackRow = (track: library.Track): unknown => {
|
||||
private renderTrackRow = (
|
||||
track: library.Track,
|
||||
index: number,
|
||||
): unknown => {
|
||||
const active = this.isActiveTrack(track);
|
||||
const selected = this.selectedTracks.has(track.FilePath);
|
||||
|
||||
@@ -341,7 +398,7 @@ export class TrackList extends LitElement {
|
||||
return html`
|
||||
<div
|
||||
class=${classes}
|
||||
@click=${(e: MouseEvent) => this.onTrackRowClick(e, track)}
|
||||
@click=${(e: MouseEvent) => this.onTrackRowClick(e, track, index)}
|
||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
||||
@contextmenu=${(e: MouseEvent) =>
|
||||
this.onTrackContextMenu(e, track)}
|
||||
|
||||
Reference in New Issue
Block a user