basic drag-and-drop, fixed end of scan behavior
This commit is contained in:
@@ -116,6 +116,13 @@ body div.sidebar {
|
|||||||
#queue-button:hover {
|
#queue-button:hover {
|
||||||
color: #ffd43b;
|
color: #ffd43b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#queue-button.drag-over {
|
||||||
|
color: #ffd43b;
|
||||||
|
outline: 2px dashed #ffd43b;
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.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 { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
import { libraryStore } from '@store/library-store';
|
import { libraryStore } from '@store/library-store';
|
||||||
import { playlistStore } from '@store/playlist-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');
|
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;
|
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.
|
* 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(
|
private onTrackContextMenu(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
track: library.Track,
|
track: library.Track,
|
||||||
@@ -288,6 +327,7 @@ export class AlbumDropdown extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
|
draggable=${selected ? 'true' : 'false'}
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onTrackClick(e, track, index)}
|
this.onTrackClick(e, track, index)}
|
||||||
@dblclick=${(e: MouseEvent) =>
|
@dblclick=${(e: MouseEvent) =>
|
||||||
@@ -298,6 +338,13 @@ export class AlbumDropdown extends LitElement {
|
|||||||
)}
|
)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onTrackContextMenu(e, track)}
|
this.onTrackContextMenu(e, track)}
|
||||||
|
@dragstart=${(e: DragEvent) =>
|
||||||
|
this.onTrackDragStart(
|
||||||
|
e,
|
||||||
|
track,
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
@dragend=${() => this.onTrackDragEnd()}
|
||||||
>
|
>
|
||||||
<span class="track-number">
|
<span class="track-number">
|
||||||
${displayNumber}
|
${displayNumber}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query } from 'lit/decorators.js';
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
import { EventsOn, EventsOff } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import '@lit-labs/virtualizer';
|
import '@lit-labs/virtualizer';
|
||||||
import type {
|
import type {
|
||||||
LitVirtualizer,
|
LitVirtualizer,
|
||||||
@@ -22,7 +22,18 @@ import type {
|
|||||||
TrackClickDetail,
|
TrackClickDetail,
|
||||||
TrackDblClickDetail,
|
TrackDblClickDetail,
|
||||||
TrackContextMenuDetail,
|
TrackContextMenuDetail,
|
||||||
|
TrackDragStartDetail,
|
||||||
} from './album-dropdown.js';
|
} 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
|
* Discriminated context menu target so we know whether the
|
||||||
@@ -51,6 +62,7 @@ const ZOOM_STEP = 16;
|
|||||||
@customElement('cover-grid')
|
@customElement('cover-grid')
|
||||||
export class CoverGrid extends LitElement {
|
export class CoverGrid extends LitElement {
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
// Fixed grid spacing constants.
|
// Fixed grid spacing constants.
|
||||||
private static readonly GRID_GAP = 8;
|
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. */
|
/** Wheel event handler ref for manual add/remove. */
|
||||||
private wheelHandler = (e: WheelEvent) => {
|
private wheelHandler = (e: WheelEvent) => {
|
||||||
this.onWheel(e);
|
this.onWheel(e);
|
||||||
@@ -450,7 +474,7 @@ export class CoverGrid extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadAlbums();
|
this.loadAlbums();
|
||||||
EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
() => this.loadAlbums(),
|
() => this.loadAlbums(),
|
||||||
);
|
);
|
||||||
@@ -474,7 +498,7 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
EventsOff(Events.LibraryScanComplete);
|
this.cancelScanComplete?.();
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'click',
|
'click',
|
||||||
this.closeHandler,
|
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
|
* Track selection helpers
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -1966,6 +2055,7 @@ export class CoverGrid extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.selectedAlbums = next;
|
this.selectedAlbums = next;
|
||||||
|
void this.warmAlbumFilePathCache();
|
||||||
} else if (isCtrl) {
|
} else if (isCtrl) {
|
||||||
const next = new Set(this.selectedAlbums);
|
const next = new Set(this.selectedAlbums);
|
||||||
|
|
||||||
@@ -1977,6 +2067,7 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
this.selectedAlbums = next;
|
this.selectedAlbums = next;
|
||||||
this.lastSelectedAlbumIndex = index;
|
this.lastSelectedAlbumIndex = index;
|
||||||
|
void this.warmAlbumFilePathCache();
|
||||||
} else {
|
} else {
|
||||||
void this.toggleDropdown(album);
|
void this.toggleDropdown(album);
|
||||||
this.lastSelectedAlbumIndex = index;
|
this.lastSelectedAlbumIndex = index;
|
||||||
@@ -2028,6 +2119,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.selectedAlbums = new Set([
|
this.selectedAlbums = new Set([
|
||||||
hit.album.ID,
|
hit.album.ID,
|
||||||
]);
|
]);
|
||||||
|
void this.warmAlbumFilePathCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.contextMenuTarget = { kind: 'album' };
|
this.contextMenuTarget = { kind: 'album' };
|
||||||
@@ -2147,6 +2239,120 @@ export class CoverGrid extends LitElement {
|
|||||||
this.openContextMenuAt(clientX, clientY);
|
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)
|
* Grid click (empty area)
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -2395,6 +2601,9 @@ export class CoverGrid extends LitElement {
|
|||||||
role="button"
|
role="button"
|
||||||
data-index=${index}
|
data-index=${index}
|
||||||
aria-label="${album.Name} by ${album.ArtistName}"
|
aria-label="${album.Name} by ${album.ArtistName}"
|
||||||
|
draggable=${selected ? 'true' : 'false'}
|
||||||
|
@dragstart=${this.onAlbumDragStart}
|
||||||
|
@dragend=${this.onAlbumDragEnd}
|
||||||
>
|
>
|
||||||
<div class="cover-container">
|
<div class="cover-container">
|
||||||
${album.CoverArtPath
|
${album.CoverArtPath
|
||||||
@@ -2519,6 +2728,8 @@ export class CoverGrid extends LitElement {
|
|||||||
@track-click=${this.onTrackClick}
|
@track-click=${this.onTrackClick}
|
||||||
@track-dblclick=${this.onTrackDblClick}
|
@track-dblclick=${this.onTrackDblClick}
|
||||||
@track-contextmenu=${this.onTrackContextMenu}
|
@track-contextmenu=${this.onTrackContextMenu}
|
||||||
|
@track-dragstart=${this.onTrackDragStart}
|
||||||
|
@track-dragend=${this.onTrackDragEnd}
|
||||||
></album-dropdown>
|
></album-dropdown>
|
||||||
|
|
||||||
${this.getAfterEntries().length > 0
|
${this.getAfterEntries().length > 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
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 { Scan, FullRescan } from '@go/library/Library';
|
||||||
import {
|
import {
|
||||||
GetLibraryDirectory,
|
GetLibraryDirectory,
|
||||||
@@ -235,6 +235,8 @@ export class LibraryManager extends LitElement {
|
|||||||
@state() private metrics: ScanMetrics | null = null;
|
@state() private metrics: ScanMetrics | null = null;
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
|
private cancelScanStarted?: () => void;
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
@@ -525,11 +527,11 @@ export class LibraryManager extends LitElement {
|
|||||||
this.loadCurrentDirectory();
|
this.loadCurrentDirectory();
|
||||||
this.loadConcurrencyMode();
|
this.loadConcurrencyMode();
|
||||||
|
|
||||||
EventsOn(
|
this.cancelScanStarted = EventsOn(
|
||||||
Events.LibraryScanStarted,
|
Events.LibraryScanStarted,
|
||||||
this.handleScanStarted,
|
this.handleScanStarted,
|
||||||
);
|
);
|
||||||
EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
this.handleScanComplete,
|
this.handleScanComplete,
|
||||||
);
|
);
|
||||||
@@ -537,8 +539,8 @@ export class LibraryManager extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
EventsOff(Events.LibraryScanStarted);
|
this.cancelScanStarted?.();
|
||||||
EventsOff(Events.LibraryScanComplete);
|
this.cancelScanComplete?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCurrentDirectory(): Promise<void> {
|
private async loadCurrentDirectory(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
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/icon/icon.js';
|
||||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.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 {
|
export class PlaylistPicker extends LitElement {
|
||||||
/** File paths to add when a playlist is selected or created. */
|
/** File paths to add when a playlist is selected or created. */
|
||||||
@property({ type: Array }) filePaths: string[] = [];
|
@property({ type: Array }) filePaths: string[] = [];
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
@state() private mode: 'list' | 'create' = 'list';
|
@state() private mode: 'list' | 'create' = 'list';
|
||||||
@state() private playlists: playlist.Summary[] = [];
|
@state() private playlists: playlist.Summary[] = [];
|
||||||
@@ -134,7 +135,7 @@ export class PlaylistPicker extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadPlaylists();
|
this.loadPlaylists();
|
||||||
EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
() => this.loadPlaylists(),
|
() => this.loadPlaylists(),
|
||||||
);
|
);
|
||||||
@@ -142,7 +143,7 @@ export class PlaylistPicker extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
EventsOff(Events.LibraryScanComplete);
|
this.cancelScanComplete?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadPlaylists() {
|
private async loadPlaylists() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query } from 'lit/decorators.js';
|
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/icon/icon.js';
|
||||||
import '@awesome.me/webawesome/dist/components/popup/popup.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 {
|
import {
|
||||||
CreatePlaylist,
|
CreatePlaylist,
|
||||||
|
AddTracksToPlaylist,
|
||||||
RemoveTracksFromPlaylist,
|
RemoveTracksFromPlaylist,
|
||||||
} from '@go/playlist/Service';
|
} from '@go/playlist/Service';
|
||||||
import { Events } from '../../events';
|
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 type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } 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;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
@@ -37,6 +48,7 @@ export class PlaylistView
|
|||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
private playlistCtrl = new PlaylistController(this);
|
private playlistCtrl = new PlaylistController(this);
|
||||||
private selection = new SelectionController(this);
|
private selection = new SelectionController(this);
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
private scrollDebounceTimer: ReturnType<
|
private scrollDebounceTimer: ReturnType<
|
||||||
typeof setTimeout
|
typeof setTimeout
|
||||||
> | null = null;
|
> | null = null;
|
||||||
@@ -54,6 +66,11 @@ export class PlaylistView
|
|||||||
@state() private contextMenuOpen = false;
|
@state() private contextMenuOpen = false;
|
||||||
@state() private playlistSubmenuOpen = 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')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: HTMLElement;
|
private contextMenuPopup!: HTMLElement;
|
||||||
|
|
||||||
@@ -107,6 +124,10 @@ export class PlaylistView
|
|||||||
return entry?.tracks.length ?? 0;
|
return entry?.tracks.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSelectionChanged(): void {
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the selected playlist track IDs (database IDs)
|
* Return the selected playlist track IDs (database IDs)
|
||||||
* in order, for removal operations.
|
* in order, for removal operations.
|
||||||
@@ -283,6 +304,12 @@ export class PlaylistView
|
|||||||
background-color: rgba(255, 255, 255, 0.05);
|
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 {
|
.chevron {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #888;
|
color: #888;
|
||||||
@@ -448,7 +475,7 @@ export class PlaylistView
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadPlaylists();
|
this.loadPlaylists();
|
||||||
EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
() => this.loadPlaylists(),
|
() => this.loadPlaylists(),
|
||||||
);
|
);
|
||||||
@@ -468,7 +495,7 @@ export class PlaylistView
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
EventsOff(Events.LibraryScanComplete);
|
this.cancelScanComplete?.();
|
||||||
|
|
||||||
if (this.scrollDebounceTimer !== null) {
|
if (this.scrollDebounceTimer !== null) {
|
||||||
clearTimeout(this.scrollDebounceTimer);
|
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) {
|
private closeContextMenu(clearSelection = false) {
|
||||||
if (!this.contextMenuOpen) return;
|
if (!this.contextMenuOpen) return;
|
||||||
|
|
||||||
@@ -1019,9 +1181,21 @@ export class PlaylistView
|
|||||||
) {
|
) {
|
||||||
const trackCount = entry.tracks.length;
|
const trackCount = entry.tracks.length;
|
||||||
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
||||||
|
const isDragOver =
|
||||||
|
this.dragOverPlaylistIndex === index;
|
||||||
|
|
||||||
return html`
|
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
|
<div
|
||||||
class="playlist-header"
|
class="playlist-header"
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
@@ -1106,6 +1280,9 @@ export class PlaylistView
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
|
draggable=${selected
|
||||||
|
? 'true'
|
||||||
|
: 'false'}
|
||||||
@click=${(
|
@click=${(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) =>
|
) =>
|
||||||
@@ -1129,6 +1306,17 @@ export class PlaylistView
|
|||||||
trackIndex,
|
trackIndex,
|
||||||
playlistIndex,
|
playlistIndex,
|
||||||
)}
|
)}
|
||||||
|
@dragstart=${(
|
||||||
|
e: DragEvent,
|
||||||
|
) =>
|
||||||
|
this.onTrackDragStart(
|
||||||
|
e,
|
||||||
|
track,
|
||||||
|
trackIndex,
|
||||||
|
playlistIndex,
|
||||||
|
)}
|
||||||
|
@dragend=${this
|
||||||
|
.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<track-info
|
<track-info
|
||||||
.trackTitle=${track.Title}
|
.trackTitle=${track.Title}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
|||||||
import type { QueueTrack } from '@store/queue-store';
|
import type { QueueTrack } from '@store/queue-store';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } 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 MIN_WIDTH = 200;
|
||||||
const MAX_WIDTH = 500;
|
const MAX_WIDTH = 500;
|
||||||
@@ -45,6 +55,11 @@ export class QueuePanel
|
|||||||
@state()
|
@state()
|
||||||
private playlistSubmenuOpen = false;
|
private playlistSubmenuOpen = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private dragOver = false;
|
||||||
|
|
||||||
|
private dragImageEl: HTMLElement | null = null;
|
||||||
|
|
||||||
@query('#add-to-playlist-popup')
|
@query('#add-to-playlist-popup')
|
||||||
private addToPlaylistPopup!: HTMLElement;
|
private addToPlaylistPopup!: HTMLElement;
|
||||||
|
|
||||||
@@ -292,6 +307,25 @@ export class QueuePanel
|
|||||||
color: #ff6b6b;
|
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 {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -607,6 +641,110 @@ export class QueuePanel
|
|||||||
this.closeContextMenu(true);
|
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
|
// Other handlers
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -679,12 +817,16 @@ export class QueuePanel
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
|
draggable=${selected ? 'true' : 'false'}
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.handleTrackClick(e, track, index)}
|
this.handleTrackClick(e, track, index)}
|
||||||
@dblclick=${() =>
|
@dblclick=${() =>
|
||||||
this.handleTrackDblClick(index)}
|
this.handleTrackDblClick(index)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.handleTrackContextMenu(e, index)}
|
this.handleTrackContextMenu(e, index)}
|
||||||
|
@dragstart=${(e: DragEvent) =>
|
||||||
|
this.onTrackDragStart(e, index)}
|
||||||
|
@dragend=${this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<span class="track-position">
|
<span class="track-position">
|
||||||
${index + 1}
|
${index + 1}
|
||||||
@@ -713,7 +855,14 @@ export class QueuePanel
|
|||||||
const tracks = this.queue.tracks;
|
const tracks = this.queue.tracks;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="panel-content">
|
<div
|
||||||
|
class="panel-content ${this.dragOver
|
||||||
|
? 'drag-over'
|
||||||
|
: ''}"
|
||||||
|
@dragover=${this.onPanelDragOver}
|
||||||
|
@dragleave=${this.onPanelDragLeave}
|
||||||
|
@drop=${this.onPanelDrop}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="resize-handle ${this.isDragging
|
class="resize-handle ${this.isDragging
|
||||||
? 'dragging'
|
? 'dragging'
|
||||||
@@ -752,6 +901,10 @@ export class QueuePanel
|
|||||||
: nothing}
|
: nothing}
|
||||||
</wa-popup>
|
</wa-popup>
|
||||||
|
|
||||||
|
<div class="drop-indicator">
|
||||||
|
Drop tracks here to add to queue
|
||||||
|
</div>
|
||||||
|
|
||||||
${tracks.length === 0
|
${tracks.length === 0
|
||||||
? html`
|
? html`
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { LitElement, html, css } from 'lit';
|
import { LitElement, html, css } from 'lit';
|
||||||
import { customElement, state } from 'lit/decorators.js';
|
import { customElement, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||||
|
|
||||||
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
|
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -69,14 +71,35 @@ export class AppSidebar extends LitElement {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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()
|
@state()
|
||||||
private activeView: View = 'tracks';
|
private activeView: View = 'tracks';
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private isDragging = false;
|
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[] = [
|
private navItems: NavItem[] = [
|
||||||
{ id: 'home', label: 'Home' },
|
{ id: 'home', label: 'Home' },
|
||||||
{ id: 'libraries', label: 'Libraries' },
|
{ id: 'libraries', label: 'Libraries' },
|
||||||
@@ -89,14 +112,35 @@ export class AppSidebar extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.style.width = `${DEFAULT_WIDTH}px`;
|
this.style.width = `${DEFAULT_WIDTH}px`;
|
||||||
document.addEventListener('mousemove', this.handleMouseMove);
|
document.addEventListener(
|
||||||
document.addEventListener('mouseup', this.handleMouseUp);
|
'mousemove',
|
||||||
|
this.handleMouseMove,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'mouseup',
|
||||||
|
this.handleMouseUp,
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'yj-drag-active',
|
||||||
|
this.onDragActive as EventListener,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove);
|
document.removeEventListener(
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
'mousemove',
|
||||||
|
this.handleMouseMove,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'mouseup',
|
||||||
|
this.handleMouseUp,
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'yj-drag-active',
|
||||||
|
this.onDragActive as EventListener,
|
||||||
|
);
|
||||||
|
this.clearDragHoverTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -106,14 +150,39 @@ export class AppSidebar extends LitElement {
|
|||||||
@mousedown=${this.handleMouseDown}
|
@mousedown=${this.handleMouseDown}
|
||||||
></div>
|
></div>
|
||||||
<ul>
|
<ul>
|
||||||
${this.navItems.map(item => html`
|
${this.navItems.map((item) => {
|
||||||
<li
|
const classes = [
|
||||||
class="${this.activeView === item.id ? 'active' : ''}"
|
this.activeView === item.id
|
||||||
@click=${() => this.navigate(item.id)}
|
? 'active'
|
||||||
>
|
: '',
|
||||||
<p>${item.label}</p>
|
this.dragHoverView === item.id
|
||||||
</li>
|
? '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>
|
</ul>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -137,6 +206,78 @@ export class AppSidebar extends LitElement {
|
|||||||
this.isDragging = false;
|
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) {
|
private navigate(view: View) {
|
||||||
this.activeView = view;
|
this.activeView = view;
|
||||||
this.dispatchEvent(new CustomEvent('navigate', {
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { library } from '@go/models';
|
import { library } from '@go/models';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query } from 'lit/decorators.js';
|
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 { formatMilliseconds } from '@utils/time';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } 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 { queueStore } from '@store/queue-store';
|
||||||
import { LibraryController } from '@store/controllers/library-controller';
|
import { LibraryController } from '@store/controllers/library-controller';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
import {
|
||||||
|
setDragPayload,
|
||||||
|
emitDragActive,
|
||||||
|
} from '@utils/drag-controller';
|
||||||
|
import {
|
||||||
|
createDragImage,
|
||||||
|
removeDragImage,
|
||||||
|
} from '@utils/drag-image';
|
||||||
import '@lit-labs/virtualizer';
|
import '@lit-labs/virtualizer';
|
||||||
import type {
|
import type {
|
||||||
LitVirtualizer,
|
LitVirtualizer,
|
||||||
@@ -31,6 +39,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
private selection = new SelectionController(this);
|
private selection = new SelectionController(this);
|
||||||
|
private cancelScanComplete?: () => void;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private tracks: library.Track[] = [];
|
private tracks: library.Track[] = [];
|
||||||
@@ -68,6 +77,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private dragImageEl: HTMLElement | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private columnWidths: number[] = [];
|
private columnWidths: number[] = [];
|
||||||
|
|
||||||
@@ -389,7 +400,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadTracks();
|
this.loadTracks();
|
||||||
EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
() => this.loadTracks(),
|
() => this.loadTracks(),
|
||||||
);
|
);
|
||||||
@@ -413,7 +424,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
);
|
);
|
||||||
this.hasRestoredScroll = false;
|
this.hasRestoredScroll = false;
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
EventsOff(Events.LibraryScanComplete);
|
this.cancelScanComplete?.();
|
||||||
document.removeEventListener('click', this.closeHandler);
|
document.removeEventListener('click', this.closeHandler);
|
||||||
document.removeEventListener('contextmenu', this.closeHandler);
|
document.removeEventListener('contextmenu', this.closeHandler);
|
||||||
document.removeEventListener('click', this.clearSelectionHandler);
|
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) {
|
private onContextMenuAction(action: string) {
|
||||||
const filePaths = this.selection.getSelectedKeysOrdered();
|
const filePaths = this.selection.getSelectedKeysOrdered();
|
||||||
|
|
||||||
@@ -671,11 +730,15 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
|
draggable=${selected ? 'true' : 'false'}
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onTrackRowClick(e, track, index)}
|
this.onTrackRowClick(e, track, index)}
|
||||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
@dblclick=${() => this.onTrackRowDblClick(track)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onTrackContextMenu(e, track)}
|
this.onTrackContextMenu(e, track)}
|
||||||
|
@dragstart=${(e: DragEvent) =>
|
||||||
|
this.onTrackDragStart(e, track)}
|
||||||
|
@dragend=${this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<div class="track-name">${track.TrackName}</div>
|
<div class="track-name">${track.TrackName}</div>
|
||||||
<div class="artist-name">${track.ArtistName}</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