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
@@ -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();
}
}
+6
View File
@@ -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';
+121
View File
@@ -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();
+126
View File
@@ -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();