added mutex to player to prevent simultaneous access

This commit is contained in:
2026-02-23 20:06:41 -05:00
parent 16060023bb
commit d925e3d659
+442 -141
View File
@@ -9,6 +9,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/TheCodeOfCaleb/beep/v2" "github.com/TheCodeOfCaleb/beep/v2"
@@ -26,7 +27,17 @@ import (
) )
// Player handles audio playback and state management. // Player handles audio playback and state management.
//
// Lock ordering: always acquire p.mu BEFORE speaker.Lock().
// The beep playback-finished callback dispatches to a new goroutine
// so it never holds p.mu while the speaker lock is held.
type Player struct { type Player struct {
// mu protects all mutable fields below from concurrent access.
// It must be held by every public method and released before
// calling the playbackFinishedHandler (which re-enters the player
// via the queue).
mu sync.Mutex
ctx context.Context ctx context.Context
logger *slog.Logger logger *slog.Logger
db *database.DB db *database.DB
@@ -64,7 +75,11 @@ var (
var speakerSampleRate = beep.SampleRate(44100) var speakerSampleRate = beep.SampleRate(44100)
// NewPlayer creates a player and initializes the audio speaker. // NewPlayer creates a player and initializes the audio speaker.
func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Player, error) { func NewPlayer(
ctx context.Context,
logger *slog.Logger,
db *database.DB,
) (*Player, error) {
defer profiling.TimeOp(logger, "player.NewPlayer")() defer profiling.TimeOp(logger, "player.NewPlayer")()
player := &Player{ player := &Player{
@@ -79,86 +94,182 @@ func NewPlayer(ctx context.Context, logger *slog.Logger, db *database.DB) (*Play
} }
// TODO: allow user to change buffer size and speaker sample rate // TODO: allow user to change buffer size and speaker sample rate
err := speaker.Init(player.format.SampleRate, player.format.SampleRate.N(time.Second/10)) err := speaker.Init(
player.format.SampleRate,
player.format.SampleRate.N(time.Second/10),
)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize speaker %w", err) return nil, fmt.Errorf(
"failed to initialize speaker: %w", err,
)
} }
return player, nil return player, nil
} }
// SetPlaybackFinishedHandler sets a callback that is invoked when a track finishes naturally. // SetPlaybackFinishedHandler sets a callback invoked when a track
// This allows the queue to drive auto-advance without circular imports. // finishes naturally. This allows the queue to drive auto-advance
// without circular imports.
func (p *Player) SetPlaybackFinishedHandler(handler func()) { func (p *Player) SetPlaybackFinishedHandler(handler func()) {
p.mu.Lock()
defer p.mu.Unlock()
p.playbackFinishedHandler = handler p.playbackFinishedHandler = handler
} }
// SetContext sets the Wails context, registers event handlers, and restores persisted state. // SetContext sets the Wails context, registers event handlers, and
// restores persisted state.
func (p *Player) SetContext(ctx context.Context) { func (p *Player) SetContext(ctx context.Context) {
p.mu.Lock()
p.ctx = ctx p.ctx = ctx
p.mu.Unlock()
p.registerEventHandlers() p.registerEventHandlers()
p.RestoreState()
p.mu.Lock()
p.restoreStateLocked()
p.mu.Unlock()
} }
func (p *Player) registerEventHandlers() { func (p *Player) registerEventHandlers() {
if p.ctx == nil { p.mu.Lock()
p.logger.Error("Context is nil, cannot register event handlers") ctx := p.ctx
p.mu.Unlock()
if ctx == nil {
p.logger.Error(
"Context is nil, cannot register event handlers",
)
return return
} }
runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) { runtime.EventsOn(
p.logger.Info("Received RequestPlayEvent") ctx,
events.RequestPlay,
func(_ ...any) {
p.logger.Info("Received RequestPlayEvent")
if err := p.Play(); err != nil { if err := p.Play(); err != nil {
p.logger.Warn("Play requested but not ready", "err", err) p.logger.Warn(
"Play requested but not ready",
"err", err,
)
}
},
)
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
} }
})
runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) {
p.logger.Info("Received RequestPauseEvent")
if err := p.Pause(); err != nil { seekFloat, ok := data[0].(float64)
p.logger.Error("failed to pause", "err", err) if !ok {
p.logger.Warn(
"Seek: invalid seek value type",
"got", fmt.Sprintf("%T", data[0]),
)
return
} }
})
runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) {
p.logger.Info("Received RequestLoadFileEvent")
filePath := data[0].(string) seekValue := int(seekFloat)
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) err := p.Seek(seekValue)
if err != nil { if err != nil {
p.logger.Error("cannot seek", "error", err) 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) runtime.EventsOn(
if err != nil { ctx,
p.logger.Error("cannot set volume", "error", err) events.RequestSetVolume,
func(data ...any) {
if len(data) < 1 {
p.logger.Warn(
"RequestSetVolume: missing volume argument",
)
return return
} }
p.emitVolumeChanged() volFloat, ok := data[0].(float64)
p.saveState() 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)
// ---------------------------------------------------------------
// emitPlaybackStateChanged emits a playback state change event. // emitPlaybackStateChanged emits a playback state change event.
func (p *Player) emitPlaybackStateChanged(state State) { func (p *Player) emitPlaybackStateChanged(state State) {
if p.ctx == nil { if p.ctx == nil {
@@ -167,7 +278,10 @@ func (p *Player) emitPlaybackStateChanged(state State) {
return return
} }
p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state) p.logger.Info(
"Emitting PlaybackStateChangedEvent", "state", state,
)
runtime.EventsEmit( runtime.EventsEmit(
p.ctx, p.ctx,
events.PlaybackStateChanged, events.PlaybackStateChanged,
@@ -194,7 +308,10 @@ func (p *Player) emitVolumeChanged() {
} }
volume := int(p.getUserVolume()) volume := int(p.getUserVolume())
p.logger.Info("Emitting VolumeChangedEvent", "volume", volume) p.logger.Info(
"Emitting VolumeChangedEvent", "volume", volume,
)
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume) runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
} }
@@ -205,14 +322,15 @@ func (p *Player) emitTrackChanged() {
return return
} }
trackLengthSecs, err := p.TrackLengthInSeconds() trackLengthSecs, err := p.trackLengthLocked()
if err != nil { if err != nil {
p.logger.Error("Cannot get track length") p.logger.Error("Cannot get track length")
} }
trackInfo, err := p.GetCurrentTrackInfo() trackInfo, err := p.getCurrentTrackInfoLocked()
if err != nil { if err != nil {
p.logger.Error("Cannot get track info") p.logger.Error("Cannot get track info")
trackInfo = map[string]interface{}{ trackInfo = map[string]interface{}{
"fileName": "", "fileName": "",
"filePath": "", "filePath": "",
@@ -225,7 +343,8 @@ func (p *Player) emitTrackChanged() {
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)
speaker.Unlock() speaker.Unlock()
} }
@@ -233,19 +352,26 @@ func (p *Player) emitTrackChanged() {
// even when the same file plays consecutively. // even when the same file plays consecutively.
p.trackChangeID++ p.trackChangeID++
// Emit comprehensive track info // Emit comprehensive track info.
trackInfo["trackLength"] = trackLengthSecs trackInfo["trackLength"] = trackLengthSecs
trackInfo["seekPosition"] = seekPosition trackInfo["seekPosition"] = seekPosition
trackInfo["trackChangeId"] = p.trackChangeID trackInfo["trackChangeId"] = p.trackChangeID
runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo) runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
p.logger.Info("Emitting TrackChangedEvent with track info", "trackInfo", trackInfo) p.logger.Info(
"Emitting TrackChangedEvent with track info",
"trackInfo", trackInfo,
)
} }
// EmitCurrentState pushes the current player state to the frontend. // EmitCurrentState pushes the current player state to the frontend.
// This is intended to be called after the frontend is ready to receive events, // This is intended to be called after the frontend is ready to
// separately from RestoreState which does the heavy lifting during OnStartup. // receive events, separately from RestoreState which does the heavy
// lifting during OnStartup.
func (p *Player) EmitCurrentState() { func (p *Player) EmitCurrentState() {
p.mu.Lock()
defer p.mu.Unlock()
p.emitVolumeChanged() p.emitVolumeChanged()
if p.currentFile != nil { if p.currentFile != nil {
@@ -254,14 +380,23 @@ func (p *Player) EmitCurrentState() {
} }
} }
func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.SampleRate) error { // ---------------------------------------------------------------
// Streamer management (must be called with p.mu held)
// ---------------------------------------------------------------
func (p *Player) updateStreamers(
newBaseStreamer beep.StreamSeeker,
sr beep.SampleRate,
) error {
// set base streamer // set base streamer
p.baseStreamer = newBaseStreamer p.baseStreamer = newBaseStreamer
p.seeker = newBaseStreamer p.seeker = newBaseStreamer
// resample file stream to match speaker // resample file stream to match speaker
// TODO: variable resample quality // TODO: variable resample quality
p.resampled = beep.Resample(4, sr, speakerSampleRate, p.baseStreamer) p.resampled = beep.Resample(
4, sr, speakerSampleRate, p.baseStreamer,
)
// wrap in ctrl streamer to allow play/pause // wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.resampled} p.control = &beep.Ctrl{Streamer: p.resampled}
@@ -289,49 +424,79 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
return nil return nil
} }
// startPaused registers the current streamer chain with the speaker in a // startPaused registers the current streamer chain with the speaker
// paused state. This keeps the speaker always active when a file is loaded, // in a paused state. Must be called with p.mu held.
// so Play() only ever needs to unpause the control gate.
func (p *Player) startPaused() { func (p *Player) startPaused() {
speaker.Lock() speaker.Lock()
p.control.Paused = true p.control.Paused = true
speaker.Unlock() speaker.Unlock()
speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() { // The beep.Callback runs with the speaker mutex held, so we
p.state = Stopped // dispatch to a goroutine that can safely acquire p.mu.
p.emitPlaybackStateChanged(p.state) speaker.Play(beep.Seq(
p.emitPlaybackFinished() p.speakerStreamer,
p.logger.Info("Playback finished naturally") beep.Callback(func() {
go p.onPlaybackFinished()
// Notify queue for auto-advance. This must be dispatched to a new }),
// goroutine because beep.Callback runs with the speaker mutex held ))
// and the handler will call LoadFile/Play which acquire that same lock.
if p.playbackFinishedHandler != nil {
go p.playbackFinishedHandler()
}
})))
p.state = Paused p.state = Paused
} }
// onPlaybackFinished handles the natural end of a track. It is
// called on a new goroutine from the beep callback (which holds
// the speaker lock) so that it can safely acquire p.mu.
func (p *Player) onPlaybackFinished() {
p.mu.Lock()
p.state = Stopped
handler := p.playbackFinishedHandler
p.mu.Unlock()
// Emit events outside the lock — these are non-blocking Wails
// calls that don't need player state.
p.emitPlaybackStateChanged(Stopped)
p.emitPlaybackFinished()
p.logger.Info("Playback finished naturally")
// Notify queue for auto-advance. Called without p.mu held
// because it re-enters the player via LoadFile/Play.
if handler != nil {
handler()
}
}
// ---------------------------------------------------------------
// LoadFile
// ---------------------------------------------------------------
// LoadFile opens and decodes an audio file for playback. // LoadFile opens and decodes an audio file for playback.
func (p *Player) LoadFile(filePath string) error { func (p *Player) LoadFile(filePath string) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.loadFileLocked(filePath)
}
func (p *Player) loadFileLocked(filePath string) error {
defer profiling.TimeOp(p.logger, "player.LoadFile")() defer profiling.TimeOp(p.logger, "player.LoadFile")()
// opening file
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
p.logger.Error("Failed to open file") p.logger.Error("Failed to open file")
return fmt.Errorf("failed to open file %w", err) return fmt.Errorf("failed to open file: %w", err)
} }
streamer, format, err := metadata.DecodeFile(f) streamer, format, err := metadata.DecodeFile(f)
if err != nil { if err != nil {
p.logger.Error("failed to decode audio file", "path", filePath, "err", err) p.logger.Error(
"failed to decode audio file",
"path", filePath, "err", err,
)
return fmt.Errorf("failed to decode audio file: %w", err) return fmt.Errorf("failed to decode audio file: %w", err)
} }
// Stop existing playback before loading new file. // Stop existing playback before loading new file.
speaker.Lock() speaker.Lock()
if p.control != nil { if p.control != nil {
@@ -343,13 +508,18 @@ func (p *Player) LoadFile(filePath string) error {
if p.currentFile != nil { if p.currentFile != nil {
if closeErr := p.currentFile.Close(); closeErr != nil { if closeErr := p.currentFile.Close(); closeErr != nil {
p.logger.Warn("failed to close previous audio file", "err", closeErr) p.logger.Warn(
"failed to close previous audio file",
"err", closeErr,
)
} }
} }
p.currentFile = f p.currentFile = f
if err := p.updateStreamers(streamer, format.SampleRate); err != nil { if err := p.updateStreamers(
streamer, format.SampleRate,
); err != nil {
return fmt.Errorf("failed to update streamers: %w", err) return fmt.Errorf("failed to update streamers: %w", err)
} }
@@ -357,11 +527,17 @@ func (p *Player) LoadFile(filePath string) error {
p.emitPlaybackStateChanged(p.state) p.emitPlaybackStateChanged(p.state)
p.emitTrackChanged() p.emitTrackChanged()
p.saveState() p.saveState()
p.logger.Info("File loaded, state set to paused", "file", filePath) p.logger.Info(
"File loaded, state set to paused", "file", filePath,
)
return nil return nil
} }
// ---------------------------------------------------------------
// Play / Pause
// ---------------------------------------------------------------
func (p *Player) validateReadyToPlay() error { func (p *Player) validateReadyToPlay() error {
if p.control == nil { if p.control == nil {
return errNoControlStreamer return errNoControlStreamer
@@ -380,6 +556,9 @@ func (p *Player) validateReadyToPlay() error {
// Play starts or resumes audio playback. // Play starts or resumes audio playback.
func (p *Player) Play() error { func (p *Player) Play() error {
p.mu.Lock()
defer p.mu.Unlock()
if err := p.validateReadyToPlay(); err != nil { if err := p.validateReadyToPlay(); err != nil {
return err return err
} }
@@ -390,26 +569,34 @@ func (p *Player) Play() error {
return nil return nil
} }
// Track finished naturally — seek to the beginning and re-register // Track finished naturally — seek to the beginning and
// a paused stream with the speaker so the unpause below starts it. // re-register a paused stream with the speaker so the unpause
// below starts it.
if p.state == Stopped && p.seeker != nil { if p.state == Stopped && p.seeker != nil {
speaker.Lock() speaker.Lock()
err := p.seeker.Seek(0) err := p.seeker.Seek(0)
speaker.Unlock() speaker.Unlock()
if err != nil { if err != nil {
return fmt.Errorf("failed to seek to beginning: %w", err) return fmt.Errorf(
"failed to seek to beginning: %w", err,
)
} }
if err := p.updateStreamers(p.seeker, p.format.SampleRate); err != nil { if err := p.updateStreamers(
return fmt.Errorf("failed to update streamers for replay: %w", err) 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")
} }
// Unpause — works for both resume-from-pause and replay-from-stopped. // Unpause — works for both resume-from-pause and
// replay-from-stopped.
speaker.Lock() speaker.Lock()
p.control.Paused = false p.control.Paused = false
speaker.Unlock() speaker.Unlock()
@@ -423,6 +610,9 @@ 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 {
p.mu.Lock()
defer p.mu.Unlock()
if p.control == nil { if p.control == nil {
return errNoAudioStream return errNoAudioStream
} }
@@ -451,13 +641,24 @@ func (p *Player) Pause() error {
// IsPlaying reports whether the player is currently playing audio. // IsPlaying reports whether the player is currently playing audio.
func (p *Player) IsPlaying() bool { func (p *Player) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.state == Playing return p.state == Playing
} }
// UnloadTrack tears down the current track, releasing the file and streamer // ---------------------------------------------------------------
// chain. The player returns to the initial "no track loaded" state and emits // UnloadTrack
// events so the frontend clears its current-track display. // ---------------------------------------------------------------
// UnloadTrack tears down the current track, releasing the file and
// streamer chain. The player returns to the initial "no track
// loaded" state and emits events so the frontend clears its
// current-track display.
func (p *Player) UnloadTrack() { func (p *Player) UnloadTrack() {
p.mu.Lock()
defer p.mu.Unlock()
// Stop audio output. // Stop audio output.
if p.control != nil { if p.control != nil {
speaker.Lock() speaker.Lock()
@@ -468,14 +669,17 @@ func (p *Player) UnloadTrack() {
// Close the open audio file. // Close the open audio file.
if p.currentFile != nil { if p.currentFile != nil {
if err := p.currentFile.Close(); err != nil { if err := p.currentFile.Close(); err != nil {
p.logger.Warn("Failed to close audio file during unload", "err", err) p.logger.Warn(
"Failed to close audio file during unload",
"err", err,
)
} }
p.currentFile = nil p.currentFile = nil
} }
// Release streamer chain. Volume is intentionally kept so the user's // Release streamer chain. Volume is intentionally kept so the
// volume setting persists across tracks. // user's volume setting persists across tracks.
p.baseStreamer = nil p.baseStreamer = nil
p.seeker = nil p.seeker = nil
p.resampled = nil p.resampled = nil
@@ -492,23 +696,38 @@ func (p *Player) UnloadTrack() {
p.logger.Info("Track unloaded") p.logger.Info("Track unloaded")
} }
// ---------------------------------------------------------------
// Volume
// ---------------------------------------------------------------
// SetVolume sets the playback volume (0-100). // SetVolume sets the playback volume (0-100).
func (p *Player) SetVolume(desiredVolume UserVolume) error { func (p *Player) SetVolume(desiredVolume UserVolume) error {
speaker.Lock() p.mu.Lock()
// clamp value between 1 and 100 defer p.mu.Unlock()
volume := clampVolume(desiredVolume)
// Apply the volume settings p.setVolumeLocked(desiredVolume)
p.volume.Volume = float64(volume.ToVolume())
p.volume.Silent = volume == MinUserVol
speaker.Unlock()
return nil return nil
} }
func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
speaker.Lock()
volume := clampVolume(desiredVolume)
p.volume.Volume = float64(volume.ToVolume())
p.volume.Silent = volume == MinUserVol
speaker.Unlock()
}
// ChangeVolume adjusts the volume by a relative amount. // ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error { func (p *Player) ChangeVolume(deltaVolume int) error {
return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume)) p.mu.Lock()
defer p.mu.Unlock()
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
return nil
} }
func (p *Player) getUserVolume() UserVolume { func (p *Player) getUserVolume() UserVolume {
@@ -517,14 +736,25 @@ func (p *Player) getUserVolume() UserVolume {
// MuteToggle toggles the mute state. // MuteToggle toggles the mute state.
func (p *Player) MuteToggle() error { func (p *Player) MuteToggle() error {
p.mu.Lock()
defer p.mu.Unlock()
p.volume.Silent = !p.volume.Silent p.volume.Silent = !p.volume.Silent
p.saveState() p.saveState()
return nil return nil
} }
// CurrentPositionSeconds returns the current playback position in seconds. // ---------------------------------------------------------------
// Position / Seek
// ---------------------------------------------------------------
// CurrentPositionSeconds returns the current playback position in
// seconds.
func (p *Player) CurrentPositionSeconds() (int, error) { func (p *Player) CurrentPositionSeconds() (int, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.seeker == nil { if p.seeker == nil {
return 0, errNoAudioFileLoaded return 0, errNoAudioFileLoaded
} }
@@ -536,14 +766,21 @@ func (p *Player) CurrentPositionSeconds() (int, error) {
return pos, nil return pos, nil
} }
// 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) {
p.mu.Lock()
defer p.mu.Unlock()
if p.seeker == nil { if p.seeker == nil {
return 0, errNoAudioFileLoaded return 0, errNoAudioFileLoaded
} }
speaker.Lock() speaker.Lock()
pos := math.Round(100.0 * float64(p.seeker.Position()) / float64(p.seeker.Len())) pos := math.Round(
100.0 * float64(p.seeker.Position()) /
float64(p.seeker.Len()),
)
speaker.Unlock() speaker.Unlock()
return int(pos), nil return int(pos), nil
@@ -551,29 +788,38 @@ func (p *Player) CurrentPosition() (int, error) {
// Seek jumps to a specific position in seconds. // Seek jumps to a specific position in seconds.
func (p *Player) Seek(targetSeconds int) error { func (p *Player) Seek(targetSeconds int) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.seekLocked(targetSeconds)
}
func (p *Player) seekLocked(targetSeconds int) error {
if p.seeker == nil { if p.seeker == nil {
runtime.EventsEmit(p.ctx, events.SeekFailed) runtime.EventsEmit(p.ctx, events.SeekFailed)
return errNoAudioFileLoaded return errNoAudioFileLoaded
} }
lengthSecs, err := p.TrackLengthInSeconds() lengthSecs, err := p.trackLengthLocked()
if err != nil { if err != nil {
return fmt.Errorf("cannot get track length: %w", err) return fmt.Errorf("cannot get track length: %w", err)
} }
speaker.Lock() speaker.Lock()
samples := int( samples := int(
math.Round((float64(targetSeconds) / float64(lengthSecs)) * float64(p.seeker.Len())), math.Round(
(float64(targetSeconds) / float64(lengthSecs)) *
float64(p.seeker.Len()),
),
) )
p.logger.Debug( p.logger.Debug(
"attempting to seek", "attempting to seek",
"target-seconds", "target-seconds", targetSeconds,
targetSeconds, "song-length", lengthSecs,
"song-length", "samples", samples,
lengthSecs,
"samples",
samples,
) )
if seekErr := p.seeker.Seek(samples); seekErr != nil { if seekErr := p.seeker.Seek(samples); seekErr != nil {
@@ -587,8 +833,24 @@ func (p *Player) Seek(targetSeconds int) error {
return nil return nil
} }
// GetCurrentTrackInfo returns information about the currently loaded track. // ---------------------------------------------------------------
func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) { // Track info
// ---------------------------------------------------------------
// GetCurrentTrackInfo returns information about the currently
// loaded track.
func (p *Player) GetCurrentTrackInfo() (
map[string]interface{}, error,
) {
p.mu.Lock()
defer p.mu.Unlock()
return p.getCurrentTrackInfoLocked()
}
func (p *Player) getCurrentTrackInfoLocked() (
map[string]interface{}, error,
) {
if p.currentFile == nil { if p.currentFile == nil {
return map[string]interface{}{ return map[string]interface{}{
"fileName": "", "fileName": "",
@@ -613,9 +875,11 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
coverArtMedium := "" coverArtMedium := ""
coverArtLarge := "" coverArtLarge := ""
// Try to get metadata from database // Try to get metadata from database.
if p.db != nil { if p.db != nil {
meta, err := p.db.Queries.GetTrackMetadataByPath(p.ctx, filePath) meta, err := p.db.Queries.GetTrackMetadataByPath(
p.ctx, filePath,
)
if err == nil { if err == nil {
if meta.Title != "" { if meta.Title != "" {
title = meta.Title title = meta.Title
@@ -658,6 +922,13 @@ 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) {
p.mu.Lock()
defer p.mu.Unlock()
return p.trackLengthLocked()
}
func (p *Player) trackLengthLocked() (int, error) {
if p.seeker == nil { if p.seeker == nil {
return 0, errNoAudioFileLoaded return 0, errNoAudioFileLoaded
} }
@@ -669,19 +940,26 @@ func (p *Player) TrackLengthInSeconds() (int, error) {
return length, nil return length, nil
} }
// ---------------------------------------------------------------
// State persistence
// ---------------------------------------------------------------
// SaveState persists the current player state to the database. // SaveState persists the current player state to the database.
// This is called during shutdown to capture the final state. // This is called during shutdown to capture the final state.
func (p *Player) SaveState() { func (p *Player) SaveState() {
p.mu.Lock()
defer p.mu.Unlock()
p.saveState() p.saveState()
} }
// saveState is the internal helper that writes the current player state to the // saveState is the internal helper that writes the current player
// database. It is called both from the public SaveState (shutdown) and from // state to the database. Must be called with p.mu held.
// individual operations that change state (volume, mute, track load/unload)
// so that the persisted state stays up-to-date between clean shutdowns.
func (p *Player) saveState() { func (p *Player) saveState() {
if p.db == nil { if p.db == nil {
p.logger.Warn("No database available, cannot save player state") p.logger.Warn(
"No database available, cannot save player state",
)
return return
} }
@@ -703,18 +981,24 @@ func (p *Player) saveState() {
if p.seeker != nil { if p.seeker != nil {
speaker.Lock() speaker.Lock()
positionSeconds = int64(p.seeker.Position()) / int64(p.format.SampleRate) positionSeconds = int64(p.seeker.Position()) /
int64(p.format.SampleRate)
speaker.Unlock() speaker.Unlock()
} }
err := p.db.Queries.UpdatePlayerState(p.db.Ctx, sqlcgen.UpdatePlayerStateParams{ err := p.db.Queries.UpdatePlayerState(
Volume: volume, p.db.Ctx,
Muted: muted, sqlcgen.UpdatePlayerStateParams{
LastTrackPath: trackPath, Volume: volume,
LastPositionSeconds: positionSeconds, Muted: muted,
}) LastTrackPath: trackPath,
LastPositionSeconds: positionSeconds,
},
)
if err != nil { if err != nil {
p.logger.Error("Failed to save player state", "err", err) p.logger.Error(
"Failed to save player state", "err", err,
)
return return
} }
@@ -727,27 +1011,42 @@ func (p *Player) saveState() {
) )
} }
// ---------------------------------------------------------------
// State restoration
// ---------------------------------------------------------------
// RestoreState loads the persisted player state from the database. // RestoreState loads the persisted player state from the database.
func (p *Player) RestoreState() { func (p *Player) RestoreState() {
p.mu.Lock()
defer p.mu.Unlock()
p.restoreStateLocked()
}
func (p *Player) restoreStateLocked() {
defer profiling.TimeOp(p.logger, "player.RestoreState")() defer profiling.TimeOp(p.logger, "player.RestoreState")()
if p.db == nil { if p.db == nil {
p.logger.Warn("No database available, cannot restore player state") p.logger.Warn(
"No database available, cannot restore player state",
)
return return
} }
state, err := p.db.Queries.GetPlayerState(p.db.Ctx) state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
if err != nil { if err != nil {
p.logger.Error("Failed to load player state", "err", err) p.logger.Error(
"Failed to load player state", "err", err,
)
return return
} }
// Restore volume. // Restore volume.
// Ensure volume is initialized before restoring settings. The volume // Ensure volume is initialized before restoring settings. The
// effect is normally created by updateStreamers during LoadFile, but // volume effect is normally created by updateStreamers during
// RestoreState runs before any file is loaded. // LoadFile, but RestoreState runs before any file is loaded.
if p.volume == nil { if p.volume == nil {
p.volume = &effects.Volume{ p.volume = &effects.Volume{
Streamer: p.control, Streamer: p.control,
@@ -756,11 +1055,7 @@ func (p *Player) RestoreState() {
} }
vol := clampVolume(UserVolume(state.Volume)) vol := clampVolume(UserVolume(state.Volume))
p.setVolumeLocked(vol)
err = p.SetVolume(vol)
if err != nil {
p.logger.Error("Failed to restore volume", "err", err)
}
if state.Muted { if state.Muted {
p.volume.Silent = true p.volume.Silent = true
@@ -769,7 +1064,9 @@ func (p *Player) RestoreState() {
// Restore last track if the file still exists. // Restore last track if the file still exists.
if state.LastTrackPath != "" { if state.LastTrackPath != "" {
if _, statErr := os.Stat(state.LastTrackPath); statErr != nil { if _, statErr := os.Stat(state.LastTrackPath); statErr != nil {
p.logger.Warn("Last track file no longer exists, skipping restore", p.logger.Warn(
"Last track file no longer exists, "+
"skipping restore",
"path", state.LastTrackPath, "path", state.LastTrackPath,
"err", statErr, "err", statErr,
) )
@@ -777,18 +1074,22 @@ func (p *Player) RestoreState() {
return return
} }
err = p.LoadFile(state.LastTrackPath) err = p.loadFileLocked(state.LastTrackPath)
if err != nil { if err != nil {
p.logger.Error("Failed to restore last track", "path", state.LastTrackPath, "err", err) p.logger.Error(
"Failed to restore last track",
"path", state.LastTrackPath, "err", err,
)
return return
} }
// Restore playback position. // Restore playback position.
if state.LastPositionSeconds > 0 { if state.LastPositionSeconds > 0 {
err = p.Seek(int(state.LastPositionSeconds)) err = p.seekLocked(int(state.LastPositionSeconds))
if err != nil { if err != nil {
p.logger.Error("Failed to restore playback position", p.logger.Error(
"Failed to restore playback position",
"seconds", state.LastPositionSeconds, "seconds", state.LastPositionSeconds,
"err", err, "err", err,
) )