Files
yellowjacket/frontend/src/components/cover-grid/album-dropdown.ts
T

411 lines
11 KiB
TypeScript

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<string> = 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<TrackClickDetail>(
'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<TrackDblClickDetail>(
'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<TrackDragStartDetail>(
'track-dragstart',
{
bubbles: true,
composed: true,
detail: {
track,
index,
dataTransfer: e.dataTransfer,
},
},
),
);
}
private onTrackDragEnd() {
this.dispatchEvent(
new CustomEvent('track-dragend', {
bubbles: true,
composed: true,
}),
);
}
private onTrackContextMenu(
e: MouseEvent,
track: library.Track,
) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<TrackContextMenuDetail>(
'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`
<div
class=${classes}
draggable="true"
@click=${(e: MouseEvent) =>
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()}
>
<span class="track-number">
${displayNumber}
</span>
<span
class="track-title"
title="${track.TrackName}"
>
${track.TrackName}
</span>
<span class="track-duration">
${formatMilliseconds(
track.TrackLength,
)}
</span>
</div>
`;
}
override render() {
return html`
<div
class="album-dropdown"
style="width:${this.gridRowWidth}px"
>
<div
class="carat"
style="left:${this.caratOffset}px;transform:translateX(-50%)"
></div>
<div
class="dropdown-tracks"
style="height:${this.tracksHeight}px;column-count:${this.columnCount}"
>
${this.tracks.map(
(track, i) =>
this.renderTrackRow(track, i),
)}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'album-dropdown': AlbumDropdown;
}
}