Files
yellowjacket/frontend/src/components/queue-panel/queue-panel.ts
T

436 lines
10 KiB
TypeScript

import { LitElement, html, css, nothing, unsafeCSS } 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 { QueueController } from '@store/controllers/queue-controller';
import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import type { QueueTrack } from '@store/queue-store';
const MIN_WIDTH = 200;
const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 320;
@customElement('queue-panel')
export class QueuePanel extends LitElement {
private queue = new QueueController(this);
@property({ type: Boolean, reflect: true })
open = false;
@state()
private isDragging = false;
@state()
private playlistPickerOpen = false;
@query('#add-to-playlist-popup')
private addToPlaylistPopup!: HTMLElement;
@query('lit-virtualizer')
private virtualizer!: LitVirtualizer;
private closePickerHandler = (e: MouseEvent) => {
const path = e.composedPath();
const popup = this.addToPlaylistPopup;
const btn = this.shadowRoot?.querySelector('.add-to-playlist-button');
if (popup && !path.includes(popup) && (!btn || !path.includes(btn))) {
this.closePlaylistPicker();
}
};
private panelWidth = DEFAULT_WIDTH;
private flowLayout = flow();
/** Track the last currentIndex so we only auto-scroll on actual track changes. */
private lastScrolledIndex = -1;
static override styles = css`
:host {
flex-shrink: 0;
width: 0;
overflow: hidden;
background-color: #212529;
display: flex;
flex-direction: row;
}
:host([open]) {
width: var(--queue-width, ${unsafeCSS(DEFAULT_WIDTH)}px);
border-left: 1px solid #333;
}
.resize-handle {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.15s ease;
z-index: 10;
}
.resize-handle:hover,
.resize-handle.dragging {
background-color: #6c757d;
}
.panel-content {
position: relative;
display: flex;
flex-direction: column;
min-width: ${unsafeCSS(MIN_WIDTH)}px;
flex: 1;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.add-to-playlist-button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
}
.add-to-playlist-button:hover {
color: #ffd43b;
}
.add-to-playlist-button:disabled {
color: #555;
cursor: not-allowed;
}
#add-to-playlist-popup {
z-index: 210;
}
lit-virtualizer {
flex: 1;
overflow-y: auto;
}
.track-item {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.track-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.track-item.active {
background-color: rgba(255, 212, 59, 0.1);
}
.track-position {
font-size: 12px;
color: #888;
min-width: 20px;
text-align: right;
}
.track-item.active .track-position {
color: #ffd43b;
}
.track-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.track-title {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-item.active .track-title {
color: #ffd43b;
}
.track-artist {
font-size: 11px;
color: #b3b3b3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-button {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s;
}
.track-item:hover .remove-button {
opacity: 1;
}
.remove-button:hover {
color: #ff6b6b;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #b3b3b3;
text-align: center;
gap: 8px;
}
.empty-state wa-icon {
font-size: 32px;
}
`;
override connectedCallback() {
super.connectedCallback();
this.style.setProperty('--queue-width', `${this.panelWidth}px`);
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
document.addEventListener('click', this.closePickerHandler);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
document.removeEventListener('click', this.closePickerHandler);
}
override updated() {
const currentIndex = this.queue.currentIndex;
// Auto-scroll to the active track when it changes.
if (
currentIndex >= 0 &&
currentIndex !== this.lastScrolledIndex &&
this.virtualizer
) {
this.lastScrolledIndex = currentIndex;
requestAnimationFrame(() => {
this.virtualizer?.scrollToIndex(currentIndex, 'center');
});
}
}
private async handleAddToPlaylist() {
if (this.queue.tracks.length === 0) return;
this.playlistPickerOpen = !this.playlistPickerOpen;
await this.updateComplete;
const popup = this.addToPlaylistPopup;
const btn = this.shadowRoot?.querySelector('.add-to-playlist-button');
if (popup && btn) {
(popup as any).anchor = btn;
(popup as any).active = this.playlistPickerOpen;
}
if (this.playlistPickerOpen) {
const picker = this.shadowRoot?.querySelector(
'playlist-picker',
) as PlaylistPicker | null;
picker?.reset();
}
}
private closePlaylistPicker() {
if (!this.playlistPickerOpen) return;
this.playlistPickerOpen = false;
const popup = this.addToPlaylistPopup;
if (popup) {
(popup as any).active = false;
}
}
private onPlaylistActionComplete = () => {
this.closePlaylistPicker();
};
private handleRemoveTrack(e: Event, position: number) {
e.stopPropagation();
this.queue.removeFromQueue(position);
}
private handleTrackClick(index: number) {
this.queue.playAtIndex(index);
}
private getDisplayTitle(
track: { title: string; filePath: string },
): string {
if (track.title) return track.title;
// Fall back to filename without extension.
const parts = track.filePath.split(/[\\/]/);
const filename = parts[parts.length - 1] ?? track.filePath;
return filename.replace(/\.[^.]+$/, '');
}
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const rect = this.getBoundingClientRect();
const newWidth = rect.right - e.clientX;
const clampedWidth = Math.min(
Math.max(newWidth, MIN_WIDTH),
MAX_WIDTH,
);
this.panelWidth = clampedWidth;
this.style.setProperty('--queue-width', `${clampedWidth}px`);
};
private handleMouseUp = () => {
if (!this.isDragging) return;
this.isDragging = false;
};
private renderTrackItem = (
track: QueueTrack,
index: number,
) => {
const currentIndex = this.queue.currentIndex;
return html`
<div
class="track-item ${index === currentIndex ? 'active' : ''}"
@click=${() => this.handleTrackClick(index)}
>
<span class="track-position">${index + 1}</span>
<div class="track-details">
<span class="track-title">
${this.getDisplayTitle(track)}
</span>
<span class="track-artist">
${track.artist || 'Unknown Artist'}
</span>
</div>
<button
class="remove-button"
@click=${(e: Event) =>
this.handleRemoveTrack(e, index)}
title="Remove from queue"
>
<wa-icon name="xmark"></wa-icon>
</button>
</div>
`;
};
override render() {
const tracks = this.queue.tracks;
return html`
<div class="panel-content">
<div
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
@mousedown=${this.handleMouseDown}
></div>
<div class="header">
<h3>Queue</h3>
<button
class="add-to-playlist-button"
@click=${this.handleAddToPlaylist}
?disabled=${tracks.length === 0}
title="Add queue to playlist"
>
<wa-icon name="plus"></wa-icon>
</button>
</div>
<wa-popup
id="add-to-playlist-popup"
placement="bottom-end"
.active=${this.playlistPickerOpen}
>
${this.playlistPickerOpen
? html`
<playlist-picker
.filePaths=${tracks.map((t) => t.filePath)}
@playlist-action-complete=${this.onPlaylistActionComplete}
@click=${(e: Event) => e.stopPropagation()}
></playlist-picker>
`
: nothing}
</wa-popup>
${tracks.length === 0
? html`
<div class="empty-state">
<wa-icon name="list"></wa-icon>
<p>Queue is empty</p>
<p style="font-size: 12px;">
Click a track to start playing
</p>
</div>
`
: html`
<lit-virtualizer
scroller
.items=${tracks}
.renderItem=${this.renderTrackItem}
.layout=${this.flowLayout}
></lit-virtualizer>
`}
</div>
`;
}
}