Squash merge audio-player-component into main
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Centralized event name constants for Wails frontend/backend communication.
|
||||
// These names must match the corresponding event names in the Go backend.
|
||||
|
||||
export const Events = {
|
||||
// Playback control events
|
||||
PlaybackStateChanged: "PlaybackStateChanged",
|
||||
PlaybackFinished: "PlaybackFinished",
|
||||
RequestPlay: "RequestPlay",
|
||||
RequestPause: "RequestPause",
|
||||
RequestLoadFile: "RequestLoadFile",
|
||||
|
||||
// Track events
|
||||
TrackChanged: "TrackChanged",
|
||||
|
||||
// Seek events
|
||||
Seek: "Seek",
|
||||
SeekFailed: "SeekFailed",
|
||||
|
||||
// Volume events
|
||||
RequestSetVolume: "RequestSetVolume",
|
||||
VolumeChanged: "VolumeChanged",
|
||||
|
||||
// Queue events
|
||||
QueueChanged: "QueueChanged",
|
||||
RequestNext: "RequestNext",
|
||||
RequestPrevious: "RequestPrevious",
|
||||
RequestSetQueue: "RequestSetQueue",
|
||||
RequestAddToQueue: "RequestAddToQueue",
|
||||
RequestPlayNext: "RequestPlayNext",
|
||||
RequestRemoveFromQueue: "RequestRemoveFromQueue",
|
||||
RequestToggleShuffle: "RequestToggleShuffle",
|
||||
RequestCycleRepeat: "RequestCycleRepeat",
|
||||
RequestAddTracksToQueue: "RequestAddTracksToQueue",
|
||||
RequestPlayTracksNext: "RequestPlayTracksNext",
|
||||
} as const;
|
||||
|
||||
export type EventName = (typeof Events)[keyof typeof Events];
|
||||
@@ -16,38 +16,15 @@
|
||||
|
||||
<body>
|
||||
<header class="container">
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<hgroup>
|
||||
<a href="/index.html">
|
||||
<h1>YellowJacket</h1>
|
||||
</a>
|
||||
<p>Config</p>
|
||||
</hgroup>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<details class="dropdown">
|
||||
<summary role="button" class="outline">
|
||||
YJ
|
||||
</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/src/pages/config/config.html">
|
||||
<img src="/src/assets/images/icons/ui/settings.svg" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<hgroup>
|
||||
<a href="/index.html">
|
||||
<h1>YellowJacket</h1>
|
||||
</a>
|
||||
</hgroup>
|
||||
</header>
|
||||
<hr />
|
||||
<main class="container">
|
||||
<library-picker currentLibraryDirectoryText="hello world" ></library-picker>
|
||||
<div hx-get="/config" hx-trigger="load"></div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
import '@components/config/options/library-picker.ts';
|
||||
import 'htmx.org/dist/htmx.js'
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
|
||||
declare global {
|
||||
interface Window { DirectoryPicker: any; }
|
||||
}
|
||||
|
||||
window.DirectoryPicker = DirectoryPicker
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { PlayerState, TrackInfo } from '../player-store';
|
||||
import { playerStore } from '../player-store';
|
||||
|
||||
/**
|
||||
* PlayerController connects a Lit component to the PlayerStore.
|
||||
*
|
||||
* Usage in a component:
|
||||
*
|
||||
* private player = new PlayerController(this);
|
||||
*
|
||||
* render() {
|
||||
* return html`
|
||||
* <span>${this.player.currentTrack?.fileName}</span>
|
||||
* <button @click=${() => this.player.play()}>Play</button>
|
||||
* `;
|
||||
* }
|
||||
*/
|
||||
export class PlayerController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE HOOKS
|
||||
// ===================================================================
|
||||
|
||||
hostConnected(): void {
|
||||
// Subscribe to store when component mounts
|
||||
this.unsubscribe = playerStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
// Unsubscribe when component unmounts (prevents memory leaks)
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESSORS
|
||||
// Convenience getters so components don't need to call getState()
|
||||
// ===================================================================
|
||||
|
||||
get state(): Readonly<PlayerState> {
|
||||
return playerStore.getState();
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.state.isPlaying;
|
||||
}
|
||||
|
||||
get currentTrack(): TrackInfo | null {
|
||||
return this.state.currentTrack;
|
||||
}
|
||||
|
||||
get volume(): number {
|
||||
return this.state.volume;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// Delegate to store (which delegates to backend)
|
||||
// ===================================================================
|
||||
|
||||
play(): void {
|
||||
playerStore.play();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
playerStore.pause();
|
||||
}
|
||||
|
||||
loadTrack(filePath: string): void {
|
||||
playerStore.loadTrack(filePath);
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
playerStore.seek(seconds);
|
||||
}
|
||||
|
||||
setVolume(level: number): void {
|
||||
playerStore.setVolume(level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { QueueState, QueueTrack, RepeatMode } from '../queue-store';
|
||||
import { queueStore } from '../queue-store';
|
||||
|
||||
/**
|
||||
* QueueController connects a Lit component to the QueueStore.
|
||||
*
|
||||
* Usage in a component:
|
||||
*
|
||||
* private queue = new QueueController(this);
|
||||
*
|
||||
* render() {
|
||||
* return html`
|
||||
* <span>Tracks: ${this.queue.tracks.length}</span>
|
||||
* <button @click=${() => this.queue.next()}>Next</button>
|
||||
* `;
|
||||
* }
|
||||
*/
|
||||
export class QueueController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// LIFECYCLE HOOKS
|
||||
// ===================================================================
|
||||
|
||||
hostConnected(): void {
|
||||
this.unsubscribe = queueStore.subscribe(() => {
|
||||
this.host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this.unsubscribe?.();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESSORS
|
||||
// ===================================================================
|
||||
|
||||
get state(): Readonly<QueueState> {
|
||||
return queueStore.getState();
|
||||
}
|
||||
|
||||
get tracks(): QueueTrack[] {
|
||||
return this.state.tracks;
|
||||
}
|
||||
|
||||
get currentIndex(): number {
|
||||
return this.state.currentIndex;
|
||||
}
|
||||
|
||||
get currentTrack(): QueueTrack | undefined {
|
||||
return this.state.tracks[this.state.currentIndex];
|
||||
}
|
||||
|
||||
get shuffleMode(): boolean {
|
||||
return this.state.shuffleMode;
|
||||
}
|
||||
|
||||
get repeatMode(): RepeatMode {
|
||||
return this.state.repeatMode;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// ===================================================================
|
||||
|
||||
next(): void {
|
||||
queueStore.next();
|
||||
}
|
||||
|
||||
previous(): void {
|
||||
queueStore.previous();
|
||||
}
|
||||
|
||||
setQueue(filePaths: string[], startIndex: number): void {
|
||||
queueStore.setQueue(filePaths, startIndex);
|
||||
}
|
||||
|
||||
addToQueue(filePath: string): void {
|
||||
queueStore.addToQueue(filePath);
|
||||
}
|
||||
|
||||
playNext(filePath: string): void {
|
||||
queueStore.playNext(filePath);
|
||||
}
|
||||
|
||||
removeFromQueue(position: number): void {
|
||||
queueStore.removeFromQueue(position);
|
||||
}
|
||||
|
||||
addTracksToQueue(filePaths: string[]): void {
|
||||
queueStore.addTracksToQueue(filePaths);
|
||||
}
|
||||
|
||||
playTracksNext(filePaths: string[]): void {
|
||||
queueStore.playTracksNext(filePaths);
|
||||
}
|
||||
|
||||
toggleShuffle(): void {
|
||||
queueStore.toggleShuffle();
|
||||
}
|
||||
|
||||
cycleRepeat(): void {
|
||||
queueStore.cycleRepeat();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { playerStore } from './player-store';
|
||||
export type { PlayerState, TrackInfo } from './player-store';
|
||||
export { PlayerController } from './controllers/player-controller';
|
||||
export { queueStore } from './queue-store';
|
||||
export type { QueueState, QueueTrack, RepeatMode } from './queue-store';
|
||||
export { QueueController } from './controllers/queue-controller';
|
||||
@@ -0,0 +1,121 @@
|
||||
import { EventsOn, EventsEmit } from '@runtime/runtime';
|
||||
import { Events } from '../events';
|
||||
|
||||
// Types
|
||||
export interface TrackInfo {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
trackLength: number; // in seconds
|
||||
seekPosition: number; // current playback position in seconds
|
||||
state: string; // playback state from backend
|
||||
title: string; // track title (falls back to fileName)
|
||||
artist: string; // artist name
|
||||
album: string; // album name
|
||||
coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
// Cached from backend
|
||||
isPlaying: boolean;
|
||||
currentTrack: TrackInfo | null;
|
||||
volume: number; // 0-100
|
||||
|
||||
// Frontend-only state (for future use)
|
||||
// selectedTrackIds: Set<number>;
|
||||
// isQueuePanelOpen: boolean;
|
||||
}
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class PlayerStore {
|
||||
private state: PlayerState = {
|
||||
isPlaying: false,
|
||||
currentTrack: null,
|
||||
volume: 50,
|
||||
};
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
constructor() {
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WAILS EVENT BRIDGE
|
||||
// Subscribe to backend events and update cached state
|
||||
// ===================================================================
|
||||
|
||||
private initializeEventListeners(): void {
|
||||
EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => {
|
||||
this.update({ isPlaying: data.state === 'playing' });
|
||||
});
|
||||
|
||||
EventsOn(Events.TrackChanged, (trackInfo: TrackInfo) => {
|
||||
this.update({ currentTrack: trackInfo });
|
||||
});
|
||||
|
||||
EventsOn(Events.PlaybackFinished, () => {
|
||||
this.update({ isPlaying: false });
|
||||
// Queue auto-advance is handled by the backend queue package.
|
||||
});
|
||||
|
||||
EventsOn(Events.VolumeChanged, (volume: number) => {
|
||||
this.update({ volume });
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESS
|
||||
// ===================================================================
|
||||
|
||||
getState(): Readonly<PlayerState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// These delegate to the backend via Wails events
|
||||
// ===================================================================
|
||||
|
||||
play(): void {
|
||||
EventsEmit(Events.RequestPlay);
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
EventsEmit(Events.RequestPause);
|
||||
}
|
||||
|
||||
loadTrack(filePath: string): void {
|
||||
EventsEmit(Events.RequestLoadFile, filePath);
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
EventsEmit(Events.Seek, seconds);
|
||||
}
|
||||
|
||||
setVolume(level: number): void {
|
||||
EventsEmit(Events.RequestSetVolume, level);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SUBSCRIPTION SYSTEM
|
||||
// ===================================================================
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
private update(partial: Partial<PlayerState>): void {
|
||||
this.state = { ...this.state, ...partial };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const playerStore = new PlayerStore();
|
||||
@@ -0,0 +1,126 @@
|
||||
import { EventsOn, EventsEmit } from '@runtime/runtime';
|
||||
import { Events } from '../events';
|
||||
|
||||
// Types
|
||||
export interface QueueTrack {
|
||||
id: number;
|
||||
audioFileId: number;
|
||||
filePath: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type RepeatMode = 'off' | 'all' | 'one';
|
||||
|
||||
export interface QueueState {
|
||||
tracks: QueueTrack[];
|
||||
currentIndex: number;
|
||||
shuffleMode: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
sourcePlaylistId: number;
|
||||
}
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class QueueStore {
|
||||
private state: QueueState = {
|
||||
tracks: [],
|
||||
currentIndex: 0,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
};
|
||||
|
||||
private subscribers = new Set<Subscriber>();
|
||||
|
||||
constructor() {
|
||||
this.initializeEventListeners();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// WAILS EVENT BRIDGE
|
||||
// Subscribe to backend events and update cached state
|
||||
// ===================================================================
|
||||
|
||||
private initializeEventListeners(): void {
|
||||
EventsOn(Events.QueueChanged, (queueState: QueueState) => {
|
||||
this.state = {
|
||||
tracks: queueState.tracks ?? [],
|
||||
currentIndex: queueState.currentIndex,
|
||||
shuffleMode: queueState.shuffleMode,
|
||||
repeatMode: queueState.repeatMode,
|
||||
sourcePlaylistId: queueState.sourcePlaylistId,
|
||||
};
|
||||
this.notify();
|
||||
});
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STATE ACCESS
|
||||
// ===================================================================
|
||||
|
||||
getState(): Readonly<QueueState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// ACTIONS
|
||||
// These delegate to the backend via Wails events
|
||||
// ===================================================================
|
||||
|
||||
next(): void {
|
||||
EventsEmit(Events.RequestNext);
|
||||
}
|
||||
|
||||
previous(): void {
|
||||
EventsEmit(Events.RequestPrevious);
|
||||
}
|
||||
|
||||
setQueue(filePaths: string[], startIndex: number): void {
|
||||
EventsEmit(Events.RequestSetQueue, filePaths, startIndex);
|
||||
}
|
||||
|
||||
addToQueue(filePath: string): void {
|
||||
EventsEmit(Events.RequestAddToQueue, filePath);
|
||||
}
|
||||
|
||||
playNext(filePath: string): void {
|
||||
EventsEmit(Events.RequestPlayNext, filePath);
|
||||
}
|
||||
|
||||
removeFromQueue(position: number): void {
|
||||
EventsEmit(Events.RequestRemoveFromQueue, position);
|
||||
}
|
||||
|
||||
addTracksToQueue(filePaths: string[]): void {
|
||||
EventsEmit(Events.RequestAddTracksToQueue, filePaths);
|
||||
}
|
||||
|
||||
playTracksNext(filePaths: string[]): void {
|
||||
EventsEmit(Events.RequestPlayTracksNext, filePaths);
|
||||
}
|
||||
|
||||
toggleShuffle(): void {
|
||||
EventsEmit(Events.RequestToggleShuffle);
|
||||
}
|
||||
|
||||
cycleRepeat(): void {
|
||||
EventsEmit(Events.RequestCycleRepeat);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SUBSCRIPTION SYSTEM
|
||||
// ===================================================================
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.subscribers.forEach((callback) => callback());
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const queueStore = new QueueStore();
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Format seconds into mm:ss format
|
||||
*/
|
||||
export function formatSeconds(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds into mm:ss format
|
||||
* Accepts number or string, returns '--:--' if invalid
|
||||
*/
|
||||
export function formatMilliseconds(ms: number | string): string {
|
||||
const millis = typeof ms === 'string' ? parseInt(ms) : ms;
|
||||
if (isNaN(millis)) return '--:--';
|
||||
return formatSeconds(Math.floor(millis / 1000));
|
||||
}
|
||||
Reference in New Issue
Block a user