refactored many events to use wails bindings, reducing boilerplate

This commit is contained in:
2026-02-25 18:51:07 -05:00
parent baa4644ece
commit cf144bf0dd
20 changed files with 1044 additions and 716 deletions
+19 -7
View File
@@ -103,11 +103,22 @@ func NewYellowJacketApp(
yjApp.logger, yjApp.database, yjApp.appConfig,
)
// create queue (before wails.Run so it can be bound)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
// create player (before wails.Run so it can be bound;
// speaker hardware is initialized later in OnStartup)
yjApp.player = player.NewPlayer(
yjApp.logger.WithGroup("player"), yjApp.database,
)
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.appConfig,
yjApp.library,
yjApp.playlist,
yjApp.queue,
yjApp.player,
}
return yjApp, nil
@@ -134,17 +145,18 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.library.SetContext(ctx)
yj.playlist.SetContext(ctx)
var err error
// create player
yj.player, err = player.NewPlayer(ctx, yj.logger.WithGroup("player"), yj.database)
if err != nil {
startupErr = errors.Join(startupErr, fmt.Errorf("could not create player: %w", err))
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
if err := yj.player.InitSpeaker(); err != nil {
startupErr = errors.Join(
startupErr,
fmt.Errorf("could not initialize speaker: %w", err),
)
}
yj.player.SetContext(ctx)
// create queue
yj.queue = queue.NewQueue(yj.logger, yj.database)
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
yj.queue.SetPlayer(yj.player)
yj.queue.RestoreState()
+9 -41
View File
@@ -3,53 +3,21 @@
// the corresponding event names in the TypeScript frontend.
package events
// Playback control events.
// Playback events (backend → frontend push).
const (
PlaybackStateChanged = "PlaybackStateChanged"
PlaybackFinished = "PlaybackFinished"
RequestPlay = "RequestPlay"
RequestPause = "RequestPause"
RequestLoadFile = "RequestLoadFile"
TrackChanged = "TrackChanged"
SeekFailed = "SeekFailed"
VolumeChanged = "VolumeChanged"
)
// Track events.
// Queue events (backend → frontend push).
const (
TrackChanged = "TrackChanged"
)
// Seek events.
const (
Seek = "Seek"
SeekFailed = "SeekFailed"
)
// Volume events.
const (
RequestSetVolume = "RequestSetVolume"
VolumeChanged = "VolumeChanged"
)
// Queue events.
const (
QueueChanged = "QueueChanged"
QueueIndexChanged = "QueueIndexChanged"
QueueModeChanged = "QueueModeChanged"
QueueTracksModified = "QueueTracksModified"
RequestNext = "RequestNext"
RequestPrevious = "RequestPrevious"
RequestSetQueue = "RequestSetQueue"
RequestAddToQueue = "RequestAddToQueue"
RequestPlayNext = "RequestPlayNext"
RequestRemoveFromQueue = "RequestRemoveFromQueue"
RequestToggleShuffle = "RequestToggleShuffle"
RequestCycleRepeat = "RequestCycleRepeat"
RequestAddTracksToQueue = "RequestAddTracksToQueue"
RequestPlayTracksNext = "RequestPlayTracksNext"
RequestPlayQueueIndex = "RequestPlayQueueIndex"
RequestRemoveTracksFromQueue = "RequestRemoveTracksFromQueue"
RequestInsertTracksAtIndex = "RequestInsertTracksAtIndex"
RequestMoveQueueTracks = "RequestMoveQueueTracks"
RequestClearQueue = "RequestClearQueue"
QueueChanged = "QueueChanged"
QueueIndexChanged = "QueueIndexChanged"
QueueModeChanged = "QueueModeChanged"
QueueTracksModified = "QueueTracksModified"
)
// Config events.
+24 -143
View File
@@ -94,16 +94,10 @@ var (
var speakerSampleRate = beep.SampleRate(44100)
// NewPlayer creates a player and initializes the audio speaker.
func NewPlayer(
ctx context.Context,
logger *slog.Logger,
db *database.DB,
) (*Player, error) {
defer profiling.TimeOp(logger, "player.NewPlayer")()
player := &Player{
ctx: ctx,
// NewPlayer creates a player. Call InitSpeaker separately to
// initialize the audio output device.
func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
return &Player{
logger: logger,
db: db,
state: Stopped,
@@ -112,19 +106,27 @@ func NewPlayer(
SampleRate: speakerSampleRate,
},
}
}
// TODO: allow user to change buffer size and speaker sample rate
// InitSpeaker initializes the audio output device. This is
// separated from NewPlayer so the player struct can be created
// before wails.Run (for binding registration) while deferring
// hardware initialization to OnStartup.
func (p *Player) InitSpeaker() error {
defer profiling.TimeOp(p.logger, "player.InitSpeaker")()
// TODO: allow user to change buffer size and speaker sample rate.
err := speaker.Init(
player.format.SampleRate,
player.format.SampleRate.N(time.Second/10),
p.format.SampleRate,
p.format.SampleRate.N(time.Second/10),
)
if err != nil {
return nil, fmt.Errorf(
return fmt.Errorf(
"failed to initialize speaker: %w", err,
)
}
return player, nil
return nil
}
// SetPlaybackFinishedHandler sets a callback invoked when a track
@@ -137,140 +139,18 @@ func (p *Player) SetPlaybackFinishedHandler(handler func()) {
p.playbackFinishedHandler = handler
}
// SetContext sets the Wails context, registers event handlers, and
// restores persisted state.
// SetContext sets the Wails runtime context and restores persisted
// state.
func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
p.ctx = ctx
p.mu.Unlock()
p.registerEventHandlers()
p.mu.Lock()
p.restoreStateLocked()
p.mu.Unlock()
}
func (p *Player) registerEventHandlers() {
p.mu.Lock()
ctx := p.ctx
p.mu.Unlock()
if ctx == nil {
p.logger.Error(
"Context is nil, cannot register event handlers",
)
return
}
runtime.EventsOn(
ctx,
events.RequestPause,
func(_ ...any) {
p.logger.Info("Received RequestPauseEvent")
if err := p.Pause(); err != nil {
p.logger.Error("failed to pause", "err", err)
}
},
)
runtime.EventsOn(
ctx,
events.RequestLoadFile,
func(data ...any) {
p.logger.Info("Received RequestLoadFileEvent")
if len(data) < 1 {
p.logger.Warn(
"RequestLoadFile: missing file path argument",
)
return
}
filePath, ok := data[0].(string)
if !ok {
p.logger.Warn(
"RequestLoadFile: invalid file path type",
"got", fmt.Sprintf("%T", data[0]),
)
return
}
err := p.LoadFile(filePath)
if err != nil {
p.logger.Error(err.Error())
}
},
)
runtime.EventsOn(ctx, events.Seek, func(data ...any) {
p.logger.Info("Received SeekEvent")
if len(data) < 1 {
p.logger.Warn("Seek: missing seek value argument")
return
}
seekFloat, ok := data[0].(float64)
if !ok {
p.logger.Warn(
"Seek: invalid seek value type",
"got", fmt.Sprintf("%T", data[0]),
)
return
}
seekValue := int(seekFloat)
err := p.Seek(seekValue)
if err != nil {
p.logger.Error("cannot seek", "error", err)
}
})
runtime.EventsOn(
ctx,
events.RequestSetVolume,
func(data ...any) {
if len(data) < 1 {
p.logger.Warn(
"RequestSetVolume: missing volume argument",
)
return
}
volFloat, ok := data[0].(float64)
if !ok {
p.logger.Warn(
"RequestSetVolume: invalid volume type",
"got", fmt.Sprintf("%T", data[0]),
)
return
}
desiredVolume := UserVolume(volFloat)
p.logger.Info(
"Received RequestSetVolumeEvent",
"volume", desiredVolume,
)
p.mu.Lock()
p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged()
p.saveState()
p.mu.Unlock()
},
)
}
// ---------------------------------------------------------------
// Emit helpers (must be called with p.mu held)
// ---------------------------------------------------------------
@@ -695,14 +575,15 @@ func (p *Player) UnloadTrack() {
// Volume
// ---------------------------------------------------------------
// SetVolume sets the playback volume (0-100).
func (p *Player) SetVolume(desiredVolume UserVolume) error {
// SetVolume sets the playback volume (0-100), emits a
// VolumeChanged event, and persists the new level.
func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock()
defer p.mu.Unlock()
p.setVolumeLocked(desiredVolume)
return nil
p.emitVolumeChanged()
p.saveState()
}
func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
+9 -11
View File
@@ -1,7 +1,6 @@
package player
import (
"context"
"log/slog"
"os"
"testing"
@@ -15,8 +14,8 @@ var testQueue = []string{
func TestPlayer(t *testing.T) {
// This is an integration test that requires:
// 1. A Wails runtime context (SetContext calls runtime.EventsOn)
// 2. An audio output device (speaker.Init)
// 1. A Wails runtime context (SetContext restores persisted state)
// 2. An audio output device (InitSpeaker)
//
// Skip unless the caller explicitly opts in via YELLOWJACKET_INTEGRATION=1.
if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
@@ -27,25 +26,24 @@ func TestPlayer(t *testing.T) {
t.Logf("Starting test")
p, err := NewPlayer(context.Background(), slog.Default(), nil)
if err != nil {
t.Fatalf("could not create player: %s", err.Error())
p := NewPlayer(slog.Default(), nil)
if err := p.InitSpeaker(); err != nil {
t.Fatalf("could not initialize speaker: %s", err.Error())
}
// SetContext registers Wails event handlers; only works with a real Wails context.
// SetContext restores persisted state; only works with a real Wails context.
p.SetContext(t.Context())
t.Logf("initializing player")
for _, track := range testQueue {
t.Logf("loading file: %s", track)
err = p.LoadFile(track)
if err != nil {
if err := p.LoadFile(track); err != nil {
t.Fatalf("could not load file %s: %s", track, err.Error())
}
err = p.Play()
if err != nil {
if err := p.Play(); err != nil {
t.Fatalf("could not play file %s: %s", track, err.Error())
}
}
-429
View File
@@ -1,11 +1,5 @@
package queue
import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// OnPlaybackFinished is called when a track finishes playing naturally.
// This drives the auto-advance behavior.
func (q *Queue) OnPlaybackFinished() {
@@ -36,426 +30,3 @@ func (q *Queue) OnPlaybackFinished() {
q.playCurrentTrack()
q.emitIndexChanged()
}
// registerEventHandlers sets up Wails event listeners for queue commands.
func (q *Queue) registerEventHandlers() {
if q.ctx == nil {
q.logger.Error("Context is nil, cannot register event handlers")
return
}
runtime.EventsOn(q.ctx, events.RequestPlay, func(_ ...any) {
q.logger.Info("Received RequestPlay")
q.Play()
})
runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) {
q.logger.Info("Received RequestNext")
q.Next()
})
runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) {
q.logger.Info("Received RequestPrevious")
q.Previous()
})
runtime.EventsOn(q.ctx, events.RequestSetQueue, func(data ...any) {
q.logger.Info("Received RequestSetQueue")
q.handleSetQueue(data...)
})
runtime.EventsOn(q.ctx, events.RequestAddToQueue, func(data ...any) {
q.logger.Info("Received RequestAddToQueue")
q.handleAddToQueue(data...)
})
runtime.EventsOn(q.ctx, events.RequestPlayNext, func(data ...any) {
q.logger.Info("Received RequestPlayNext")
q.handlePlayNext(data...)
})
runtime.EventsOn(
q.ctx,
events.RequestRemoveFromQueue,
func(data ...any) {
q.logger.Info("Received RequestRemoveFromQueue")
q.handleRemoveFromQueue(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestToggleShuffle,
func(_ ...any) {
q.logger.Info("Received RequestToggleShuffle")
q.ToggleShuffle()
},
)
runtime.EventsOn(
q.ctx,
events.RequestCycleRepeat,
func(_ ...any) {
q.logger.Info("Received RequestCycleRepeat")
q.CycleRepeat()
},
)
runtime.EventsOn(
q.ctx,
events.RequestAddTracksToQueue,
func(data ...any) {
q.logger.Info("Received RequestAddTracksToQueue")
q.handleAddTracksToQueue(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestPlayTracksNext,
func(data ...any) {
q.logger.Info("Received RequestPlayTracksNext")
q.handlePlayTracksNext(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestPlayQueueIndex,
func(data ...any) {
q.logger.Info("Received RequestPlayQueueIndex")
q.handlePlayQueueIndex(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestRemoveTracksFromQueue,
func(data ...any) {
q.logger.Info(
"Received RequestRemoveTracksFromQueue",
)
q.handleRemoveTracksFromQueue(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestInsertTracksAtIndex,
func(data ...any) {
q.logger.Info(
"Received RequestInsertTracksAtIndex",
)
q.handleInsertTracksAtIndex(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestMoveQueueTracks,
func(data ...any) {
q.logger.Info(
"Received RequestMoveQueueTracks",
)
q.handleMoveQueueTracks(data...)
},
)
runtime.EventsOn(
q.ctx,
events.RequestClearQueue,
func(_ ...any) {
q.logger.Info("Received RequestClearQueue")
q.Clear()
},
)
}
// toStringSlice extracts strings from a Wails event argument.
func toStringSlice(raw []interface{}) []string {
result := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
return result
}
// toIntSlice extracts ints (from float64) from a Wails event argument.
func toIntSlice(raw []interface{}) []int {
result := make([]int, 0, len(raw))
for _, v := range raw {
if f, ok := v.(float64); ok {
result = append(result, int(f))
}
}
return result
}
// handleSetQueue processes the RequestSetQueue event payload.
// Expects data[0] = []interface{} of file path strings,
// data[1] = float64 start index, data[2] = bool shuffleStart (optional).
func (q *Queue) handleSetQueue(data ...any) {
if len(data) < 2 {
q.logger.Error("RequestSetQueue: missing data")
return
}
filePathsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error("RequestSetQueue: invalid filePaths type")
return
}
filePaths := toStringSlice(filePathsRaw)
startIndex := 0
if si, ok := data[1].(float64); ok {
startIndex = int(si)
}
shuffleStart := false
if len(data) > 2 {
if ss, ok := data[2].(bool); ok {
shuffleStart = ss
}
}
q.SetQueue(filePaths, startIndex, shuffleStart)
}
// handleAddToQueue processes the RequestAddToQueue event payload.
// Expects data[0] = string file path.
func (q *Queue) handleAddToQueue(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestAddToQueue: missing data")
return
}
filePath, ok := data[0].(string)
if !ok {
q.logger.Error(
"RequestAddToQueue: invalid filePath type",
"got", data[0],
)
return
}
q.AddTrack(filePath)
}
// handlePlayNext processes the RequestPlayNext event payload.
// Expects data[0] = string file path.
func (q *Queue) handlePlayNext(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestPlayNext: missing data")
return
}
filePath, ok := data[0].(string)
if !ok {
q.logger.Error(
"RequestPlayNext: invalid filePath type",
"got", data[0],
)
return
}
q.InsertNext(filePath)
}
// handleRemoveFromQueue processes the RequestRemoveFromQueue event payload.
// Expects data[0] = float64 position.
func (q *Queue) handleRemoveFromQueue(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestRemoveFromQueue: missing data")
return
}
position, ok := data[0].(float64)
if !ok {
q.logger.Error(
"RequestRemoveFromQueue: invalid position type",
"got", data[0],
)
return
}
q.RemoveTrack(int(position))
}
// handleRemoveTracksFromQueue processes the RequestRemoveTracksFromQueue
// event payload. Expects data[0] = []interface{} of float64 positions.
func (q *Queue) handleRemoveTracksFromQueue(data ...any) {
if len(data) < 1 {
q.logger.Error(
"RequestRemoveTracksFromQueue: missing data",
)
return
}
positionsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestRemoveTracksFromQueue: invalid positions type",
"got", data[0],
)
return
}
q.RemoveTracks(toIntSlice(positionsRaw))
}
// handleAddTracksToQueue processes the RequestAddTracksToQueue event payload.
// Expects data[0] = []interface{} of file path strings.
func (q *Queue) handleAddTracksToQueue(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestAddTracksToQueue: missing data")
return
}
filePathsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestAddTracksToQueue: invalid filePaths type",
"got", data[0],
)
return
}
q.AddTracks(toStringSlice(filePathsRaw))
}
// handleInsertTracksAtIndex processes the RequestInsertTracksAtIndex event
// payload. Expects data[0] = []interface{} of file path strings,
// data[1] = float64 target index.
func (q *Queue) handleInsertTracksAtIndex(data ...any) {
if len(data) < 2 {
q.logger.Error(
"RequestInsertTracksAtIndex: missing data",
)
return
}
filePathsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestInsertTracksAtIndex: invalid filePaths type",
"got", data[0],
)
return
}
idx, ok := data[1].(float64)
if !ok {
q.logger.Error(
"RequestInsertTracksAtIndex: invalid index type",
"got", data[1],
)
return
}
q.InsertTracksAt(toStringSlice(filePathsRaw), int(idx))
}
// handleMoveQueueTracks processes the RequestMoveQueueTracks event payload.
// Expects data[0] = []interface{} of float64 source indices,
// data[1] = float64 target index.
func (q *Queue) handleMoveQueueTracks(data ...any) {
if len(data) < 2 {
q.logger.Error(
"RequestMoveQueueTracks: missing data",
)
return
}
indicesRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestMoveQueueTracks: invalid indices type",
"got", data[0],
)
return
}
toIdx, ok := data[1].(float64)
if !ok {
q.logger.Error(
"RequestMoveQueueTracks: invalid toIndex type",
"got", data[1],
)
return
}
q.MoveQueueTracks(toIntSlice(indicesRaw), int(toIdx))
}
// handlePlayQueueIndex processes the RequestPlayQueueIndex event payload.
// Expects data[0] = float64 index.
func (q *Queue) handlePlayQueueIndex(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestPlayQueueIndex: missing data")
return
}
index, ok := data[0].(float64)
if !ok {
q.logger.Error(
"RequestPlayQueueIndex: invalid index type",
"got", data[0],
)
return
}
q.PlayIndex(int(index))
}
// handlePlayTracksNext processes the RequestPlayTracksNext event payload.
// Expects data[0] = []interface{} of file path strings.
func (q *Queue) handlePlayTracksNext(data ...any) {
if len(data) < 1 {
q.logger.Error("RequestPlayTracksNext: missing data")
return
}
filePathsRaw, ok := data[0].([]interface{})
if !ok {
q.logger.Error(
"RequestPlayTracksNext: invalid filePaths type",
"got", data[0],
)
return
}
q.InsertNextTracks(toStringSlice(filePathsRaw))
}
+1 -2
View File
@@ -130,10 +130,9 @@ func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
}
}
// SetContext sets the Wails runtime context and registers event handlers.
// SetContext sets the Wails runtime context for event emission.
func (q *Queue) SetContext(ctx context.Context) {
q.ctx = ctx
q.registerEventHandlers()
}
// SetPlayer provides the queue with a reference to the player for auto-advance.