From cf144bf0dd6f18c3c1ab4ab1d69781244b770caf Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Feb 2026 18:51:07 -0500 Subject: [PATCH] refactored many events to use wails bindings, reducing boilerplate --- .opencode/plans/12-queue-bindings.md | 283 ++++++++++++ .opencode/plans/player-bindings.md | 286 ++++++++++++ .opencode/plans/refactoring-catalog.md | 8 - backend/app.go | 26 +- backend/events/events.go | 50 +- backend/player/player.go | 167 +------ backend/player/player_test.go | 20 +- backend/queue/handlers.go | 429 ------------------ backend/queue/queue.go | 3 +- .../audio-player/controls/player-controls.ts | 2 +- frontend/src/events.ts | 30 +- .../store/controllers/player-controller.ts | 6 +- .../src/store/controllers/queue-controller.ts | 4 + frontend/src/store/player-store.ts | 17 +- frontend/src/store/queue-store.ts | 57 ++- frontend/wailsjs/go/models.ts | 106 +++++ frontend/wailsjs/go/player/Player.d.ts | 42 ++ frontend/wailsjs/go/player/Player.js | 79 ++++ frontend/wailsjs/go/queue/Queue.d.ts | 50 ++ frontend/wailsjs/go/queue/Queue.js | 95 ++++ 20 files changed, 1044 insertions(+), 716 deletions(-) create mode 100644 .opencode/plans/12-queue-bindings.md create mode 100644 .opencode/plans/player-bindings.md create mode 100755 frontend/wailsjs/go/player/Player.d.ts create mode 100755 frontend/wailsjs/go/player/Player.js create mode 100755 frontend/wailsjs/go/queue/Queue.d.ts create mode 100755 frontend/wailsjs/go/queue/Queue.js diff --git a/.opencode/plans/12-queue-bindings.md b/.opencode/plans/12-queue-bindings.md new file mode 100644 index 0000000..ec2c6ac --- /dev/null +++ b/.opencode/plans/12-queue-bindings.md @@ -0,0 +1,283 @@ +# Plan: #12 — Move queue frontend→backend communication to Wails bindings + +## Goal + +Replace the 15 `Request*` events (frontend→backend) with direct Wails bindings while keeping the 4 backend→frontend push events (`QueueChanged`, `QueueIndexChanged`, `QueueModeChanged`, `QueueTracksModified`) intact. This eliminates ~420 lines of handler boilerplate in Go and aligns the queue's communication pattern with playlists. + +## Rationale + +**Why bindings for frontend→backend (replacing events):** +- Eliminates 420 lines of hand-written type-assertion boilerplate in `handlers.go` +- Provides compile-time type safety — Wails auto-generates typed TypeScript bindings from Go method signatures, so `float64`→`int` casting, `[]interface{}`→`[]string` conversion, and `len(data)` validation all disappear +- Adding a new queue operation becomes a 1-file change (add Go method) vs the current 4-file change (Go event constant, TS event constant, Go handler, TS store method) +- Aligns with the playlist pattern, reducing cognitive overhead + +**Why keep events for backend→frontend (not replacing with invalidate-and-refetch):** +- The queue's delta system (`QueueTracksModified` with add/insert/remove/move actions) is genuinely good architecture for a data structure that changes frequently during playback +- Avoids unnecessary round-trips — the backend pushes only what changed +- The playlist's invalidate-and-refetch pattern works for playlists (infrequent mutations) but would be wasteful for a queue (changes on every track advance) + +## Prerequisites + +The queue must be created in `NewYellowJacketApp()` (before `wails.Run()`) rather than in `OnStartup()`, because Wails v2 consumes the `Bind` slice eagerly at startup via reflection. The struct pointer must be non-nil and fully constructed at `Bind` time. + +This is safe because `queue.NewQueue()` only needs `logger` and `db` (both already available in `NewYellowJacketApp`). The player dependency and context are set later via `SetPlayer()` and `SetContext()` during `OnStartup`, which is the existing two-phase initialization pattern used by all other bound services. + +## Detailed Steps + +### Step 1: Move queue construction to `NewYellowJacketApp` and add to `FEBindings` + +**File:** `backend/app.go` + +In `NewYellowJacketApp()`, after the playlist service is created (~line 104), add: + +```go +yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database) +``` + +Add the queue to `FEBindings`: + +```go +yjApp.FEBindings = []any{ + yjApp.FrontendUtil, + yjApp.appConfig, + yjApp.library, + yjApp.playlist, + yjApp.queue, +} +``` + +In `OnStartup()`, remove `queue.NewQueue(...)` and keep only the deferred initialization: + +```go +yj.queue.SetContext(ctx) +yj.queue.SetPlayer(yj.player) +yj.queue.RestoreState() +``` + +### Step 2: Remove `registerEventHandlers()` and all handler boilerplate + +**File:** `backend/queue/handlers.go` + +Remove: +- `registerEventHandlers()` — all 16 `runtime.EventsOn` registrations (lines 41-172) +- All 10 `handle*` functions (lines 201-461): `handleSetQueue`, `handleAddToQueue`, `handlePlayNext`, `handleRemoveFromQueue`, `handleRemoveTracksFromQueue`, `handleAddTracksToQueue`, `handleInsertTracksAtIndex`, `handleMoveQueueTracks`, `handlePlayQueueIndex`, `handlePlayTracksNext` +- The two helper functions `toStringSlice` and `toIntSlice` (lines 175-199) + +Keep: +- `OnPlaybackFinished()` (lines 11-38) — this is domain logic, not event boilerplate + +**File:** `backend/queue/queue.go` + +In `SetContext()`, remove the call to `q.registerEventHandlers()`. The method becomes: + +```go +func (q *Queue) SetContext(ctx context.Context) { + q.ctx = ctx +} +``` + +### Step 3: Remove the 15 `Request*` queue event constants + +**File:** `backend/events/events.go` + +Remove from the "Queue events" const block (lines 38-52): +- `RequestNext` +- `RequestPrevious` +- `RequestSetQueue` +- `RequestAddToQueue` +- `RequestPlayNext` +- `RequestRemoveFromQueue` +- `RequestToggleShuffle` +- `RequestCycleRepeat` +- `RequestAddTracksToQueue` +- `RequestPlayTracksNext` +- `RequestPlayQueueIndex` +- `RequestRemoveTracksFromQueue` +- `RequestInsertTracksAtIndex` +- `RequestMoveQueueTracks` +- `RequestClearQueue` + +Keep `RequestPlay` — it's in the "Playback control events" block and is used by the queue's event handler. Since we're removing `registerEventHandlers`, also remove `RequestPlay` from the queue's event handler. But check if `RequestPlay` is still used by the player package first. + +> **Note:** `RequestPlay` is currently handled by the queue (in `handlers.go:48`), not the player. After this refactor, the queue's `Play()` method will be callable directly via bindings, so the `RequestPlay` event handler in the queue is no longer needed. However, `RequestPlay` may still be emitted by the frontend for player-related actions — audit all `RequestPlay` usages before removing the constant. + +**File:** `frontend/src/events.ts` + +Remove the corresponding 15 `Request*` constants from lines 28-42. Keep the 4 backend→frontend queue events (lines 24-27). + +### Step 4: Rewrite the queue store to use Wails bindings + +**File:** `frontend/src/store/queue-store.ts` + +Replace the 14 action methods that call `EventsEmit(Events.Request*)` with direct calls to the auto-generated Wails bindings. + +**Before** (example): +```typescript +import { EventsOn, EventsEmit } from '@runtime/runtime'; +import { Events } from '../events'; + +// ... +next(): void { + EventsEmit(Events.RequestNext); +} + +setQueue(filePaths: string[], startIndex: number, shuffleStart = false): void { + EventsEmit(Events.RequestSetQueue, filePaths, startIndex, shuffleStart); +} +``` + +**After** (example): +```typescript +import { EventsOn } from '@runtime/runtime'; +import { Events } from '../events'; +import * as QueueService from '@go/queue/Queue'; + +// ... +next(): void { + QueueService.Next(); +} + +setQueue(filePaths: string[], startIndex: number, shuffleStart = false): void { + QueueService.SetQueue(filePaths, startIndex, shuffleStart); +} +``` + +Keep the entire `initializeEventListeners()` method unchanged — the 4 backend→frontend event subscriptions (`QueueChanged`, `QueueIndexChanged`, `QueueModeChanged`, `QueueTracksModified`) and the `applyTracksDelta()` logic remain as-is. + +Remove the `EventsEmit` import if no longer needed after removing all `Request*` emissions. + +**Complete action method mapping** (queue store method → Wails binding): + +| Store method | Current event | Wails binding call | +|---|---|---| +| `next()` | `RequestNext` | `QueueService.Next()` | +| `previous()` | `RequestPrevious` | `QueueService.Previous()` | +| `setQueue(filePaths, startIndex, shuffleStart)` | `RequestSetQueue` | `QueueService.SetQueue(filePaths, startIndex, shuffleStart)` | +| `addToQueue(filePath)` | `RequestAddToQueue` | `QueueService.AddTrack(filePath)` | +| `playNext(filePath)` | `RequestPlayNext` | `QueueService.InsertNext(filePath)` | +| `removeFromQueue(position)` | `RequestRemoveFromQueue` | `QueueService.RemoveTrack(position)` | +| `removeTracksFromQueue(positions)` | `RequestRemoveTracksFromQueue` | `QueueService.RemoveTracks(positions)` | +| `addTracksToQueue(filePaths)` | `RequestAddTracksToQueue` | `QueueService.AddTracks(filePaths)` | +| `playTracksNext(filePaths)` | `RequestPlayTracksNext` | `QueueService.InsertNextTracks(filePaths)` | +| `toggleShuffle()` | `RequestToggleShuffle` | `QueueService.ToggleShuffle()` | +| `cycleRepeat()` | `RequestCycleRepeat` | `QueueService.CycleRepeat()` | +| `playAtIndex(index)` | `RequestPlayQueueIndex` | `QueueService.PlayIndex(index)` | +| `insertTracksAtIndex(filePaths, index)` | `RequestInsertTracksAtIndex` | `QueueService.InsertTracksAt(filePaths, index)` | +| `moveTracksInQueue(fromIndices, toIndex)` | `RequestMoveQueueTracks` | `QueueService.MoveQueueTracks(fromIndices, toIndex)` | +| `clearQueue()` | `RequestClearQueue` | `QueueService.Clear()` | + +> **Note:** Some store method names don't match Go method names (e.g., `addToQueue` → `AddTrack`, `playNext` → `InsertNext`). The store method names can remain unchanged for API stability — only the implementation changes. + +### Step 5: Handle `Play()` specifically + +The queue's `Play()` method is currently triggered by the `RequestPlay` event, which is in the "Playback control events" group and is also emitted by `player-controls.ts`. After this refactor: + +- The `RequestPlay` event handler in `handlers.go:48` is removed along with all other handlers +- The frontend should call `QueueService.Play()` directly instead of `EventsEmit(Events.RequestPlay)` + +Audit all places that emit `RequestPlay`: +- `frontend/src/components/audio-player/controls/player-controls.ts` — the play button emits `RequestPlay`. This should be changed to call `QueueService.Play()` (or more likely, the queue store should expose a `play()` method that delegates to the binding) + +If `RequestPlay` has no other consumers after this change, remove the event constant from both `events.go` and `events.ts`. + +### Step 6: Regenerate Wails bindings + +Run `wails generate module` (or `make dev` which triggers binding generation) to produce the auto-generated files: + +- `frontend/wailsjs/go/queue/Queue.js` — JavaScript bridge calling `window['go']['queue']['Queue'][method](...)` +- `frontend/wailsjs/go/queue/Queue.d.ts` — TypeScript declarations with proper types +- `frontend/wailsjs/go/models.ts` — Updated with `queue.Track`, `queue.State`, `queue.RepeatMode`, etc. + +> **Important:** The auto-generated TypeScript types will mirror the Go struct JSON tags, so the frontend types already defined in `queue-store.ts` (`QueueTrack`, `QueueState`, `IndexChanged`, `ModeChanged`, `TracksModified`) will have matching auto-generated equivalents in `models.ts`. We should keep the manually-defined types in the store (they're used by the event listeners which still need them) but could optionally import the model types where convenient. + +### Step 7: Handle `SetContext` visibility + +When a struct is added to Wails `FEBindings`, **all exported methods** become callable from JavaScript. `SetContext(ctx context.Context)` and `SetPlayer(player TrackLoader)` would be exposed, which is undesirable — they're internal lifecycle methods, not frontend API. + +Options: +1. **Unexport them** — rename to `setContext`/`setPlayer`. This requires updating `app.go` to call `q.setContext(ctx)` etc. But unexported methods on structs in other packages aren't accessible, so this won't work without making them package-internal. +2. **Create a thin facade struct** — a `Service` (or `API`) struct that embeds or wraps `*Queue` and only exposes the methods the frontend should call. This is the playlist pattern (`playlist.Service`). +3. **Accept the exposure** — Wails will generate bindings for `SetContext` and `SetPlayer`, but the frontend simply won't call them. They'll be inert in the generated JS. This is what happens with `playlist.Service.SetContext` — it's in the generated `Service.js` but never imported by the frontend. + +**Recommendation:** Option 3 — accept it. The playlist already has `SetContext` exposed in its generated bindings (`frontend/wailsjs/go/playlist/Service.js:69`) and it's not a problem. Wails bindings are not a security boundary (the frontend and backend are in the same process). The generated bindings are auto-generated artifacts, not a public API. No one will accidentally call `SetContext` from the frontend. + +If `SetPlayer` is a concern because `TrackLoader` is an interface type that Wails can't serialize, Wails may skip it or error during binding generation. If so, either unexport `SetPlayer` only, or have `app.go` set it via an unexported package-level function. This needs testing during step 6. + +### Step 8: Update `queue-controller.ts` (no changes needed) + +The `QueueController` (`frontend/src/store/controllers/queue-controller.ts`) proxies all actions through `queueStore.*()`. Since we're only changing the store's internal implementation (from `EventsEmit` to binding calls), the controller needs zero changes. All 14 action proxy methods remain identical. + +### Step 9: Update components that call `queueStore` directly (no changes needed) + +These 6 components import `queueStore` and call its action methods: +- `player-controls.ts` — `queueStore.next()`, `.previous()`, `.toggleShuffle()`, `.cycleRepeat()` +- `track-list.ts` — `queueStore.setQueue()`, `.addTracksToQueue()`, `.playTracksNext()` +- `cover-grid.ts` — `queueStore.setQueue()`, `.addTracksToQueue()`, `.playTracksNext()` +- `genres-view.ts` — `queueStore.setQueue()`, `.addTracksToQueue()`, `.playTracksNext()` +- `artists-view.ts` — `queueStore.setQueue()`, `.addTracksToQueue()`, `.playTracksNext()` +- `playlist-view.ts` — `queueStore.setQueue()`, `.addTracksToQueue()`, `.playTracksNext()` + +Since the store's public API (method signatures) is unchanged, none of these components need modifications. + +**Exception:** `player-controls.ts` currently emits `Events.RequestPlay` directly for the play/pause button (not through the queue store). This specific call site needs to be updated to either: +- Call `queueStore.play()` (add a `play()` method to the store), or +- Call `QueueService.Play()` directly + +### Step 10: Run tests, lint, and build + +```bash +make test # Verify Go tests pass (especially queue tests) +make lint # Verify linting passes +cd frontend && pnpm exec tsc --noEmit # Verify TypeScript types +make build-dev # Full build to verify Wails binding generation works +``` + +## Files Modified + +| File | Action | Description | +|---|---|---| +| `backend/app.go` | Edit | Move queue construction; add to `FEBindings` | +| `backend/queue/handlers.go` | Major edit | Remove all `handle*` functions, `registerEventHandlers`, `toStringSlice`, `toIntSlice`. Keep only `OnPlaybackFinished` | +| `backend/queue/queue.go` | Edit | Remove `registerEventHandlers()` call from `SetContext` | +| `backend/events/events.go` | Edit | Remove 15 `Request*` queue constants | +| `frontend/src/events.ts` | Edit | Remove 15 `Request*` queue constants | +| `frontend/src/store/queue-store.ts` | Edit | Replace `EventsEmit` action methods with Wails binding calls | +| `frontend/src/components/audio-player/controls/player-controls.ts` | Edit | Replace `RequestPlay` event emission with binding call | +| `frontend/wailsjs/go/queue/Queue.js` | Auto-generated | New file from `wails generate` | +| `frontend/wailsjs/go/queue/Queue.d.ts` | Auto-generated | New file from `wails generate` | +| `frontend/wailsjs/go/models.ts` | Auto-generated | Updated with queue types | + +## Files NOT Modified + +| File | Reason | +|---|---| +| `backend/queue/emit.go` | Backend→frontend push events are kept as-is | +| `frontend/src/store/controllers/queue-controller.ts` | Proxies through store; no API change | +| `frontend/src/components/queue-panel/queue-panel.ts` | Uses controller; no API change | +| `frontend/src/components/track-list/track-list.ts` | Calls store methods; no API change | +| `frontend/src/components/cover-grid/cover-grid.ts` | Calls store methods; no API change | +| `frontend/src/components/genres-view/genres-view.ts` | Calls store methods; no API change | +| `frontend/src/components/artists-view/artists-view.ts` | Calls store methods; no API change | +| `frontend/src/components/playlist-view/playlist-view.ts` | Calls store methods; no API change | + +## Risk Assessment + +**Low risk:** +- The queue's public Go methods are already well-tested and have clear type signatures +- The store's public API doesn't change, so no component-level regressions +- The backend→frontend event system is untouched +- The pattern is proven by the playlist package + +**Medium risk:** +- `SetPlayer(TrackLoader)` exposure in Wails bindings — Wails may not handle the interface parameter. If binding generation fails, we'll need to unexport `SetPlayer` and wire it via a package-level function or an exported setter that takes concrete types +- `RequestPlay` event has cross-cutting usage in `player-controls.ts` — needs careful auditing to avoid breaking play/pause + +## Net Effect + +- **~420 lines removed** from `handlers.go` (boilerplate) +- **~20 lines removed** from `events.go` and `events.ts` (15 event constants each) +- **~30 lines changed** in `queue-store.ts` (swap `EventsEmit` for binding calls) +- **~10 lines changed** in `app.go` (move construction, add to bindings) +- **~3 auto-generated files** created/updated by Wails +- Adding a new queue operation goes from a 4-file change to a 1-2 file change diff --git a/.opencode/plans/player-bindings.md b/.opencode/plans/player-bindings.md new file mode 100644 index 0000000..e589ed0 --- /dev/null +++ b/.opencode/plans/player-bindings.md @@ -0,0 +1,286 @@ +# 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:** +```go +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:** +```go +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: + +```go +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:** +```go +func (p *Player) SetVolume(desiredVolume UserVolume) error { + p.mu.Lock() + defer p.mu.Unlock() + p.setVolumeLocked(desiredVolume) + return nil +} +``` + +**After:** +```go +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): + +```go +yjApp.player = player.NewPlayer(yjApp.logger.WithGroup("player"), yjApp.database) +``` + +Add to `FEBindings`: + +```go +yjApp.FEBindings = []any{ + yjApp.FrontendUtil, + yjApp.appConfig, + yjApp.library, + yjApp.playlist, + yjApp.queue, + yjApp.player, +} +``` + +In `OnStartup`, replace player creation with deferred initialization: + +```go +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:** +```go +p, err := NewPlayer(context.Background(), slog.Default(), nil) +if err != nil { t.Fatalf(...) } +p.SetContext(t.Context()) +``` + +**After:** +```go +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` (from `func (p *Player) Pause() error`) +- `LoadFile(arg1: string): Promise` (from `func (p *Player) LoadFile(filePath string) error`) +- `Seek(arg1: number): Promise` (from `func (p *Player) Seek(targetSeconds int) error`) +- `SetVolume(arg1: number): Promise` (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 + +```bash +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 diff --git a/.opencode/plans/refactoring-catalog.md b/.opencode/plans/refactoring-catalog.md index 7f5c22a..15ea630 100644 --- a/.opencode/plans/refactoring-catalog.md +++ b/.opencode/plans/refactoring-catalog.md @@ -30,8 +30,6 @@ Prioritized list of architectural improvements identified during a full codebase ### ~~6. Extract `SizedFilename` to a shared utility package~~ — solved -Created `backend/coverart/` package with `SizedFilename`, a `ResolveURLs` helper (encapsulates the repeated pattern of resolving filesystem paths to all size-variant URL paths), a `URLs` struct, and a `PathPrefix` constant. Removed `SizedFilename` from `library/coverart.go`. Updated all four callers (`library/query.go`, `player/player.go`, `playlist/playlist.go`, `app.go`) to use `coverart.ResolveURLs`, eliminating the `player` -> `library` and `playlist` -> `library` coupling. Added tests for the new package. - --- ### 7. ~~Consolidate `LibraryScanComplete` handling~~ — solved @@ -48,14 +46,10 @@ Created `backend/coverart/` package with `SizedFilename`, a `ResolveURLs` helper ### ~~10. Move `FullRescan` orchestration from library to app~~ — solved -Replaced `queueClearer`/`playlistRestorer` interfaces and `SetQueue`/`SetPlaylistRestorer` setters with a single `RescanHooks` struct containing `PreClear`/`PostScan` function callbacks. The app wires `queue.Clear` and `playlist.RestoreAllPlaylists` as hooks, so the library no longer has any knowledge of or dependency on those packages. - --- ### ~~11. Fix double `LibraryScanStarted` event during FullRescan~~ — solved -Removed the `LibraryScanStarted` emission from `FullRescan` (resolved as part of item #10). The event is now only emitted from `Scan()`, giving exactly one emission per rescan. - --- ### 12. Inconsistent communication patterns: queue (events) vs playlist (bindings) @@ -83,8 +77,6 @@ Removed the `LibraryScanStarted` emission from `FullRescan` (resolved as part of ### 14. ~~Unused queue sentinels: `ErrEmptyQueue`, `ErrNoPlayer`~~ — solved -Removed during the queue.go split/rewrite (item #3). - --- ### 15. `SeekFailed` event emitted but never listened to diff --git a/backend/app.go b/backend/app.go index 0ece2d6..2a2df03 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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() diff --git a/backend/events/events.go b/backend/events/events.go index ce53b05..df48c83 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -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. diff --git a/backend/player/player.go b/backend/player/player.go index cb49bae..12161cf 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -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) { diff --git a/backend/player/player_test.go b/backend/player/player_test.go index eb54a9e..0ce63fc 100644 --- a/backend/player/player_test.go +++ b/backend/player/player_test.go @@ -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()) } } diff --git a/backend/queue/handlers.go b/backend/queue/handlers.go index 197484f..6c40583 100644 --- a/backend/queue/handlers.go +++ b/backend/queue/handlers.go @@ -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)) -} diff --git a/backend/queue/queue.go b/backend/queue/queue.go index 422db7b..e2ef303 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -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. diff --git a/frontend/src/components/audio-player/controls/player-controls.ts b/frontend/src/components/audio-player/controls/player-controls.ts index 620b984..f7fcd9d 100644 --- a/frontend/src/components/audio-player/controls/player-controls.ts +++ b/frontend/src/components/audio-player/controls/player-controls.ts @@ -80,7 +80,7 @@ export class PlayerControls extends LitElement { `; private handlePlayClick = () => { - this.player.play(); + queueStore.play(); }; private handlePauseClick = () => { diff --git a/frontend/src/events.ts b/frontend/src/events.ts index a22b531..439a4ec 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -2,44 +2,18 @@ // These names must match the corresponding event names in the Go backend. export const Events = { - // Playback control events + // Playback events (backend → frontend push) PlaybackStateChanged: "PlaybackStateChanged", PlaybackFinished: "PlaybackFinished", - RequestPlay: "RequestPlay", - RequestPause: "RequestPause", - RequestLoadFile: "RequestLoadFile", - - // Track events TrackChanged: "TrackChanged", - - // Seek events - Seek: "Seek", SeekFailed: "SeekFailed", - - // Volume events - RequestSetVolume: "RequestSetVolume", VolumeChanged: "VolumeChanged", - // Queue events + // Queue events (backend → frontend push) 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", // Playlist events PlaylistCreated: "PlaylistCreated", diff --git a/frontend/src/store/controllers/player-controller.ts b/frontend/src/store/controllers/player-controller.ts index 5d1f100..642436a 100644 --- a/frontend/src/store/controllers/player-controller.ts +++ b/frontend/src/store/controllers/player-controller.ts @@ -12,7 +12,7 @@ import { playerStore } from '../player-store'; * render() { * return html` * ${this.player.currentTrack?.fileName} - * + * * `; * } */ @@ -67,10 +67,6 @@ export class PlayerController implements ReactiveController { // Delegate to store (which delegates to backend) // =================================================================== - play(): void { - playerStore.play(); - } - pause(): void { playerStore.pause(); } diff --git a/frontend/src/store/controllers/queue-controller.ts b/frontend/src/store/controllers/queue-controller.ts index b65bef9..fb8965c 100644 --- a/frontend/src/store/controllers/queue-controller.ts +++ b/frontend/src/store/controllers/queue-controller.ts @@ -71,6 +71,10 @@ export class QueueController implements ReactiveController { // ACTIONS // =================================================================== + play(): void { + queueStore.play(); + } + next(): void { queueStore.next(); } diff --git a/frontend/src/store/player-store.ts b/frontend/src/store/player-store.ts index 0ec67c8..2441a80 100644 --- a/frontend/src/store/player-store.ts +++ b/frontend/src/store/player-store.ts @@ -1,5 +1,6 @@ -import { EventsOn, EventsEmit } from '@runtime/runtime'; +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. @@ -79,27 +80,23 @@ class PlayerStore { // =================================================================== // ACTIONS - // These delegate to the backend via Wails events + // These delegate to the backend via Wails bindings // =================================================================== - play(): void { - EventsEmit(Events.RequestPlay); - } - pause(): void { - EventsEmit(Events.RequestPause); + Player.Pause(); } loadTrack(filePath: string): void { - EventsEmit(Events.RequestLoadFile, filePath); + Player.LoadFile(filePath); } seek(seconds: number): void { - EventsEmit(Events.Seek, seconds); + Player.Seek(seconds); } setVolume(level: number): void { - EventsEmit(Events.RequestSetVolume, level); + Player.SetVolume(level); } // =================================================================== diff --git a/frontend/src/store/queue-store.ts b/frontend/src/store/queue-store.ts index 2be9872..366abe8 100644 --- a/frontend/src/store/queue-store.ts +++ b/frontend/src/store/queue-store.ts @@ -1,5 +1,6 @@ -import { EventsOn, EventsEmit } from '@runtime/runtime'; +import { EventsOn } from '@runtime/runtime'; import { Events } from '../events'; +import * as Queue from '@go/queue/Queue'; // Types export interface QueueTrack { @@ -179,15 +180,19 @@ class QueueStore { // =================================================================== // ACTIONS - // These delegate to the backend via Wails events + // These delegate to the backend via Wails bindings // =================================================================== + play(): void { + Queue.Play(); + } + next(): void { - EventsEmit(Events.RequestNext); + Queue.Next(); } previous(): void { - EventsEmit(Events.RequestPrevious); + Queue.Previous(); } setQueue( @@ -195,71 +200,61 @@ class QueueStore { startIndex: number, shuffleStart = false, ): void { - EventsEmit( - Events.RequestSetQueue, - filePaths, - startIndex, - shuffleStart, - ); + Queue.SetQueue(filePaths, startIndex, shuffleStart); } addToQueue(filePath: string): void { - EventsEmit(Events.RequestAddToQueue, filePath); + Queue.AddTrack(filePath); } playNext(filePath: string): void { - EventsEmit(Events.RequestPlayNext, filePath); + Queue.InsertNext(filePath); } removeFromQueue(position: number): void { - EventsEmit(Events.RequestRemoveFromQueue, position); + Queue.RemoveTrack(position); } removeTracksFromQueue(positions: number[]): void { - EventsEmit(Events.RequestRemoveTracksFromQueue, positions); + Queue.RemoveTracks(positions); } addTracksToQueue(filePaths: string[]): void { - EventsEmit(Events.RequestAddTracksToQueue, filePaths); + Queue.AddTracks(filePaths); } playTracksNext(filePaths: string[]): void { - EventsEmit(Events.RequestPlayTracksNext, filePaths); + Queue.InsertNextTracks(filePaths); } toggleShuffle(): void { - EventsEmit(Events.RequestToggleShuffle); + Queue.ToggleShuffle(); } cycleRepeat(): void { - EventsEmit(Events.RequestCycleRepeat); + Queue.CycleRepeat(); } playAtIndex(index: number): void { - EventsEmit(Events.RequestPlayQueueIndex, index); + Queue.PlayIndex(index); } - insertTracksAtIndex(filePaths: string[], index: number): void { - EventsEmit( - Events.RequestInsertTracksAtIndex, - filePaths, - index, - ); + insertTracksAtIndex( + filePaths: string[], + index: number, + ): void { + Queue.InsertTracksAt(filePaths, index); } moveTracksInQueue( fromIndices: number[], toIndex: number, ): void { - EventsEmit( - Events.RequestMoveQueueTracks, - fromIndices, - toIndex, - ); + Queue.MoveQueueTracks(fromIndices, toIndex); } clearQueue(): void { - EventsEmit(Events.RequestClearQueue); + Queue.Clear(); } // =================================================================== diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 40523d4..53ff78d 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -171,6 +171,47 @@ export namespace library { } +export namespace player { + + export class TrackInfo { + fileName: string; + filePath: string; + state: string; + title: string; + artist: string; + album: string; + coverArt: string; + coverArtSmall: string; + coverArtMedium: string; + coverArtLarge: string; + trackLength: number; + seekPosition: number; + trackChangeId: number; + + static createFrom(source: any = {}) { + return new TrackInfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.fileName = source["fileName"]; + this.filePath = source["filePath"]; + this.state = source["state"]; + this.title = source["title"]; + this.artist = source["artist"]; + this.album = source["album"]; + this.coverArt = source["coverArt"]; + this.coverArtSmall = source["coverArtSmall"]; + this.coverArtMedium = source["coverArtMedium"]; + this.coverArtLarge = source["coverArtLarge"]; + this.trackLength = source["trackLength"]; + this.seekPosition = source["seekPosition"]; + this.trackChangeId = source["trackChangeId"]; + } + } + +} + export namespace playlist { export class CandidateTrack { @@ -344,6 +385,71 @@ export namespace playlist { } +export namespace queue { + + export class Track { + id: number; + audioFileId: number; + filePath: string; + position: number; + title: string; + artist: string; + + static createFrom(source: any = {}) { + return new Track(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.audioFileId = source["audioFileId"]; + this.filePath = source["filePath"]; + this.position = source["position"]; + this.title = source["title"]; + this.artist = source["artist"]; + } + } + export class State { + tracks: Track[]; + currentIndex: number; + shuffleMode: boolean; + repeatMode: string; + sourcePlaylistId: number; + + static createFrom(source: any = {}) { + return new State(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.tracks = this.convertValues(source["tracks"], Track); + this.currentIndex = source["currentIndex"]; + this.shuffleMode = source["shuffleMode"]; + this.repeatMode = source["repeatMode"]; + this.sourcePlaylistId = source["sourcePlaylistId"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace tracklist { export class Column { diff --git a/frontend/wailsjs/go/player/Player.d.ts b/frontend/wailsjs/go/player/Player.d.ts new file mode 100755 index 0000000..b63e6c1 --- /dev/null +++ b/frontend/wailsjs/go/player/Player.d.ts @@ -0,0 +1,42 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {player} from '../models'; +import {context} from '../models'; + +export function ChangeVolume(arg1:number):Promise; + +export function CurrentPosition():Promise; + +export function CurrentPositionSeconds():Promise; + +export function EmitCurrentState():Promise; + +export function GetCurrentTrackInfo():Promise; + +export function InitSpeaker():Promise; + +export function IsPlaying():Promise; + +export function LoadFile(arg1:string):Promise; + +export function MuteToggle():Promise; + +export function Pause():Promise; + +export function Play():Promise; + +export function RestoreState():Promise; + +export function SaveState():Promise; + +export function Seek(arg1:number):Promise; + +export function SetContext(arg1:context.Context):Promise; + +export function SetPlaybackFinishedHandler(arg1:any):Promise; + +export function SetVolume(arg1:player.UserVolume):Promise; + +export function TrackLengthInSeconds():Promise; + +export function UnloadTrack():Promise; diff --git a/frontend/wailsjs/go/player/Player.js b/frontend/wailsjs/go/player/Player.js new file mode 100755 index 0000000..fea7417 --- /dev/null +++ b/frontend/wailsjs/go/player/Player.js @@ -0,0 +1,79 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function ChangeVolume(arg1) { + return window['go']['player']['Player']['ChangeVolume'](arg1); +} + +export function CurrentPosition() { + return window['go']['player']['Player']['CurrentPosition'](); +} + +export function CurrentPositionSeconds() { + return window['go']['player']['Player']['CurrentPositionSeconds'](); +} + +export function EmitCurrentState() { + return window['go']['player']['Player']['EmitCurrentState'](); +} + +export function GetCurrentTrackInfo() { + return window['go']['player']['Player']['GetCurrentTrackInfo'](); +} + +export function InitSpeaker() { + return window['go']['player']['Player']['InitSpeaker'](); +} + +export function IsPlaying() { + return window['go']['player']['Player']['IsPlaying'](); +} + +export function LoadFile(arg1) { + return window['go']['player']['Player']['LoadFile'](arg1); +} + +export function MuteToggle() { + return window['go']['player']['Player']['MuteToggle'](); +} + +export function Pause() { + return window['go']['player']['Player']['Pause'](); +} + +export function Play() { + return window['go']['player']['Player']['Play'](); +} + +export function RestoreState() { + return window['go']['player']['Player']['RestoreState'](); +} + +export function SaveState() { + return window['go']['player']['Player']['SaveState'](); +} + +export function Seek(arg1) { + return window['go']['player']['Player']['Seek'](arg1); +} + +export function SetContext(arg1) { + return window['go']['player']['Player']['SetContext'](arg1); +} + +export function SetPlaybackFinishedHandler(arg1) { + return window['go']['player']['Player']['SetPlaybackFinishedHandler'](arg1); +} + +export function SetVolume(arg1) { + return window['go']['player']['Player']['SetVolume'](arg1); +} + +export function TrackLengthInSeconds() { + return window['go']['player']['Player']['TrackLengthInSeconds'](); +} + +export function UnloadTrack() { + return window['go']['player']['Player']['UnloadTrack'](); +} diff --git a/frontend/wailsjs/go/queue/Queue.d.ts b/frontend/wailsjs/go/queue/Queue.d.ts new file mode 100755 index 0000000..0cc75f0 --- /dev/null +++ b/frontend/wailsjs/go/queue/Queue.d.ts @@ -0,0 +1,50 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {queue} from '../models'; +import {context} from '../models'; + +export function AddTrack(arg1:string):Promise; + +export function AddTracks(arg1:Array):Promise; + +export function Clear():Promise; + +export function CycleRepeat():Promise; + +export function EmitCurrentState():Promise; + +export function GetState():Promise; + +export function InsertNext(arg1:string):Promise; + +export function InsertNextTracks(arg1:Array):Promise; + +export function InsertTracksAt(arg1:Array,arg2:number):Promise; + +export function MoveQueueTracks(arg1:Array,arg2:number):Promise; + +export function Next():Promise; + +export function OnPlaybackFinished():Promise; + +export function Play():Promise; + +export function PlayIndex(arg1:number):Promise; + +export function Previous():Promise; + +export function RemoveTrack(arg1:number):Promise; + +export function RemoveTracks(arg1:Array):Promise; + +export function RestoreState():Promise; + +export function SaveState():Promise; + +export function SetContext(arg1:context.Context):Promise; + +export function SetPlayer(arg1:queue.TrackLoader):Promise; + +export function SetQueue(arg1:Array,arg2:number,arg3:boolean):Promise; + +export function ToggleShuffle():Promise; diff --git a/frontend/wailsjs/go/queue/Queue.js b/frontend/wailsjs/go/queue/Queue.js new file mode 100755 index 0000000..b3fcbc5 --- /dev/null +++ b/frontend/wailsjs/go/queue/Queue.js @@ -0,0 +1,95 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function AddTrack(arg1) { + return window['go']['queue']['Queue']['AddTrack'](arg1); +} + +export function AddTracks(arg1) { + return window['go']['queue']['Queue']['AddTracks'](arg1); +} + +export function Clear() { + return window['go']['queue']['Queue']['Clear'](); +} + +export function CycleRepeat() { + return window['go']['queue']['Queue']['CycleRepeat'](); +} + +export function EmitCurrentState() { + return window['go']['queue']['Queue']['EmitCurrentState'](); +} + +export function GetState() { + return window['go']['queue']['Queue']['GetState'](); +} + +export function InsertNext(arg1) { + return window['go']['queue']['Queue']['InsertNext'](arg1); +} + +export function InsertNextTracks(arg1) { + return window['go']['queue']['Queue']['InsertNextTracks'](arg1); +} + +export function InsertTracksAt(arg1, arg2) { + return window['go']['queue']['Queue']['InsertTracksAt'](arg1, arg2); +} + +export function MoveQueueTracks(arg1, arg2) { + return window['go']['queue']['Queue']['MoveQueueTracks'](arg1, arg2); +} + +export function Next() { + return window['go']['queue']['Queue']['Next'](); +} + +export function OnPlaybackFinished() { + return window['go']['queue']['Queue']['OnPlaybackFinished'](); +} + +export function Play() { + return window['go']['queue']['Queue']['Play'](); +} + +export function PlayIndex(arg1) { + return window['go']['queue']['Queue']['PlayIndex'](arg1); +} + +export function Previous() { + return window['go']['queue']['Queue']['Previous'](); +} + +export function RemoveTrack(arg1) { + return window['go']['queue']['Queue']['RemoveTrack'](arg1); +} + +export function RemoveTracks(arg1) { + return window['go']['queue']['Queue']['RemoveTracks'](arg1); +} + +export function RestoreState() { + return window['go']['queue']['Queue']['RestoreState'](); +} + +export function SaveState() { + return window['go']['queue']['Queue']['SaveState'](); +} + +export function SetContext(arg1) { + return window['go']['queue']['Queue']['SetContext'](arg1); +} + +export function SetPlayer(arg1) { + return window['go']['queue']['Queue']['SetPlayer'](arg1); +} + +export function SetQueue(arg1, arg2, arg3) { + return window['go']['queue']['Queue']['SetQueue'](arg1, arg2, arg3); +} + +export function ToggleShuffle() { + return window['go']['queue']['Queue']['ToggleShuffle'](); +}