Files
yellowjacket/frontend/src/store/player-store.ts
T
logan 0ca37a31a6 fix(player): show mute in the volume indicator
Muting does not change the volume level, and VolumeChanged carried
nothing but that level — so pressing M silenced playback and left the
indicator showing the volume it still had. The UI had nothing to react
to.

Mute rides on its own event rather than widening the volume payload,
since the two are genuinely independent: a muted player at 40% is a
different state from a player at 0%, and only one of them comes back
when you unmute. The icon crosses out and dims, and the popup gains an
explicit Mute/Unmute so the keyboard shortcut is not the only way in.

MuteToggle also now takes the speaker lock (it was mutating the effects
chain from outside it) and refuses politely rather than dereferencing a
nil streamer when nothing has been loaded yet.
2026-08-11 01:14:47 -04:00

145 lines
4.2 KiB
TypeScript

import { EventsOn } from '@runtime/runtime';
import { Events } from '../events';
import * as Player from '@go/player/Player';
// TrackInfo mirrors the player.TrackInfo struct in the Go backend.
// Fields are serialized as camelCase JSON via struct tags.
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 full-size cover art or empty string
coverArtSmall: string; // URL path to small variant (100px max) or empty string
coverArtMedium: string; // URL path to medium variant (200px max) or empty string
coverArtLarge: string; // URL path to large variant (400px max) or empty string
trackChangeId: number; // monotonic counter to detect track changes even when the same file plays consecutively
artistMbid: string; // MusicBrainz artist ID or empty string
releaseGroupMbid: string; // MusicBrainz release group ID or empty string
recordingMbid: string; // MusicBrainz recording ID or empty string
}
export interface PlayerState {
// Cached from backend
isPlaying: boolean;
currentTrack: TrackInfo | null;
volume: number; // 0-100
muted: boolean; // silenced independently of the volume level
// 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,
muted: false,
};
private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
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 | null) => {
this.update({ currentTrack: trackInfo ?? null });
});
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 });
});
EventsOn(Events.MuteChanged, (muted: boolean) => {
this.update({ muted });
});
}
// ===================================================================
// STATE ACCESS
// ===================================================================
getState(): Readonly<PlayerState> {
return this.state;
}
// ===================================================================
// ACTIONS
// These delegate to the backend via Wails bindings
// ===================================================================
pause(): void {
Player.Pause();
}
loadTrack(filePath: string): void {
Player.LoadFile(filePath);
}
seek(seconds: number): void {
Player.Seek(seconds);
}
setVolume(level: number): void {
Player.SetVolume(level);
}
toggleMute(): void {
void Player.MuteToggle();
}
// ===================================================================
// 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 {
if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
for (const sub of this.subscribers) {
sub();
}
});
}
}
// Singleton instance
export const playerStore = new PlayerStore();