fix: resolve all lint errors and make linting a required CI check (#62)
- Fix 10 err113 violations: extract dynamic errors to package-level sentinels - Fix 12 errcheck violations: handle unchecked error returns in player, metadata, and config packages - Fix 4 revive stutter warnings: rename player.PlayerState to player.State, player.PlayerVolume to player.Volume, queue.QueueTrack to queue.Track, queue.QueueState to queue.State - Fix 2 staticcheck SA4001: simplify *&x to x in assets handler - Fix 5 unused constants: remove dead AudioFileType iota block in models - Fix gci/gofumpt/wsl formatting issues across multiple files - Add gofumpt module-path setting to .golangci.yml for correct import grouping - Fix player test: gate integration test behind YELLOWJACKET_INTEGRATION env var instead of only skipping in CI, and replace t.Errorf+t.Failed with t.Fatalf - Remove continue-on-error from golangci-lint CI step so linting is now required
This commit is contained in:
@@ -75,7 +75,6 @@ jobs:
|
|||||||
run: go vet -tags webkit2_41 ./...
|
run: go vet -tags webkit2_41 ./...
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
continue-on-error: true
|
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.5.0
|
version: v2.5.0
|
||||||
|
|||||||
@@ -42,3 +42,5 @@ formatters:
|
|||||||
- standard
|
- standard
|
||||||
- default
|
- default
|
||||||
- prefix(yellowjacket)
|
- prefix(yellowjacket)
|
||||||
|
gofumpt:
|
||||||
|
module-path: yellowjacket
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.logger.Debug(
|
h.logger.Debug(
|
||||||
"custom handler for request not found, using wails asset handler",
|
"custom handler for request not found, using wails asset handler",
|
||||||
"path",
|
"path",
|
||||||
*&r.URL.Path,
|
r.URL.Path,
|
||||||
)
|
)
|
||||||
h.wailsAssetHandler.ServeHTTP(w, r)
|
h.wailsAssetHandler.ServeHTTP(w, r)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.logger.Debug(
|
h.logger.Debug(
|
||||||
"using custom handler for request",
|
"using custom handler for request",
|
||||||
"path",
|
"path",
|
||||||
*&r.URL.Path,
|
r.URL.Path,
|
||||||
)
|
)
|
||||||
h.serveMux.ServeHTTP(w, r)
|
h.serveMux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
|
||||||
"yellowjacket/backend/library"
|
"yellowjacket/backend/library"
|
||||||
"yellowjacket/backend/system"
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gorilla/schema"
|
"github.com/gorilla/schema"
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,13 +28,20 @@ func (c *Config) handle(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
if err := c.handleConfigPost(r); err != nil {
|
if err := c.handleConfigPost(r); err != nil {
|
||||||
c.logger.Error("problem handling config post request", "err", err.Error())
|
c.logger.Error("problem handling config post request", "err", err.Error())
|
||||||
c.formSubmitError(err.Error()).Render(r.Context(), w)
|
|
||||||
|
if renderErr := c.formSubmitError(err.Error()).Render(r.Context(), w); renderErr != nil {
|
||||||
|
c.logger.Error("problem rendering error response", "err", renderErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.formSubmitSuccess().Render(r.Context(), w)
|
if err := c.formSubmitSuccess().Render(r.Context(), w); err != nil {
|
||||||
|
c.logger.Error("problem rendering success response", "err", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
|
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/system"
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
@@ -61,6 +62,7 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
|||||||
for _, dirEntry := range dirEntries {
|
for _, dirEntry := range dirEntries {
|
||||||
if !dirEntry.IsDir() {
|
if !dirEntry.IsDir() {
|
||||||
filePath := path.Join("sql/schemas", dirEntry.Name())
|
filePath := path.Join("sql/schemas", dirEntry.Name())
|
||||||
|
|
||||||
sqlContent, err := fs.ReadFile(schemas, filePath)
|
sqlContent, err := fs.ReadFile(schemas, filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
|
return nil, fmt.Errorf("could not read file %s: %w", filePath, err)
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
package library
|
package library
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errNotDirectory = errors.New("path is not a directory")
|
||||||
|
|
||||||
// Config holds Library config data.
|
// Config holds Library config data.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DirectoryPath Directory `form:"Directory" schema:"directory,required"`
|
DirectoryPath Directory `form:"Directory" schema:"directory,required"`
|
||||||
@@ -35,7 +38,7 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !dirInfo.IsDir() {
|
if !dirInfo.IsDir() {
|
||||||
return fmt.Errorf("%s is not a directory", c.DirectoryPath)
|
return fmt.Errorf("%s: %w", c.DirectoryPath, errNotDirectory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import (
|
|||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
"yellowjacket/backend/metadata"
|
"yellowjacket/backend/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errLibraryDirNotConfigured = errors.New("library directory not configured")
|
||||||
|
|
||||||
// Library manages scanning and querying the music collection.
|
// Library manages scanning and querying the music collection.
|
||||||
type Library struct {
|
type Library struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -107,7 +110,7 @@ func (l *Library) Scan() error {
|
|||||||
l.logger.Info("beginning library scan", "workers", scanWorkerCount)
|
l.logger.Info("beginning library scan", "workers", scanWorkerCount)
|
||||||
|
|
||||||
if len(l.conf.DirectoryPath) == 0 {
|
if len(l.conf.DirectoryPath) == 0 {
|
||||||
return errors.New("library directory not configured")
|
return errLibraryDirNotConfigured
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing file paths from the database into a sync.Map for concurrent access.
|
// Load existing file paths from the database into a sync.Map for concurrent access.
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for library queries.
|
||||||
|
var (
|
||||||
|
errNoTracksInLibrary = errors.New("no tracks in library")
|
||||||
|
errNoTracksForAlbum = errors.New("no tracks found for album")
|
||||||
|
)
|
||||||
|
|
||||||
// Track represents a playable audio file in the library.
|
// Track represents a playable audio file in the library.
|
||||||
type Track struct {
|
type Track struct {
|
||||||
TrackName string
|
TrackName string
|
||||||
@@ -38,7 +44,7 @@ func (l *Library) GetAllTracks() ([]Track, error) {
|
|||||||
if len(audioFiles) == 0 {
|
if len(audioFiles) == 0 {
|
||||||
l.logger.Error("no tracks in library")
|
l.logger.Error("no tracks in library")
|
||||||
|
|
||||||
return nil, errors.New("no tracks in library")
|
return nil, errNoTracksInLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedTracks []Track
|
var formattedTracks []Track
|
||||||
@@ -68,7 +74,7 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(rows) == 0 {
|
if len(rows) == 0 {
|
||||||
return nil, fmt.Errorf("no tracks found for album %d", albumID)
|
return nil, fmt.Errorf("%w %d", errNoTracksForAlbum, albumID)
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks := make([]Track, 0, len(rows))
|
tracks := make([]Track, 0, len(rows))
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ func GetTrackLengthMillis(path string) (int64, error) {
|
|||||||
|
|
||||||
streamer, format, err := DecodeFile(f)
|
streamer, format, err := DecodeFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.Close()
|
_ = f.Close()
|
||||||
|
|
||||||
return 0, fmt.Errorf("error decoding file: %w", err)
|
return 0, fmt.Errorf("error decoding file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lengthMillis := int64(float64(streamer.Len()*1000) / float64(format.SampleRate))
|
lengthMillis := int64(float64(streamer.Len()*1000) / float64(format.SampleRate))
|
||||||
streamer.Close()
|
_ = streamer.Close()
|
||||||
f.Close()
|
_ = f.Close()
|
||||||
|
|
||||||
return lengthMillis, nil
|
return lengthMillis, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ func ExtractTags(path string) (*TrackMetadata, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not open file for tag extraction: %w", err)
|
return nil, fmt.Errorf("could not open file for tag extraction: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
return ExtractTagsFromReader(f)
|
return ExtractTagsFromReader(f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import "time"
|
|||||||
// AudioFileType identifies the format of an audio file.
|
// AudioFileType identifies the format of an audio file.
|
||||||
type AudioFileType int
|
type AudioFileType int
|
||||||
|
|
||||||
const (
|
|
||||||
mp3 AudioFileType = iota
|
|
||||||
flac
|
|
||||||
wav
|
|
||||||
ogg
|
|
||||||
midi
|
|
||||||
)
|
|
||||||
|
|
||||||
// AudioFile represents a music file with its metadata.
|
// AudioFile represents a music file with its metadata.
|
||||||
type AudioFile struct {
|
type AudioFile struct {
|
||||||
Path string
|
Path string
|
||||||
|
|||||||
+52
-24
@@ -28,7 +28,7 @@ type Player struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
db *database.DB
|
db *database.DB
|
||||||
state PlayerState
|
state State
|
||||||
currentFile *os.File
|
currentFile *os.File
|
||||||
format beep.Format
|
format beep.Format
|
||||||
baseStreamer beep.Streamer
|
baseStreamer beep.Streamer
|
||||||
@@ -40,14 +40,22 @@ type Player struct {
|
|||||||
playbackFinishedHandler func()
|
playbackFinishedHandler func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayerState represents the current playback state.
|
// State represents the current playback state.
|
||||||
type PlayerState string
|
type State string
|
||||||
|
|
||||||
// Playback state values.
|
// Playback state values.
|
||||||
const (
|
const (
|
||||||
Playing PlayerState = "playing"
|
Playing State = "playing"
|
||||||
Paused PlayerState = "paused"
|
Paused State = "paused"
|
||||||
Stopped PlayerState = "stopped"
|
Stopped State = "stopped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for player operations.
|
||||||
|
var (
|
||||||
|
errNoControlStreamer = errors.New("no control streamer")
|
||||||
|
errNoAudioFileLoaded = errors.New("no audio file loaded")
|
||||||
|
errNoStreamerToPlay = errors.New("no streamer to play")
|
||||||
|
errNoAudioStream = errors.New("no audio stream to pause")
|
||||||
)
|
)
|
||||||
|
|
||||||
var speakerSampleRate = beep.SampleRate(44100)
|
var speakerSampleRate = beep.SampleRate(44100)
|
||||||
@@ -96,11 +104,17 @@ func (p *Player) registerEventHandlers() {
|
|||||||
|
|
||||||
runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) {
|
runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) {
|
||||||
p.logger.Info("Received RequestPlayEvent")
|
p.logger.Info("Received RequestPlayEvent")
|
||||||
p.Play()
|
|
||||||
|
if err := p.Play(); err != nil {
|
||||||
|
p.logger.Error("failed to play", "err", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
|
runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
|
||||||
p.logger.Info("Received RequestPauseEvent")
|
p.logger.Info("Received RequestPauseEvent")
|
||||||
p.Pause()
|
|
||||||
|
if err := p.Pause(); err != nil {
|
||||||
|
p.logger.Error("failed to pause", "err", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
|
runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
|
||||||
p.logger.Info("Received RequestLoadFileEvent")
|
p.logger.Info("Received RequestLoadFileEvent")
|
||||||
@@ -140,7 +154,7 @@ func (p *Player) registerEventHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// emitPlaybackStateChanged emits a playback state change event.
|
// emitPlaybackStateChanged emits a playback state change event.
|
||||||
func (p *Player) emitPlaybackStateChanged(state PlayerState) {
|
func (p *Player) emitPlaybackStateChanged(state State) {
|
||||||
if p.ctx == nil {
|
if p.ctx == nil {
|
||||||
p.logger.Error("Context is nil, cannot emit event")
|
p.logger.Error("Context is nil, cannot emit event")
|
||||||
|
|
||||||
@@ -202,6 +216,7 @@ func (p *Player) emitTrackChanged() {
|
|||||||
|
|
||||||
// Compute current seek position in seconds.
|
// Compute current seek position in seconds.
|
||||||
seekPosition := 0
|
seekPosition := 0
|
||||||
|
|
||||||
if p.seeker != nil {
|
if p.seeker != nil {
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
seekPosition = p.seeker.Position() / int(p.format.SampleRate)
|
seekPosition = p.seeker.Position() / int(p.format.SampleRate)
|
||||||
@@ -312,12 +327,17 @@ func (p *Player) LoadFile(filePath string) error {
|
|||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
if p.currentFile != nil {
|
if p.currentFile != nil {
|
||||||
p.currentFile.Close()
|
if closeErr := p.currentFile.Close(); closeErr != nil {
|
||||||
|
p.logger.Warn("failed to close previous audio file", "err", closeErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p.currentFile = f
|
p.currentFile = f
|
||||||
|
|
||||||
p.updateStreamers(streamer, format.SampleRate)
|
if err := p.updateStreamers(streamer, format.SampleRate); err != nil {
|
||||||
|
return fmt.Errorf("failed to update streamers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
p.startPaused()
|
p.startPaused()
|
||||||
p.emitPlaybackStateChanged(p.state)
|
p.emitPlaybackStateChanged(p.state)
|
||||||
p.emitTrackChanged()
|
p.emitTrackChanged()
|
||||||
@@ -328,15 +348,15 @@ func (p *Player) LoadFile(filePath string) error {
|
|||||||
|
|
||||||
func (p *Player) validateReadyToPlay() error {
|
func (p *Player) validateReadyToPlay() error {
|
||||||
if p.control == nil {
|
if p.control == nil {
|
||||||
return errors.New("no control streamer")
|
return errNoControlStreamer
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.currentFile == nil {
|
if p.currentFile == nil {
|
||||||
return errors.New("no audio file loaded")
|
return errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.speakerStreamer == nil {
|
if p.speakerStreamer == nil {
|
||||||
return errors.New("no streamer to play")
|
return errNoStreamerToPlay
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -365,7 +385,10 @@ func (p *Player) Play() error {
|
|||||||
return fmt.Errorf("failed to seek to beginning: %w", err)
|
return fmt.Errorf("failed to seek to beginning: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.updateStreamers(p.seeker, p.format.SampleRate)
|
if err := p.updateStreamers(p.seeker, p.format.SampleRate); err != nil {
|
||||||
|
return fmt.Errorf("failed to update streamers for replay: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
p.startPaused()
|
p.startPaused()
|
||||||
p.logger.Info("Rebuilt streamers for replay")
|
p.logger.Info("Rebuilt streamers for replay")
|
||||||
}
|
}
|
||||||
@@ -385,7 +408,7 @@ func (p *Player) Play() error {
|
|||||||
// Pause pauses the current playback.
|
// Pause pauses the current playback.
|
||||||
func (p *Player) Pause() error {
|
func (p *Player) Pause() error {
|
||||||
if p.control == nil {
|
if p.control == nil {
|
||||||
return errors.New("no audio stream to pause")
|
return errNoAudioStream
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.state == Paused {
|
if p.state == Paused {
|
||||||
@@ -416,7 +439,7 @@ func (p *Player) SetVolume(desiredVolume UserVolume) error {
|
|||||||
volume := clampVolume(desiredVolume)
|
volume := clampVolume(desiredVolume)
|
||||||
|
|
||||||
// Apply the volume settings
|
// Apply the volume settings
|
||||||
p.volume.Volume = float64(volume.ToPlayerVolume())
|
p.volume.Volume = float64(volume.ToVolume())
|
||||||
p.volume.Silent = volume == MinUserVol
|
p.volume.Silent = volume == MinUserVol
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
@@ -429,7 +452,7 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) getUserVolume() UserVolume {
|
func (p *Player) getUserVolume() UserVolume {
|
||||||
return PlayerVolume(p.volume.Volume).ToUserVolume()
|
return Volume(p.volume.Volume).ToUserVolume()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MuteToggle toggles the mute state.
|
// MuteToggle toggles the mute state.
|
||||||
@@ -442,7 +465,7 @@ func (p *Player) MuteToggle() error {
|
|||||||
// CurrentPositionSeconds returns the current playback position in seconds.
|
// CurrentPositionSeconds returns the current playback position in seconds.
|
||||||
func (p *Player) CurrentPositionSeconds() (int, error) {
|
func (p *Player) CurrentPositionSeconds() (int, error) {
|
||||||
if p.seeker == nil {
|
if p.seeker == nil {
|
||||||
return 0, errors.New("no audio file loaded")
|
return 0, errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
@@ -455,7 +478,7 @@ func (p *Player) CurrentPositionSeconds() (int, error) {
|
|||||||
// CurrentPosition returns the playback position as a percentage (0-100).
|
// CurrentPosition returns the playback position as a percentage (0-100).
|
||||||
func (p *Player) CurrentPosition() (int, error) {
|
func (p *Player) CurrentPosition() (int, error) {
|
||||||
if p.seeker == nil {
|
if p.seeker == nil {
|
||||||
return 0, errors.New("no audio file loaded")
|
return 0, errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
@@ -470,7 +493,7 @@ func (p *Player) Seek(targetSeconds int) error {
|
|||||||
if p.seeker == nil {
|
if p.seeker == nil {
|
||||||
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
||||||
|
|
||||||
return errors.New("no audio file loaded")
|
return errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
lengthSecs, err := p.TrackLengthInSeconds()
|
lengthSecs, err := p.TrackLengthInSeconds()
|
||||||
@@ -491,7 +514,13 @@ func (p *Player) Seek(targetSeconds int) error {
|
|||||||
"samples",
|
"samples",
|
||||||
samples,
|
samples,
|
||||||
)
|
)
|
||||||
p.seeker.Seek(samples)
|
|
||||||
|
if seekErr := p.seeker.Seek(samples); seekErr != nil {
|
||||||
|
speaker.Unlock()
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||||
|
}
|
||||||
|
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -553,7 +582,7 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
|||||||
// TrackLengthInSeconds returns the duration of the current track.
|
// TrackLengthInSeconds returns the duration of the current track.
|
||||||
func (p *Player) TrackLengthInSeconds() (int, error) {
|
func (p *Player) TrackLengthInSeconds() (int, error) {
|
||||||
if p.seeker == nil {
|
if p.seeker == nil {
|
||||||
return 0, errors.New("no audio file loaded")
|
return 0, errNoAudioFileLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
speaker.Lock()
|
speaker.Lock()
|
||||||
@@ -677,7 +706,6 @@ func (p *Player) RestoreState() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p.logger.Info("Player state restored",
|
p.logger.Info("Player state restored",
|
||||||
|
|||||||
@@ -14,20 +14,25 @@ var testQueue = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPlayer(t *testing.T) {
|
func TestPlayer(t *testing.T) {
|
||||||
// This test requires a Wails runtime context for event registration and
|
// This is an integration test that requires:
|
||||||
// an audio device for playback. Skip in CI where neither is available.
|
// 1. A Wails runtime context (SetContext calls runtime.EventsOn)
|
||||||
if os.Getenv("CI") != "" {
|
// 2. An audio output device (speaker.Init)
|
||||||
t.Skip("skipping: requires Wails runtime context and audio device")
|
//
|
||||||
|
// Skip unless the caller explicitly opts in via YELLOWJACKET_INTEGRATION=1.
|
||||||
|
if os.Getenv("YELLOWJACKET_INTEGRATION") == "" {
|
||||||
|
t.Skip(
|
||||||
|
"skipping: integration test requires Wails runtime and audio device (set YELLOWJACKET_INTEGRATION=1 to run)",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Starting test")
|
t.Logf("Starting test")
|
||||||
|
|
||||||
p, err := NewPlayer(context.Background(), slog.Default(), nil)
|
p, err := NewPlayer(context.Background(), slog.Default(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not create player\n%s", err.Error())
|
t.Fatalf("could not create player: %s", err.Error())
|
||||||
t.Failed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetContext registers Wails event handlers; only works with a real Wails context.
|
||||||
p.SetContext(t.Context())
|
p.SetContext(t.Context())
|
||||||
t.Logf("initializing player")
|
t.Logf("initializing player")
|
||||||
|
|
||||||
@@ -36,14 +41,12 @@ func TestPlayer(t *testing.T) {
|
|||||||
|
|
||||||
err = p.LoadFile(track)
|
err = p.LoadFile(track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not load file\n%s\n%s", track, err.Error())
|
t.Fatalf("could not load file %s: %s", track, err.Error())
|
||||||
t.Failed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = p.Play()
|
err = p.Play()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("could not play file\n%s\n%s", track, err.Error())
|
t.Fatalf("could not play file %s: %s", track, err.Error())
|
||||||
t.Failed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-14
@@ -3,8 +3,8 @@ package player
|
|||||||
// UserVolume represents volume on a user-facing scale (0-100).
|
// UserVolume represents volume on a user-facing scale (0-100).
|
||||||
type UserVolume int
|
type UserVolume int
|
||||||
|
|
||||||
// PlayerVolume represents volume on an internal scale (-10 to 10).
|
// Volume represents volume on an internal scale (-4 to 0).
|
||||||
type PlayerVolume float64
|
type Volume float64
|
||||||
|
|
||||||
// User volume range bounds.
|
// User volume range bounds.
|
||||||
const (
|
const (
|
||||||
@@ -12,31 +12,31 @@ const (
|
|||||||
MaxUserVol UserVolume = 100
|
MaxUserVol UserVolume = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// Player volume range bounds.
|
// Internal volume range bounds.
|
||||||
const (
|
const (
|
||||||
MinPlayerVol PlayerVolume = -4
|
MinVol Volume = -4
|
||||||
MaxPlayerVol PlayerVolume = 0
|
MaxVol Volume = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToPlayerVolume converts user volume to internal player volume.
|
// ToVolume converts user volume to internal player volume.
|
||||||
func (oldVol UserVolume) ToPlayerVolume() PlayerVolume {
|
func (oldVol UserVolume) ToVolume() Volume {
|
||||||
var newVol PlayerVolume
|
var newVol Volume
|
||||||
|
|
||||||
if oldVol >= MinUserVol && oldVol <= MaxUserVol {
|
if oldVol >= MinUserVol && oldVol <= MaxUserVol {
|
||||||
ratio := PlayerVolume(oldVol-MinUserVol) / PlayerVolume(MaxUserVol-MinUserVol)
|
ratio := Volume(oldVol-MinUserVol) / Volume(MaxUserVol-MinUserVol)
|
||||||
newVol = ratio*(MaxPlayerVol-MinPlayerVol) + MinPlayerVol
|
newVol = ratio*(MaxVol-MinVol) + MinVol
|
||||||
}
|
}
|
||||||
|
|
||||||
return newVol
|
return newVol
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToUserVolume converts internal player volume to user volume.
|
// ToUserVolume converts internal player volume to user volume.
|
||||||
func (oldVolFloat PlayerVolume) ToUserVolume() UserVolume {
|
func (oldVolFloat Volume) ToUserVolume() UserVolume {
|
||||||
var newVol UserVolume
|
var newVol UserVolume
|
||||||
|
|
||||||
if oldVolFloat >= MinPlayerVol && oldVolFloat <= MaxPlayerVol {
|
if oldVolFloat >= MinVol && oldVolFloat <= MaxVol {
|
||||||
ratio := (oldVolFloat - MinPlayerVol) / (MaxPlayerVol - MinPlayerVol)
|
ratio := (oldVolFloat - MinVol) / (MaxVol - MinVol)
|
||||||
newVol = UserVolume(ratio*PlayerVolume(MaxUserVol-MinUserVol)) + MinUserVol
|
newVol = UserVolume(ratio*Volume(MaxUserVol-MinUserVol)) + MinUserVol
|
||||||
}
|
}
|
||||||
|
|
||||||
return newVol
|
return newVol
|
||||||
|
|||||||
+42
-29
@@ -38,8 +38,8 @@ type TrackLoader interface {
|
|||||||
CurrentPositionSeconds() (int, error)
|
CurrentPositionSeconds() (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueTrack represents a track in the queue with its metadata.
|
// Track represents a track in the queue with its metadata.
|
||||||
type QueueTrack struct {
|
type Track struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
AudioFileID int64 `json:"audioFileId"`
|
AudioFileID int64 `json:"audioFileId"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
@@ -48,13 +48,13 @@ type QueueTrack struct {
|
|||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueState is the full state emitted to the frontend.
|
// State is the full state emitted to the frontend.
|
||||||
type QueueState struct {
|
type State struct {
|
||||||
Tracks []QueueTrack `json:"tracks"`
|
Tracks []Track `json:"tracks"`
|
||||||
CurrentIndex int `json:"currentIndex"`
|
CurrentIndex int `json:"currentIndex"`
|
||||||
ShuffleMode bool `json:"shuffleMode"`
|
ShuffleMode bool `json:"shuffleMode"`
|
||||||
RepeatMode RepeatMode `json:"repeatMode"`
|
RepeatMode RepeatMode `json:"repeatMode"`
|
||||||
SourcePlaylistID int64 `json:"sourcePlaylistId"`
|
SourcePlaylistID int64 `json:"sourcePlaylistId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue manages an ordered list of tracks for playback.
|
// Queue manages an ordered list of tracks for playback.
|
||||||
@@ -65,7 +65,7 @@ type Queue struct {
|
|||||||
player TrackLoader
|
player TrackLoader
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tracks []QueueTrack
|
tracks []Track
|
||||||
currentIndex int
|
currentIndex int
|
||||||
shuffleMode bool
|
shuffleMode bool
|
||||||
repeatMode RepeatMode
|
repeatMode RepeatMode
|
||||||
@@ -331,7 +331,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
|||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
// Look up audio file IDs and metadata for all paths.
|
// Look up audio file IDs and metadata for all paths.
|
||||||
tracks := make([]QueueTrack, 0, len(filePaths))
|
tracks := make([]Track, 0, len(filePaths))
|
||||||
|
|
||||||
for i, fp := range filePaths {
|
for i, fp := range filePaths {
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
||||||
@@ -341,7 +341,7 @@ func (q *Queue) SetQueue(filePaths []string, startIndex int) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := QueueTrack{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: af.ID,
|
||||||
FilePath: fp,
|
FilePath: fp,
|
||||||
Position: int64(i),
|
Position: int64(i),
|
||||||
@@ -395,7 +395,7 @@ func (q *Queue) AddTrack(filePath string) {
|
|||||||
|
|
||||||
wasEmpty := len(q.tracks) == 0
|
wasEmpty := len(q.tracks) == 0
|
||||||
|
|
||||||
track := QueueTrack{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: af.ID,
|
||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
Position: int64(len(q.tracks)),
|
Position: int64(len(q.tracks)),
|
||||||
@@ -449,7 +449,7 @@ func (q *Queue) AddTracks(filePaths []string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := QueueTrack{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: af.ID,
|
||||||
FilePath: fp,
|
FilePath: fp,
|
||||||
Position: int64(len(q.tracks)),
|
Position: int64(len(q.tracks)),
|
||||||
@@ -490,7 +490,8 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wasEmpty := len(q.tracks) == 0
|
wasEmpty := len(q.tracks) == 0
|
||||||
var newTracks []QueueTrack
|
|
||||||
|
var newTracks []Track
|
||||||
|
|
||||||
for _, fp := range filePaths {
|
for _, fp := range filePaths {
|
||||||
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
af, err := q.db.Queries.GetAudioFileByPath(q.db.Ctx, fp)
|
||||||
@@ -500,7 +501,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
track := QueueTrack{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: af.ID,
|
||||||
FilePath: fp,
|
FilePath: fp,
|
||||||
}
|
}
|
||||||
@@ -519,7 +520,7 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert the block into the slice at insertPos.
|
// Insert the block into the slice at insertPos.
|
||||||
tail := make([]QueueTrack, len(q.tracks[insertPos:]))
|
tail := make([]Track, len(q.tracks[insertPos:]))
|
||||||
copy(tail, q.tracks[insertPos:])
|
copy(tail, q.tracks[insertPos:])
|
||||||
q.tracks = append(q.tracks[:insertPos], newTracks...)
|
q.tracks = append(q.tracks[:insertPos], newTracks...)
|
||||||
q.tracks = append(q.tracks, tail...)
|
q.tracks = append(q.tracks, tail...)
|
||||||
@@ -558,7 +559,7 @@ func (q *Queue) InsertNext(filePath string) {
|
|||||||
insertPos = len(q.tracks)
|
insertPos = len(q.tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
track := QueueTrack{
|
track := Track{
|
||||||
AudioFileID: af.ID,
|
AudioFileID: af.ID,
|
||||||
FilePath: filePath,
|
FilePath: filePath,
|
||||||
Position: int64(insertPos),
|
Position: int64(insertPos),
|
||||||
@@ -572,7 +573,7 @@ func (q *Queue) InsertNext(filePath string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert into slice.
|
// Insert into slice.
|
||||||
q.tracks = append(q.tracks, QueueTrack{})
|
q.tracks = append(q.tracks, Track{})
|
||||||
copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
|
copy(q.tracks[insertPos+1:], q.tracks[insertPos:])
|
||||||
q.tracks[insertPos] = track
|
q.tracks[insertPos] = track
|
||||||
|
|
||||||
@@ -710,14 +711,14 @@ func (q *Queue) CycleRepeat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetState returns the current queue state for the frontend.
|
// GetState returns the current queue state for the frontend.
|
||||||
func (q *Queue) GetState() QueueState {
|
func (q *Queue) GetState() State {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
tracks := make([]QueueTrack, len(q.tracks))
|
tracks := make([]Track, len(q.tracks))
|
||||||
copy(tracks, q.tracks)
|
copy(tracks, q.tracks)
|
||||||
|
|
||||||
return QueueState{
|
return State{
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
CurrentIndex: q.currentIndex,
|
CurrentIndex: q.currentIndex,
|
||||||
ShuffleMode: q.shuffleMode,
|
ShuffleMode: q.shuffleMode,
|
||||||
@@ -790,10 +791,10 @@ func (q *Queue) RestoreState() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
q.tracks = make([]QueueTrack, 0, len(rows))
|
q.tracks = make([]Track, 0, len(rows))
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
q.tracks = append(q.tracks, QueueTrack{
|
q.tracks = append(q.tracks, Track{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
AudioFileID: row.AudioFileID,
|
AudioFileID: row.AudioFileID,
|
||||||
FilePath: row.FilePath,
|
FilePath: row.FilePath,
|
||||||
@@ -954,13 +955,25 @@ func (q *Queue) playCurrentTrack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
||||||
q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks))
|
q.logger.Warn(
|
||||||
|
"Current index out of range",
|
||||||
|
"index",
|
||||||
|
q.currentIndex,
|
||||||
|
"trackCount",
|
||||||
|
len(q.tracks),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
track := q.tracks[q.currentIndex]
|
track := q.tracks[q.currentIndex]
|
||||||
q.logger.Info("Playing track from queue", "filePath", track.FilePath, "position", q.currentIndex)
|
q.logger.Info(
|
||||||
|
"Playing track from queue",
|
||||||
|
"filePath",
|
||||||
|
track.FilePath,
|
||||||
|
"position",
|
||||||
|
q.currentIndex,
|
||||||
|
)
|
||||||
|
|
||||||
err := q.player.LoadFile(track.FilePath)
|
err := q.player.LoadFile(track.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -980,8 +993,8 @@ func (q *Queue) playCurrentTrack() {
|
|||||||
// onQueueExhausted is called when there are no more tracks to play.
|
// onQueueExhausted is called when there are no more tracks to play.
|
||||||
// This is the extension point for a future fallback playlist feature.
|
// This is the extension point for a future fallback playlist feature.
|
||||||
func (q *Queue) onQueueExhausted() {
|
func (q *Queue) onQueueExhausted() {
|
||||||
q.logger.Info("Queue exhausted, stopping playback")
|
|
||||||
// Future: load fallback playlist here.
|
// Future: load fallback playlist here.
|
||||||
|
q.logger.Info("Queue exhausted, stopping playback")
|
||||||
}
|
}
|
||||||
|
|
||||||
// reindexPositions updates the Position field of all tracks to match slice index.
|
// reindexPositions updates the Position field of all tracks to match slice index.
|
||||||
@@ -1047,7 +1060,7 @@ func (q *Queue) emitQueueChanged() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := QueueState{
|
state := State{
|
||||||
Tracks: q.tracks,
|
Tracks: q.tracks,
|
||||||
CurrentIndex: q.currentIndex,
|
CurrentIndex: q.currentIndex,
|
||||||
ShuffleMode: q.shuffleMode,
|
ShuffleMode: q.shuffleMode,
|
||||||
@@ -1057,7 +1070,7 @@ func (q *Queue) emitQueueChanged() {
|
|||||||
|
|
||||||
// Ensure tracks is never nil in JSON.
|
// Ensure tracks is never nil in JSON.
|
||||||
if state.Tracks == nil {
|
if state.Tracks == nil {
|
||||||
state.Tracks = []QueueTrack{}
|
state.Tracks = []Track{}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
|
runtime.EventsEmit(q.ctx, events.QueueChanged, state)
|
||||||
|
|||||||
Reference in New Issue
Block a user