import { LitElement, html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { library } from '@go/models'; import { PlayerController } from '@store/controllers/player-controller'; import { formatMilliseconds } from '@utils/time'; /** Detail payload for the track-click custom event. */ export interface TrackClickDetail { track: library.Track; index: number; ctrlKey: boolean; shiftKey: boolean; metaKey: boolean; } /** Detail payload for the track-dblclick custom event. */ export interface TrackDblClickDetail { track: library.Track; index: number; } /** Detail payload for the track-contextmenu custom event. */ export interface TrackContextMenuDetail { track: library.Track; clientX: number; clientY: number; } /** Detail payload for the track-dragstart custom event. */ export interface TrackDragStartDetail { track: library.Track; index: number; dataTransfer: DataTransfer | null; } /** * Self-contained dropdown that renders an album's track list. * * Owns a PlayerController so that active-track highlighting * only re-renders this component, not the parent grid. */ @customElement('album-dropdown') export class AlbumDropdown extends LitElement { private player = new PlayerController(this); @property({ attribute: false }) tracks: library.Track[] = []; @property({ attribute: false }) selectedTracks: Set = new Set(); /** Width of the grid container in pixels (passed from parent). */ @property({ type: Number }) containerWidth = 800; /** Width of the album row in pixels (cards + gaps, no outer padding). */ @property({ type: Number }) gridRowWidth = 800; /** Horizontal offset of the carat from the dropdown's left edge. */ @property({ type: Number }) caratOffset = 0; static override styles = css` :host { display: block; margin-top: 14px; } .album-dropdown { background-color: var(--yj-bg-elevated, #343a40); border-radius: 0 0 4px 4px; padding: 12px 16px; box-sizing: border-box; position: relative; } .carat { position: absolute; top: -10px; width: 0; height: 0; border-left: 11px solid transparent; border-right: 11px solid transparent; border-bottom: 10px solid var(--yj-bg-elevated, #343a40); } .dropdown-tracks { column-fill: auto; column-gap: 24px; } .track-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 4px; cursor: default; user-select: none; font-size: 12px; line-height: 16px; break-inside: avoid; } .track-row:hover { background-color: var( --yj-hover-overlay, rgba(255, 255, 255, 0.05) ); } .track-row.selected { background-color: rgba( 100, 160, 255, 0.15 ); } .track-row.active { background-color: var( --yj-accent-bg, rgba(255, 212, 59, 0.1) ); color: var(--yj-accent, #ffd43b); } .track-row.selected.active { background-color: rgba( 100, 160, 255, 0.15 ); } .track-number { color: var(--yj-text-tertiary, #888); min-width: 22px; text-align: right; flex-shrink: 0; } .track-title { color: var(--yj-text-primary, #fff); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } .track-duration { color: var(--yj-text-tertiary, #888); flex-shrink: 0; margin-left: auto; } `; /* ================================================================ * Layout helpers * ================================================================ */ /** * Derive the number of track-list columns from the * grid container width. */ get columnCount(): number { const w = this.containerWidth; if (w < 500) return 1; if (w < 800) return 2; if (w < 1200) return 3; return 4; } /* ================================================================ * Rendering helpers * ================================================================ */ private isActiveTrack( track: library.Track, ): boolean { const currentTrack = this.player.currentTrack; if (!currentTrack) return false; return currentTrack.filePath === track.FilePath; } /** Height of each track row: 16px line-height + 4+4px padding. */ private static readonly TRACK_ROW_HEIGHT = 24; /** * Height of the inner .dropdown-tracks container. * Sized so that column-fill:auto fills each column * completely before moving to the next. */ private get tracksHeight(): number { const cols = this.columnCount; const rowsPerCol = Math.ceil( this.tracks.length / cols, ); return ( rowsPerCol * AlbumDropdown.TRACK_ROW_HEIGHT ); } /* ================================================================ * Event dispatching * ================================================================ */ private onTrackClick( e: MouseEvent, track: library.Track, index: number, ) { e.stopPropagation(); this.dispatchEvent( new CustomEvent( 'track-click', { bubbles: true, composed: true, detail: { track, index, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey, }, }, ), ); } private onTrackDblClick( e: MouseEvent, track: library.Track, index: number, ) { e.stopPropagation(); this.dispatchEvent( new CustomEvent( 'track-dblclick', { bubbles: true, composed: true, detail: { track, index }, }, ), ); } private onTrackDragStart( e: DragEvent, track: library.Track, index: number, ) { // Delegate to the parent cover-grid which // owns the selection state and drag-image. this.dispatchEvent( new CustomEvent( 'track-dragstart', { bubbles: true, composed: true, detail: { track, index, dataTransfer: e.dataTransfer, }, }, ), ); } private onTrackDragEnd() { this.dispatchEvent( new CustomEvent('track-dragend', { bubbles: true, composed: true, }), ); } private onTrackContextMenu( e: MouseEvent, track: library.Track, ) { e.preventDefault(); e.stopPropagation(); this.dispatchEvent( new CustomEvent( 'track-contextmenu', { bubbles: true, composed: true, detail: { track, clientX: e.clientX, clientY: e.clientY, }, }, ), ); } /* ================================================================ * Render * ================================================================ */ private renderTrackRow( track: library.Track, index: number, ) { const active = this.isActiveTrack(track); const selected = this.selectedTracks.has( track.FilePath, ); const classes = [ 'track-row', active ? 'active' : '', selected ? 'selected' : '', ] .filter(Boolean) .join(' '); const displayNumber = track.TrackNumber > 0 ? track.TrackNumber : index + 1; return html`
this.onTrackClick(e, track, index)} @dblclick=${(e: MouseEvent) => this.onTrackDblClick( e, track, index, )} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)} @dragstart=${(e: DragEvent) => this.onTrackDragStart( e, track, index, )} @dragend=${() => this.onTrackDragEnd()} > ${displayNumber} ${track.TrackName} ${formatMilliseconds( track.TrackLength, )}
`; } override render() { return html`
`; } } declare global { interface HTMLElementTagNameMap { 'album-dropdown': AlbumDropdown; } }