Files
yellowjacket/.opencode/plans/player-bindings.md
T

11 KiB

Plan: Move player frontend→backend communication to Wails bindings

Goal

Replace the 4 remaining EventsEmit calls (frontend→backend) in player-store.ts with direct Wails bindings, eliminating ~90 lines of handler boilerplate in Go. This completes the pattern established by the queue refactoring (#12) — after this change, all frontend→backend communication uses Wails bindings.

Rationale

Same benefits as the queue refactor:

  • Eliminates untyped data[0].(float64) casting boilerplate
  • Provides compile-time type safety via auto-generated TypeScript declarations
  • Adding a new player operation becomes a 1-file change (Go method) instead of 4 files
  • Completes the architectural consistency — every frontend→backend call uses bindings, every backend→frontend push uses events

Key Challenge: speaker.Init() in constructor

The player is currently created in OnStartup (after wails.Run()) because NewPlayer calls speaker.Init() to initialize audio hardware. Wails bindings must be registered before wails.Run(), so we need to split the constructor.

Solution: Extract speaker.Init() into a separate InitSpeaker() method. NewPlayer creates the struct with all fields initialized (logger, db, state, default format) but does NOT touch audio hardware. InitSpeaker() is called during OnStartup when hardware is available.

This is safe because:

  • NewPlayer already initializes all struct fields before speaker.Init() runs
  • speaker.Init() doesn't depend on any struct state — it only uses the sample rate constant
  • The player's methods that touch the speaker (Play, Pause, Seek, LoadFile) are only called after OnStartup completes, so the speaker will always be initialized before any method is invoked via binding

Detailed Steps

Step 1: Split NewPlayer — extract InitSpeaker

File: backend/player/player.go

Change NewPlayer to accept only logger and db (remove the ctx parameter — context is set later via SetContext). Remove speaker.Init() from the constructor.

Add a new InitSpeaker() error method that does the speaker.Init() call.

Before:

func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) {
    player := &Player{ctx: ctx, logger: logger, db: db, state: Stopped, ...}
    err := speaker.Init(...)
    if err != nil { return nil, ... }
    return player, nil
}

After:

func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
    return &Player{logger: logger, db: db, state: Stopped, ...}
}

func (p *Player) InitSpeaker() error {
    err := speaker.Init(p.format.SampleRate, p.format.SampleRate.N(time.Second/10))
    if err != nil { return fmt.Errorf("failed to initialize speaker: %w", err) }
    return nil
}

Note: NewPlayer no longer returns an error (struct creation can't fail) and no longer takes ctx (set via SetContext).

Step 2: Remove registerEventHandlers from player

File: backend/player/player.go

Delete the entire registerEventHandlers() method (lines 154-272) — all 4 runtime.EventsOn registrations and their handler closures.

Update SetContext to remove the registerEventHandlers() call. Keep only the context assignment and state restoration:

func (p *Player) SetContext(ctx context.Context) {
    p.mu.Lock()
    p.ctx = ctx
    p.mu.Unlock()

    p.mu.Lock()
    p.restoreStateLocked()
    p.mu.Unlock()
}

Remove the "fmt" import if it becomes unused (it was used by fmt.Sprintf("%T", data[0]) in the handlers). Check if fmt is still used elsewhere in the file — yes, it's used in loadFileLocked, seekLocked, etc. Keep it.

Remove the "yellowjacket/backend/events" import — check first. It's used by:

  • registerEventHandlers (being removed) — uses events.RequestPause, events.RequestLoadFile, events.Seek, events.RequestSetVolume
  • emitPlaybackStateChanged — uses events.PlaybackStateChanged
  • emitPlaybackFinished — uses events.PlaybackFinished
  • emitVolumeChanged — uses events.VolumeChanged
  • emitTrackChanged — uses events.TrackChanged
  • seekLocked — uses events.SeekFailed
  • UnloadTrack — uses events.TrackChanged

So events import stays (it's still used by the emit helpers).

The runtime import also stays (used by emit helpers and UnloadTrack).

Step 3: Update SetVolume to include side effects

File: backend/player/player.go

The current SetVolume only calls setVolumeLocked(). The event handler also called emitVolumeChanged() and saveState(). Update SetVolume to match what the event handler did:

Before:

func (p *Player) SetVolume(desiredVolume UserVolume) error {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.setVolumeLocked(desiredVolume)
    return nil
}

After:

func (p *Player) SetVolume(desiredVolume UserVolume) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.setVolumeLocked(desiredVolume)
    p.emitVolumeChanged()
    p.saveState()
}

Note: changed return type from error to void — setVolumeLocked never fails, and this avoids Wails generating a Promise rejection for a method that can't error. Check if any Go code calls SetVolume and checks the error — no callers exist (confirmed above).

Step 4: Update app.go — create player early, add to FEBindings

File: backend/app.go

In NewYellowJacketApp, create the player early (after db is available):

yjApp.player = player.NewPlayer(yjApp.logger.WithGroup("player"), yjApp.database)

Add to FEBindings:

yjApp.FEBindings = []any{
    yjApp.FrontendUtil,
    yjApp.appConfig,
    yjApp.library,
    yjApp.playlist,
    yjApp.queue,
    yjApp.player,
}

In OnStartup, replace player creation with deferred initialization:

if err := yj.player.InitSpeaker(); err != nil {
    startupErr = errors.Join(startupErr, fmt.Errorf("could not initialize speaker: %w", err))
}
yj.player.SetContext(ctx)

Step 5: Update player test

File: backend/player/player_test.go

Update the test to match the new two-phase constructor:

Before:

p, err := NewPlayer(context.Background(), slog.Default(), nil)
if err != nil { t.Fatalf(...) }
p.SetContext(t.Context())

After:

p := NewPlayer(slog.Default(), nil)
if err := p.InitSpeaker(); err != nil { t.Fatalf(...) }
p.SetContext(t.Context())

Step 6: Remove player Request* event constants from Go and TS

File: backend/events/events.go

Remove from "Playback control events" block:

  • RequestPause
  • RequestLoadFile

Remove the entire "Seek events" block — Seek was only used as a frontend→backend event. Keep SeekFailed by moving it elsewhere (e.g., into a "Playback control events" block or its own group).

Remove from "Volume events" block:

  • RequestSetVolume

File: frontend/src/events.ts

Remove:

  • RequestPause
  • RequestLoadFile
  • Seek
  • RequestSetVolume

Keep:

  • PlaybackStateChanged, PlaybackFinished (backend→frontend push)
  • SeekFailed (backend→frontend push, even though unused — separate issue #15)
  • TrackChanged (backend→frontend push)
  • VolumeChanged (backend→frontend push)

Step 7: Regenerate Wails bindings

Run wails generate module to produce:

  • frontend/wailsjs/go/player/Player.js
  • frontend/wailsjs/go/player/Player.d.ts
  • Updated frontend/wailsjs/go/models.ts with player.TrackInfo, player.UserVolume, etc.

Expected generated bindings for the methods we need:

  • Pause(): Promise<void> (from func (p *Player) Pause() error)
  • LoadFile(arg1: string): Promise<void> (from func (p *Player) LoadFile(filePath string) error)
  • Seek(arg1: number): Promise<void> (from func (p *Player) Seek(targetSeconds int) error)
  • SetVolume(arg1: number): Promise<void> (from func (p *Player) SetVolume(desiredVolume UserVolume))

Note: UserVolume is type UserVolume int, so Wails will serialize it as a plain number. The generated TS type will be number (or player.UserVolume which maps to number).

Step 8: Rewrite player-store.ts actions to use Wails bindings

File: frontend/src/store/player-store.ts

Replace EventsEmit action methods with Wails binding calls:

Store method Current After
pause() EventsEmit(Events.RequestPause) Player.Pause()
loadTrack(filePath) EventsEmit(Events.RequestLoadFile, filePath) Player.LoadFile(filePath)
seek(seconds) EventsEmit(Events.Seek, seconds) Player.Seek(seconds)
setVolume(level) EventsEmit(Events.RequestSetVolume, level) Player.SetVolume(level)

Remove the EventsEmit import (only EventsOn will be needed).

Add import: import * as Player from '@go/player/Player';

The 4 backend→frontend event subscriptions (PlaybackStateChanged, TrackChanged, PlaybackFinished, VolumeChanged) remain unchanged.

Step 9: Run tests, lint, and TypeScript type check

make test
make lint
cd frontend && pnpm exec tsc --noEmit
cd frontend && pnpm build

Files Modified

File Action Description
backend/player/player.go Edit Split NewPlayer, add InitSpeaker, remove registerEventHandlers, update SetVolume
backend/app.go Edit Move player creation early, add to FEBindings, call InitSpeaker in OnStartup
backend/player/player_test.go Edit Update test to use new constructor + InitSpeaker
backend/events/events.go Edit Remove RequestPause, RequestLoadFile, Seek, RequestSetVolume
frontend/src/events.ts Edit Remove same 4 constants
frontend/src/store/player-store.ts Edit Replace EventsEmit with Wails binding calls
frontend/wailsjs/go/player/Player.js Auto-generated New
frontend/wailsjs/go/player/Player.d.ts Auto-generated New
frontend/wailsjs/go/models.ts Auto-generated Updated with player types

Files NOT Modified

File Reason
frontend/src/store/controllers/player-controller.ts Proxies through store; no API change
frontend/src/components/audio-player/ Uses controller/store; no API change
All other component files No direct player store interaction for these actions

Risk Assessment

Low risk:

  • The player's public methods (Pause, LoadFile, Seek) already have the correct behavior — the event handlers were just thin wrappers
  • SetVolume is the only method that needs side effects added, and it has zero existing callers
  • The test is an integration test that skips by default

Medium risk:

  • InitSpeaker() splitting — if any code path calls a player method that touches the speaker before InitSpeaker() runs, it will panic. This is safe because all player method calls happen after OnStartup completes, but worth being aware of.
  • Wails may expose lifecycle methods (SetContext, SetPlaybackFinishedHandler, InitSpeaker) as callable bindings. Same non-issue as queue — these are harmless in generated JS.

Net Effect

  • ~120 lines removed from player.go (event handlers + boilerplate)
  • ~8 lines removed from event constants (Go + TS)
  • ~8 lines changed in player-store.ts (swap EventsEmit for binding calls)
  • ~10 lines changed in app.go (move construction)
  • After this change, zero EventsEmit calls remain in the frontend for backend requests — all frontend→backend communication uses Wails bindings