`plus` meant "add to the queue", "add to a playlist", "make a new playlist" and "you do not own this" -- the first two adjacent in the same context menu, so two neighbouring items were the same glyph doing different things. `list` meant the queue (the button that opens it), the Playlists destination, and adding to the queue in `queue-panel` alone. Two icons carrying seven meanings is not a vocabulary, and nothing catches it: a wrong-but-real icon renders perfectly. `utils/icon-language.ts` is the table, beside `library-status.ts` as the issue suggested. The rule it is built on is that an icon names the **noun** it acts on, not the verb: "add to queue" and "add to playlist" are one verb on two nouns, so the noun is what differs -- which is why adding to a playlist wears the Playlists destination's own icon, and why the queue took `bars-staggered` and stopped wearing Playlists'. `plus` keeps the one meaning it is unambiguous about, making something that is not there yet, which covers New Playlist and the drop zones. `bars-staggered` is the only new glyph, vendored through names.txt and fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1. Two things this found rather than changed: - The request toggle's outline/solid pair was already in the app and already right -- `explore-album-details`'s "Request this" button has used `regular/bookmark` -> `solid/bookmark` since it was written -- while the badge forty pixels away showed a **plus** for the same state. That is `utils/library-status.ts`'s fault one layer down: it made the two surfaces agree on what wanting *means* and left them disagreeing on what it looks like. - `explore-artist-details`'s Follow button was `bookmark-check`, which is Font Awesome **Pro** and has never been bundled, so it has drawn the missing-icon fallback -- a circled question mark -- for every followed artist since it was written. `requested-badge.spec.ts` was written for exactly this bug on the album button and says so in its docstring; this is the same bug one component over, still live, because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec had ever followed an artist. So the test does what reaching the state cannot. `icon-language.test.ts` reads every `src/**/*.ts` as raw text and fails on a governed name written outside the table, and separately asserts every `ICON_*` is a *bundled* name -- which is what makes a Pro name a failing test rather than a runtime report from a state something has to reach first. Its first assertion is that it read any source at all, because a sweep over an empty glob passes. `chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned the badge's glyphs against the vocabulary they were meant to follow; it names them from the table now, and keeps the assertion that the three differ, which is the property the states actually need. Downloads keeps the solid bookmark on purpose. That is one word twice, not two words: the badge says the entity is on your list and the nav item is that list. Closes #34
1815 lines
60 KiB
TypeScript
1815 lines
60 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import {
|
|
customElement,
|
|
property,
|
|
state,
|
|
query,
|
|
} from 'lit/decorators.js';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
|
|
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
|
import '@lit-labs/virtualizer';
|
|
import type { LitVirtualizer } from '@lit-labs/virtualizer';
|
|
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
|
|
|
|
import {
|
|
GetPlaylistTracks,
|
|
AddTracksToPlaylist,
|
|
RemoveTracksFromPlaylist,
|
|
RemovePhantomTracks,
|
|
FindDuplicateTracksInPlaylist,
|
|
} from '@go/playlist/service.js';
|
|
import type * as playlist from '@go/playlist/models.js';
|
|
import { EventsOn } from '@runtime/runtime';
|
|
import { Events } from '../../events';
|
|
import { queueStore } from '@store/queue-store';
|
|
import { creditStore } from '@store/credit-store';
|
|
import { PlayerController } from '@store/controllers/player-controller';
|
|
import { SearchController } from '@store/controllers/search-controller';
|
|
import { SelectionController } from '@utils/selection-controller';
|
|
import type { SelectionHost } from '@utils/selection-controller';
|
|
import {
|
|
ContextMenuController,
|
|
contextMenuStyles,
|
|
isContextMenuKey,
|
|
} from '@utils/context-menu-controller.js';
|
|
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
|
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
|
|
import { FavoritesController } from '@store/controllers/favorites-controller';
|
|
import { notificationStore } from '@store/notification-store';
|
|
import { describeError } from '@utils/describe-error';
|
|
import {
|
|
hasTrackPayload,
|
|
getDragPayload,
|
|
setDragPayload,
|
|
emitDragActive,
|
|
} from '@utils/drag-controller';
|
|
import {
|
|
createDragImage,
|
|
createTrackCardDragImage,
|
|
removeDragImage,
|
|
} from '@utils/drag-image';
|
|
import { libraryStore } from '@store/library-store';
|
|
import '@components/playlist-picker/playlist-picker.js';
|
|
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
|
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
|
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
import '@components/phantom-resolver/phantom-resolver.js';
|
|
import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
|
|
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
|
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
|
|
import { formatMilliseconds } from '@utils/time';
|
|
import {
|
|
creditLink,
|
|
creditText,
|
|
albumLink,
|
|
trackLink,
|
|
exploreLinkStyles,
|
|
} from '@utils/explore-link';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
import { list } from '@utils/binding';
|
|
import {
|
|
ICON_PLAYLIST,
|
|
ICON_QUEUE,
|
|
} from '@utils/icon-language';
|
|
|
|
/** One playlist row: the track and its position in the *playlist*,
|
|
* which is not its position in the filtered view. */
|
|
interface VisibleTrack {
|
|
track: playlist.Track;
|
|
trackIndex: number;
|
|
}
|
|
|
|
@customElement('playlist-details')
|
|
export class PlaylistDetails
|
|
extends LitElement
|
|
implements SelectionHost, ContextMenuHost
|
|
{
|
|
@property({ type: Number, attribute: 'playlist-id' })
|
|
playlistId = 0;
|
|
|
|
@property({ type: String, attribute: 'playlist-name' })
|
|
playlistName = '';
|
|
|
|
@state()
|
|
private tracks: playlist.Track[] = [];
|
|
|
|
@state()
|
|
private loading = true;
|
|
|
|
private player = new PlayerController(this);
|
|
private searchCtrl = new SearchController(this);
|
|
private selection = new SelectionController(this);
|
|
private ctxMenu = new ContextMenuController(this);
|
|
private favCtrl = new FavoritesController(this);
|
|
|
|
private tracksChangedCleanup: (() => void) | null = null;
|
|
private playlistDeletedCleanup: (() => void) | null = null;
|
|
|
|
/* `_itemSize` matches the fixed 45 px `.track-item` height, measured
|
|
* rather than guessed. Without the hint the flow layout's 100 px
|
|
* default drives constant scroll-error correction, which reads as
|
|
* the list jumping under the pointer. */
|
|
/** Unsubscribes the credit-arrival repaint. */
|
|
private creditsUnsub?: () => void;
|
|
|
|
@query('lit-virtualizer')
|
|
private virtualizer?: LitVirtualizer;
|
|
|
|
/* The row templates live inside the virtualizer, not in this
|
|
* component's own template, so a host re-render alone does not
|
|
* repaint them: the virtualizer re-renders when its `items` change,
|
|
* and `items` is now memoised precisely so it does not change when
|
|
* nothing has. Selection and the playing-track highlight are
|
|
* therefore pushed to it explicitly, which is what `track-list` has
|
|
* always done. Costing ~36 rows, not the whole playlist. */
|
|
private lastActiveTrackPath: string | null = null;
|
|
|
|
private flowLayout = flow({
|
|
_itemSize: { width: 100, height: 45 },
|
|
} as Parameters<typeof flow>[0]);
|
|
|
|
/* The memo behind `getVisibleTracks()`. Its wrappers used to be
|
|
* rebuilt on every render, so the array identity changed even when
|
|
* nothing had — which is the one thing a virtualizer keys its work
|
|
* on (perf.M5). */
|
|
private visibleCache: VisibleTrack[] | null = null;
|
|
private visibleCacheKey: {
|
|
tracks: playlist.Track[];
|
|
term: string;
|
|
} | null = null;
|
|
|
|
private dragImageEl: HTMLElement | null = null;
|
|
|
|
@query('#context-menu')
|
|
private contextMenuPopup!: WaPopup;
|
|
|
|
@query('#playlist-submenu')
|
|
private playlistSubmenuPopup!: WaPopup;
|
|
|
|
@query('track-details')
|
|
private trackDetailsDialog!: TrackDetails;
|
|
|
|
@query('phantom-resolver')
|
|
private phantomResolver!: PhantomResolver;
|
|
|
|
@query('duplicate-tracks-dialog')
|
|
private duplicateDialog!: DuplicateTracksDialog;
|
|
|
|
// =================================================================
|
|
// ContextMenuHost interface
|
|
// =================================================================
|
|
|
|
getContextMenuPopup(): WaPopup | undefined {
|
|
return this.contextMenuPopup;
|
|
}
|
|
|
|
getPlaylistSubmenuPopup(): WaPopup | undefined {
|
|
return this.playlistSubmenuPopup;
|
|
}
|
|
|
|
onContextMenuClose(): void {
|
|
// No-op.
|
|
}
|
|
|
|
// =================================================================
|
|
// SelectionHost interface
|
|
// =================================================================
|
|
|
|
getItemKey(index: number): string | undefined {
|
|
if (index < 0 || index >= this.tracks.length) {
|
|
return undefined;
|
|
}
|
|
|
|
return String(index);
|
|
}
|
|
|
|
getItemCount(): number {
|
|
return this.tracks.length;
|
|
}
|
|
|
|
onSelectionChanged(): void {
|
|
this.requestUpdate();
|
|
this.virtualizer?.requestUpdate();
|
|
}
|
|
|
|
protected override updated(
|
|
changed: Map<string, unknown>,
|
|
): void {
|
|
super.updated(changed);
|
|
|
|
const currentPath =
|
|
this.player.currentTrack?.filePath ?? null;
|
|
|
|
if (currentPath !== this.lastActiveTrackPath) {
|
|
this.lastActiveTrackPath = currentPath;
|
|
this.virtualizer?.requestUpdate();
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Lifecycle
|
|
// =================================================================
|
|
|
|
private handleSelectAll = (): void => {
|
|
this.selection.selectAll();
|
|
};
|
|
|
|
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();
|
|
}
|
|
};
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// Credits arrive after the rows that asked for them, and a
|
|
// virtualizer repaints from its *own* properties — a host
|
|
// update alone leaves the rows exactly as they were.
|
|
this.creditsUnsub = creditStore.subscribe(() => {
|
|
this.requestUpdate();
|
|
this.virtualizer?.requestUpdate();
|
|
});
|
|
this.loadTracks();
|
|
|
|
this.tracksChangedCleanup = EventsOn(
|
|
Events.PlaylistTracksChanged,
|
|
() => this.refreshTracks(),
|
|
);
|
|
|
|
this.playlistDeletedCleanup = EventsOn(
|
|
Events.PlaylistDeleted,
|
|
() => this.navigateBack(),
|
|
);
|
|
|
|
document.addEventListener(
|
|
'click',
|
|
this.clearSelectionHandler,
|
|
);
|
|
document.addEventListener(
|
|
'shortcut:select-all',
|
|
this.handleSelectAll,
|
|
);
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this.creditsUnsub?.();
|
|
this.creditsUnsub = undefined;
|
|
|
|
if (this.tracksChangedCleanup) {
|
|
this.tracksChangedCleanup();
|
|
this.tracksChangedCleanup = null;
|
|
}
|
|
|
|
if (this.playlistDeletedCleanup) {
|
|
this.playlistDeletedCleanup();
|
|
this.playlistDeletedCleanup = null;
|
|
}
|
|
|
|
document.removeEventListener(
|
|
'click',
|
|
this.clearSelectionHandler,
|
|
);
|
|
document.removeEventListener(
|
|
'shortcut:select-all',
|
|
this.handleSelectAll,
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// Data loading
|
|
// =================================================================
|
|
|
|
private async loadTracks() {
|
|
if (!this.playlistId) return;
|
|
|
|
try {
|
|
this.tracks = await list(
|
|
GetPlaylistTracks(this.playlistId),
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
'Error loading playlist tracks:',
|
|
error,
|
|
);
|
|
this.tracks = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
|
|
private async refreshTracks() {
|
|
if (!this.playlistId) return;
|
|
|
|
try {
|
|
this.tracks = await list(
|
|
GetPlaylistTracks(this.playlistId),
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
'Error refreshing playlist tracks:',
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Navigation
|
|
// =================================================================
|
|
|
|
private navigateBack() {
|
|
this.dispatchEvent(
|
|
new CustomEvent('navigate', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: { view: 'playlists' },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// Track interactions
|
|
// =================================================================
|
|
|
|
private handlePlayAll() {
|
|
const filePaths = this.tracks
|
|
.filter((t) => !t.Phantom)
|
|
.map((t) => t.FilePath);
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
|
}
|
|
|
|
private handleTrackClick(
|
|
e: MouseEvent,
|
|
trackIndex: number,
|
|
) {
|
|
this.selection.handleItemClick(
|
|
e,
|
|
String(trackIndex),
|
|
trackIndex,
|
|
);
|
|
}
|
|
|
|
|
|
/** The row holding the roving tab stop. Rows had no keyboard path at
|
|
* all before this: not focusable, so Shift+F10 had nowhere to fire
|
|
* from and the menu was right-click only (a11y.3). */
|
|
@state() private focusedIndex = 0;
|
|
|
|
private onRowKeydown(e: KeyboardEvent, trackIndex: number): void {
|
|
const count = this.tracks.length;
|
|
|
|
if (count === 0) return;
|
|
|
|
const row = e.currentTarget as HTMLElement | null;
|
|
|
|
if (isContextMenuKey(e) && row) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.selection.handleContextMenu(String(trackIndex));
|
|
this.ctxMenu.openFrom(row);
|
|
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.handleTrackDblClick(trackIndex);
|
|
|
|
return;
|
|
}
|
|
|
|
const next = nextRovingIndex(e.key, this.focusedIndex, count);
|
|
|
|
if (next === null) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.focusedIndex = next;
|
|
void focusRovingRow(
|
|
this,
|
|
this.virtualizer,
|
|
next,
|
|
(i) => `.track-item[data-index="${i}"]`,
|
|
);
|
|
}
|
|
|
|
private handleTrackDblClick(trackIndex: number) {
|
|
this.selection.clear();
|
|
|
|
const filePaths = this.tracks.map(
|
|
(t) => t.FilePath,
|
|
);
|
|
|
|
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
|
}
|
|
|
|
private handleTrackContextMenu(
|
|
e: MouseEvent,
|
|
trackIndex: number,
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this.selection.handleContextMenu(
|
|
String(trackIndex),
|
|
);
|
|
this.ctxMenu.openAt(e.clientX, e.clientY);
|
|
}
|
|
|
|
private isActiveTrack(
|
|
track: playlist.Track,
|
|
): boolean {
|
|
const currentTrack = this.player.currentTrack;
|
|
|
|
if (!currentTrack) return false;
|
|
|
|
return currentTrack.filePath === track.FilePath;
|
|
}
|
|
|
|
// =================================================================
|
|
// Selection helpers
|
|
// =================================================================
|
|
|
|
private getSelectedTrackIDs(): number[] {
|
|
return this.selection
|
|
.getSelectedIndices()
|
|
.map((i) => this.tracks[i]!.ID);
|
|
}
|
|
|
|
private getSelectedFilePaths(): string[] {
|
|
return this.selection
|
|
.getSelectedIndices()
|
|
.map((i) => this.tracks[i]!.FilePath);
|
|
}
|
|
|
|
// =================================================================
|
|
// Context menu actions
|
|
// =================================================================
|
|
|
|
private onContextMenuAction(action: string) {
|
|
const filePaths = this.getSelectedFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
switch (action) {
|
|
case 'play':
|
|
// One row is a position in the playlist, so it queues
|
|
// the playlist from there - the same thing
|
|
// double-clicking the row does. Several rows are an
|
|
// explicit choice of *those* tracks and become the
|
|
// queue on their own.
|
|
if (filePaths.length === 1) {
|
|
this.handleTrackDblClick(
|
|
this.selection.getSelectedIndices()[0]!,
|
|
);
|
|
} else {
|
|
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
|
|
}
|
|
|
|
break;
|
|
case 'add-to-queue':
|
|
queueStore.addTracksToQueue(filePaths);
|
|
break;
|
|
case 'play-next':
|
|
queueStore.playTracksNext(filePaths);
|
|
break;
|
|
case 'remove':
|
|
void this.removeSelectedTracks();
|
|
break;
|
|
case 'track-details':
|
|
if (filePaths.length === 1) {
|
|
void this.openTrackDetails(filePaths[0]!);
|
|
} else {
|
|
void this.openBatchTrackDetails(filePaths);
|
|
}
|
|
break;
|
|
case 'phantom-locate':
|
|
this.openPhantomResolver();
|
|
break;
|
|
case 'phantom-remove':
|
|
void this.removeSelectedPhantoms();
|
|
break;
|
|
}
|
|
|
|
this.selection.clear();
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
private onContextMenuFavoriteToggle() {
|
|
const filePaths = this.getSelectedFilePaths();
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
if (this.favCtrl.allFavorited(filePaths)) {
|
|
void this.favCtrl.removeFromFavorites(
|
|
filePaths,
|
|
);
|
|
} else {
|
|
void this.favCtrl.addToFavorites(
|
|
filePaths,
|
|
);
|
|
}
|
|
|
|
this.selection.clear();
|
|
this.ctxMenu.close();
|
|
}
|
|
|
|
/** The row is still on screen, so the failure is visible; this
|
|
* only says why (errors.m7). */
|
|
private reportRemoveFailure(what: string, err: unknown): void {
|
|
notificationStore.transient({
|
|
key: 'playlist-remove',
|
|
text: `Could not ${what}. ${describeError(err)}`,
|
|
detail: String(err),
|
|
});
|
|
}
|
|
|
|
private async removeSelectedTracks() {
|
|
const trackIDs = this.getSelectedTrackIDs();
|
|
|
|
if (trackIDs.length === 0) return;
|
|
|
|
try {
|
|
await RemoveTracksFromPlaylist(
|
|
this.playlistId,
|
|
trackIDs,
|
|
);
|
|
await this.refreshTracks();
|
|
} catch (err) {
|
|
console.error('Failed to remove tracks:', err);
|
|
this.reportRemoveFailure('remove those tracks', err);
|
|
}
|
|
}
|
|
|
|
private async removeSelectedPhantoms(): Promise<void> {
|
|
const selectedIndices =
|
|
this.selection.getSelectedIndices();
|
|
const phantomPaths = selectedIndices
|
|
.map((i) => this.tracks[i])
|
|
.filter(
|
|
(t): t is playlist.Track =>
|
|
t !== undefined && t.Phantom,
|
|
)
|
|
.map((t) => t.FilePath);
|
|
|
|
if (phantomPaths.length === 0) return;
|
|
|
|
try {
|
|
await RemovePhantomTracks(
|
|
this.playlistId,
|
|
phantomPaths,
|
|
);
|
|
await this.refreshTracks();
|
|
} catch (err) {
|
|
console.error('Failed to remove phantom tracks:', err);
|
|
this.reportRemoveFailure('remove those missing tracks', err);
|
|
}
|
|
}
|
|
|
|
private async openTrackDetails(filePath: string) {
|
|
const tracks = libraryStore.getCachedTracks();
|
|
const track = tracks
|
|
? tracksByFilePath(tracks).get(filePath)
|
|
: undefined;
|
|
|
|
if (!track) return;
|
|
|
|
const ready = await loadTrackDetails(
|
|
() => void this.openTrackDetails(filePath),
|
|
);
|
|
|
|
if (!ready) return;
|
|
|
|
const coverArt = track.CoverArtPath
|
|
? {
|
|
coverArtPath: track.CoverArtPath,
|
|
coverArtSmall: track.CoverArtSmall,
|
|
coverArtMedium: track.CoverArtMedium,
|
|
coverArtLarge: track.CoverArtLarge,
|
|
}
|
|
: undefined;
|
|
|
|
this.trackDetailsDialog?.show(
|
|
track,
|
|
coverArt,
|
|
);
|
|
}
|
|
|
|
private async openBatchTrackDetails(
|
|
filePaths: string[],
|
|
) {
|
|
const cachedTracks =
|
|
libraryStore.getCachedTracks();
|
|
|
|
if (!cachedTracks) return;
|
|
|
|
const tracks = tracksForPaths(
|
|
cachedTracks,
|
|
filePaths,
|
|
);
|
|
|
|
if (tracks.length === 0) return;
|
|
|
|
const ready = await loadTrackDetails(
|
|
() => void this.openBatchTrackDetails(filePaths),
|
|
);
|
|
|
|
if (!ready) return;
|
|
|
|
const first = tracks[0]!;
|
|
const albumNames = new Set(tracks.map((t) => t.Album));
|
|
let coverArt: CoverArtUrls | null = null;
|
|
let coverArtMixed = false;
|
|
|
|
if (albumNames.size === 1 && first.CoverArtPath) {
|
|
coverArt = {
|
|
coverArtPath: first.CoverArtPath,
|
|
coverArtSmall: first.CoverArtSmall,
|
|
coverArtMedium: first.CoverArtMedium,
|
|
coverArtLarge: first.CoverArtLarge,
|
|
};
|
|
} else if (albumNames.size > 1) {
|
|
coverArtMixed = true;
|
|
}
|
|
|
|
this.trackDetailsDialog?.showBatch(
|
|
tracks,
|
|
coverArt,
|
|
coverArtMixed,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check whether all currently selected tracks are phantoms.
|
|
*/
|
|
private isPhantomSelection(): boolean {
|
|
const indices =
|
|
this.selection.getSelectedIndices();
|
|
|
|
if (indices.length === 0) return false;
|
|
|
|
return indices.every((i) => {
|
|
const t = this.tracks[i];
|
|
|
|
return t !== undefined && t.Phantom;
|
|
});
|
|
}
|
|
|
|
// =================================================================
|
|
// Phantom track interactions
|
|
// =================================================================
|
|
|
|
private handlePhantomClick(
|
|
e: MouseEvent,
|
|
trackIndex: number,
|
|
): void {
|
|
this.selection.handleItemClick(
|
|
e,
|
|
String(trackIndex),
|
|
trackIndex,
|
|
);
|
|
}
|
|
|
|
private handlePhantomContextMenu(
|
|
e: MouseEvent,
|
|
trackIndex: number,
|
|
): void {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.selection.handleContextMenu(
|
|
String(trackIndex),
|
|
);
|
|
this.ctxMenu.openAt(e.clientX, e.clientY);
|
|
}
|
|
|
|
private openPhantomResolver(
|
|
trackIndex?: number,
|
|
): void {
|
|
const selectedIndices =
|
|
this.selection.getSelectedIndices();
|
|
let phantoms = selectedIndices
|
|
.map((i) => this.tracks[i])
|
|
.filter(
|
|
(t): t is playlist.Track =>
|
|
t !== undefined && t.Phantom,
|
|
);
|
|
|
|
// Fall back to the clicked track.
|
|
if (
|
|
phantoms.length === 0 &&
|
|
trackIndex !== undefined
|
|
) {
|
|
const track = this.tracks[trackIndex];
|
|
|
|
if (track?.Phantom) {
|
|
phantoms = [track];
|
|
}
|
|
}
|
|
|
|
if (phantoms.length === 0) return;
|
|
|
|
this.phantomResolver.show(
|
|
this.playlistId,
|
|
phantoms,
|
|
);
|
|
}
|
|
|
|
private async removePhantomTrack(
|
|
trackIndex: number,
|
|
): Promise<void> {
|
|
const track = this.tracks[trackIndex];
|
|
|
|
if (!track?.Phantom) return;
|
|
|
|
try {
|
|
await RemovePhantomTracks(
|
|
this.playlistId,
|
|
[track.FilePath],
|
|
);
|
|
await this.refreshTracks();
|
|
} catch (err) {
|
|
console.error('Failed to remove phantom track:', err);
|
|
this.reportRemoveFailure('remove that missing track', err);
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Drag source (playlist tracks -> queue or other playlist)
|
|
// =================================================================
|
|
|
|
private onTrackDragStart = (
|
|
e: DragEvent,
|
|
track: playlist.Track,
|
|
trackIndex: number,
|
|
) => {
|
|
let filePaths: string[];
|
|
|
|
if (
|
|
this.selection.isSelected(
|
|
String(trackIndex),
|
|
)
|
|
) {
|
|
filePaths = this.getSelectedFilePaths();
|
|
} else {
|
|
filePaths = [track.FilePath];
|
|
}
|
|
|
|
if (filePaths.length === 0) return;
|
|
|
|
setDragPayload(e, {
|
|
filePaths,
|
|
source: 'playlist',
|
|
sourcePlaylistId: this.playlistId,
|
|
});
|
|
|
|
this.dragImageEl =
|
|
filePaths.length === 1
|
|
? createTrackCardDragImage(
|
|
track.Title,
|
|
track.Artist,
|
|
track.FilePath,
|
|
)
|
|
: 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 this playlist)
|
|
// =================================================================
|
|
|
|
@state()
|
|
private dragOver = false;
|
|
|
|
private onDragOver = (e: DragEvent) => {
|
|
if (!hasTrackPayload(e)) return;
|
|
|
|
e.preventDefault();
|
|
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
|
|
if (!this.dragOver) {
|
|
this.dragOver = true;
|
|
}
|
|
};
|
|
|
|
private onDragLeave = (e: DragEvent) => {
|
|
const related =
|
|
e.relatedTarget as Node | null;
|
|
|
|
if (!related || !this.contains(related)) {
|
|
this.dragOver = false;
|
|
}
|
|
};
|
|
|
|
private onDrop = async (e: DragEvent) => {
|
|
e.preventDefault();
|
|
this.dragOver = false;
|
|
|
|
const payload = getDragPayload(e);
|
|
|
|
if (
|
|
!payload ||
|
|
payload.filePaths.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Don't allow dropping tracks back onto the
|
|
// same playlist.
|
|
if (
|
|
payload.source === 'playlist' &&
|
|
payload.sourcePlaylistId === this.playlistId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result =
|
|
await FindDuplicateTracksInPlaylist(
|
|
this.playlistId,
|
|
payload.filePaths,
|
|
);
|
|
const duplicates =
|
|
result.Duplicates ?? [];
|
|
const unique = result.Unique ?? [];
|
|
|
|
if (duplicates.length > 0) {
|
|
await this.updateComplete;
|
|
this.duplicateDialog.show(
|
|
this.playlistId,
|
|
duplicates,
|
|
unique,
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
await AddTracksToPlaylist(
|
|
this.playlistId,
|
|
payload.filePaths,
|
|
);
|
|
await this.refreshTracks();
|
|
} catch (err) {
|
|
console.error(
|
|
'Failed to add tracks to playlist:',
|
|
err,
|
|
);
|
|
}
|
|
};
|
|
|
|
// =================================================================
|
|
// Search filtering
|
|
// =================================================================
|
|
|
|
private getVisibleTracks(): VisibleTrack[] {
|
|
const term =
|
|
this.searchCtrl.term.toLowerCase();
|
|
|
|
// Keyed on the identity of the tracks array and the term, the
|
|
// same signal `track-list`'s memoized caches use: the store
|
|
// replaces the array when its contents change and shares every
|
|
// unchanged member.
|
|
if (
|
|
this.visibleCache &&
|
|
this.visibleCacheKey &&
|
|
this.visibleCacheKey.tracks === this.tracks &&
|
|
this.visibleCacheKey.term === term
|
|
) {
|
|
return this.visibleCache;
|
|
}
|
|
|
|
const all = this.tracks.map(
|
|
(track, trackIndex) => ({
|
|
track,
|
|
trackIndex,
|
|
}),
|
|
);
|
|
|
|
const visible = term
|
|
? all.filter(
|
|
({ track }) =>
|
|
track.Title.toLowerCase().includes(
|
|
term,
|
|
) ||
|
|
track.Artist.toLowerCase().includes(
|
|
term,
|
|
),
|
|
)
|
|
: all;
|
|
|
|
this.visibleCache = visible;
|
|
this.visibleCacheKey = { tracks: this.tracks, term };
|
|
|
|
return visible;
|
|
}
|
|
|
|
/** Stable across renders, because `lit-virtualizer` declares both
|
|
* `renderItem` and `keyFunction` as plain `@property()` — a fresh
|
|
* arrow function marks them dirty and forces the virtualizer's own
|
|
* render pass on every host update (perf.m1). */
|
|
private renderRow = (entry: VisibleTrack) =>
|
|
this.renderTrackRow(entry);
|
|
|
|
private rowKey = (entry: VisibleTrack) =>
|
|
entry.trackIndex;
|
|
|
|
// =================================================================
|
|
// Styles
|
|
// =================================================================
|
|
|
|
static override styles = [
|
|
designTokens,
|
|
contextMenuStyles,
|
|
exploreLinkStyles,
|
|
css`
|
|
:host {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
height: 100%;
|
|
}
|
|
|
|
/* ====================================
|
|
* Header
|
|
* ==================================== */
|
|
|
|
.playlist-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 20px;
|
|
padding: 16px 20px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid
|
|
var(
|
|
--yj-border-subtle,
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
}
|
|
|
|
.back-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: var(
|
|
--yj-bg-overlay,
|
|
rgba(255, 255, 255, 0.06)
|
|
);
|
|
color: var(--yj-text-primary, #fff);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.back-button:hover {
|
|
background: var(
|
|
--yj-bg-hover,
|
|
rgba(255, 255, 255, 0.12)
|
|
);
|
|
}
|
|
|
|
.back-button wa-icon {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.playlist-avatar {
|
|
width: 80px;
|
|
height: 80px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background: linear-gradient(
|
|
135deg,
|
|
var(--yj-bg-overlay, #404040) 0%,
|
|
var(--yj-bg-surface, #282828) 100%
|
|
);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.playlist-avatar wa-icon {
|
|
font-size: 32px;
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
}
|
|
|
|
.playlist-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.playlist-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--yj-text-primary, #fff);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
margin: 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.track-count {
|
|
font-size: var(--yj-text-md);
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
}
|
|
|
|
/* ====================================
|
|
* Content
|
|
* ==================================== */
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0 16px 12px 16px;
|
|
}
|
|
|
|
.search-bar-row {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 30px;
|
|
border-bottom: 1px solid
|
|
var(--yj-border-subtle, #333);
|
|
flex-shrink: 0;
|
|
user-select: none;
|
|
}
|
|
|
|
.search-indicator {
|
|
position: absolute;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
pointer-events: none;
|
|
background: var(
|
|
--yj-bg-overlay,
|
|
#495057
|
|
);
|
|
color: var(
|
|
--yj-text-secondary,
|
|
#b3b3b3
|
|
);
|
|
font-size: 12px;
|
|
padding: 2px 14px;
|
|
border-radius: 12px;
|
|
border: 1px solid
|
|
var(--yj-border-subtle, #555);
|
|
white-space: nowrap;
|
|
opacity: 0.92;
|
|
}
|
|
|
|
.playlist-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 0 8px;
|
|
}
|
|
|
|
.play-all-button {
|
|
background: none;
|
|
border: 1px solid var(--yj-border-subtle, #555);
|
|
border-radius: 4px;
|
|
color: var(--yj-text-primary, #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: var(--yj-accent, #ffd43b);
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
/* Column grid layout */
|
|
.track-header,
|
|
.track-item {
|
|
display: grid;
|
|
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
|
|
align-items: center;
|
|
gap: 0;
|
|
}
|
|
|
|
/* The virtualizer positions its children absolutely, so a row
|
|
* shrinks to fit its content and the columns stop lining up
|
|
* with the header above them. track-list has always carried the
|
|
* same declaration for the same reason.
|
|
*
|
|
* No backticks in here: this is inside a css tagged template
|
|
* literal, and one ends it. */
|
|
.track-item {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.track-header {
|
|
padding: 6px 8px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.03em;
|
|
border-bottom: 1px solid var(--yj-text-tertiary, #666);
|
|
user-select: none;
|
|
}
|
|
|
|
.track-art {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
|
}
|
|
|
|
.track-art img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
.header-cell,
|
|
.cell {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.col-number {
|
|
text-align: center;
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.col-duration {
|
|
text-align: right;
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Phantom rows span full grid */
|
|
.track-item.phantom {
|
|
display: grid;
|
|
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
|
|
}
|
|
|
|
.track-item {
|
|
padding: 6px 8px;
|
|
border-bottom: 1px solid
|
|
rgba(255, 255, 255, 0.03);
|
|
cursor: default;
|
|
user-select: none;
|
|
}
|
|
|
|
.track-item:hover {
|
|
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
|
}
|
|
|
|
.track-item.selected {
|
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
|
}
|
|
|
|
.track-item.active {
|
|
background-color: var(--yj-accent-bg, rgba(255, 212, 59, 0.1));
|
|
color: var(--yj-accent-text, #ffd43b);
|
|
}
|
|
|
|
.track-item.selected.active {
|
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
|
}
|
|
|
|
.track-item.phantom {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.track-item.phantom:hover {
|
|
background-color: var(
|
|
--yj-hover-overlay,
|
|
rgba(255, 255, 255, 0.05)
|
|
);
|
|
}
|
|
|
|
.track-item.phantom.selected {
|
|
background-color: var(
|
|
--yj-selection-bg,
|
|
rgba(100, 160, 255, 0.15)
|
|
);
|
|
}
|
|
|
|
.phantom-row {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.phantom-caution {
|
|
flex-shrink: 0;
|
|
font-size: 14px;
|
|
color: var(--yj-warning-text, #ffa94d);
|
|
}
|
|
|
|
.phantom-path {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-size: 12px;
|
|
color: var(--yj-text-tertiary, #888);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.phantom-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.phantom-icon-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
color: var(--yj-text-tertiary, #888);
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
border-radius: 3px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.phantom-icon-btn:hover {
|
|
color: var(
|
|
--yj-text-primary,
|
|
#fff
|
|
);
|
|
background: rgba(
|
|
255,
|
|
255,
|
|
255,
|
|
0.08
|
|
);
|
|
}
|
|
|
|
.phantom-icon-btn.phantom-icon-remove:hover {
|
|
color: var(--yj-error-text, #ff8787);
|
|
background: rgba(224, 49, 49, 0.12);
|
|
}
|
|
|
|
.tracks-empty {
|
|
padding: 12px 0;
|
|
color: var(--yj-text-tertiary, #666);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 32px;
|
|
color: var(--yj-text-secondary, #b3b3b3);
|
|
}
|
|
`];
|
|
|
|
// =================================================================
|
|
// Render
|
|
// =================================================================
|
|
|
|
override render() {
|
|
const trackCount = this.tracks.length;
|
|
const trackLabel =
|
|
trackCount === 1 ? 'track' : 'tracks';
|
|
|
|
const searchBar = this.searchCtrl.term
|
|
? html`<div class="search-bar-row">
|
|
<div class="search-indicator">
|
|
Showing results for
|
|
“${this.searchCtrl
|
|
.term}”
|
|
</div>
|
|
</div>`
|
|
: nothing;
|
|
|
|
return html`
|
|
<div
|
|
class="playlist-header"
|
|
@dragover=${this.onDragOver}
|
|
@dragleave=${this.onDragLeave}
|
|
@drop=${this.onDrop}
|
|
>
|
|
<button
|
|
class="back-button"
|
|
@click=${() => this.navigateBack()}
|
|
title="Back to playlists"
|
|
aria-label="Back to playlists"
|
|
>
|
|
<wa-icon
|
|
name="arrow-left"
|
|
></wa-icon>
|
|
</button>
|
|
<div class="playlist-avatar">
|
|
<wa-icon
|
|
name=${ICON_PLAYLIST}
|
|
></wa-icon>
|
|
</div>
|
|
<div class="playlist-info">
|
|
<h1
|
|
class="playlist-title"
|
|
title="${this.playlistName}"
|
|
>
|
|
${this.playlistName}
|
|
</h1>
|
|
${!this.loading
|
|
? html`
|
|
<span
|
|
class="track-count"
|
|
>
|
|
${trackCount}
|
|
${trackLabel}
|
|
</span>
|
|
`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
${searchBar}
|
|
<div
|
|
class="content"
|
|
@dragover=${this.onDragOver}
|
|
@dragleave=${this.onDragLeave}
|
|
@drop=${this.onDrop}
|
|
>
|
|
${this.loading
|
|
? html`<div class="loading">
|
|
Loading tracks...
|
|
</div>`
|
|
: this.renderTrackList()}
|
|
</div>
|
|
|
|
${this.renderContextMenu()}
|
|
|
|
<track-details></track-details>
|
|
<phantom-resolver
|
|
@phantom-resolved=${() =>
|
|
this.refreshTracks()}
|
|
></phantom-resolver>
|
|
<duplicate-tracks-dialog
|
|
@playlist-action-complete=${() =>
|
|
this.refreshTracks()}
|
|
></duplicate-tracks-dialog>
|
|
`;
|
|
}
|
|
|
|
private renderTrackList() {
|
|
if (this.tracks.length === 0) {
|
|
return html`
|
|
<div class="tracks-empty">
|
|
This playlist is empty.
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const visibleTracks = this.getVisibleTracks();
|
|
|
|
return html`
|
|
<div class="playlist-actions">
|
|
<button
|
|
class="play-all-button"
|
|
@click=${() => this.handlePlayAll()}
|
|
>
|
|
<wa-icon name="play"></wa-icon>
|
|
Play All
|
|
</button>
|
|
</div>
|
|
<div class="track-header">
|
|
<div class="header-cell col-number">#</div>
|
|
<div class="header-cell col-art"></div>
|
|
<div class="header-cell col-title">Title</div>
|
|
<div class="header-cell col-artist">Artist</div>
|
|
<div class="header-cell col-album">Album</div>
|
|
<div class="header-cell col-duration">Duration</div>
|
|
</div>
|
|
<lit-virtualizer
|
|
class="track-scroller"
|
|
role="listbox"
|
|
aria-label="Playlist tracks"
|
|
aria-multiselectable="true"
|
|
.items=${visibleTracks}
|
|
.renderItem=${this.renderRow}
|
|
.keyFunction=${this.rowKey}
|
|
.layout=${this.flowLayout}
|
|
></lit-virtualizer>
|
|
`;
|
|
}
|
|
|
|
/* One row. Extracted from `renderTrackList` so it can be a stable
|
|
* bound field rather than a closure the virtualizer sees as new
|
|
* every pass. */
|
|
private renderTrackRow({ track, trackIndex }: VisibleTrack) {
|
|
const isPhantom = track.Phantom;
|
|
const active =
|
|
!isPhantom &&
|
|
this.isActiveTrack(track);
|
|
const selected =
|
|
this.selection.isSelected(
|
|
String(trackIndex),
|
|
);
|
|
|
|
const classes = [
|
|
'track-item',
|
|
active ? 'active' : '',
|
|
selected ? 'selected' : '',
|
|
isPhantom ? 'phantom' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
return html`
|
|
<div
|
|
class=${classes}
|
|
role="option"
|
|
aria-selected=${selected}
|
|
data-index=${trackIndex}
|
|
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
|
|
@keydown=${(e: KeyboardEvent) =>
|
|
this.onRowKeydown(e, trackIndex)}
|
|
draggable=${isPhantom
|
|
? 'false'
|
|
: 'true'}
|
|
@click=${isPhantom
|
|
? (e: MouseEvent) =>
|
|
this.handlePhantomClick(
|
|
e,
|
|
trackIndex,
|
|
)
|
|
: (e: MouseEvent) =>
|
|
this.handleTrackClick(
|
|
e,
|
|
trackIndex,
|
|
)}
|
|
@dblclick=${isPhantom
|
|
? nothing
|
|
: () =>
|
|
this.handleTrackDblClick(
|
|
trackIndex,
|
|
)}
|
|
@contextmenu=${isPhantom
|
|
? (e: MouseEvent) =>
|
|
this.handlePhantomContextMenu(
|
|
e,
|
|
trackIndex,
|
|
)
|
|
: (e: MouseEvent) =>
|
|
this.handleTrackContextMenu(
|
|
e,
|
|
trackIndex,
|
|
)}
|
|
@dragstart=${isPhantom
|
|
? nothing
|
|
: (e: DragEvent) =>
|
|
this.onTrackDragStart(
|
|
e,
|
|
track,
|
|
trackIndex,
|
|
)}
|
|
@dragend=${isPhantom
|
|
? nothing
|
|
: this.onTrackDragEnd}
|
|
>
|
|
${isPhantom
|
|
? html`<div
|
|
class="phantom-row"
|
|
>
|
|
<wa-icon
|
|
class="phantom-caution"
|
|
name="triangle-exclamation"
|
|
title="File not found"
|
|
></wa-icon>
|
|
<span
|
|
class="phantom-path"
|
|
title=${track.FilePath}
|
|
>
|
|
${track.FilePath}
|
|
</span>
|
|
<div
|
|
class="phantom-actions"
|
|
>
|
|
<button
|
|
class="phantom-icon-btn"
|
|
title="Locate in library"
|
|
@click=${(
|
|
e: Event,
|
|
) => {
|
|
e.stopPropagation();
|
|
this.openPhantomResolver(
|
|
trackIndex,
|
|
);
|
|
}}
|
|
>
|
|
<wa-icon
|
|
name="magnifying-glass"
|
|
></wa-icon>
|
|
</button>
|
|
<button
|
|
class="phantom-icon-btn phantom-icon-remove"
|
|
title="Remove from playlist"
|
|
@click=${(
|
|
e: Event,
|
|
) => {
|
|
e.stopPropagation();
|
|
void this.removePhantomTrack(
|
|
trackIndex,
|
|
);
|
|
}}
|
|
>
|
|
<wa-icon
|
|
name="xmark"
|
|
></wa-icon>
|
|
</button>
|
|
</div>
|
|
</div>`
|
|
: html`<span class="cell col-number">${trackIndex + 1}</span>
|
|
<div class="track-art">
|
|
${track.CoverArtSmall || track.CoverArtMedium
|
|
? html`<img
|
|
src="${track.CoverArtSmall || track.CoverArtMedium}"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
width="32"
|
|
height="32"
|
|
/>`
|
|
: nothing}
|
|
</div>
|
|
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
|
|
<span class="cell col-artist" title="${creditText(creditStore.credits(track.RecordingMBID), track.Artist)}">${creditLink(creditStore.credits(track.RecordingMBID), track.Artist, track.ArtistMBID)}</span>
|
|
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID, undefined, track.Artist)}</span>
|
|
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderContextMenu() {
|
|
return html`
|
|
<wa-popup
|
|
id="context-menu"
|
|
placement="bottom-start"
|
|
flip
|
|
shift
|
|
.active=${this.ctxMenu
|
|
.contextMenuOpen}
|
|
>
|
|
${this.ctxMenu.contextMenuOpen
|
|
? this.isPhantomSelection()
|
|
? html`
|
|
<div class="context-menu-panel" role="menu" aria-label="Track actions">
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'phantom-locate',
|
|
)}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="magnifying-glass"
|
|
></wa-icon>
|
|
Locate in
|
|
Library
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'phantom-remove',
|
|
)}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="trash"
|
|
></wa-icon>
|
|
Remove from
|
|
Playlist
|
|
</wa-dropdown-item>
|
|
</div>
|
|
`
|
|
: html`
|
|
<div class="context-menu-panel" role="menu" aria-label="Track actions">
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'play',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="play"
|
|
></wa-icon>
|
|
Play
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'add-to-queue',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name=${ICON_QUEUE}
|
|
></wa-icon>
|
|
Add to Queue
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'play-next',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="forward-step"
|
|
></wa-icon>
|
|
Play Next
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'remove',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="trash"
|
|
></wa-icon>
|
|
Remove from
|
|
Playlist
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
class="submenu-item"
|
|
@mouseenter=${() => {
|
|
this.ctxMenu.clearSubmenuCloseTimer();
|
|
void this.ctxMenu.showPlaylistSubmenu(
|
|
this.getSelectedFilePaths(),
|
|
);
|
|
}}
|
|
@mouseleave=${this
|
|
.ctxMenu
|
|
.scheduleSubmenuClose}
|
|
@click=${(
|
|
e: Event,
|
|
) => {
|
|
e.stopPropagation();
|
|
void this.ctxMenu.showPlaylistSubmenu(
|
|
this.getSelectedFilePaths(),
|
|
);
|
|
}}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name=${ICON_PLAYLIST}
|
|
></wa-icon>
|
|
Add to Playlist
|
|
<span
|
|
class="submenu-arrow"
|
|
>
|
|
▶
|
|
</span>
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuFavoriteToggle()}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name=${this
|
|
.favCtrl
|
|
.iconName}
|
|
></wa-icon>
|
|
${this.favCtrl.allFavorited(
|
|
this.getSelectedFilePaths(),
|
|
)
|
|
? `Remove from ${this.favCtrl.playlistName}`
|
|
: `Add to ${this.favCtrl.playlistName}`}
|
|
</wa-dropdown-item>
|
|
<wa-dropdown-item
|
|
@click=${() =>
|
|
this.onContextMenuAction(
|
|
'track-details',
|
|
)}
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.closePlaylistSubmenu()}
|
|
>
|
|
<wa-icon
|
|
slot="icon"
|
|
name="circle-info"
|
|
></wa-icon>
|
|
Track
|
|
Details
|
|
</wa-dropdown-item>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
|
|
<wa-popup
|
|
id="playlist-submenu"
|
|
placement="right-start"
|
|
flip
|
|
shift
|
|
.active=${this.ctxMenu
|
|
.playlistSubmenuOpen}
|
|
>
|
|
${this.ctxMenu.playlistSubmenuOpen &&
|
|
this.selection.hasSelection
|
|
? html`
|
|
<div
|
|
@mouseenter=${() =>
|
|
this.ctxMenu.clearSubmenuCloseTimer()}
|
|
@mouseleave=${this
|
|
.ctxMenu
|
|
.scheduleSubmenuClose}
|
|
>
|
|
<playlist-picker
|
|
.filePaths=${this.getSelectedFilePaths()}
|
|
@playlist-action-complete=${this
|
|
.ctxMenu
|
|
.onPlaylistActionComplete}
|
|
@click=${(
|
|
e: Event,
|
|
) =>
|
|
e.stopPropagation()}
|
|
></playlist-picker>
|
|
</div>
|
|
`
|
|
: nothing}
|
|
</wa-popup>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'playlist-details': PlaylistDetails;
|
|
}
|
|
}
|