basic drag-and-drop, fixed end of scan behavior

This commit is contained in:
2026-02-19 15:25:49 -05:00
parent d9ebd38382
commit 193f65bd98
12 changed files with 1043 additions and 31 deletions
+7
View File
@@ -116,6 +116,13 @@ body div.sidebar {
#queue-button:hover {
color: #ffd43b;
}
#queue-button.drag-over {
color: #ffd43b;
outline: 2px dashed #ffd43b;
outline-offset: -2px;
border-radius: 4px;
}
}
.content-area {
+48
View File
@@ -11,6 +11,12 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { libraryStore } from '@store/library-store';
import { playlistStore } from '@store/playlist-store';
import { queueStore } from '@store/queue-store';
import {
hasTrackPayload,
getDragPayload,
} from '@utils/drag-controller';
import type { DragActiveDetail } from '@utils/drag-controller';
setBasePath('/dist/webawesome');
@@ -62,4 +68,46 @@ if (queueButton && queuePanel) {
}
});
// ---------------------------------------------------------------
// Queue button as drop target (when queue panel is closed)
// ---------------------------------------------------------------
queueButton.addEventListener('dragover', (e: DragEvent) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
queueButton.classList.add('drag-over');
});
queueButton.addEventListener('dragleave', () => {
queueButton.classList.remove('drag-over');
});
queueButton.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
queueButton.classList.remove('drag-over');
const payload = getDragPayload(e);
if (!payload || payload.filePaths.length === 0) return;
if (payload.source === 'queue') return;
queueStore.addTracksToQueue(payload.filePaths);
});
// Show/hide drag-over styling globally.
document.addEventListener(
'yj-drag-active',
((e: CustomEvent<DragActiveDetail>) => {
if (!e.detail.active) {
queueButton.classList.remove('drag-over');
}
}) as EventListener,
);
}
@@ -26,6 +26,13 @@ export interface TrackContextMenuDetail {
clientY: number;
}
/** Detail payload for the track-dragstart custom event. */
export interface TrackDragStartDetail {
track: library.Track;
index: number;
dataTransfer: DataTransfer | null;
}
/**
* Self-contained dropdown that renders an album's track list.
*
@@ -236,6 +243,38 @@ export class AlbumDropdown extends LitElement {
);
}
private onTrackDragStart(
e: DragEvent,
track: library.Track,
index: number,
) {
// Delegate to the parent cover-grid which
// owns the selection state and drag-image.
this.dispatchEvent(
new CustomEvent<TrackDragStartDetail>(
'track-dragstart',
{
bubbles: true,
composed: true,
detail: {
track,
index,
dataTransfer: e.dataTransfer,
},
},
),
);
}
private onTrackDragEnd() {
this.dispatchEvent(
new CustomEvent('track-dragend', {
bubbles: true,
composed: true,
}),
);
}
private onTrackContextMenu(
e: MouseEvent,
track: library.Track,
@@ -288,6 +327,7 @@ export class AlbumDropdown extends LitElement {
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
@click=${(e: MouseEvent) =>
this.onTrackClick(e, track, index)}
@dblclick=${(e: MouseEvent) =>
@@ -298,6 +338,13 @@ export class AlbumDropdown extends LitElement {
)}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
@dragstart=${(e: DragEvent) =>
this.onTrackDragStart(
e,
track,
index,
)}
@dragend=${() => this.onTrackDragEnd()}
>
<span class="track-number">
${displayNumber}
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
@@ -22,7 +22,18 @@ import type {
TrackClickDetail,
TrackDblClickDetail,
TrackContextMenuDetail,
TrackDragStartDetail,
} from './album-dropdown.js';
import {
DRAG_MIME,
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import type { DragPayload } from '@utils/drag-controller';
import {
createDragImage,
removeDragImage,
} from '@utils/drag-image';
/**
* Discriminated context menu target so we know whether the
@@ -51,6 +62,7 @@ const ZOOM_STEP = 16;
@customElement('cover-grid')
export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this);
private cancelScanComplete?: () => void;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
@@ -130,6 +142,18 @@ export class CoverGrid extends LitElement {
});
}
private dragImageEl: HTMLElement | null = null;
/**
* Pre-resolved file paths for selected albums, keyed by album ID.
* Populated asynchronously when albums are selected so that
* dragstart can read them synchronously.
*/
private albumFilePathCache = new Map<
number,
string[]
>();
/** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
@@ -450,7 +474,7 @@ export class CoverGrid extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadAlbums(),
);
@@ -474,7 +498,7 @@ export class CoverGrid extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
EventsOff(Events.LibraryScanComplete);
this.cancelScanComplete?.();
document.removeEventListener(
'click',
this.closeHandler,
@@ -1834,6 +1858,71 @@ export class CoverGrid extends LitElement {
}
}
/**
* Pre-resolve file paths for all selected albums so
* that dragstart can read them synchronously. Called
* fire-and-forget whenever the album selection changes.
*/
private async warmAlbumFilePathCache(): Promise<void> {
const selected = this.albums.filter((a) =>
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)) {
continue;
}
try {
const tracks = await GetAlbumTracks(
album.ID,
);
// Only store if still selected.
if (this.selectedAlbums.has(album.ID)) {
this.albumFilePathCache.set(
album.ID,
tracks.map((t) => t.FilePath),
);
}
} catch {
// Silently skip — drag will just not
// include this album's paths.
}
}
}
/**
* Read cached file paths for the current album
* selection. Returns an empty array if any albums
* haven't been cached yet.
*/
private getCachedSelectedAlbumFilePaths(): string[] {
const result: string[] = [];
for (const album of this.albums) {
if (!this.selectedAlbums.has(album.ID)) {
continue;
}
const paths =
this.albumFilePathCache.get(album.ID);
if (paths) {
result.push(...paths);
}
}
return result;
}
/* ====================================================================
* Track selection helpers
* ==================================================================== */
@@ -1966,6 +2055,7 @@ export class CoverGrid extends LitElement {
}
this.selectedAlbums = next;
void this.warmAlbumFilePathCache();
} else if (isCtrl) {
const next = new Set(this.selectedAlbums);
@@ -1977,6 +2067,7 @@ export class CoverGrid extends LitElement {
this.selectedAlbums = next;
this.lastSelectedAlbumIndex = index;
void this.warmAlbumFilePathCache();
} else {
void this.toggleDropdown(album);
this.lastSelectedAlbumIndex = index;
@@ -2028,6 +2119,7 @@ export class CoverGrid extends LitElement {
this.selectedAlbums = new Set([
hit.album.ID,
]);
void this.warmAlbumFilePathCache();
}
this.contextMenuTarget = { kind: 'album' };
@@ -2147,6 +2239,120 @@ export class CoverGrid extends LitElement {
this.openContextMenuAt(clientX, clientY);
};
/* ====================================================================
* Drag source (dropdown tracks)
* ==================================================================== */
private onTrackDragStart = (
e: CustomEvent<TrackDragStartDetail>,
) => {
const { track, dataTransfer } = e.detail;
let filePaths: string[];
if (this.selectedTracks.has(track.FilePath)) {
filePaths =
this.getSelectedTrackFilePaths();
} else {
filePaths = [track.FilePath];
}
if (filePaths.length === 0) return;
if (dataTransfer) {
const payload: DragPayload = {
filePaths,
source: 'cover-grid',
};
dataTransfer.effectAllowed = 'copy';
dataTransfer.setData(
DRAG_MIME,
JSON.stringify(payload),
);
this.dragImageEl = createDragImage(
filePaths.length,
);
dataTransfer.setDragImage(
this.dragImageEl,
0,
0,
);
}
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Drag source (album cards)
* ==================================================================== */
private onAlbumDragStart = (e: DragEvent) => {
const hit = this.resolveAlbumFromEvent(e);
if (!hit) return;
// 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.
let filePaths: string[];
if (this.selectedAlbums.has(hit.album.ID)) {
filePaths =
this.getCachedSelectedAlbumFilePaths();
} else {
// Single unselected album — check cache.
filePaths =
this.albumFilePathCache.get(
hit.album.ID,
) ?? [];
}
if (filePaths.length === 0) {
// Cache miss — cancel the drag.
e.preventDefault();
return;
}
setDragPayload(e, {
filePaths,
source: 'cover-grid',
});
this.dragImageEl = createDragImage(
filePaths.length,
);
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onAlbumDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
/* ====================================================================
* Grid click (empty area)
* ==================================================================== */
@@ -2395,6 +2601,9 @@ export class CoverGrid extends LitElement {
role="button"
data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}"
draggable=${selected ? 'true' : 'false'}
@dragstart=${this.onAlbumDragStart}
@dragend=${this.onAlbumDragEnd}
>
<div class="cover-container">
${album.CoverArtPath
@@ -2519,6 +2728,8 @@ export class CoverGrid extends LitElement {
@track-click=${this.onTrackClick}
@track-dblclick=${this.onTrackDblClick}
@track-contextmenu=${this.onTrackContextMenu}
@track-dragstart=${this.onTrackDragStart}
@track-dragend=${this.onTrackDragEnd}
></album-dropdown>
${this.getAfterEntries().length > 0
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import { Scan, FullRescan } from '@go/library/Library';
import {
GetLibraryDirectory,
@@ -235,6 +235,8 @@ export class LibraryManager extends LitElement {
@state() private metrics: ScanMetrics | null = null;
@state() private copied = false;
@state() private concurrencyMode = 'auto';
private cancelScanStarted?: () => void;
private cancelScanComplete?: () => void;
static override styles = css`
:host {
@@ -525,11 +527,11 @@ export class LibraryManager extends LitElement {
this.loadCurrentDirectory();
this.loadConcurrencyMode();
EventsOn(
this.cancelScanStarted = EventsOn(
Events.LibraryScanStarted,
this.handleScanStarted,
);
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
this.handleScanComplete,
);
@@ -537,8 +539,8 @@ export class LibraryManager extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
EventsOff(Events.LibraryScanStarted);
EventsOff(Events.LibraryScanComplete);
this.cancelScanStarted?.();
this.cancelScanComplete?.();
}
private async loadCurrentDirectory(): Promise<void> {
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
@@ -24,6 +24,7 @@ import type { playlist } from '@go/models';
export class PlaylistPicker extends LitElement {
/** File paths to add when a playlist is selected or created. */
@property({ type: Array }) filePaths: string[] = [];
private cancelScanComplete?: () => void;
@state() private mode: 'list' | 'create' = 'list';
@state() private playlists: playlist.Summary[] = [];
@@ -134,7 +135,7 @@ export class PlaylistPicker extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.loadPlaylists();
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadPlaylists(),
);
@@ -142,7 +143,7 @@ export class PlaylistPicker extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
EventsOff(Events.LibraryScanComplete);
this.cancelScanComplete?.();
}
private async loadPlaylists() {
@@ -1,6 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import {
CreatePlaylist,
AddTracksToPlaylist,
RemoveTracksFromPlaylist,
} from '@go/playlist/Service';
import { Events } from '../../events';
@@ -20,6 +21,16 @@ import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
hasTrackPayload,
getDragPayload,
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import {
createDragImage,
removeDragImage,
} from '@utils/drag-image';
const SCROLL_DEBOUNCE_MS = 100;
@@ -37,6 +48,7 @@ export class PlaylistView
private player = new PlayerController(this);
private playlistCtrl = new PlaylistController(this);
private selection = new SelectionController(this);
private cancelScanComplete?: () => void;
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
@@ -54,6 +66,11 @@ export class PlaylistView
@state() private contextMenuOpen = false;
@state() private playlistSubmenuOpen = false;
/** Index of the playlist currently hovered during a drag. */
@state() private dragOverPlaylistIndex = -1;
private dragImageEl: HTMLElement | null = null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
@@ -107,6 +124,10 @@ export class PlaylistView
return entry?.tracks.length ?? 0;
}
onSelectionChanged(): void {
this.requestUpdate();
}
/**
* Return the selected playlist track IDs (database IDs)
* in order, for removal operations.
@@ -283,6 +304,12 @@ export class PlaylistView
background-color: rgba(255, 255, 255, 0.05);
}
.playlist-item.drag-over > .playlist-header {
background-color: rgba(255, 212, 59, 0.15);
outline: 1px dashed #ffd43b;
outline-offset: -1px;
}
.chevron {
font-size: 14px;
color: #888;
@@ -448,7 +475,7 @@ export class PlaylistView
override connectedCallback() {
super.connectedCallback();
this.loadPlaylists();
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadPlaylists(),
);
@@ -468,7 +495,7 @@ export class PlaylistView
override disconnectedCallback() {
super.disconnectedCallback();
EventsOff(Events.LibraryScanComplete);
this.cancelScanComplete?.();
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
@@ -705,6 +732,141 @@ export class PlaylistView
}
}
// =================================================================
// Drag source (playlist tracks → queue or other playlist)
// =================================================================
private onTrackDragStart = (
e: DragEvent,
track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) => {
this.ensureSelectionScope(playlistIndex);
const entry = this.entries[playlistIndex];
if (!entry) return;
let filePaths: string[];
if (
this.activePlaylistIndex ===
playlistIndex &&
this.selection.isSelected(
String(trackIndex),
)
) {
filePaths = this.getSelectedFilePaths();
} else {
filePaths = [track.FilePath];
}
if (filePaths.length === 0) return;
setDragPayload(e, {
filePaths,
source: 'playlist',
sourcePlaylistId: entry.summary.ID,
});
this.dragImageEl = createDragImage(
filePaths.length,
);
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
// =================================================================
// Drop target (tracks dropped onto a specific playlist)
// =================================================================
private onPlaylistDragOver = (
e: DragEvent,
index: number,
) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (this.dragOverPlaylistIndex !== index) {
this.dragOverPlaylistIndex = index;
}
};
private onPlaylistDragLeave = (
e: DragEvent,
index: number,
) => {
// Only clear if we're actually leaving this
// playlist item (not entering a child).
const related = e.relatedTarget as Node | null;
const items =
this.shadowRoot?.querySelectorAll(
'.playlist-item',
);
const item = items?.[index];
if (item && !item.contains(related)) {
if (this.dragOverPlaylistIndex === index) {
this.dragOverPlaylistIndex = -1;
}
}
};
private onPlaylistDrop = async (
e: DragEvent,
index: number,
) => {
e.preventDefault();
this.dragOverPlaylistIndex = -1;
const payload = getDragPayload(e);
if (
!payload ||
payload.filePaths.length === 0
) {
return;
}
const entry = this.entries[index];
if (!entry) return;
try {
await AddTracksToPlaylist(
entry.summary.ID,
payload.filePaths,
);
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error(
'Failed to add tracks to playlist:',
err,
);
}
};
private closeContextMenu(clearSelection = false) {
if (!this.contextMenuOpen) return;
@@ -1019,9 +1181,21 @@ export class PlaylistView
) {
const trackCount = entry.tracks.length;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
return html`
<li class="playlist-item">
<li
class="playlist-item ${isDragOver
? 'drag-over'
: ''}"
@dragover=${(e: DragEvent) =>
this.onPlaylistDragOver(e, index)}
@dragleave=${(e: DragEvent) =>
this.onPlaylistDragLeave(e, index)}
@drop=${(e: DragEvent) =>
this.onPlaylistDrop(e, index)}
>
<div
class="playlist-header"
@click=${() =>
@@ -1106,6 +1280,9 @@ export class PlaylistView
return html`
<div
class=${classes}
draggable=${selected
? 'true'
: 'false'}
@click=${(
e: MouseEvent,
) =>
@@ -1129,6 +1306,17 @@ export class PlaylistView
trackIndex,
playlistIndex,
)}
@dragstart=${(
e: DragEvent,
) =>
this.onTrackDragStart(
e,
track,
trackIndex,
playlistIndex,
)}
@dragend=${this
.onTrackDragEnd}
>
<track-info
.trackTitle=${track.Title}
@@ -17,6 +17,16 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import type { QueueTrack } from '@store/queue-store';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
hasTrackPayload,
getDragPayload,
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import {
createDragImage,
removeDragImage,
} from '@utils/drag-image';
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
@@ -45,6 +55,11 @@ export class QueuePanel
@state()
private playlistSubmenuOpen = false;
@state()
private dragOver = false;
private dragImageEl: HTMLElement | null = null;
@query('#add-to-playlist-popup')
private addToPlaylistPopup!: HTMLElement;
@@ -292,6 +307,25 @@ export class QueuePanel
color: #ff6b6b;
}
.panel-content.drag-over {
outline: 2px dashed #ffd43b;
outline-offset: -2px;
}
.drop-indicator {
display: none;
padding: 12px 16px;
text-align: center;
font-size: 12px;
color: #ffd43b;
border-bottom: 1px solid
rgba(255, 212, 59, 0.2);
}
.panel-content.drag-over .drop-indicator {
display: block;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -607,6 +641,110 @@ export class QueuePanel
this.closeContextMenu(true);
};
// =================================================================
// Drop target (tracks dropped into queue)
// =================================================================
private onPanelDragOver = (e: DragEvent) => {
if (!hasTrackPayload(e)) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (!this.dragOver) {
this.dragOver = true;
}
};
private onPanelDragLeave = (e: DragEvent) => {
// Only reset when leaving the panel-content
// element itself (not a child).
const related = e.relatedTarget as Node | null;
const panel =
this.shadowRoot?.querySelector(
'.panel-content',
);
if (panel && !panel.contains(related)) {
this.dragOver = false;
}
};
private onPanelDrop = (e: DragEvent) => {
e.preventDefault();
this.dragOver = false;
const payload = getDragPayload(e);
if (
!payload ||
payload.filePaths.length === 0
) {
return;
}
// Don't allow dropping queue items back
// onto the queue.
if (payload.source === 'queue') return;
this.queue.addTracksToQueue(payload.filePaths);
};
// =================================================================
// Drag source (queue tracks to playlist)
// =================================================================
private onTrackDragStart = (
e: DragEvent,
index: number,
) => {
const tracks = this.queue.tracks;
let filePaths: string[];
if (this.selection.isSelected(String(index))) {
filePaths = this.selection
.getSelectedIndices()
.map((i) => tracks[i]!.filePath);
} else {
const track = tracks[index];
if (!track) return;
filePaths = [track.filePath];
}
if (filePaths.length === 0) return;
setDragPayload(e, {
filePaths,
source: 'queue',
});
this.dragImageEl = createDragImage(
filePaths.length,
);
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
// =================================================================
// Other handlers
// =================================================================
@@ -679,12 +817,16 @@ export class QueuePanel
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
@click=${(e: MouseEvent) =>
this.handleTrackClick(e, track, index)}
@dblclick=${() =>
this.handleTrackDblClick(index)}
@contextmenu=${(e: MouseEvent) =>
this.handleTrackContextMenu(e, index)}
@dragstart=${(e: DragEvent) =>
this.onTrackDragStart(e, index)}
@dragend=${this.onTrackDragEnd}
>
<span class="track-position">
${index + 1}
@@ -713,7 +855,14 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
<div class="panel-content">
<div
class="panel-content ${this.dragOver
? 'drag-over'
: ''}"
@dragover=${this.onPanelDragOver}
@dragleave=${this.onPanelDragLeave}
@drop=${this.onPanelDrop}
>
<div
class="resize-handle ${this.isDragging
? 'dragging'
@@ -752,6 +901,10 @@ export class QueuePanel
: nothing}
</wa-popup>
<div class="drop-indicator">
Drop tracks here to add to queue
</div>
${tracks.length === 0
? html`
<div class="empty-state">
+153 -12
View File
@@ -1,6 +1,8 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
interface NavItem {
@@ -69,14 +71,35 @@ export class AppSidebar extends LitElement {
overflow: hidden;
text-overflow: ellipsis;
}
li.drag-hover {
background-color: rgba(255, 212, 59, 0.15);
outline: 1px dashed #ffd43b;
outline-offset: -1px;
}
`;
/** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600;
@state()
private activeView: View = 'tracks';
@state()
private isDragging = false;
/** Whether a track drag is in progress somewhere in the app. */
@state()
private trackDragActive = false;
/** The nav item ID being hovered during a drag. */
@state()
private dragHoverView: View | null = null;
private dragHoverTimer: ReturnType<
typeof setTimeout
> | null = null;
private navItems: NavItem[] = [
{ id: 'home', label: 'Home' },
{ id: 'libraries', label: 'Libraries' },
@@ -89,14 +112,35 @@ export class AppSidebar extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.style.width = `${DEFAULT_WIDTH}px`;
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
document.addEventListener(
'mousemove',
this.handleMouseMove,
);
document.addEventListener(
'mouseup',
this.handleMouseUp,
);
document.addEventListener(
'yj-drag-active',
this.onDragActive as EventListener,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
document.removeEventListener(
'mousemove',
this.handleMouseMove,
);
document.removeEventListener(
'mouseup',
this.handleMouseUp,
);
document.removeEventListener(
'yj-drag-active',
this.onDragActive as EventListener,
);
this.clearDragHoverTimer();
}
override render() {
@@ -106,14 +150,39 @@ export class AppSidebar extends LitElement {
@mousedown=${this.handleMouseDown}
></div>
<ul>
${this.navItems.map(item => html`
<li
class="${this.activeView === item.id ? 'active' : ''}"
@click=${() => this.navigate(item.id)}
>
<p>${item.label}</p>
</li>
`)}
${this.navItems.map((item) => {
const classes = [
this.activeView === item.id
? 'active'
: '',
this.dragHoverView === item.id
? 'drag-hover'
: '',
]
.filter(Boolean)
.join(' ');
return html`
<li
class=${classes}
@click=${() =>
this.navigate(item.id)}
@dragover=${(e: DragEvent) =>
this.onNavDragOver(
e,
item.id,
)}
@dragleave=${() =>
this.onNavDragLeave(
item.id,
)}
@drop=${(e: DragEvent) =>
this.onNavDrop(e)}
>
<p>${item.label}</p>
</li>
`;
})}
</ul>
`;
}
@@ -137,6 +206,78 @@ export class AppSidebar extends LitElement {
this.isDragging = false;
};
// =================================================================
// Drag-hover navigation
// =================================================================
/** Views that accept track drops. */
private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']);
private onDragActive = (
e: CustomEvent<DragActiveDetail>,
) => {
this.trackDragActive = e.detail.active;
if (!e.detail.active) {
this.clearDragHoverTimer();
this.dragHoverView = null;
}
};
private onNavDragOver = (
e: DragEvent,
view: View,
) => {
if (!this.trackDragActive) return;
if (!AppSidebar.DROP_VIEWS.has(view)) return;
// Prevent default so that `drop` can fire.
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
// Already hovering this item — no-op.
if (this.dragHoverView === view) return;
this.clearDragHoverTimer();
this.dragHoverView = view;
this.dragHoverTimer = setTimeout(() => {
this.dragHoverTimer = null;
if (this.dragHoverView === view) {
this.navigate(view);
}
}, AppSidebar.HOVER_NAV_DELAY);
};
private onNavDragLeave = (view: View) => {
if (this.dragHoverView !== view) return;
this.clearDragHoverTimer();
this.dragHoverView = null;
};
private onNavDrop = (e: DragEvent) => {
// The drop target is the playlist-view, not
// the sidebar itself — just prevent the
// default browser action.
e.preventDefault();
this.clearDragHoverTimer();
this.dragHoverView = null;
};
private clearDragHoverTimer() {
if (this.dragHoverTimer !== null) {
clearTimeout(this.dragHoverTimer);
this.dragHoverTimer = null;
}
}
private navigate(view: View) {
this.activeView = view;
this.dispatchEvent(new CustomEvent('navigate', {
@@ -1,7 +1,7 @@
import { library } from '@go/models';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn, EventsOff } from '@runtime/runtime';
import { EventsOn } from '@runtime/runtime';
import { formatMilliseconds } from '@utils/time';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
@@ -9,6 +9,14 @@ import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import { Events } from '../../events';
import {
setDragPayload,
emitDragActive,
} from '@utils/drag-controller';
import {
createDragImage,
removeDragImage,
} from '@utils/drag-image';
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
@@ -31,6 +39,7 @@ export class TrackList extends LitElement implements SelectionHost {
private player = new PlayerController(this);
private libraryCtrl = new LibraryController(this);
private selection = new SelectionController(this);
private cancelScanComplete?: () => void;
@state()
private tracks: library.Track[] = [];
@@ -68,6 +77,8 @@ export class TrackList extends LitElement implements SelectionHost {
}
};
private dragImageEl: HTMLElement | null = null;
@state()
private columnWidths: number[] = [];
@@ -389,7 +400,7 @@ export class TrackList extends LitElement implements SelectionHost {
override connectedCallback() {
super.connectedCallback();
this.loadTracks();
EventsOn(
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadTracks(),
);
@@ -413,7 +424,7 @@ export class TrackList extends LitElement implements SelectionHost {
);
this.hasRestoredScroll = false;
super.disconnectedCallback();
EventsOff(Events.LibraryScanComplete);
this.cancelScanComplete?.();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
document.removeEventListener('click', this.clearSelectionHandler);
@@ -570,6 +581,54 @@ export class TrackList extends LitElement implements SelectionHost {
});
}
// =================================================================
// Drag source
// =================================================================
private onTrackDragStart = (
e: DragEvent,
track: library.Track,
) => {
// Gather file paths: all selected if this track is selected,
// otherwise just the dragged track.
let filePaths: string[];
if (this.selection.isSelected(track.FilePath)) {
filePaths =
this.selection.getSelectedKeysOrdered();
} else {
filePaths = [track.FilePath];
}
if (filePaths.length === 0) return;
setDragPayload(e, {
filePaths,
source: 'track-list',
});
// Custom drag image.
this.dragImageEl = createDragImage(
filePaths.length,
);
e.dataTransfer?.setDragImage(
this.dragImageEl,
0,
0,
);
emitDragActive(true);
};
private onTrackDragEnd = () => {
if (this.dragImageEl) {
removeDragImage(this.dragImageEl);
this.dragImageEl = null;
}
emitDragActive(false);
};
private onContextMenuAction(action: string) {
const filePaths = this.selection.getSelectedKeysOrdered();
@@ -671,11 +730,15 @@ export class TrackList extends LitElement implements SelectionHost {
return html`
<div
class=${classes}
draggable=${selected ? 'true' : 'false'}
@click=${(e: MouseEvent) =>
this.onTrackRowClick(e, track, index)}
@dblclick=${() => this.onTrackRowDblClick(track)}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
@dragstart=${(e: DragEvent) =>
this.onTrackDragStart(e, track)}
@dragend=${this.onTrackDragEnd}
>
<div class="track-name">${track.TrackName}</div>
<div class="artist-name">${track.ArtistName}</div>
+117
View File
@@ -0,0 +1,117 @@
/**
* Shared drag-and-drop coordination for track items.
*
* Uses the HTML5 Drag and Drop API with a custom MIME type so that
* drag sources and drop targets across different shadow roots can
* communicate. A global custom event ("yj-drag-active") is
* dispatched on `document` so that non-participating components
* (sidebar, queue button) can react to the drag lifecycle.
*/
/** MIME type used in dataTransfer for in-app track drags. */
export const DRAG_MIME = 'application/x-yj-tracks';
/** Sources that can originate a drag. */
export type DragSource =
| 'track-list'
| 'cover-grid'
| 'queue'
| 'playlist';
/** Serialized payload stored in dataTransfer. */
export interface DragPayload {
filePaths: string[];
source: DragSource;
sourcePlaylistId?: number;
}
// =====================================================================
// Global drag-active event
// =====================================================================
export interface DragActiveDetail {
active: boolean;
}
/**
* Notify the entire document that a track drag has started or ended.
* Non-participating components listen for this to show/hide drop
* affordances (e.g. sidebar hover-to-navigate, queue button glow).
*/
export function emitDragActive(active: boolean): void {
document.dispatchEvent(
new CustomEvent<DragActiveDetail>(
'yj-drag-active',
{
bubbles: true,
composed: true,
detail: { active },
},
),
);
}
// =====================================================================
// Helpers for drag sources
// =====================================================================
/**
* Populate a DragEvent's dataTransfer with the standard payload.
* Returns false if dataTransfer is unavailable.
*/
export function setDragPayload(
e: DragEvent,
payload: DragPayload,
): boolean {
if (!e.dataTransfer) return false;
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData(
DRAG_MIME,
JSON.stringify(payload),
);
return true;
}
// =====================================================================
// Helpers for drop targets
// =====================================================================
/** Check whether a dragover event carries our custom MIME type. */
export function hasTrackPayload(e: DragEvent): boolean {
return (
e.dataTransfer?.types.includes(DRAG_MIME) ?? false
);
}
/**
* Extract the DragPayload from a drop event.
* Returns null if the data is missing or malformed.
*/
export function getDragPayload(
e: DragEvent,
): DragPayload | null {
const raw = e.dataTransfer?.getData(DRAG_MIME);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === 'object' &&
parsed !== null &&
'filePaths' in parsed &&
Array.isArray(
(parsed as DragPayload).filePaths,
)
) {
return parsed as DragPayload;
}
return null;
} catch {
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Creates a custom drag image element showing a track count badge.
* The element is appended to the document body (required by the
* setDragImage API) and removed after the drag ends.
*/
export function createDragImage(count: number): HTMLElement {
const el = document.createElement('div');
el.textContent = `${count} track${count !== 1 ? 's' : ''}`;
el.style.cssText = [
'position: fixed',
'top: -1000px',
'left: -1000px',
'padding: 6px 14px',
'border-radius: 6px',
'background: #ffd43b',
'color: #000',
'font-size: 13px',
'font-weight: 600',
'font-family: inherit',
'white-space: nowrap',
'pointer-events: none',
'z-index: 9999',
].join(';');
document.body.appendChild(el);
return el;
}
/** Remove a drag image element created by createDragImage. */
export function removeDragImage(el: HTMLElement): void {
el.remove();
}