Files
yellowjacket/frontend/src/components/playlist-view/playlist-view.ts
T

1341 lines
38 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import {
CreatePlaylist,
AddTracksToPlaylist,
RemoveTracksFromPlaylist,
} from '@go/playlist/Service';
import { Events } from '../../events';
import type { playlist } from '@go/models';
import { queueStore } from '@store/queue-store';
import { PlayerController } from '@store/controllers/player-controller';
import { PlaylistController } from '@store/controllers/playlist-controller';
import '@components/track-info/track-info';
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;
interface PlaylistEntry {
summary: playlist.Summary;
expanded: boolean;
tracks: playlist.Track[];
}
@customElement('playlist-view')
export class PlaylistView
extends LitElement
implements SelectionHost
{
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;
/**
* Index of the playlist whose tracks are currently
* selectable. -1 means no active selection scope.
*/
private activePlaylistIndex = -1;
@state() private entries: PlaylistEntry[] = [];
@state() private loading = true;
@state() private creating = false;
@state() private newPlaylistName = '';
@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;
@query('#playlist-submenu')
private playlistSubmenuPopup!: HTMLElement;
private closeContextMenuHandler = () =>
this.closeContextMenu();
private clearSelectionHandler = (e: MouseEvent) => {
const path = e.composedPath();
const isTrackClick = path.some(
(el) =>
el instanceof HTMLElement &&
el.classList.contains('track-item') &&
this.shadowRoot?.contains(el),
);
if (!isTrackClick) {
this.selection.clear();
}
};
// =================================================================
// SelectionHost interface
// =================================================================
getItemKey(index: number): string | undefined {
if (this.activePlaylistIndex < 0) return undefined;
const entry =
this.entries[this.activePlaylistIndex];
if (
!entry ||
index < 0 ||
index >= entry.tracks.length
) {
return undefined;
}
return String(index);
}
getItemCount(): number {
if (this.activePlaylistIndex < 0) return 0;
const entry =
this.entries[this.activePlaylistIndex];
return entry?.tracks.length ?? 0;
}
onSelectionChanged(): void {
this.requestUpdate();
}
/**
* Return the selected playlist track IDs (database IDs)
* in order, for removal operations.
*/
private getSelectedTrackIDs(): number[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.ID);
}
/**
* Derive file paths from selected indices for
* operations that need file paths.
*/
private getSelectedFilePaths(): string[] {
if (this.activePlaylistIndex < 0) return [];
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return [];
return this.selection
.getSelectedIndices()
.map((i) => entry.tracks[i]!.FilePath);
}
/**
* Ensure the selection scope matches the given playlist
* index. If switching playlists, clear the old selection.
*/
private ensureSelectionScope(
playlistIndex: number,
): void {
if (
this.activePlaylistIndex !== playlistIndex
) {
this.selection.clear();
this.activePlaylistIndex = playlistIndex;
}
}
static override styles = css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.new-playlist-button {
background: none;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-family: inherit;
}
.new-playlist-button:hover {
border-color: #ffd43b;
color: #ffd43b;
}
.create-form {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.create-form input {
flex: 1;
background: #2a2d30;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
padding: 6px 10px;
font-size: 13px;
outline: none;
font-family: inherit;
}
.create-form input:focus {
border-color: #ffd43b;
}
.create-form input::placeholder {
color: #888;
}
.create-form button {
background: #495057;
border: none;
border-radius: 4px;
color: #fff;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.create-form button:hover {
background: #5a6268;
}
.create-form button.primary {
background: #ffd43b;
color: #000;
}
.create-form button.primary:hover {
background: #ffe066;
}
.create-form button.primary:disabled {
background: #665a1e;
color: #888;
cursor: not-allowed;
}
.playlist-list {
flex: 1;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
}
.playlist-item {
border-bottom: 1px solid
rgba(255, 255, 255, 0.05);
}
.playlist-header {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 10px;
cursor: pointer;
user-select: none;
}
.playlist-header:hover {
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;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.chevron.expanded {
transform: rotate(90deg);
}
.playlist-icon {
font-size: 18px;
color: #888;
flex-shrink: 0;
}
.playlist-name {
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.track-count {
font-size: 11px;
color: #666;
flex-shrink: 0;
}
.playlist-body {
padding: 0 16px 12px 42px;
}
.playlist-actions {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.play-all-button {
background: none;
border: 1px solid #555;
border-radius: 4px;
color: #fff;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
font-family: inherit;
}
.play-all-button:hover {
border-color: #ffd43b;
color: #ffd43b;
}
.track-item {
padding: 6px 0;
border-bottom: 1px solid
rgba(255, 255, 255, 0.03);
cursor: default;
user-select: none;
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.track-item.selected {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
color: #ffd43b;
}
.track-item.selected.active {
background-color: rgba(100, 160, 255, 0.15);
}
.track-item:last-child {
border-bottom: none;
}
.tracks-empty {
padding: 12px 0;
color: #666;
font-size: 12px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: #b3b3b3;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: #b3b3b3;
text-align: center;
gap: 8px;
}
.empty-state wa-icon {
font-size: 32px;
}
.empty-state p {
margin: 4px 0;
}
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: #343a40;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: #fff;
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.submenu-item {
position: relative;
}
.submenu-arrow {
font-size: 10px;
margin-left: auto;
padding-left: 12px;
}
#playlist-submenu {
z-index: 210;
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadPlaylists();
this.cancelScanComplete = EventsOn(
Events.LibraryScanComplete,
() => this.loadPlaylists(),
);
document.addEventListener(
'click',
this.closeContextMenuHandler,
);
document.addEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.addEventListener(
'click',
this.clearSelectionHandler,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cancelScanComplete?.();
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
this.scrollDebounceTimer = null;
}
document.removeEventListener(
'click',
this.closeContextMenuHandler,
);
document.removeEventListener(
'contextmenu',
this.closeContextMenuHandler,
);
document.removeEventListener(
'click',
this.clearSelectionHandler,
);
}
private get scrollContainer(): HTMLElement | null {
return (
this.shadowRoot?.querySelector(
'.playlist-list',
) ?? null
);
}
private restoreScrollPosition() {
const saved =
this.playlistCtrl.getScrollPosition();
if (saved > 0 && this.scrollContainer) {
this.scrollContainer.scrollTop = saved;
}
}
private onScroll = () => {
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
this.scrollDebounceTimer = setTimeout(() => {
if (this.scrollContainer) {
this.playlistCtrl.setScrollPosition(
this.scrollContainer.scrollTop,
);
}
}, SCROLL_DEBOUNCE_MS);
};
private async loadPlaylists() {
try {
this.loading = true;
const playlists =
await this.playlistCtrl.getPlaylists();
this.entries = playlists.map((p) => ({
summary: p.Summary,
expanded: false,
tracks: p.Tracks ?? [],
}));
} catch (err) {
console.error(
'Failed to load playlists:',
err,
);
this.entries = [];
} finally {
this.loading = false;
}
await this.updateComplete;
this.restoreScrollPosition();
}
private handleToggle = (index: number) => {
const entry = this.entries[index];
if (!entry) return;
// If collapsing the active playlist, clear selection.
if (
entry.expanded &&
this.activePlaylistIndex === index
) {
this.selection.clear();
this.activePlaylistIndex = -1;
}
this.entries = this.entries.map((e, i) =>
i === index
? { ...e, expanded: !e.expanded }
: e,
);
};
private handlePlayAll = (index: number) => {
const entry = this.entries[index];
if (!entry || entry.tracks.length === 0) return;
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, 0);
};
// =================================================================
// Track selection & context menu
// =================================================================
private handleTrackClick(
e: MouseEvent,
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
this.ensureSelectionScope(playlistIndex);
this.selection.handleItemClick(
e,
String(trackIndex),
trackIndex,
);
}
private handleTrackDblClick(
_track: playlist.Track,
trackIndex: number,
playlistIndex: number,
) {
const entry = this.entries[playlistIndex];
if (!entry) return;
this.selection.clear();
const filePaths = entry.tracks.map(
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, trackIndex);
}
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
playlistIndex: number,
) {
e.preventDefault();
e.stopPropagation();
this.ensureSelectionScope(playlistIndex);
this.selection.handleContextMenu(
String(trackIndex),
);
this.contextMenuOpen = true;
// Position at mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
}
private onContextMenuAction(action: string) {
const filePaths =
this.getSelectedFilePaths();
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
break;
case 'play-next':
queueStore.playTracksNext(filePaths);
break;
case 'remove':
void this.removeSelectedTracks();
break;
}
this.closeContextMenu(true);
}
private async removeSelectedTracks() {
if (this.activePlaylistIndex < 0) return;
const entry =
this.entries[this.activePlaylistIndex];
if (!entry) return;
const trackIDs = this.getSelectedTrackIDs();
if (trackIDs.length === 0) return;
try {
await RemoveTracksFromPlaylist(
entry.summary.ID,
trackIDs,
);
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error(
'Failed to remove tracks:',
err,
);
}
}
// =================================================================
// 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;
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
if (clearSelection) {
this.selection.clear();
}
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private async showPlaylistSubmenu() {
if (this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = true;
await this.updateComplete;
const submenu = this.playlistSubmenuPopup;
const trigger =
this.shadowRoot?.querySelector(
'.submenu-item',
);
if (submenu && trigger) {
(submenu as any).anchor = trigger;
(submenu as any).active = true;
}
const picker = this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
private closePlaylistSubmenu() {
if (!this.playlistSubmenuOpen) return;
this.playlistSubmenuOpen = false;
const submenu = this.playlistSubmenuPopup;
if (submenu) {
(submenu as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closeContextMenu(true);
};
private isActiveTrack(
track: playlist.Track,
): boolean {
const currentTrack = this.player.currentTrack;
if (!currentTrack) return false;
return currentTrack.filePath === track.FilePath;
}
// =================================================================
// Create playlist
// =================================================================
private handleNewPlaylistClick = () => {
this.creating = true;
this.newPlaylistName = '';
void this.updateComplete.then(() => {
const input =
this.shadowRoot?.querySelector<HTMLInputElement>(
'.create-form input',
);
input?.focus();
});
};
private handleCancelCreate = () => {
this.creating = false;
this.newPlaylistName = '';
};
private handleCreatePlaylist = async () => {
const name = this.newPlaylistName.trim();
if (!name) return;
try {
await CreatePlaylist(name);
this.creating = false;
this.newPlaylistName = '';
this.playlistCtrl.invalidate();
await this.loadPlaylists();
} catch (err) {
console.error(
'Failed to create playlist:',
err,
);
}
};
private handleInputChange = (e: Event) => {
const input = e.target as HTMLInputElement;
this.newPlaylistName = input.value;
};
private handleInputKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
void this.handleCreatePlaylist();
} else if (e.key === 'Escape') {
this.handleCancelCreate();
}
};
// =================================================================
// Render
// =================================================================
override render() {
return html`
<div class="header">
<h2>Playlists</h2>
<button
class="new-playlist-button"
@click=${this.handleNewPlaylistClick}
>
<wa-icon name="plus"></wa-icon>
New Playlist
</button>
</div>
${this.creating
? this.renderCreateForm()
: nothing}
${this.loading
? html`<div class="loading">
Loading playlists...
</div>`
: this.renderPlaylistList()}
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() =>
this.showPlaylistSubmenu()}
@click=${(e: Event) => {
e.stopPropagation();
void this.showPlaylistSubmenu();
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
this.selection.hasSelection
? html`
<playlist-picker
.filePaths=${this.getSelectedFilePaths()}
@playlist-action-complete=${this
.onPlaylistActionComplete}
@click=${(e: Event) =>
e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
`;
}
private renderCreateForm() {
const canCreate =
this.newPlaylistName.trim().length > 0;
return html`
<div class="create-form">
<input
type="text"
placeholder="Playlist name"
.value=${this.newPlaylistName}
@input=${this.handleInputChange}
@keydown=${this.handleInputKeydown}
/>
<button
@click=${this.handleCancelCreate}
>
Cancel
</button>
<button
class="primary"
?disabled=${!canCreate}
@click=${this.handleCreatePlaylist}
>
Create
</button>
</div>
`;
}
private renderPlaylistList() {
if (this.entries.length === 0) {
return html`
<div class="empty-state">
<wa-icon name="list"></wa-icon>
<p>No playlists yet</p>
<p style="font-size: 12px;">
Create a playlist to get started.
</p>
</div>
`;
}
return html`
<ul
class="playlist-list"
@scroll=${this.onScroll}
>
${this.entries.map((entry, i) =>
this.renderPlaylistItem(entry, i),
)}
</ul>
`;
}
private renderPlaylistItem(
entry: PlaylistEntry,
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
return html`
<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=${() =>
this.handleToggle(index)}
>
<wa-icon
class="chevron ${entry.expanded
? 'expanded'
: ''}"
name="chevron-right"
></wa-icon>
<wa-icon
class="playlist-icon"
name="list"
></wa-icon>
<span class="playlist-name">
${entry.summary.Name}
</span>
<span class="track-count">
${countLabel}
</span>
</div>
${entry.expanded
? this.renderPlaylistBody(
entry,
index,
)
: nothing}
</li>
`;
}
private renderPlaylistBody(
entry: PlaylistEntry,
playlistIndex: number,
) {
if (entry.tracks.length === 0) {
return html`
<div class="playlist-body">
<div class="tracks-empty">
This playlist is empty.
</div>
</div>
`;
}
return html`
<div class="playlist-body">
<div class="playlist-actions">
<button
class="play-all-button"
@click=${(e: Event) => {
e.stopPropagation();
this.handlePlayAll(
playlistIndex,
);
}}
>
<wa-icon name="play"></wa-icon>
Play All
</button>
</div>
${entry.tracks.map(
(track, trackIndex) => {
const active =
this.isActiveTrack(track);
const selected =
this.activePlaylistIndex ===
playlistIndex &&
this.selection.isSelected(
String(trackIndex),
);
const classes = [
'track-item',
active ? 'active' : '',
selected ? 'selected' : '',
]
.filter(Boolean)
.join(' ');
return html`
<div
class=${classes}
draggable=${selected
? 'true'
: 'false'}
@click=${(
e: MouseEvent,
) =>
this.handleTrackClick(
e,
track,
trackIndex,
playlistIndex,
)}
@dblclick=${() =>
this.handleTrackDblClick(
track,
trackIndex,
playlistIndex,
)}
@contextmenu=${(
e: MouseEvent,
) =>
this.handleTrackContextMenu(
e,
trackIndex,
playlistIndex,
)}
@dragstart=${(
e: DragEvent,
) =>
this.onTrackDragStart(
e,
track,
trackIndex,
playlistIndex,
)}
@dragend=${this
.onTrackDragEnd}
>
<track-info
.trackTitle=${track.Title}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
</div>
`;
},
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'playlist-view': PlaylistView;
}
}