Squash merge audio-player-component into main
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user