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