fixed cover-grid selection and queuing behavior, added "clear queue" button
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# Plan: Clear Queue Button
|
||||
|
||||
## Goal
|
||||
Add a "Clear Queue" button (trash icon) next to the existing "Add queue to playlist" button in the queue panel header. The button clears all tracks from the queue, stops playback, and resets queue state.
|
||||
|
||||
## Architecture Overview
|
||||
The backend already has a `Queue.Clear()` method (`backend/queue/queue.go:1540`) that handles everything — clearing tracks, stopping playback, resetting state, persisting, and emitting `QueueChanged`. The only missing piece is wiring it to the frontend via the event system and adding the UI button.
|
||||
|
||||
## Changes Required (5 files)
|
||||
|
||||
### 1. `backend/events/events.go` — Add new event constant
|
||||
Add `RequestClearQueue` to the queue events const block.
|
||||
|
||||
```go
|
||||
RequestMoveQueueTracks = "RequestMoveQueueTracks"
|
||||
RequestClearQueue = "RequestClearQueue"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. `frontend/src/events.ts` — Add matching TypeScript event constant
|
||||
Add `RequestClearQueue` to the queue events section.
|
||||
|
||||
```typescript
|
||||
RequestMoveQueueTracks: "RequestMoveQueueTracks",
|
||||
RequestClearQueue: "RequestClearQueue",
|
||||
```
|
||||
|
||||
### 3. `backend/queue/queue.go` — Wire event handler in `registerEventHandlers()`
|
||||
Add a new `runtime.EventsOn` call at the end of `registerEventHandlers()` (after the existing `RequestMoveQueueTracks` handler around line 289):
|
||||
|
||||
```go
|
||||
runtime.EventsOn(
|
||||
q.ctx,
|
||||
events.RequestClearQueue,
|
||||
func(_ ...any) {
|
||||
q.logger.Info("Received RequestClearQueue")
|
||||
q.Clear()
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 4. `frontend/src/store/queue-store.ts` — Add `clearQueue()` action
|
||||
Add after the existing `moveTracksInQueue()` method (around line 250):
|
||||
|
||||
```typescript
|
||||
clearQueue(): void {
|
||||
EventsEmit(Events.RequestClearQueue);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `frontend/src/components/queue-panel/queue-panel.ts` — Add UI button, styles, and handler
|
||||
|
||||
#### 5a. Add handler method
|
||||
Add a new handler method near the other handlers (around line 518, near `handleAddToPlaylist`):
|
||||
|
||||
```typescript
|
||||
private handleClearQueue = () => {
|
||||
queueStore.clearQueue();
|
||||
};
|
||||
```
|
||||
|
||||
Note: Import `queueStore` — check if it's already imported (it likely is via the controller).
|
||||
|
||||
#### 5b. Add CSS styles for shared header button class
|
||||
Add a `.header-actions` container style and refactor the button styles. Replace the existing `.add-to-playlist-button` styles:
|
||||
|
||||
**Replace:**
|
||||
```css
|
||||
.add-to-playlist-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-to-playlist-button:hover {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.add-to-playlist-button:disabled {
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
```
|
||||
|
||||
**With:**
|
||||
```css
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-action-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-action-button:hover {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.header-action-button:disabled {
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
```
|
||||
|
||||
#### 5c. Update the header HTML
|
||||
Replace the header section (around lines 1183-1193):
|
||||
|
||||
**Replace:**
|
||||
```html
|
||||
<div class="header">
|
||||
<h3>Queue</h3>
|
||||
<button
|
||||
class="add-to-playlist-button"
|
||||
@click=${this.handleAddToPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Add queue to playlist"
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**With:**
|
||||
```html
|
||||
<div class="header">
|
||||
<h3>Queue</h3>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="header-action-button"
|
||||
@click=${this.handleClearQueue}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
<wa-icon name="trash"></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="header-action-button add-to-playlist-button"
|
||||
@click=${this.handleAddToPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Add queue to playlist"
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Important:** The `add-to-playlist-button` class must remain on the playlist button because it's referenced by `@query` selectors and the `closePickerHandler` (lines 84-85, 527). The new class `header-action-button` provides the shared visual style.
|
||||
|
||||
#### 5d. Update CSS selector references
|
||||
Check that the `.add-to-playlist-button` query selector references still work. Since we're keeping `add-to-playlist-button` as a class on the playlist button, the existing `@query('.add-to-playlist-button')` and `querySelector('.add-to-playlist-button')` calls will continue to work unchanged.
|
||||
|
||||
### 6. Import check
|
||||
Verify that `queueStore` is accessible in `queue-panel.ts`. The component uses a `QueueController` which wraps the store, but the `clearQueue()` call needs to go through the store directly. Check if `queueStore` is already imported; if not, add:
|
||||
|
||||
```typescript
|
||||
import { queueStore } from '@store/queue-store';
|
||||
```
|
||||
|
||||
## Testing
|
||||
- Run `make lint` to verify Go code passes linting
|
||||
- Run `cd frontend && pnpm exec tsc --noEmit` to verify TypeScript compiles
|
||||
- Manual testing: click the trash button when queue has tracks → queue should clear, playback should stop, button should become disabled
|
||||
@@ -49,6 +49,7 @@ const (
|
||||
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
|
||||
RequestInsertTracksAtIndex = "RequestInsertTracksAtIndex"
|
||||
RequestMoveQueueTracks = "RequestMoveQueueTracks"
|
||||
RequestClearQueue = "RequestClearQueue"
|
||||
)
|
||||
|
||||
// Config events.
|
||||
|
||||
@@ -294,6 +294,15 @@ func (q *Queue) registerEventHandlers() {
|
||||
q.handleMoveQueueTracks(data...)
|
||||
},
|
||||
)
|
||||
|
||||
runtime.EventsOn(
|
||||
q.ctx,
|
||||
events.RequestClearQueue,
|
||||
func(_ ...any) {
|
||||
q.logger.Info("Received RequestClearQueue")
|
||||
q.Clear()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// handleSetQueue processes the RequestSetQueue event payload.
|
||||
|
||||
@@ -53,17 +53,35 @@ export class AlbumDropdown extends LitElement {
|
||||
@property({ type: Number })
|
||||
containerWidth = 800;
|
||||
|
||||
/** Width of the album row in pixels (cards + gaps, no outer padding). */
|
||||
@property({ type: Number })
|
||||
gridRowWidth = 800;
|
||||
|
||||
/** Horizontal offset of the carat from the dropdown's left edge. */
|
||||
@property({ type: Number })
|
||||
caratOffset = 0;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.album-dropdown {
|
||||
background-color: #212529;
|
||||
border: 2px solid #ffd43b;
|
||||
border-radius: 4px;
|
||||
background-color: #343a40;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carat {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 9px solid transparent;
|
||||
border-right: 9px solid transparent;
|
||||
border-bottom: 8px solid #343a40;
|
||||
}
|
||||
|
||||
.dropdown-tracks {
|
||||
@@ -366,7 +384,14 @@ export class AlbumDropdown extends LitElement {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="album-dropdown">
|
||||
<div
|
||||
class="album-dropdown"
|
||||
style="width:${this.gridRowWidth}px"
|
||||
>
|
||||
<div
|
||||
class="carat"
|
||||
style="left:${this.caratOffset}px;transform:translateX(-50%)"
|
||||
></div>
|
||||
<div
|
||||
class="dropdown-tracks"
|
||||
style="height:${this.tracksHeight}px;column-count:${this.columnCount}"
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@utils/drag-controller';
|
||||
import type { DragPayload } from '@utils/drag-controller';
|
||||
import {
|
||||
createAlbumArtDragImage,
|
||||
createDragImage,
|
||||
removeDragImage,
|
||||
} from '@utils/drag-image';
|
||||
@@ -77,6 +78,14 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
private closeHandler = () => this.closeContextMenu();
|
||||
|
||||
/**
|
||||
* When true, the next split→single transition
|
||||
* skips the expensive overlay capture and scroll
|
||||
* restore. Used by the select-toggle checkbox
|
||||
* so closing the dropdown is instant.
|
||||
*/
|
||||
private skipOverlay = false;
|
||||
|
||||
/** Current card width — driven by the store. */
|
||||
private get cardWidth(): number {
|
||||
return this.libraryCtrl.coverSize;
|
||||
@@ -162,8 +171,7 @@ export class CoverGrid extends LitElement {
|
||||
private wheelListenerAttached = false;
|
||||
|
||||
// buildGridEntries() memoization cache.
|
||||
private entriesCacheAlbums: library.Album[] = [];
|
||||
private entriesCache: GridEntry[] = [];
|
||||
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
@@ -204,8 +212,8 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
.album-card.expanded {
|
||||
outline: 2px solid #ffd43b;
|
||||
outline-offset: 2px;
|
||||
background-color: #343a40;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.album-card:focus-visible {
|
||||
@@ -220,12 +228,57 @@ export class CoverGrid extends LitElement {
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: #282828;
|
||||
transition: scale 0.15s ease;
|
||||
}
|
||||
|
||||
.album-card.selected .cover-container {
|
||||
scale: 0.95;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Selection checkbox overlay
|
||||
* ======================================== */
|
||||
|
||||
.select-toggle {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.album-card:hover .select-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.select-toggle.checked {
|
||||
opacity: 1;
|
||||
background-color: #ffd43b;
|
||||
border-color: #ffd43b;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.placeholder-cover {
|
||||
@@ -247,6 +300,11 @@ export class CoverGrid extends LitElement {
|
||||
margin-top: 4px;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
transition: scale 0.15s ease;
|
||||
}
|
||||
|
||||
.album-card.selected .album-info {
|
||||
scale: 0.95;
|
||||
}
|
||||
|
||||
.album-name {
|
||||
@@ -545,110 +603,124 @@ export class CoverGrid extends LitElement {
|
||||
this.expandedTracks.length === 0 &&
|
||||
this.splitMode
|
||||
) {
|
||||
// Capture the raw split-mode scrollTop
|
||||
// before converting to single-mode coords.
|
||||
const rawScrollTop =
|
||||
this.scrollContainer?.scrollTop ?? 0;
|
||||
if (this.skipOverlay) {
|
||||
// Lightweight exit: skip the
|
||||
// overlay capture and scroll
|
||||
// restore so the transition is
|
||||
// instant.
|
||||
this.skipOverlay = false;
|
||||
this.splitMode = false;
|
||||
} else {
|
||||
// Capture the raw split-mode scrollTop
|
||||
// before converting to single-mode coords.
|
||||
const rawScrollTop =
|
||||
this.scrollContainer?.scrollTop ??
|
||||
0;
|
||||
|
||||
// Convert to dropdown-free coordinates
|
||||
// before exiting split mode.
|
||||
this.savedScrollTop =
|
||||
this.computeAdjustedScrollTop();
|
||||
// Convert to dropdown-free coordinates
|
||||
// before exiting split mode.
|
||||
this.savedScrollTop =
|
||||
this.computeAdjustedScrollTop();
|
||||
|
||||
// If switching to a new album (not
|
||||
// closing), record the viewport offset
|
||||
// of the newly-expanded album in the
|
||||
// OLD split layout so the enter-split
|
||||
// restore can place it at the same
|
||||
// visual position.
|
||||
if (this.expandedAlbumId !== null) {
|
||||
const idx = this.albums.findIndex(
|
||||
(a) =>
|
||||
a.ID ===
|
||||
this.expandedAlbumId,
|
||||
// If switching to a new album (not
|
||||
// closing), record the viewport offset
|
||||
// of the newly-expanded album in the
|
||||
// OLD split layout so the enter-split
|
||||
// restore can place it at the same
|
||||
// visual position.
|
||||
if (this.expandedAlbumId !== null) {
|
||||
const idx = this.albums.findIndex(
|
||||
(a) =>
|
||||
a.ID ===
|
||||
this.expandedAlbumId,
|
||||
);
|
||||
|
||||
if (idx >= 0) {
|
||||
const gap =
|
||||
CoverGrid.GRID_GAP;
|
||||
const pad =
|
||||
CoverGrid.GRID_PADDING;
|
||||
const cols =
|
||||
this.getColumnCount();
|
||||
const rowStep =
|
||||
this.cardHeight + gap;
|
||||
const row = Math.floor(
|
||||
idx / cols,
|
||||
);
|
||||
|
||||
// Album's Y in single-mode
|
||||
// (no dropdown) coordinates.
|
||||
const albumY =
|
||||
pad + row * rowStep;
|
||||
|
||||
// In the old split layout
|
||||
// the dropdown shifts
|
||||
// everything below it.
|
||||
const oldBeforeRows =
|
||||
Math.ceil(
|
||||
this.splitIndex /
|
||||
cols,
|
||||
);
|
||||
const oldDropdownTop =
|
||||
pad +
|
||||
oldBeforeRows * rowStep;
|
||||
const dropdown =
|
||||
this.shadowRoot?.querySelector(
|
||||
'album-dropdown',
|
||||
);
|
||||
const oldDropdownHeight =
|
||||
(
|
||||
dropdown as HTMLElement
|
||||
)?.offsetHeight ?? 0;
|
||||
|
||||
const albumYOldSplit =
|
||||
albumY >= oldDropdownTop
|
||||
? albumY +
|
||||
oldDropdownHeight
|
||||
: albumY;
|
||||
|
||||
// Viewport offset in
|
||||
// old-split coordinates.
|
||||
this.savedAlbumViewportOffset =
|
||||
albumYOldSplit -
|
||||
rawScrollTop;
|
||||
|
||||
console.log(
|
||||
'[willUpdate] anchor capture',
|
||||
{
|
||||
albumY,
|
||||
oldDropdownTop,
|
||||
oldDropdownHeight,
|
||||
albumYOldSplit,
|
||||
rawScrollTop,
|
||||
offset: this
|
||||
.savedAlbumViewportOffset,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.savedAlbumViewportOffset =
|
||||
null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[willUpdate] exit split (tracks empty)',
|
||||
{
|
||||
savedScrollTop:
|
||||
this.savedScrollTop,
|
||||
savedAlbumViewportOffset:
|
||||
this
|
||||
.savedAlbumViewportOffset,
|
||||
expandedAlbumId:
|
||||
this.expandedAlbumId,
|
||||
},
|
||||
);
|
||||
|
||||
if (idx >= 0) {
|
||||
const gap = CoverGrid.GRID_GAP;
|
||||
const pad =
|
||||
CoverGrid.GRID_PADDING;
|
||||
const cols =
|
||||
this.getColumnCount();
|
||||
const rowStep =
|
||||
this.cardHeight + gap;
|
||||
const row = Math.floor(
|
||||
idx / cols,
|
||||
);
|
||||
|
||||
// Album's Y in single-mode
|
||||
// (no dropdown) coordinates.
|
||||
const albumY =
|
||||
pad + row * rowStep;
|
||||
|
||||
// In the old split layout the
|
||||
// dropdown shifts everything
|
||||
// below it. Determine if
|
||||
// album B sits below the old
|
||||
// dropdown.
|
||||
const oldBeforeRows = Math.ceil(
|
||||
this.splitIndex / cols,
|
||||
);
|
||||
const oldDropdownTop =
|
||||
pad + oldBeforeRows * rowStep;
|
||||
const dropdown =
|
||||
this.shadowRoot?.querySelector(
|
||||
'album-dropdown',
|
||||
);
|
||||
const oldDropdownHeight =
|
||||
(dropdown as HTMLElement)
|
||||
?.offsetHeight ?? 0;
|
||||
|
||||
const albumYOldSplit =
|
||||
albumY >= oldDropdownTop
|
||||
? albumY +
|
||||
oldDropdownHeight
|
||||
: albumY;
|
||||
|
||||
// Viewport offset in old-split
|
||||
// coordinates: use the raw
|
||||
// (un-adjusted) scrollTop.
|
||||
this.savedAlbumViewportOffset =
|
||||
albumYOldSplit - rawScrollTop;
|
||||
|
||||
console.log(
|
||||
'[willUpdate] anchor capture',
|
||||
{
|
||||
albumY,
|
||||
oldDropdownTop,
|
||||
oldDropdownHeight,
|
||||
albumYOldSplit,
|
||||
rawScrollTop,
|
||||
offset: this
|
||||
.savedAlbumViewportOffset,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.savedAlbumViewportOffset =
|
||||
null;
|
||||
this.captureOverlay();
|
||||
this.splitMode = false;
|
||||
this.needsScrollRestore = true;
|
||||
this.showDropdownAfterRestore = false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[willUpdate] exit split (tracks empty)',
|
||||
{
|
||||
savedScrollTop:
|
||||
this.savedScrollTop,
|
||||
savedAlbumViewportOffset:
|
||||
this.savedAlbumViewportOffset,
|
||||
expandedAlbumId:
|
||||
this.expandedAlbumId,
|
||||
},
|
||||
);
|
||||
|
||||
this.captureOverlay();
|
||||
this.splitMode = false;
|
||||
this.needsScrollRestore = true;
|
||||
this.showDropdownAfterRestore = false;
|
||||
}
|
||||
|
||||
// Enter split mode when tracks have loaded.
|
||||
@@ -1360,6 +1432,46 @@ export class CoverGrid extends LitElement {
|
||||
return el?.clientWidth ?? 800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of the album row: from the left edge of
|
||||
* the leftmost card to the right edge of the
|
||||
* rightmost card, including card padding but not
|
||||
* the outer grid padding.
|
||||
*/
|
||||
private getGridRowWidth(): number {
|
||||
const cols = this.getColumnCount();
|
||||
const gap = CoverGrid.GRID_GAP;
|
||||
|
||||
return (
|
||||
cols * this.cardWidth +
|
||||
(cols - 1) * gap
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal offset of the carat (in pixels from
|
||||
* the left edge of the dropdown) so that it points
|
||||
* at the center of the expanded album card.
|
||||
*/
|
||||
private getCaratOffset(): number {
|
||||
if (this.expandedAlbumId === null) return 0;
|
||||
|
||||
const idx = this.albums.findIndex(
|
||||
(a) => a.ID === this.expandedAlbumId,
|
||||
);
|
||||
|
||||
if (idx < 0) return 0;
|
||||
|
||||
const cols = this.getColumnCount();
|
||||
const colIndex = idx % cols;
|
||||
const gap = CoverGrid.GRID_GAP;
|
||||
|
||||
return (
|
||||
colIndex * (this.cardWidth + gap) +
|
||||
this.cardWidth / 2
|
||||
);
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Split-mode helpers
|
||||
*
|
||||
@@ -1406,14 +1518,11 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
/**
|
||||
* Build a flat GridEntry array for a given album
|
||||
* slice. Used by both single and split modes.
|
||||
* slice. Always returns a new array so the
|
||||
* virtualizer re-renders visible items when
|
||||
* component state (e.g. selectedAlbums) changes.
|
||||
*/
|
||||
private buildGridEntries(): GridEntry[] {
|
||||
// Return cached result when input unchanged.
|
||||
if (this.entriesCacheAlbums === this.albums) {
|
||||
return this.entriesCache;
|
||||
}
|
||||
|
||||
const entries: GridEntry[] = [];
|
||||
|
||||
for (let i = 0; i < this.albums.length; i++) {
|
||||
@@ -1423,9 +1532,6 @@ export class CoverGrid extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
this.entriesCacheAlbums = this.albums;
|
||||
this.entriesCache = entries;
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -1868,13 +1974,6 @@ export class CoverGrid extends LitElement {
|
||||
this.selectedAlbums.has(a.ID),
|
||||
);
|
||||
|
||||
// Prune stale entries.
|
||||
for (const id of this.albumFilePathCache.keys()) {
|
||||
if (!this.selectedAlbums.has(id)) {
|
||||
this.albumFilePathCache.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch missing entries.
|
||||
for (const album of selected) {
|
||||
if (this.albumFilePathCache.has(album.ID)) {
|
||||
@@ -1992,6 +2091,16 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the dropdown if one is open. */
|
||||
private closeDropdown() {
|
||||
if (this.expandedAlbumId === null) return;
|
||||
|
||||
this.expandedAlbumId = null;
|
||||
this.expandedTracks = [];
|
||||
this.selectedTracks = new Set();
|
||||
this.lastSelectedTrackIndex = null;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
* Event delegation helpers
|
||||
* ==================================================================== */
|
||||
@@ -2055,6 +2164,7 @@ export class CoverGrid extends LitElement {
|
||||
}
|
||||
|
||||
this.selectedAlbums = next;
|
||||
this.closeDropdown();
|
||||
void this.warmAlbumFilePathCache();
|
||||
} else if (isCtrl) {
|
||||
const next = new Set(this.selectedAlbums);
|
||||
@@ -2067,8 +2177,10 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
this.selectedAlbums = next;
|
||||
this.lastSelectedAlbumIndex = index;
|
||||
this.closeDropdown();
|
||||
void this.warmAlbumFilePathCache();
|
||||
} else {
|
||||
this.selectedAlbums = new Set();
|
||||
void this.toggleDropdown(album);
|
||||
this.lastSelectedAlbumIndex = index;
|
||||
}
|
||||
@@ -2126,6 +2238,48 @@ export class CoverGrid extends LitElement {
|
||||
this.openContextMenuAt(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
/**
|
||||
* Selection checkbox toggle on album cards.
|
||||
* Stops propagation so that the card click
|
||||
* handler (which toggles the dropdown) does
|
||||
* not fire.
|
||||
*/
|
||||
private onSelectToggleClick = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const hit = this.resolveAlbumFromEvent(e);
|
||||
|
||||
if (!hit) return;
|
||||
|
||||
const { album, index } = hit;
|
||||
const next = new Set(this.selectedAlbums);
|
||||
|
||||
if (next.has(album.ID)) {
|
||||
next.delete(album.ID);
|
||||
} else {
|
||||
next.add(album.ID);
|
||||
}
|
||||
|
||||
this.selectedAlbums = next;
|
||||
this.lastSelectedAlbumIndex = index;
|
||||
|
||||
// Lightweight dropdown close: set the
|
||||
// skipOverlay flag so willUpdate skips
|
||||
// the expensive overlay capture and
|
||||
// scroll-restore machinery.
|
||||
if (this.expandedAlbumId !== null) {
|
||||
this.skipOverlay = true;
|
||||
this.expandedAlbumId = null;
|
||||
this.expandedTracks = [];
|
||||
this.selectedTracks = new Set();
|
||||
this.lastSelectedTrackIndex = null;
|
||||
}
|
||||
|
||||
void this.warmAlbumFilePathCache();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delegated image error handler — falls back from
|
||||
* thumbnail to full-size cover art.
|
||||
@@ -2297,6 +2451,36 @@ export class CoverGrid extends LitElement {
|
||||
* Drag source (album cards)
|
||||
* ==================================================================== */
|
||||
|
||||
/**
|
||||
* Pre-warm the file-path cache for the album under
|
||||
* the pointer so that a subsequent dragstart (which
|
||||
* is synchronous) can read the paths immediately.
|
||||
*/
|
||||
private onAlbumPointerDown = (e: PointerEvent) => {
|
||||
// Only act on primary button (left-click).
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const hit = this.resolveAlbumFromEvent(e);
|
||||
|
||||
if (!hit) return;
|
||||
|
||||
if (this.albumFilePathCache.has(hit.album.ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget: warm the cache entry.
|
||||
void this.getAlbumFilePaths(hit.album).then(
|
||||
(paths) => {
|
||||
if (paths.length > 0) {
|
||||
this.albumFilePathCache.set(
|
||||
hit.album.ID,
|
||||
paths,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
private onAlbumDragStart = (e: DragEvent) => {
|
||||
const hit = this.resolveAlbumFromEvent(e);
|
||||
|
||||
@@ -2304,20 +2488,26 @@ export class CoverGrid extends LitElement {
|
||||
|
||||
// Read file paths synchronously from the
|
||||
// pre-warmed cache. The cache is populated
|
||||
// asynchronously whenever the album selection
|
||||
// changes, so by the time the user drags, the
|
||||
// data is already available.
|
||||
// via pointerdown or selection-change warming.
|
||||
let filePaths: string[];
|
||||
let isSingleAlbum: boolean;
|
||||
|
||||
if (this.selectedAlbums.has(hit.album.ID)) {
|
||||
// Dragged album is part of the selection —
|
||||
// drag all selected albums' tracks.
|
||||
filePaths =
|
||||
this.getCachedSelectedAlbumFilePaths();
|
||||
isSingleAlbum =
|
||||
this.selectedAlbums.size === 1;
|
||||
} else {
|
||||
// Single unselected album — check cache.
|
||||
// Dragged album is not selected — clear
|
||||
// selection and drag only this album.
|
||||
this.selectedAlbums = new Set();
|
||||
filePaths =
|
||||
this.albumFilePathCache.get(
|
||||
hit.album.ID,
|
||||
) ?? [];
|
||||
isSingleAlbum = true;
|
||||
}
|
||||
|
||||
if (filePaths.length === 0) {
|
||||
@@ -2332,9 +2522,19 @@ export class CoverGrid extends LitElement {
|
||||
source: 'cover-grid',
|
||||
});
|
||||
|
||||
this.dragImageEl = createDragImage(
|
||||
filePaths.length,
|
||||
);
|
||||
// Single album: show cover art thumbnail.
|
||||
// Multiple albums: show track-count badge.
|
||||
if (isSingleAlbum && hit.album.CoverArtPath) {
|
||||
this.dragImageEl =
|
||||
createAlbumArtDragImage(
|
||||
this.getCoverUrl(hit.album),
|
||||
);
|
||||
} else {
|
||||
this.dragImageEl = createDragImage(
|
||||
filePaths.length,
|
||||
);
|
||||
}
|
||||
|
||||
e.dataTransfer?.setDragImage(
|
||||
this.dragImageEl,
|
||||
0,
|
||||
@@ -2602,6 +2802,7 @@ export class CoverGrid extends LitElement {
|
||||
data-index=${index}
|
||||
aria-label="${album.Name} by ${album.ArtistName}"
|
||||
draggable="true"
|
||||
@pointerdown=${this.onAlbumPointerDown}
|
||||
@dragstart=${this.onAlbumDragStart}
|
||||
@dragend=${this.onAlbumDragEnd}
|
||||
>
|
||||
@@ -2621,6 +2822,18 @@ export class CoverGrid extends LitElement {
|
||||
>
|
||||
${this.getAlbumInitial(album.Name)}
|
||||
</div>`}
|
||||
<div
|
||||
class="select-toggle ${selected ? 'checked' : ''}"
|
||||
@click=${this.onSelectToggleClick}
|
||||
>
|
||||
${selected
|
||||
? html`<span
|
||||
class="check-icon"
|
||||
>
|
||||
✓
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-info">
|
||||
<div
|
||||
@@ -2725,6 +2938,9 @@ export class CoverGrid extends LitElement {
|
||||
.tracks=${this.expandedTracks}
|
||||
.selectedTracks=${this.selectedTracks}
|
||||
.containerWidth=${this.getContainerWidth()}
|
||||
.gridRowWidth=${this.getGridRowWidth()}
|
||||
.caratOffset=${this.getCaratOffset()}
|
||||
style="margin-left:${(this.getContainerWidth() - this.getGridRowWidth()) / 2}px"
|
||||
@track-click=${this.onTrackClick}
|
||||
@track-dblclick=${this.onTrackDblClick}
|
||||
@track-contextmenu=${this.onTrackContextMenu}
|
||||
|
||||
@@ -206,7 +206,13 @@ export class QueuePanel
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-to-playlist-button {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-action-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
@@ -216,11 +222,11 @@ export class QueuePanel
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-to-playlist-button:hover {
|
||||
.header-action-button:hover {
|
||||
color: #ffd43b;
|
||||
}
|
||||
|
||||
.add-to-playlist-button:disabled {
|
||||
.header-action-button:disabled {
|
||||
color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -515,6 +521,10 @@ export class QueuePanel
|
||||
}
|
||||
}
|
||||
|
||||
private handleClearQueue = () => {
|
||||
this.queue.clearQueue();
|
||||
};
|
||||
|
||||
private async handleAddToPlaylist() {
|
||||
if (this.queue.tracks.length === 0) return;
|
||||
|
||||
@@ -1182,14 +1192,28 @@ export class QueuePanel
|
||||
></div>
|
||||
<div class="header">
|
||||
<h3>Queue</h3>
|
||||
<button
|
||||
class="add-to-playlist-button"
|
||||
@click=${this.handleAddToPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Add queue to playlist"
|
||||
>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="header-action-button"
|
||||
@click=${this.handleClearQueue}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Clear queue"
|
||||
>
|
||||
<wa-icon
|
||||
name="trash"
|
||||
></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="header-action-button add-to-playlist-button"
|
||||
@click=${this.handleAddToPlaylist}
|
||||
?disabled=${tracks.length === 0}
|
||||
title="Add queue to playlist"
|
||||
>
|
||||
<wa-icon
|
||||
name="plus"
|
||||
></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<wa-popup
|
||||
|
||||
@@ -39,6 +39,7 @@ export const Events = {
|
||||
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
||||
RequestInsertTracksAtIndex: "RequestInsertTracksAtIndex",
|
||||
RequestMoveQueueTracks: "RequestMoveQueueTracks",
|
||||
RequestClearQueue: "RequestClearQueue",
|
||||
|
||||
// Playlist events
|
||||
PlaylistCreated: "PlaylistCreated",
|
||||
|
||||
@@ -132,4 +132,8 @@ export class QueueController implements ReactiveController {
|
||||
): void {
|
||||
queueStore.moveTracksInQueue(fromIndices, toIndex);
|
||||
}
|
||||
|
||||
clearQueue(): void {
|
||||
queueStore.clearQueue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,10 @@ class QueueStore {
|
||||
);
|
||||
}
|
||||
|
||||
clearQueue(): void {
|
||||
EventsEmit(Events.RequestClearQueue);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SUBSCRIPTION SYSTEM
|
||||
// ===================================================================
|
||||
|
||||
@@ -28,6 +28,44 @@ export function createDragImage(count: number): HTMLElement {
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a drag image showing an album cover art thumbnail.
|
||||
* Falls back to the track-count badge if the image fails to load.
|
||||
*/
|
||||
export function createAlbumArtDragImage(
|
||||
coverUrl: string,
|
||||
): HTMLElement {
|
||||
const size = 64;
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
wrapper.style.cssText = [
|
||||
'position: fixed',
|
||||
'top: -1000px',
|
||||
'left: -1000px',
|
||||
'pointer-events: none',
|
||||
'z-index: 9999',
|
||||
].join(';');
|
||||
|
||||
const img = document.createElement('img');
|
||||
|
||||
img.src = coverUrl;
|
||||
img.width = size;
|
||||
img.height = size;
|
||||
img.style.cssText = [
|
||||
'display: block',
|
||||
`width: ${size}px`,
|
||||
`height: ${size}px`,
|
||||
'border-radius: 6px',
|
||||
'object-fit: cover',
|
||||
'box-shadow: 0 2px 8px rgba(0,0,0,0.4)',
|
||||
].join(';');
|
||||
|
||||
wrapper.appendChild(img);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** Remove a drag image element created by createDragImage. */
|
||||
export function removeDragImage(el: HTMLElement): void {
|
||||
el.remove();
|
||||
|
||||
Reference in New Issue
Block a user