basic drag-and-drop, fixed end of scan behavior
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user