Squash merge audio-player-component into main

This commit is contained in:
2026-02-13 20:39:23 -06:00
parent 9b7cfd5bd1
commit d78c0584e2
122 changed files with 11750 additions and 1175 deletions
@@ -1,25 +1,34 @@
import { LitElement, html } from 'lit';
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import './controls/player-controls';
import './seekbar/seek-bar';
import '@go/player/Player';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
const audioPlayer = () => html`
<div>
<player-controls></player-controls>
<div style="width: 50%">
<seek-bar></seek-bar>
</div>
</div>
`;
import './volume-control/volume-control';
@customElement('audio-player')
export class AudioPlayer extends LitElement {
static override styles = css`
.audio-player-container {
display: flex;
align-items: center;
gap: 0.5em;
}
.player-main {
flex: 1;
}
`;
override render() {
return audioPlayer();
return html`
<div class="audio-player-container">
<div class="player-main">
<player-controls></player-controls>
<div>
<seek-bar></seek-bar>
</div>
</div>
<volume-control></volume-control>
</div>
`;
}
}
@@ -1,57 +1,110 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Play, Pause } from '@go/player/Player';
// assets
import pauseSVG from "@assets/images/icons/music/pause-solid.svg"
import playSVG from "@assets/images/icons/music/play-solid.svg"
import shuffleSVG from "@assets/images/icons/music/shuffle.svg"
import skipPrevSVG from "@assets/images/icons/music/skip-prev-solid.svg"
import skipNextSVG from "@assets/images/icons/music/skip-next-solid.svg"
import repeatSVG from "@assets/images/icons/music/repeat.svg"
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private queue = new QueueController(this);
@property({ type: Boolean })
isPlaying = false
static override styles = css`
#player-control-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
}
button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
color: #ffd43b;
}
.active {
color: #ffd43b;
}
.repeat-one {
position: relative;
}
.repeat-one::after {
content: '1';
font-size: 8px;
font-weight: bold;
position: absolute;
bottom: 2px;
right: 2px;
}
`;
private handlePlayClick = () => {
this.player.play();
};
private handlePauseClick = () => {
this.player.pause();
};
private handleNextClick = () => {
this.queue.next();
};
private handlePreviousClick = () => {
this.queue.previous();
};
private handleShuffleClick = () => {
this.queue.toggleShuffle();
};
private handleRepeatClick = () => {
this.queue.cycleRepeat();
};
override render() {
var imagePath = this.isPlaying ? pauseSVG : playSVG
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
const shuffleClass = this.queue.shuffleMode ? 'active' : '';
const repeatMode = this.queue.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' ');
return html`
<div>
<button>
<img src="${shuffleSVG}"></img>
</button>
<button>
<img src="${skipPrevSVG}"></img>
</button>
<button @click="${this.onPlayPauseClick}">
<img src="${imagePath}"></img>
</button>
<button>
<img src="${skipNextSVG}"></img>
</button>
<button>
<img src="${repeatSVG}"></img>
</button>
</div>
<div id="player-control-buttons">
<button class=${shuffleClass} @click=${this.handleShuffleClick}>
<wa-icon name="shuffle"></wa-icon>
</button>
<button @click=${this.handlePreviousClick}>
<wa-icon name="backward-step"></wa-icon>
</button>
<button @click="${playOrPauseHandler}">
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button @click=${this.handleNextClick}>
<wa-icon name="forward-step"></wa-icon>
</button>
<button class=${repeatClasses} @click=${this.handleRepeatClick}>
<wa-icon name="repeat"></wa-icon>
</button>
</div>
`;
}
onPlayPauseClick() {
if (this.isPlaying) {
Play().then(() => {
}).catch((err) => {
console.error("there is an error with playing " + err);
});
} else {
Pause().then(() => {
}).catch((err) => {
console.error("there is an error with pausing " + err);
});
}
this.isPlaying = !this.isPlaying
}
}
@@ -1,84 +1,169 @@
import { LitElement, html, css, type PropertyValues } from 'lit';
import { customElement, property} from 'lit/decorators.js';
import {SignalWatcher, watch, signal} from '@lit-labs/signals';
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { ref, createRef } from 'lit/directives/ref.js';
import { SlRange } from '@node_modules/@shoelace-style/shoelace/dist/shoelace';
import WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
import { formatSeconds } from '@utils/time';
import { PlayerController } from '@store/controllers/player-controller';
const progress = signal(20);
const ProgressIntervalMillis = 1000;
@customElement('seek-bar')
export class SeekBar extends SignalWatcher(LitElement) {
@property()
progressIntervalMillis: number = 1000;
export class SeekBar extends LitElement {
private player = new PlayerController(this);
private rangeRef = createRef<WaSlider>();
private timerID: number = -1;
private previousTrackPath: string | null = null;
@property()
isProgressing: boolean = false;
@state()
private seekValue: number = 0;
timerID: number = -1;
private rangeRef = createRef<SlRange>();
static override styles = css`
wa-slider {
--track-size: 6px;
flex: 1;
margin: 0 1em;
--wa-tooltip-background-color: #343a40;
--wa-tooltip-content-color: white;
--wa-tooltip-border-color: #343a40;
--wa-tooltip-border-radius: 4px;
--wa-tooltip-font-size: 0.875em;
}
constructor(){
super();
wa-slider::part(track) {
background: white;
}
wa-slider::part(indicator) {
background: yellow;
}
wa-slider::part(thumb) {
background: black;
}
#seek-bar-container {
display: flex;
justify-content: space-between;
align-items: center;
}
`;
// ===================================================================
// DERIVED STATE
// ===================================================================
private get hasTrack(): boolean {
return this.player.currentTrack !== null;
}
private get trackLength(): number {
return this.player.currentTrack?.trackLength ?? 0;
}
private get isPlaying(): boolean {
return this.player.isPlaying;
}
// ===================================================================
// LIFECYCLE
// ===================================================================
override disconnectedCallback() {
super.disconnectedCallback();
this.stopProgress();
}
static override styles = css`
sl-range::part(base) {
--track-color-active: red;
--track-color-inactive: white;
--track-height: 6px;
}
sl-range::part(form-control-input) {
--sl-color-primary-600: yellow;
}
`;
override render() {
return html`
<sl-range
value="${progress.get()}"
${ref(this.rangeRef)}
@sl-change="${(event: CustomEvent) => {
this.setProgressValue((event.target as SlRange).value);
if(this.isProgressing) this.startProgress();
else this.stopProgress();
}}"
@sl-input="${() => {
var progressing = this.isProgressing;
override updated() {
// Detect track change and reset seek position
const currentPath = this.player.currentTrack?.filePath ?? null;
if (currentPath !== this.previousTrackPath) {
this.previousTrackPath = currentPath;
this.seekValue = this.player.currentTrack?.seekPosition ?? 0;
this.stopProgress();
this.isProgressing = progressing;
}}"></sl-range>
`;
}
}
init(interval: number, playing: boolean){
this.progressIntervalMillis = interval;
if(playing)this.startProgress
}
stopProgress(){
this.isProgressing = false;
clearInterval(this.timerID);
}
startProgress(){
this.isProgressing = true;
this.timerID = setInterval(this.incrementProgressValue, this.progressIntervalMillis);
}
setProgressValue(val: number){
if(val < 0) val = 0;
if(val > 100) val = 100;
progress.set(val);
}
setProgressInterval(intervalMillis: number){
if(intervalMillis < 10) intervalMillis = 10;
}
async incrementProgressValue(){
if(progress.get() <100)
{
progress.set(progress.get() + 1);
// Start/stop progress interval based on playback state
if (this.isPlaying && this.hasTrack) {
this.startProgress();
} else {
this.stopProgress();
}
}
}
// ===================================================================
// PROGRESS INTERVAL
// ===================================================================
private stopProgress() {
if (this.timerID !== -1) {
clearInterval(this.timerID);
this.timerID = -1;
}
}
private startProgress() {
// Don't start multiple intervals
if (this.timerID !== -1) {
return;
}
this.timerID = window.setInterval(() => {
if (this.seekValue < this.trackLength) {
this.seekValue += 1;
}
}, ProgressIntervalMillis);
}
// ===================================================================
// EVENT HANDLERS
// ===================================================================
private handleChange(e: Event) {
const newSeekVal = (e.target as WaSlider).value;
this.setSeekValue(newSeekVal);
this.player.seek(newSeekVal);
if (this.isPlaying) {
this.startProgress();
}
}
// Stops progress while user is dragging the thumb
private handleInput() {
this.stopProgress();
}
private setSeekValue(val: number) {
if (val < 0) val = 0;
if (val > this.trackLength) val = this.trackLength;
this.seekValue = val;
}
// ===================================================================
// RENDER
// ===================================================================
override render() {
const elapsedTime = this.hasTrack ? formatSeconds(this.seekValue) : '--:--';
const remainingTime = this.hasTrack
? formatSeconds(this.trackLength - this.seekValue)
: '--:--';
return html`
<div id="seek-bar-container">
<small>${elapsedTime}</small>
<wa-slider
.value="${this.seekValue}"
max="${this.trackLength}"
?with-tooltip="${this.hasTrack}"
.valueFormatter="${this.hasTrack ? formatSeconds : null}"
${ref(this.rangeRef)}
@change="${this.handleChange}"
@input="${this.handleInput}"
></wa-slider>
<small>${remainingTime}</small>
</div>
`;
}
}
@@ -0,0 +1,147 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/slider/slider.js';
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
import { PlayerController } from '@store/controllers/player-controller';
@customElement('volume-control')
export class VolumeControl extends LitElement {
private player = new PlayerController(this);
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
@state()
private showSlider = false;
static override styles = css`
:host {
position: relative;
display: inline-flex;
align-items: center;
}
button {
background: none;
border: none;
cursor: pointer;
color: inherit;
padding: 0.25em;
display: flex;
align-items: center;
}
.volume-popup {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 1em 0.5em;
margin-bottom: 0.5em;
display: flex;
justify-content: center;
z-index: 100;
}
wa-slider {
--track-size: 6px;
--thumb-width: 1em;
--thumb-height: 1em;
}
wa-slider::part(track) {
background: white;
height: 120px;
}
wa-slider::part(indicator) {
background: yellow;
}
wa-slider::part(thumb) {
background: black;
}
`;
// ===================================================================
// DERIVED STATE
// ===================================================================
private get volumeIcon(): string {
const vol = this.player.volume;
if (vol === 0) return 'volume-xmark';
if (vol <= 50) return 'volume-low';
return 'volume-high';
}
// ===================================================================
// LIFECYCLE
// ===================================================================
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick);
}
// ===================================================================
// EVENT HANDLERS
// ===================================================================
private toggleSlider(e: Event) {
e.stopPropagation();
this.showSlider = !this.showSlider;
if (this.showSlider) {
document.addEventListener('click', this.boundHandleOutsideClick);
} else {
document.removeEventListener('click', this.boundHandleOutsideClick);
}
}
private handleOutsideClick(e: Event) {
const path = e.composedPath();
if (!path.includes(this)) {
this.showSlider = false;
document.removeEventListener('click', this.boundHandleOutsideClick);
}
}
private handleInput(e: Event) {
const value = (e.target as WaSlider).value;
this.player.setVolume(value);
}
private handlePopupClick(e: Event) {
e.stopPropagation();
}
// ===================================================================
// RENDER
// ===================================================================
override render() {
return html`
<button @click="${this.toggleSlider}">
<wa-icon name=${this.volumeIcon}></wa-icon>
</button>
${this.showSlider
? html`
<div class="volume-popup" @click="${this.handlePopupClick}">
<wa-slider
orientation="vertical"
min="0"
max="100"
.value="${this.player.volume}"
@change="${this.handleInput}"
></wa-slider>
</div>
`
: ''}
`;
}
}
@@ -1,6 +1,5 @@
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { GetDir, SetDir } from '@go/library/Library.js';
import { DirectoryPicker } from '@go/frontendbindings/FrontendBindings.js';
@customElement('library-picker')
@@ -0,0 +1,375 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsEmit } from '@runtime/runtime';
import { GetAllAlbums, GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models';
import { QueueController } from '@store/controllers/queue-controller';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@customElement('cover-grid')
export class CoverGrid extends LitElement {
private queue = new QueueController(this);
private closeHandler = () => this.closeContextMenu();
static override styles = css`
:host {
display: block;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
padding: 16px;
}
.album-card {
display: flex;
flex-direction: column;
cursor: pointer;
border-radius: 8px;
padding: 8px;
transition: background-color 0.2s ease;
}
.album-card:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.album-card:focus {
outline: 2px solid #1db954;
outline-offset: 2px;
}
.cover-container {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background-color: #282828;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder-cover {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #404040 0%, #282828 100%);
color: #b3b3b3;
font-size: 48px;
}
.album-info {
margin-top: 8px;
min-width: 0;
}
.album-name {
font-size: 14px;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist-name {
font-size: 12px;
color: #b3b3b3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 32px;
color: #b3b3b3;
}
.empty-state {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px;
color: #b3b3b3;
text-align: center;
}
.empty-state p {
margin: 8px 0;
}
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: #2a2a3e;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
}
.context-menu-panel wa-dropdown-item::part(base) {
color: #e0e0e0;
font-size: 13px;
}
.context-menu-panel wa-dropdown-item::part(base):hover {
background-color: rgba(255, 255, 255, 0.1);
}
`;
@state()
private albums: library.Album[] = [];
@state()
private loading = true;
@state()
private contextMenuOpen = false;
@state()
private contextMenuAlbum: library.Album | null = null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
override connectedCallback() {
super.connectedCallback();
this.loadAlbums();
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
}
private async loadAlbums() {
try {
this.loading = true;
const albums = await GetAllAlbums();
this.albums = albums ?? [];
} catch (error) {
console.error("Error loading albums:", error);
this.albums = [];
} finally {
this.loading = false;
}
}
private async getAlbumFilePaths(album: library.Album): Promise<string[]> {
try {
const tracks = await GetAlbumTracks(album.ID);
return tracks.map((t) => t.FilePath);
} catch (error) {
console.error("Error loading album tracks:", error);
return [];
}
}
private onAlbumContextMenu(e: MouseEvent, album: library.Album) {
e.preventDefault();
e.stopPropagation();
this.contextMenuAlbum = album;
this.contextMenuOpen = true;
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
}
private async onContextMenuAction(action: string) {
if (!this.contextMenuAlbum) return;
const filePaths = await this.getAlbumFilePaths(this.contextMenuAlbum);
if (filePaths.length === 0) return;
switch (action) {
case 'play':
this.queue.setQueue(filePaths, 0);
break;
case 'add-to-queue':
this.queue.addTracksToQueue(filePaths);
break;
case 'play-next':
this.queue.playTracksNext(filePaths);
break;
}
this.closeContextMenu();
}
private closeContextMenu() {
if (!this.contextMenuOpen) return;
this.contextMenuOpen = false;
this.contextMenuAlbum = null;
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
override render() {
if (this.loading) {
return html`<div class="loading">Loading albums...</div>`;
}
if (this.albums.length === 0) {
return html`
<div class="empty-state">
<p>No albums found</p>
<p>Add music to your library to see album covers here.</p>
</div>
`;
}
return html`
<div class="grid">
${this.albums.map(album => this.renderAlbumCard(album))}
</div>
<wa-popup
id="context-menu"
placement="bottom-start"
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('add-to-queue')}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
`;
}
private renderAlbumCard(album: library.Album) {
return html`
<div
class="album-card"
tabindex="0"
role="button"
aria-label="${album.Name} by ${album.ArtistName}"
@click=${() => this.onAlbumClick(album)}
@keydown=${(e: KeyboardEvent) => this.onAlbumKeydown(e, album)}
@contextmenu=${(e: MouseEvent) => this.onAlbumContextMenu(e, album)}
>
<div class="cover-container">
${album.CoverArtPath
? html`<img
class="cover-image"
src="${album.CoverArtPath}"
alt="${album.Name} cover"
loading="lazy"
/>`
: html`<div class="placeholder-cover">
${this.getAlbumInitial(album.Name)}
</div>`}
</div>
<div class="album-info">
<div class="album-name" title="${album.Name}">${album.Name}</div>
<div class="artist-name" title="${album.ArtistName}">
${album.ArtistName}${album.Year ? ` - ${album.Year}` : ''}
</div>
</div>
</div>
`;
}
private getAlbumInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
private onAlbumClick(album: library.Album) {
EventsEmit('AlbumSelected', album);
this.dispatchEvent(
new CustomEvent('album-selected', {
detail: album,
bubbles: true,
composed: true,
})
);
}
private onAlbumKeydown(e: KeyboardEvent, album: library.Album) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.onAlbumClick(album);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'cover-grid': CoverGrid;
}
}
@@ -0,0 +1,98 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
@customElement('now-playing')
export class NowPlaying extends LitElement {
private player = new PlayerController(this);
static override styles = css`
.now-playing {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
}
.cover-art {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
}
.cover-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-placeholder {
width: 100%;
height: 100%;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.cover-placeholder wa-icon {
color: #fff;
font-size: 24px;
}
.track-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.track-title {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
override render() {
const track = this.player.currentTrack;
if (!track) {
return html`
<div class="now-playing">
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
</div>
</div>
`;
}
return html`
<div class="now-playing">
<div class="cover-art">
${track.coverArt
? html`<img src="${track.coverArt}" alt="Album cover" />`
: html`<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>`}
</div>
<div class="track-info">
<span class="track-title">${track.title}</span>
<span class="track-artist">${track.artist || 'Unknown Artist'}</span>
</div>
</div>
`;
}
}
@@ -0,0 +1,226 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { QueueController } from '@store/controllers/queue-controller';
@customElement('queue-panel')
export class QueuePanel extends LitElement {
private queue = new QueueController(this);
@property({ type: Boolean, reflect: true })
open = false;
static override styles = css`
:host {
display: block;
position: fixed;
top: 4em; /* below header */
right: 0;
bottom: 4em; /* above footer */
width: 320px;
background-color: #1a1a2e;
border-left: 1px solid #333;
transform: translateX(100%);
transition: transform 0.25s ease-in-out;
z-index: 100;
overflow: hidden;
display: flex;
flex-direction: column;
}
:host([open]) {
transform: translateX(0);
}
.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;
}
.close-button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
}
.close-button:hover {
color: #ffd43b;
}
.track-list {
flex: 1;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
}
.track-item {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: default;
}
.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: #666;
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: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-button {
background: none;
border: none;
color: #666;
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: #666;
text-align: center;
gap: 8px;
}
.empty-state wa-icon {
font-size: 32px;
}
`;
private handleClose() {
this.open = false;
this.dispatchEvent(new CustomEvent('queue-panel-close', { bubbles: true, composed: true }));
}
private handleRemoveTrack(position: number) {
this.queue.removeFromQueue(position);
}
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(/\.[^.]+$/, '');
}
override render() {
const tracks = this.queue.tracks;
const currentIndex = this.queue.currentIndex;
return html`
<div class="header">
<h3>Queue</h3>
<button class="close-button" @click=${this.handleClose}>
<wa-icon name="xmark"></wa-icon>
</button>
</div>
${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`
<ul class="track-list">
${tracks.map(
(track, index) => html`
<li class="track-item ${index === currentIndex ? 'active' : ''}">
<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=${() => this.handleRemoveTrack(index)}
title="Remove from queue"
>
<wa-icon name="xmark"></wa-icon>
</button>
</li>
`
)}
</ul>
`}
`;
}
}
@@ -0,0 +1,154 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
type View = 'home' | 'libraries' | 'playlists' | 'artists' | 'albums' | 'tracks';
interface NavItem {
id: View;
label: string;
}
const MIN_WIDTH = 120;
const MAX_WIDTH = 400;
const DEFAULT_WIDTH = 200;
@customElement('app-sidebar')
export class AppSidebar extends LitElement {
static override styles = css`
:host {
display: block;
position: relative;
height: 100%;
background-color: #212529;
min-width: ${MIN_WIDTH}px;
max-width: ${MAX_WIDTH}px;
}
.resize-handle {
position: absolute;
top: 0;
right: 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;
}
ul {
list-style-type: none;
margin: 0;
padding: 1em;
}
li {
text-align: left;
border-radius: 5px;
padding: 0.5em;
cursor: pointer;
transition: background-color 0.15s ease;
}
li:hover {
background-color: #343a40;
}
li.active {
background-color: #495057;
}
li p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
@state()
private activeView: View = 'tracks';
@state()
private isDragging = false;
private navItems: NavItem[] = [
{ id: 'home', label: 'Home' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'artists', label: 'Artists' },
{ id: 'albums', label: 'Albums' },
{ id: 'tracks', label: 'Tracks' },
];
override connectedCallback() {
super.connectedCallback();
this.style.width = `${DEFAULT_WIDTH}px`;
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
override render() {
return html`
<div
class="resize-handle ${this.isDragging ? 'dragging' : ''}"
@mousedown=${this.handleMouseDown}
></div>
<ul>
${this.navItems.map(item => html`
<li
class="${this.activeView === item.id ? 'active' : ''}"
@click=${() => this.navigate(item.id)}
>
<p>${item.label}</p>
</li>
`)}
</ul>
`;
}
private handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
this.isDragging = true;
};
private handleMouseMove = (e: MouseEvent) => {
if (!this.isDragging) return;
const rect = this.getBoundingClientRect();
const newWidth = e.clientX - rect.left;
const clampedWidth = Math.min(Math.max(newWidth, MIN_WIDTH), MAX_WIDTH);
this.style.width = `${clampedWidth}px`;
};
private handleMouseUp = () => {
this.isDragging = false;
};
private navigate(view: View) {
this.activeView = view;
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
composed: true,
}));
}
}
declare global {
interface HTMLElementTagNameMap {
'app-sidebar': AppSidebar;
}
}
@@ -0,0 +1,284 @@
import { GetAllTracks } from '@go/library/Library';
import { library } from '@go/models';
import { LogPrint } from '@runtime/runtime';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { formatMilliseconds } from '@utils/time';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@customElement('track-list')
export class TrackList extends LitElement {
private player = new PlayerController(this);
private queue = new QueueController(this);
@state()
private tracks: library.Track[] = [];
@state()
private contextMenuOpen = false;
@state()
private contextMenuTrack: library.Track | null = null;
@query('#context-menu')
private contextMenuPopup!: HTMLElement;
private closeHandler = () => this.closeContextMenu();
static override styles = css`
table {
width: 100%;
border-collapse: collapse;
}
th {
padding: 8px;
text-align: left;
font-weight: bold;
color: #fff;
}
thead tr {
border-bottom: 1px solid #666;
}
tbody tr {
border-bottom: 1px solid #333;
}
tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
tbody tr.active {
background-color: rgba(255, 212, 59, 0.1);
}
tbody tr.active .track-name-button {
color: #ffd43b;
}
td {
padding: 8px;
}
.track-name-button {
background: none;
border: none;
color: inherit;
text-align: left;
padding: 0;
cursor: pointer;
width: 100%;
}
.track-name-button:hover {
text-decoration: underline;
}
#context-menu {
z-index: 200;
}
.context-menu-panel {
background-color: #2a2a3e;
border: 1px solid #444;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 160px;
}
.context-menu-panel wa-dropdown-item {
cursor: pointer;
}
.context-menu-panel wa-dropdown-item::part(base) {
color: #e0e0e0;
font-size: 13px;
}
.context-menu-panel wa-dropdown-item::part(base):hover {
background-color: rgba(255, 255, 255, 0.1);
}
`;
override connectedCallback() {
super.connectedCallback();
this.loadTracks();
document.addEventListener('click', this.closeHandler);
document.addEventListener('contextmenu', this.closeHandler);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.closeHandler);
document.removeEventListener('contextmenu', this.closeHandler);
}
async loadTracks() {
try {
const tracks = await GetAllTracks();
this.tracks = tracks;
if (tracks[0]) {
LogPrint(tracks[0].TrackName);
}
} catch (error) {
console.error('Error loading tracks:', error);
}
}
private onTrackClick(track: library.Track) {
this.queue.setQueue([track.FilePath], 0);
}
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
e.preventDefault();
e.stopPropagation();
this.contextMenuTrack = track;
this.contextMenuOpen = true;
// Position the popup at the mouse cursor using a virtual anchor.
this.updateComplete.then(() => {
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).anchor = {
getBoundingClientRect() {
return {
width: 0,
height: 0,
x: e.clientX,
y: e.clientY,
top: e.clientY,
left: e.clientX,
right: e.clientX,
bottom: e.clientY,
};
},
};
(popup as any).active = true;
}
});
}
private onContextMenuAction(action: string) {
if (!this.contextMenuTrack) return;
const filePath = this.contextMenuTrack.FilePath;
switch (action) {
case 'play':
this.queue.setQueue([filePath], 0);
break;
case 'add-to-queue':
this.queue.addToQueue(filePath);
break;
case 'play-next':
this.queue.playNext(filePath);
break;
}
this.closeContextMenu();
}
private closeContextMenu() {
if (!this.contextMenuOpen) return;
this.contextMenuOpen = false;
this.contextMenuTrack = null;
const popup = this.contextMenuPopup;
if (popup) {
(popup as any).active = false;
}
}
private isActiveTrack(track: library.Track): boolean {
const currentTrack = this.player.currentTrack;
if (!currentTrack) return false;
return currentTrack.filePath === track.FilePath;
}
override render() {
return html`
<div>
${this.tracks.length === 0
? html`<p>Loading tracks...</p>`
: html`
<table>
<thead>
<tr>
<th>Track Name</th>
<th>Artist</th>
<th>Track Length</th>
</tr>
</thead>
<tbody>
${this.tracks.map(
(track) => html`
<tr
class=${this.isActiveTrack(track) ? 'active' : ''}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
>
<td>
<button
class="track-name-button"
@click=${() => this.onTrackClick(track)}
>
${track.TrackName}
</button>
</td>
<td>${track.ArtistName}</td>
<td>${formatMilliseconds(track.TrackLength)}</td>
</tr>
`
)}
</tbody>
</table>
`}
</div>
<wa-popup
id="context-menu"
placement="bottom-start"
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('add-to-queue')}
>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play-next')}
>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
`;
}
}