refactored many events to use wails bindings, reducing boilerplate
This commit is contained in:
@@ -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
|
||||
@@ -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<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
|
||||
|
||||
```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
|
||||
@@ -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
|
||||
|
||||
+19
-7
@@ -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()
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -80,7 +80,7 @@ export class PlayerControls extends LitElement {
|
||||
`;
|
||||
|
||||
private handlePlayClick = () => {
|
||||
this.player.play();
|
||||
queueStore.play();
|
||||
};
|
||||
|
||||
private handlePauseClick = () => {
|
||||
|
||||
+2
-28
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { playerStore } from '../player-store';
|
||||
* render() {
|
||||
* return html`
|
||||
* <span>${this.player.currentTrack?.fileName}</span>
|
||||
* <button @click=${() => this.player.play()}>Play</button>
|
||||
* <button @click=${() => this.player.pause()}>Pause</button>
|
||||
* `;
|
||||
* }
|
||||
*/
|
||||
@@ -67,10 +67,6 @@ export class PlayerController implements ReactiveController {
|
||||
// Delegate to store (which delegates to backend)
|
||||
// ===================================================================
|
||||
|
||||
play(): void {
|
||||
playerStore.play();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
playerStore.pause();
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ export class QueueController implements ReactiveController {
|
||||
// ACTIONS
|
||||
// ===================================================================
|
||||
|
||||
play(): void {
|
||||
queueStore.play();
|
||||
}
|
||||
|
||||
next(): void {
|
||||
queueStore.next();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+42
@@ -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<void>;
|
||||
|
||||
export function CurrentPosition():Promise<number>;
|
||||
|
||||
export function CurrentPositionSeconds():Promise<number>;
|
||||
|
||||
export function EmitCurrentState():Promise<void>;
|
||||
|
||||
export function GetCurrentTrackInfo():Promise<player.TrackInfo>;
|
||||
|
||||
export function InitSpeaker():Promise<void>;
|
||||
|
||||
export function IsPlaying():Promise<boolean>;
|
||||
|
||||
export function LoadFile(arg1:string):Promise<void>;
|
||||
|
||||
export function MuteToggle():Promise<void>;
|
||||
|
||||
export function Pause():Promise<void>;
|
||||
|
||||
export function Play():Promise<void>;
|
||||
|
||||
export function RestoreState():Promise<void>;
|
||||
|
||||
export function SaveState():Promise<void>;
|
||||
|
||||
export function Seek(arg1:number):Promise<void>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetPlaybackFinishedHandler(arg1:any):Promise<void>;
|
||||
|
||||
export function SetVolume(arg1:player.UserVolume):Promise<void>;
|
||||
|
||||
export function TrackLengthInSeconds():Promise<number>;
|
||||
|
||||
export function UnloadTrack():Promise<void>;
|
||||
Executable
+79
@@ -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']();
|
||||
}
|
||||
+50
@@ -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<void>;
|
||||
|
||||
export function AddTracks(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function Clear():Promise<void>;
|
||||
|
||||
export function CycleRepeat():Promise<void>;
|
||||
|
||||
export function EmitCurrentState():Promise<void>;
|
||||
|
||||
export function GetState():Promise<queue.State>;
|
||||
|
||||
export function InsertNext(arg1:string):Promise<void>;
|
||||
|
||||
export function InsertNextTracks(arg1:Array<string>):Promise<void>;
|
||||
|
||||
export function InsertTracksAt(arg1:Array<string>,arg2:number):Promise<void>;
|
||||
|
||||
export function MoveQueueTracks(arg1:Array<number>,arg2:number):Promise<void>;
|
||||
|
||||
export function Next():Promise<void>;
|
||||
|
||||
export function OnPlaybackFinished():Promise<void>;
|
||||
|
||||
export function Play():Promise<void>;
|
||||
|
||||
export function PlayIndex(arg1:number):Promise<void>;
|
||||
|
||||
export function Previous():Promise<void>;
|
||||
|
||||
export function RemoveTrack(arg1:number):Promise<void>;
|
||||
|
||||
export function RemoveTracks(arg1:Array<number>):Promise<void>;
|
||||
|
||||
export function RestoreState():Promise<void>;
|
||||
|
||||
export function SaveState():Promise<void>;
|
||||
|
||||
export function SetContext(arg1:context.Context):Promise<void>;
|
||||
|
||||
export function SetPlayer(arg1:queue.TrackLoader):Promise<void>;
|
||||
|
||||
export function SetQueue(arg1:Array<string>,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function ToggleShuffle():Promise<void>;
|
||||
Executable
+95
@@ -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']();
|
||||
}
|
||||
Reference in New Issue
Block a user