diff --git a/frontend/src/components/cover-grid/album-dropdown.ts b/frontend/src/components/cover-grid/album-dropdown.ts index 4edaa59..5d270f0 100644 --- a/frontend/src/components/cover-grid/album-dropdown.ts +++ b/frontend/src/components/cover-grid/album-dropdown.ts @@ -64,6 +64,7 @@ export class AlbumDropdown extends LitElement { static override styles = css` :host { display: block; + margin-top: 14px; } .album-dropdown { @@ -76,12 +77,12 @@ export class AlbumDropdown extends LitElement { .carat { position: absolute; - top: -8px; + top: -10px; width: 0; height: 0; - border-left: 9px solid transparent; - border-right: 9px solid transparent; - border-bottom: 8px solid var(--yj-bg-elevated, #343a40); + border-left: 11px solid transparent; + border-right: 11px solid transparent; + border-bottom: 10px solid var(--yj-bg-elevated, #343a40); } .dropdown-tracks { @@ -343,21 +344,21 @@ export class AlbumDropdown extends LitElement { class=${classes} draggable="true" @click=${(e: MouseEvent) => - this.onTrackClick(e, track, index)} + this.onTrackClick(e, track, index)} @dblclick=${(e: MouseEvent) => - this.onTrackDblClick( - e, - track, - index, - )} + this.onTrackDblClick( + e, + track, + index, + )} @contextmenu=${(e: MouseEvent) => - this.onTrackContextMenu(e, track)} + this.onTrackContextMenu(e, track)} @dragstart=${(e: DragEvent) => - this.onTrackDragStart( - e, - track, - index, - )} + this.onTrackDragStart( + e, + track, + index, + )} @dragend=${() => this.onTrackDragEnd()} > @@ -371,8 +372,8 @@ export class AlbumDropdown extends LitElement { ${formatMilliseconds( - track.TrackLength, - )} + track.TrackLength, + )} `; @@ -393,9 +394,9 @@ export class AlbumDropdown extends LitElement { style="height:${this.tracksHeight}px;column-count:${this.columnCount}" > ${this.tracks.map( - (track, i) => - this.renderTrackRow(track, i), - )} + (track, i) => + this.renderTrackRow(track, i), + )} `; diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 02beb1a..ab11670 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -81,11 +81,23 @@ export class CoverGrid extends LitElement { private closeHandler = () => this.closeContextMenu(); + private mousedownCloseHandler = ( + e: MouseEvent, + ) => { + const path = e.composedPath(); + const popup = this.contextMenuPopup; + const submenu = this.playlistSubmenuPopup; + + if (popup && path.includes(popup)) return; + if (submenu && path.includes(submenu)) return; + + 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. + * restore. */ private skipOverlay = false; @@ -233,11 +245,6 @@ export class CoverGrid extends LitElement { outline-offset: 2px; } - .album-card.expanded { - background-color: var(--yj-bg-elevated, #343a40); - border-radius: 8px 8px 0 0; - } - .album-card:focus-visible { outline: 2px solid var(--yj-accent, #ffd43b); outline-offset: 2px; @@ -257,45 +264,6 @@ export class CoverGrid extends LitElement { 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: var(--yj-accent, #ffd43b); - border-color: var(--yj-accent, #ffd43b); - } - - .check-icon { - color: #000; - font-size: 14px; - font-weight: bold; - line-height: 1; - } - .cover-image { width: 100%; height: 100%; @@ -564,6 +532,10 @@ export class CoverGrid extends LitElement { 'contextmenu', this.closeHandler, ); + document.addEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); // error events do not bubble — use capture // phase to catch load failures. @@ -585,6 +557,10 @@ export class CoverGrid extends LitElement { 'contextmenu', this.closeHandler, ); + document.removeEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); this.removeEventListener( 'error', this.onGridImageError, @@ -2104,28 +2080,36 @@ export class CoverGrid extends LitElement { * Dropdown (expand/collapse) * ==================================================================== */ - private async toggleDropdown( + /** 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; + } + + /** + * Open (or switch to) the given album's + * dropdown. If the dropdown is already open + * for the same album this is a no-op. + */ + private async openDropdown( album: library.Album, ) { - if (this.expandedAlbumId === album.ID) { - // Close - this.expandedAlbumId = null; - this.expandedTracks = []; - this.selectedTracks = new Set(); - this.lastSelectedTrackIndex = null; + if (this.expandedAlbumId === album.ID) return; - return; - } - - // Open (or switch) this.expandedAlbumId = album.ID; this.expandedTracks = []; this.selectedTracks = new Set(); this.lastSelectedTrackIndex = null; - try { - const tracks = await GetAlbumTracks(album.ID); - // Only apply if still the same album + try { + const tracks = await GetAlbumTracks( + album.ID, + ); + if (this.expandedAlbumId === album.ID) { this.expandedTracks = tracks; } @@ -2137,14 +2121,25 @@ export class CoverGrid extends LitElement { } } - /** Close the dropdown if one is open. */ - private closeDropdown() { - if (this.expandedAlbumId === null) return; + /** + * Synchronise the dropdown to the current + * selection: open the sole selected album's + * dropdown, or close it when zero or many + * albums are selected. + */ + private syncDropdownToSelection() { + if (this.selectedAlbums.size === 1) { + const [albumId] = this.selectedAlbums; + const album = this.filteredAlbums.find( + (a) => a.ID === albumId, + ); - this.expandedAlbumId = null; - this.expandedTracks = []; - this.selectedTracks = new Set(); - this.lastSelectedTrackIndex = null; + if (album) { + void this.openDropdown(album); + } + } else { + this.closeDropdown(); + } } /* ==================================================================== @@ -2211,7 +2206,7 @@ export class CoverGrid extends LitElement { } this.selectedAlbums = next; - this.closeDropdown(); + this.syncDropdownToSelection(); void this.warmAlbumFilePathCache(); } else if (isCtrl) { const next = new Set(this.selectedAlbums); @@ -2224,12 +2219,28 @@ export class CoverGrid extends LitElement { this.selectedAlbums = next; this.lastSelectedAlbumIndex = index; - this.closeDropdown(); + this.syncDropdownToSelection(); void this.warmAlbumFilePathCache(); } else { - this.selectedAlbums = new Set(); - void this.toggleDropdown(album); + // Plain click: if this album is the + // sole selection, deselect + close. + // Otherwise select only this album + // and open its dropdown. + if ( + this.selectedAlbums.size === 1 && + this.selectedAlbums.has(album.ID) + ) { + this.selectedAlbums = new Set(); + this.closeDropdown(); + } else { + this.selectedAlbums = new Set([ + album.ID, + ]); + void this.openDropdown(album); + } + this.lastSelectedAlbumIndex = index; + void this.warmAlbumFilePathCache(); } }; @@ -2247,6 +2258,7 @@ export class CoverGrid extends LitElement { if (filePaths.length === 0) return; this.selectedAlbums = new Set(); + this.closeDropdown(); queueStore.setQueue(filePaths, 0); }; @@ -2260,8 +2272,24 @@ export class CoverGrid extends LitElement { if (!hit) return; e.preventDefault(); - void this.toggleDropdown(hit.album); - this.lastSelectedAlbumIndex = hit.index; + + const { album, index } = hit; + + // Mirror plain-click behaviour: toggle + // sole selection, or select and open. + if ( + this.selectedAlbums.size === 1 && + this.selectedAlbums.has(album.ID) + ) { + this.selectedAlbums = new Set(); + this.closeDropdown(); + } else { + this.selectedAlbums = new Set([album.ID]); + void this.openDropdown(album); + } + + this.lastSelectedAlbumIndex = index; + void this.warmAlbumFilePathCache(); }; private onGridAlbumContextMenu = ( @@ -2278,6 +2306,7 @@ export class CoverGrid extends LitElement { this.selectedAlbums = new Set([ hit.album.ID, ]); + this.syncDropdownToSelection(); void this.warmAlbumFilePathCache(); } @@ -2285,48 +2314,6 @@ 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. @@ -2869,18 +2856,6 @@ export class CoverGrid extends LitElement { > ${this.getAlbumInitial(album.Name)} `} -
- ${selected - ? html` - ✓ - ` - : nothing} -
{ + const path = e.composedPath(); + const popup = this.contextMenuPopup; + const submenu = this.playlistSubmenuPopup; + const plPopup = + this.playlistContextMenuPopup; + + if (popup && path.includes(popup)) return; + if (submenu && path.includes(submenu)) return; + if (plPopup && path.includes(plPopup)) return; + + this.closeContextMenu(); + this.closePlaylistContextMenu(); + }; + private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( @@ -664,6 +681,10 @@ export class PlaylistView 'contextmenu', this.closeContextMenuHandler, ); + document.addEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); document.addEventListener( 'click', this.clearSelectionHandler, @@ -687,6 +708,10 @@ export class PlaylistView 'contextmenu', this.closeContextMenuHandler, ); + document.removeEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); document.removeEventListener( 'click', this.clearSelectionHandler, diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index bedfce5..b3ee151 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -97,6 +97,17 @@ export class QueuePanel private closeContextMenuHandler = () => this.closeContextMenu(); + private mousedownCloseHandler = (e: MouseEvent) => { + const path = e.composedPath(); + const popup = this.contextMenuPopup; + const submenu = this.playlistSubmenuPopup; + + if (popup && path.includes(popup)) return; + if (submenu && path.includes(submenu)) return; + + this.closeContextMenu(); + }; + private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( @@ -453,6 +464,10 @@ export class QueuePanel 'contextmenu', this.closeContextMenuHandler, ); + document.addEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); document.addEventListener( 'click', this.clearSelectionHandler, @@ -485,6 +500,10 @@ export class QueuePanel 'contextmenu', this.closeContextMenuHandler, ); + document.removeEventListener( + 'mousedown', + this.mousedownCloseHandler, + ); document.removeEventListener( 'click', this.clearSelectionHandler, diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index b9e1c24..abe15ba 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -66,6 +66,19 @@ export class TrackList extends LitElement implements SelectionHost { private closeHandler = () => this.closeContextMenu(); + private mousedownCloseHandler = ( + e: MouseEvent, + ) => { + const path = e.composedPath(); + const popup = this.contextMenuPopup; + const submenu = this.playlistSubmenuPopup; + + if (popup && path.includes(popup)) return; + if (submenu && path.includes(submenu)) return; + + this.closeContextMenu(); + }; + private clearSelectionHandler = (e: MouseEvent) => { const path = e.composedPath(); const isTrackClick = path.some( @@ -437,6 +450,7 @@ export class TrackList extends LitElement implements SelectionHost { ); document.addEventListener('click', this.closeHandler); document.addEventListener('contextmenu', this.closeHandler); + document.addEventListener('mousedown', this.mousedownCloseHandler); document.addEventListener('click', this.clearSelectionHandler); document.addEventListener('mousemove', this.onColResizeMove); document.addEventListener('mouseup', this.onColResizeEnd); @@ -458,6 +472,7 @@ export class TrackList extends LitElement implements SelectionHost { this.cancelScanComplete?.(); document.removeEventListener('click', this.closeHandler); document.removeEventListener('contextmenu', this.closeHandler); + document.removeEventListener('mousedown', this.mousedownCloseHandler); document.removeEventListener('click', this.clearSelectionHandler); document.removeEventListener('mousemove', this.onColResizeMove); document.removeEventListener('mouseup', this.onColResizeEnd);