From 193f65bd98f63fdb8b7291c5e16402879f1902e8 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 19 Feb 2026 15:25:49 -0500 Subject: [PATCH] basic drag-and-drop, fixed end of scan behavior --- frontend/index.css | 7 + frontend/index.ts | 48 ++++ .../components/cover-grid/album-dropdown.ts | 47 ++++ .../src/components/cover-grid/cover-grid.ts | 217 +++++++++++++++++- .../library-manager/library-manager.ts | 12 +- .../playlist-picker/playlist-picker.ts | 7 +- .../components/playlist-view/playlist-view.ts | 196 +++++++++++++++- .../src/components/queue-panel/queue-panel.ts | 155 ++++++++++++- .../src/components/sidebar/app-sidebar.ts | 165 ++++++++++++- .../src/components/track-list/track-list.ts | 69 +++++- frontend/src/utils/drag-controller.ts | 117 ++++++++++ frontend/src/utils/drag-image.ts | 34 +++ 12 files changed, 1043 insertions(+), 31 deletions(-) create mode 100644 frontend/src/utils/drag-controller.ts create mode 100644 frontend/src/utils/drag-image.ts diff --git a/frontend/index.css b/frontend/index.css index b156e58..dd50ca3 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -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 { diff --git a/frontend/index.ts b/frontend/index.ts index 440e04a..c9b81c3 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -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) => { + if (!e.detail.active) { + queueButton.classList.remove('drag-over'); + } + }) as EventListener, + ); } diff --git a/frontend/src/components/cover-grid/album-dropdown.ts b/frontend/src/components/cover-grid/album-dropdown.ts index d3dfda5..2443c37 100644 --- a/frontend/src/components/cover-grid/album-dropdown.ts +++ b/frontend/src/components/cover-grid/album-dropdown.ts @@ -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( + '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`
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()} > ${displayNumber} diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index bcc095c..3a8fdc3 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -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 { + 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, + ) => { + 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} >
${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} > ${this.getAfterEntries().length > 0 diff --git a/frontend/src/components/library-manager/library-manager.ts b/frontend/src/components/library-manager/library-manager.ts index 5f4edd6..904c12a 100644 --- a/frontend/src/components/library-manager/library-manager.ts +++ b/frontend/src/components/library-manager/library-manager.ts @@ -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 { diff --git a/frontend/src/components/playlist-picker/playlist-picker.ts b/frontend/src/components/playlist-picker/playlist-picker.ts index 4bfad80..2fa4701 100644 --- a/frontend/src/components/playlist-picker/playlist-picker.ts +++ b/frontend/src/components/playlist-picker/playlist-picker.ts @@ -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() { diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index e421ec8..adda1bc 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -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` -
  • +
  • + this.onPlaylistDragOver(e, index)} + @dragleave=${(e: DragEvent) => + this.onPlaylistDragLeave(e, index)} + @drop=${(e: DragEvent) => + this.onPlaylistDrop(e, index)} + >
    @@ -1106,6 +1280,9 @@ export class PlaylistView return html`
    @@ -1129,6 +1306,17 @@ export class PlaylistView trackIndex, playlistIndex, )} + @dragstart=${( + e: DragEvent, + ) => + this.onTrackDragStart( + e, + track, + trackIndex, + playlistIndex, + )} + @dragend=${this + .onTrackDragEnd} > { + 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`
    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} > ${index + 1} @@ -713,7 +855,14 @@ export class QueuePanel const tracks = this.queue.tracks; return html` -
    +
    + Drop tracks here to add to queue +
    + ${tracks.length === 0 ? html`
    diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index 1f4edce..5bb7747 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -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} >
      - ${this.navItems.map(item => html` -
    • this.navigate(item.id)} - > -

      ${item.label}

      -
    • - `)} + ${this.navItems.map((item) => { + const classes = [ + this.activeView === item.id + ? 'active' + : '', + this.dragHoverView === item.id + ? 'drag-hover' + : '', + ] + .filter(Boolean) + .join(' '); + + return html` +
    • + this.navigate(item.id)} + @dragover=${(e: DragEvent) => + this.onNavDragOver( + e, + item.id, + )} + @dragleave=${() => + this.onNavDragLeave( + item.id, + )} + @drop=${(e: DragEvent) => + this.onNavDrop(e)} + > +

      ${item.label}

      +
    • + `; + })}
    `; } @@ -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 = + new Set(['playlists']); + + private onDragActive = ( + e: CustomEvent, + ) => { + 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', { diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 305863d..d14161a 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -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`
    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} >
    ${track.TrackName}
    ${track.ArtistName}
    diff --git a/frontend/src/utils/drag-controller.ts b/frontend/src/utils/drag-controller.ts new file mode 100644 index 0000000..f32ce33 --- /dev/null +++ b/frontend/src/utils/drag-controller.ts @@ -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( + '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; + } +} diff --git a/frontend/src/utils/drag-image.ts b/frontend/src/utils/drag-image.ts new file mode 100644 index 0000000..c1e959e --- /dev/null +++ b/frontend/src/utils/drag-image.ts @@ -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(); +}