From dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sat, 7 Mar 2026 22:06:53 -0500 Subject: [PATCH] feat(quick-17): create playlist-details subpage component - New playlist-details component with header (back button, playlist icon, title, track count) - Full track list with all interactions: select, play, context menu, drag, phantom handling - Drop target support for adding tracks from other views - Search filtering for tracks within the detail view - Navigation routing in index.ts for playlist-details view - Added playlist-details to SEARCHABLE_VIEWS in search-store --- frontend/index.ts | 12 + .../playlist-details/playlist-details.ts | 1509 +++++++++++++++++ frontend/src/store/search-store.ts | 2 +- 3 files changed, 1522 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/playlist-details/playlist-details.ts diff --git a/frontend/index.ts b/frontend/index.ts index 174861b..9d5bf8a 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -11,6 +11,7 @@ import '@components/artists-view/artists-view.ts'; import '@components/artist-details/artist-details.ts'; import '@components/genres-view/genres-view.ts'; import '@components/genre-details/genre-details.ts'; +import '@components/playlist-details/playlist-details.ts'; import '@components/search-bar/search-bar.ts'; import '@components/track-details/track-details.ts'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; @@ -67,6 +68,17 @@ document.addEventListener('navigate', (e: Event) => { mainContent.appendChild(el); break; } + case 'playlist-details': { + const { playlistId, playlistName } = + (e as CustomEvent).detail; + const plEl = document.createElement('playlist-details'); + + plEl.setAttribute('playlist-id', String(playlistId)); + plEl.setAttribute('playlist-name', playlistName); + mainContent.innerHTML = ''; + mainContent.appendChild(plEl); + break; + } case 'genres': mainContent.innerHTML = ''; break; diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts new file mode 100644 index 0000000..5ba3564 --- /dev/null +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -0,0 +1,1509 @@ +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 { + GetPlaylistTracks, + AddTracksToPlaylist, + RemoveTracksFromPlaylist, + RemovePhantomTracks, + FindDuplicateTracksInPlaylist, +} from '@go/playlist/Service'; +import type { playlist } from '@go/models'; +import { EventsOn } from '@runtime/runtime'; +import { Events } from '../../events'; +import { queueStore } from '@store/queue-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, +} from '@utils/context-menu-controller.js'; +import type { ContextMenuHost } from '@utils/context-menu-controller.js'; +import { FavoritesController } from '@store/controllers/favorites-controller'; +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/track-info/track-info'; +import '@components/playlist-picker/playlist-picker.js'; +import '@components/track-details/track-details.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 { designTokens } from '../../styles/tokens.css'; + +@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; + + 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(); + } + + // ================================================================= + // 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(); + 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(); + + 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 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 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); + } + + private handleTrackClick( + e: MouseEvent, + trackIndex: number, + ) { + this.selection.handleItemClick( + e, + String(trackIndex), + trackIndex, + ); + } + + private handleTrackDblClick(trackIndex: number) { + this.selection.clear(); + + const filePaths = this.tracks.map( + (t) => t.FilePath, + ); + + queueStore.setQueue(filePaths, trackIndex); + } + + 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': + queueStore.setQueue(filePaths, 0, true); + 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': + this.openTrackDetails(filePaths[0]!); + 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(); + } + + 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, + ); + } + } + + private async removeSelectedPhantoms(): Promise { + 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, + ); + } + } + + private openTrackDetails(filePath: string) { + const tracks = libraryStore.getCachedTracks(); + const track = tracks?.find( + (t) => t.FilePath === filePath, + ); + + if (!track) return; + + const coverArt = + this.resolvePlaylistCoverArt(track.Album); + + this.trackDetailsDialog?.show( + track, + coverArt ?? undefined, + ); + } + + private resolvePlaylistCoverArt( + albumName: string, + ): CoverArtUrls | null { + if (!albumName) return null; + + const albums = libraryStore.getCachedAlbums(); + + if (!albums) return null; + + const album = albums.find( + (a) => a.Name === albumName, + ); + + if (!album || !album.CoverArtPath) { + return null; + } + + return { + coverArtPath: album.CoverArtPath, + coverArtSmall: album.CoverArtSmall, + coverArtMedium: album.CoverArtMedium, + coverArtLarge: album.CoverArtLarge, + }; + } + + /** + * 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 { + 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, + ); + } + } + + // ================================================================= + // 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(): { + track: playlist.Track; + trackIndex: number; + }[] { + const term = + this.searchCtrl.term.toLowerCase(); + + if (!term) { + return this.tracks.map( + (track, trackIndex) => ({ + track, + trackIndex, + }), + ); + } + + return this.tracks + .map((track, trackIndex) => ({ + track, + trackIndex, + })) + .filter( + ({ track }) => + track.Title.toLowerCase().includes( + term, + ) || + track.Artist.toLowerCase().includes( + term, + ), + ); + } + + // ================================================================= + // Styles + // ================================================================= + + static override styles = [ + designTokens, + contextMenuStyles, + 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, #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: 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, #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 { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + } + + .phantom-caution { + flex-shrink: 0; + font-size: 14px; + color: var(--yj-warning, #e67700); + } + + .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, #e03131); + background: rgba(224, 49, 49, 0.12); + } + + .track-item:last-child { + border-bottom: none; + } + + .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`
+
+ Showing results for + “${this.searchCtrl + .term}” +
+
` + : nothing; + + return html` +
+ +
+ +
+
+

+ ${this.playlistName} +

+ ${!this.loading + ? html` + + ${trackCount} + ${trackLabel} + + ` + : ''} +
+
+ ${searchBar} +
+ ${this.loading + ? html`
+ Loading tracks... +
` + : this.renderTrackList()} +
+ + ${this.renderContextMenu()} + + + + this.refreshTracks()} + > + + this.refreshTracks()} + > + `; + } + + private renderTrackList() { + if (this.tracks.length === 0) { + return html` +
+ This playlist is empty. +
+ `; + } + + const visibleTracks = this.getVisibleTracks(); + + return html` +
+ +
+ ${visibleTracks.map( + ({ track, trackIndex }) => { + 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` +
+ 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`
+ + + ${track.FilePath} + +
+ + +
+
` + : html``} +
+ `; + }, + )} + `; + } + + private renderContextMenu() { + return html` + + ${this.ctxMenu.contextMenuOpen + ? this.isPhantomSelection() + ? html` +
+ + this.onContextMenuAction( + 'phantom-locate', + )} + > + + Locate in + Library + + + this.onContextMenuAction( + 'phantom-remove', + )} + > + + Remove from + Playlist + +
+ ` + : html` +
+ + this.onContextMenuAction( + 'play', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Play + + + this.onContextMenuAction( + 'add-to-queue', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Add to Queue + + + this.onContextMenuAction( + 'play-next', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Play Next + + + this.onContextMenuAction( + 'remove', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Remove from + Playlist + + { + this.ctxMenu.clearSubmenuCloseTimer(); + void this.ctxMenu.showPlaylistSubmenu( + this.getSelectedFilePaths(), + ); + }} + @mouseleave=${this + .ctxMenu + .scheduleSubmenuClose} + @click=${( + e: Event, + ) => { + e.stopPropagation(); + void this.ctxMenu.showPlaylistSubmenu( + this.getSelectedFilePaths(), + ); + }} + > + + Add to Playlist + + ▶ + + + + this.onContextMenuFavoriteToggle()} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + ${this.favCtrl.allFavorited( + this.getSelectedFilePaths(), + ) + ? `Remove from ${this.favCtrl.playlistName}` + : `Add to ${this.favCtrl.playlistName}`} + + ${this.selection + .selectionCount === + 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Track + Details + + ` + : nothing} +
+ ` + : nothing} +
+ + + ${this.ctxMenu.playlistSubmenuOpen && + this.selection.hasSelection + ? html` +
+ this.ctxMenu.clearSubmenuCloseTimer()} + @mouseleave=${this + .ctxMenu + .scheduleSubmenuClose} + > + + e.stopPropagation()} + > +
+ ` + : nothing} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'playlist-details': PlaylistDetails; + } +} diff --git a/frontend/src/store/search-store.ts b/frontend/src/store/search-store.ts index ed84053..0a6ca43 100644 --- a/frontend/src/store/search-store.ts +++ b/frontend/src/store/search-store.ts @@ -1,5 +1,5 @@ /** Searchable views that respond to the global search term. */ -const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'artists', 'genres']); +const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'playlist-details', 'artists', 'genres']); type Subscriber = () => void;