690 lines
16 KiB
Go
690 lines
16 KiB
Go
// Package player provides audio playback functionality.
|
|
package player
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/TheCodeOfCaleb/beep/v2"
|
|
"github.com/TheCodeOfCaleb/beep/v2/effects"
|
|
"github.com/TheCodeOfCaleb/beep/v2/generators"
|
|
"github.com/TheCodeOfCaleb/beep/v2/speaker"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/events"
|
|
"yellowjacket/backend/metadata"
|
|
)
|
|
|
|
// Player handles audio playback and state management.
|
|
type Player struct {
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
db *database.DB
|
|
state PlayerState
|
|
currentFile *os.File
|
|
format beep.Format
|
|
baseStreamer beep.Streamer
|
|
seeker beep.StreamSeeker
|
|
resampled beep.Streamer
|
|
control *beep.Ctrl
|
|
volume *effects.Volume
|
|
speakerStreamer beep.Streamer
|
|
playbackFinishedHandler func()
|
|
}
|
|
|
|
// PlayerState represents the current playback state.
|
|
type PlayerState string
|
|
|
|
// Playback state values.
|
|
const (
|
|
Playing PlayerState = "playing"
|
|
Paused PlayerState = "paused"
|
|
Stopped PlayerState = "stopped"
|
|
)
|
|
|
|
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) {
|
|
player := &Player{
|
|
ctx: ctx,
|
|
logger: logger,
|
|
db: db,
|
|
state: Stopped,
|
|
baseStreamer: generators.Silence(-1),
|
|
format: beep.Format{
|
|
SampleRate: speakerSampleRate,
|
|
},
|
|
}
|
|
|
|
// TODO: allow user to change buffer size and speaker sample rate
|
|
err := speaker.Init(player.format.SampleRate, player.format.SampleRate.N(time.Second/10))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to initialize speaker %w", err)
|
|
}
|
|
|
|
return player, nil
|
|
}
|
|
|
|
// SetPlaybackFinishedHandler sets a callback that is invoked when a track finishes naturally.
|
|
// This allows the queue to drive auto-advance without circular imports.
|
|
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
|
|
p.playbackFinishedHandler = handler
|
|
}
|
|
|
|
// SetContext sets the Wails context, registers event handlers, and restores persisted state.
|
|
func (p *Player) SetContext(ctx context.Context) {
|
|
p.ctx = ctx
|
|
p.registerEventHandlers()
|
|
p.RestoreState()
|
|
}
|
|
|
|
func (p *Player) registerEventHandlers() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot register event handlers")
|
|
|
|
return
|
|
}
|
|
|
|
runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) {
|
|
p.logger.Info("Received RequestPlayEvent")
|
|
p.Play()
|
|
})
|
|
runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
|
|
p.logger.Info("Received RequestPauseEvent")
|
|
p.Pause()
|
|
})
|
|
runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
|
|
p.logger.Info("Received RequestLoadFileEvent")
|
|
|
|
filePath := data[0].(string)
|
|
p.logger.Info(filePath)
|
|
|
|
err := p.LoadFile(filePath)
|
|
if err != nil {
|
|
p.logger.Error(err.Error())
|
|
} else {
|
|
p.logger.Info(p.currentFile.Name())
|
|
}
|
|
})
|
|
runtime.EventsOn(p.ctx, events.Seek, func(data ...any) {
|
|
p.logger.Info("Received SeekEvent", "Data", data[0])
|
|
seekValue := int(data[0].(float64))
|
|
|
|
err := p.Seek(seekValue)
|
|
if err != nil {
|
|
p.logger.Error("cannot seek", "error", err)
|
|
}
|
|
})
|
|
runtime.EventsOn(p.ctx, events.RequestSetVolume, func(data ...any) {
|
|
desiredVolume := UserVolume(data[0].(float64))
|
|
p.logger.Info("Received RequestSetVolumeEvent", "volume", desiredVolume)
|
|
|
|
err := p.SetVolume(desiredVolume)
|
|
if err != nil {
|
|
p.logger.Error("cannot set volume", "error", err)
|
|
|
|
return
|
|
}
|
|
|
|
p.emitVolumeChanged()
|
|
})
|
|
}
|
|
|
|
// emitPlaybackStateChanged emits a playback state change event.
|
|
func (p *Player) emitPlaybackStateChanged(state PlayerState) {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state)
|
|
runtime.EventsEmit(
|
|
p.ctx,
|
|
events.PlaybackStateChanged,
|
|
map[string]string{"state": string(state)},
|
|
)
|
|
}
|
|
|
|
func (p *Player) emitPlaybackFinished() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info("Emitting PlaybackFinishedEvent")
|
|
runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil)
|
|
}
|
|
|
|
func (p *Player) emitVolumeChanged() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
volume := int(p.getUserVolume())
|
|
p.logger.Info("Emitting VolumeChangedEvent", "volume", volume)
|
|
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
|
|
}
|
|
|
|
func (p *Player) emitTrackChanged() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
trackLengthSecs, err := p.TrackLengthInSeconds()
|
|
if err != nil {
|
|
p.logger.Error("Cannot get track length")
|
|
}
|
|
|
|
trackInfo, err := p.GetCurrentTrackInfo()
|
|
if err != nil {
|
|
p.logger.Error("Cannot get track info")
|
|
trackInfo = map[string]interface{}{
|
|
"fileName": "",
|
|
"filePath": "",
|
|
"state": string(p.state),
|
|
}
|
|
}
|
|
|
|
// Compute current seek position in seconds.
|
|
seekPosition := 0
|
|
if p.seeker != nil {
|
|
speaker.Lock()
|
|
seekPosition = p.seeker.Position() / int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
}
|
|
|
|
// Emit comprehensive track info
|
|
trackInfo["trackLength"] = trackLengthSecs
|
|
trackInfo["seekPosition"] = seekPosition
|
|
runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
|
|
|
|
p.logger.Info("Emitting TrackChangedEvent with track info", "trackInfo", trackInfo)
|
|
}
|
|
|
|
// EmitCurrentState pushes the current player state to the frontend.
|
|
// This is intended to be called after the frontend is ready to receive events,
|
|
// separately from RestoreState which does the heavy lifting during OnStartup.
|
|
func (p *Player) EmitCurrentState() {
|
|
p.emitVolumeChanged()
|
|
|
|
if p.currentFile != nil {
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.emitTrackChanged()
|
|
}
|
|
}
|
|
|
|
func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.SampleRate) error {
|
|
// set base streamer
|
|
p.baseStreamer = newBaseStreamer
|
|
p.seeker = newBaseStreamer
|
|
|
|
// resample file stream to match speaker
|
|
// TODO: variable resample quality
|
|
p.resampled = beep.Resample(4, sr, speakerSampleRate, p.baseStreamer)
|
|
|
|
// wrap in ctrl streamer to allow play/pause
|
|
p.control = &beep.Ctrl{Streamer: p.resampled}
|
|
|
|
// Preserve existing volume settings across track changes.
|
|
prevVolume := 0.0
|
|
prevSilent := false
|
|
|
|
if p.volume != nil {
|
|
prevVolume = p.volume.Volume
|
|
prevSilent = p.volume.Silent
|
|
}
|
|
|
|
// wrap in volume streamer
|
|
p.volume = &effects.Volume{
|
|
Streamer: p.control,
|
|
Base: 2,
|
|
Volume: prevVolume,
|
|
Silent: prevSilent,
|
|
}
|
|
|
|
// set "final" streamer
|
|
p.speakerStreamer = p.volume
|
|
|
|
return nil
|
|
}
|
|
|
|
// startPaused registers the current streamer chain with the speaker in a
|
|
// paused state. This keeps the speaker always active when a file is loaded,
|
|
// so Play() only ever needs to unpause the control gate.
|
|
func (p *Player) startPaused() {
|
|
speaker.Lock()
|
|
p.control.Paused = true
|
|
speaker.Unlock()
|
|
|
|
speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() {
|
|
p.state = Stopped
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.emitPlaybackFinished()
|
|
p.logger.Info("Playback finished naturally")
|
|
|
|
// Notify queue for auto-advance.
|
|
if p.playbackFinishedHandler != nil {
|
|
p.playbackFinishedHandler()
|
|
}
|
|
})))
|
|
|
|
p.state = Paused
|
|
}
|
|
|
|
// LoadFile opens and decodes an audio file for playback.
|
|
func (p *Player) LoadFile(filePath string) error {
|
|
// opening file
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
p.logger.Error("Failed to open file")
|
|
|
|
return fmt.Errorf("failed to open file %w", err)
|
|
}
|
|
|
|
streamer, format, err := metadata.DecodeFile(f)
|
|
if err != nil {
|
|
p.logger.Error("failed to decode audio file", "path", filePath, "err", err)
|
|
|
|
return fmt.Errorf("failed to decode audio file: %w", err)
|
|
}
|
|
// Stop existing playback before loading new file.
|
|
speaker.Lock()
|
|
if p.control != nil {
|
|
p.control.Paused = true
|
|
}
|
|
|
|
p.state = Stopped
|
|
speaker.Unlock()
|
|
|
|
if p.currentFile != nil {
|
|
p.currentFile.Close()
|
|
}
|
|
|
|
p.currentFile = f
|
|
|
|
p.updateStreamers(streamer, format.SampleRate)
|
|
p.startPaused()
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.emitTrackChanged()
|
|
p.logger.Info("File loaded, state set to paused", "file", filePath)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Player) validateReadyToPlay() error {
|
|
if p.control == nil {
|
|
return errors.New("no control streamer")
|
|
}
|
|
|
|
if p.currentFile == nil {
|
|
return errors.New("no audio file loaded")
|
|
}
|
|
|
|
if p.speakerStreamer == nil {
|
|
return errors.New("no streamer to play")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Play starts or resumes audio playback.
|
|
func (p *Player) Play() error {
|
|
if err := p.validateReadyToPlay(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if p.state == Playing {
|
|
p.logger.Info("Already playing")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Track finished naturally — seek to the beginning and re-register
|
|
// a paused stream with the speaker so the unpause below starts it.
|
|
if p.state == Stopped && p.seeker != nil {
|
|
speaker.Lock()
|
|
err := p.seeker.Seek(0)
|
|
speaker.Unlock()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to seek to beginning: %w", err)
|
|
}
|
|
|
|
p.updateStreamers(p.seeker, p.format.SampleRate)
|
|
p.startPaused()
|
|
p.logger.Info("Rebuilt streamers for replay")
|
|
}
|
|
|
|
// Unpause — works for both resume-from-pause and replay-from-stopped.
|
|
speaker.Lock()
|
|
p.control.Paused = false
|
|
speaker.Unlock()
|
|
|
|
p.state = Playing
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.logger.Info("Started playback")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Pause pauses the current playback.
|
|
func (p *Player) Pause() error {
|
|
if p.control == nil {
|
|
return errors.New("no audio stream to pause")
|
|
}
|
|
|
|
if p.state == Paused {
|
|
p.logger.Info("Already paused")
|
|
|
|
return nil
|
|
}
|
|
|
|
if p.state == Playing {
|
|
speaker.Lock()
|
|
p.control.Paused = true
|
|
speaker.Unlock()
|
|
|
|
p.state = Paused
|
|
p.logger.Info("Paused playback")
|
|
p.emitPlaybackStateChanged(p.state)
|
|
} else {
|
|
p.logger.Info("Already paused or not playing")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetVolume sets the playback volume (0-100).
|
|
func (p *Player) SetVolume(desiredVolume UserVolume) error {
|
|
speaker.Lock()
|
|
// clamp value between 1 and 100
|
|
volume := clampVolume(desiredVolume)
|
|
|
|
// Apply the volume settings
|
|
p.volume.Volume = float64(volume.ToPlayerVolume())
|
|
p.volume.Silent = volume == MinUserVol
|
|
speaker.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ChangeVolume adjusts the volume by a relative amount.
|
|
func (p *Player) ChangeVolume(deltaVolume int) error {
|
|
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume))
|
|
}
|
|
|
|
func (p *Player) getUserVolume() UserVolume {
|
|
return PlayerVolume(p.volume.Volume).ToUserVolume()
|
|
}
|
|
|
|
// MuteToggle toggles the mute state.
|
|
func (p *Player) MuteToggle() error {
|
|
p.volume.Silent = !p.volume.Silent
|
|
|
|
return nil
|
|
}
|
|
|
|
// CurrentPositionSeconds returns the current playback position in seconds.
|
|
func (p *Player) CurrentPositionSeconds() (int, error) {
|
|
if p.seeker == nil {
|
|
return 0, errors.New("no audio file loaded")
|
|
}
|
|
|
|
speaker.Lock()
|
|
pos := p.seeker.Position() / int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
|
|
return pos, nil
|
|
}
|
|
|
|
// CurrentPosition returns the playback position as a percentage (0-100).
|
|
func (p *Player) CurrentPosition() (int, error) {
|
|
if p.seeker == nil {
|
|
return 0, errors.New("no audio file loaded")
|
|
}
|
|
|
|
speaker.Lock()
|
|
pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len()))
|
|
speaker.Unlock()
|
|
|
|
return int(pos), nil
|
|
}
|
|
|
|
// Seek jumps to a specific position in seconds.
|
|
func (p *Player) Seek(targetSeconds int) error {
|
|
if p.seeker == nil {
|
|
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
|
|
|
return errors.New("no audio file loaded")
|
|
}
|
|
|
|
lengthSecs, err := p.TrackLengthInSeconds()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get track length: %w", err)
|
|
}
|
|
|
|
speaker.Lock()
|
|
samples := int(
|
|
math.Round((float64(targetSeconds) / float64(lengthSecs)) * float64(p.seeker.Len())),
|
|
)
|
|
p.logger.Debug(
|
|
"attempting to seek",
|
|
"target-seconds",
|
|
targetSeconds,
|
|
"song-length",
|
|
lengthSecs,
|
|
"samples",
|
|
samples,
|
|
)
|
|
p.seeker.Seek(samples)
|
|
speaker.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetCurrentTrackInfo returns information about the currently loaded track.
|
|
func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
|
if p.currentFile == nil {
|
|
return map[string]interface{}{
|
|
"fileName": "",
|
|
"filePath": "",
|
|
"state": string(p.state),
|
|
"title": "",
|
|
"artist": "",
|
|
"album": "",
|
|
"coverArt": "",
|
|
}, nil
|
|
}
|
|
|
|
fileName := filepath.Base(p.currentFile.Name())
|
|
filePath := p.currentFile.Name()
|
|
|
|
// Default values
|
|
title := fileName
|
|
artist := ""
|
|
album := ""
|
|
coverArt := ""
|
|
|
|
// Try to get metadata from database
|
|
if p.db != nil {
|
|
meta, err := p.db.Queries.GetTrackMetadataByPath(p.ctx, filePath)
|
|
if err == nil {
|
|
if meta.Title != "" {
|
|
title = meta.Title
|
|
}
|
|
|
|
artist = meta.Artist
|
|
album = meta.Album
|
|
|
|
if meta.CoverArtPath != "" {
|
|
coverArt = "/covers/" + filepath.Base(meta.CoverArtPath)
|
|
}
|
|
} else {
|
|
p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err)
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"fileName": fileName,
|
|
"filePath": filePath,
|
|
"state": string(p.state),
|
|
"title": title,
|
|
"artist": artist,
|
|
"album": album,
|
|
"coverArt": coverArt,
|
|
}, nil
|
|
}
|
|
|
|
// TrackLengthInSeconds returns the duration of the current track.
|
|
func (p *Player) TrackLengthInSeconds() (int, error) {
|
|
if p.seeker == nil {
|
|
return 0, errors.New("no audio file loaded")
|
|
}
|
|
|
|
speaker.Lock()
|
|
length := p.seeker.Len() / int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
|
|
return length, nil
|
|
}
|
|
|
|
// SaveState persists the current player state to the database.
|
|
func (p *Player) SaveState() {
|
|
if p.db == nil {
|
|
p.logger.Warn("No database available, cannot save player state")
|
|
|
|
return
|
|
}
|
|
|
|
volume := int64(MaxUserVol)
|
|
muted := false
|
|
|
|
if p.volume != nil {
|
|
volume = int64(p.getUserVolume())
|
|
muted = p.volume.Silent
|
|
}
|
|
|
|
trackPath := ""
|
|
if p.currentFile != nil {
|
|
trackPath = p.currentFile.Name()
|
|
}
|
|
|
|
positionSeconds := int64(0)
|
|
|
|
if p.seeker != nil {
|
|
speaker.Lock()
|
|
positionSeconds = int64(p.seeker.Position()) / int64(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
}
|
|
|
|
err := p.db.Queries.UpdatePlayerState(p.db.Ctx, sqlcgen.UpdatePlayerStateParams{
|
|
Volume: volume,
|
|
Muted: muted,
|
|
LastTrackPath: trackPath,
|
|
LastPositionSeconds: positionSeconds,
|
|
})
|
|
if err != nil {
|
|
p.logger.Error("Failed to save player state", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info("Player state saved",
|
|
"volume", volume,
|
|
"muted", muted,
|
|
"trackPath", trackPath,
|
|
"positionSeconds", positionSeconds,
|
|
)
|
|
}
|
|
|
|
// RestoreState loads the persisted player state from the database.
|
|
func (p *Player) RestoreState() {
|
|
if p.db == nil {
|
|
p.logger.Warn("No database available, cannot restore player state")
|
|
|
|
return
|
|
}
|
|
|
|
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
|
|
if err != nil {
|
|
p.logger.Error("Failed to load player state", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
// Restore volume.
|
|
// Ensure volume is initialized before restoring settings. The volume
|
|
// effect is normally created by updateStreamers during LoadFile, but
|
|
// RestoreState runs before any file is loaded.
|
|
if p.volume == nil {
|
|
p.volume = &effects.Volume{
|
|
Streamer: p.control,
|
|
Base: 2,
|
|
}
|
|
}
|
|
|
|
vol := clampVolume(UserVolume(state.Volume))
|
|
|
|
err = p.SetVolume(vol)
|
|
if err != nil {
|
|
p.logger.Error("Failed to restore volume", "err", err)
|
|
}
|
|
|
|
if state.Muted {
|
|
p.volume.Silent = true
|
|
}
|
|
|
|
// Restore last track if the file still exists.
|
|
if state.LastTrackPath != "" {
|
|
if _, statErr := os.Stat(state.LastTrackPath); statErr != nil {
|
|
p.logger.Warn("Last track file no longer exists, skipping restore",
|
|
"path", state.LastTrackPath,
|
|
"err", statErr,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
err = p.LoadFile(state.LastTrackPath)
|
|
if err != nil {
|
|
p.logger.Error("Failed to restore last track", "path", state.LastTrackPath, "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
// Restore playback position.
|
|
if state.LastPositionSeconds > 0 {
|
|
err = p.Seek(int(state.LastPositionSeconds))
|
|
if err != nil {
|
|
p.logger.Error("Failed to restore playback position",
|
|
"seconds", state.LastPositionSeconds,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
p.logger.Info("Player state restored",
|
|
"volume", vol,
|
|
"muted", state.Muted,
|
|
"trackPath", state.LastTrackPath,
|
|
"positionSeconds", state.LastPositionSeconds,
|
|
)
|
|
}
|