added mutex to player to prevent simultaneous access
This commit is contained in:
+442
-141
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/TheCodeOfCaleb/beep/v2"
|
||||
@@ -26,7 +27,17 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
@@ -64,7 +75,11 @@ 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) {
|
||||
func NewPlayer(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
db *database.DB,
|
||||
) (*Player, error) {
|
||||
defer profiling.TimeOp(logger, "player.NewPlayer")()
|
||||
|
||||
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
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to initialize speaker %w", err)
|
||||
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.
|
||||
// SetPlaybackFinishedHandler sets a callback invoked when a track
|
||||
// finishes naturally. This allows the queue to drive auto-advance
|
||||
// without circular imports.
|
||||
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
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) {
|
||||
p.mu.Lock()
|
||||
p.ctx = ctx
|
||||
p.mu.Unlock()
|
||||
|
||||
p.registerEventHandlers()
|
||||
p.RestoreState()
|
||||
|
||||
p.mu.Lock()
|
||||
p.restoreStateLocked()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Player) registerEventHandlers() {
|
||||
if p.ctx == nil {
|
||||
p.logger.Error("Context is nil, cannot register event handlers")
|
||||
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(p.ctx, events.RequestPlay, func(_ ...any) {
|
||||
p.logger.Info("Received RequestPlayEvent")
|
||||
runtime.EventsOn(
|
||||
ctx,
|
||||
events.RequestPlay,
|
||||
func(_ ...any) {
|
||||
p.logger.Info("Received RequestPlayEvent")
|
||||
|
||||
if err := p.Play(); err != nil {
|
||||
p.logger.Warn("Play requested but not ready", "err", err)
|
||||
if err := p.Play(); err != nil {
|
||||
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 {
|
||||
p.logger.Error("failed to pause", "err", err)
|
||||
seekFloat, ok := data[0].(float64)
|
||||
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)
|
||||
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))
|
||||
seekValue := int(seekFloat)
|
||||
|
||||
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)
|
||||
runtime.EventsOn(
|
||||
ctx,
|
||||
events.RequestSetVolume,
|
||||
func(data ...any) {
|
||||
if len(data) < 1 {
|
||||
p.logger.Warn(
|
||||
"RequestSetVolume: missing volume argument",
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
p.emitVolumeChanged()
|
||||
p.saveState()
|
||||
})
|
||||
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)
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// emitPlaybackStateChanged emits a playback state change event.
|
||||
func (p *Player) emitPlaybackStateChanged(state State) {
|
||||
if p.ctx == nil {
|
||||
@@ -167,7 +278,10 @@ func (p *Player) emitPlaybackStateChanged(state State) {
|
||||
return
|
||||
}
|
||||
|
||||
p.logger.Info("Emitting PlaybackStateChangedEvent", "state", state)
|
||||
p.logger.Info(
|
||||
"Emitting PlaybackStateChangedEvent", "state", state,
|
||||
)
|
||||
|
||||
runtime.EventsEmit(
|
||||
p.ctx,
|
||||
events.PlaybackStateChanged,
|
||||
@@ -194,7 +308,10 @@ func (p *Player) emitVolumeChanged() {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -205,14 +322,15 @@ func (p *Player) emitTrackChanged() {
|
||||
return
|
||||
}
|
||||
|
||||
trackLengthSecs, err := p.TrackLengthInSeconds()
|
||||
trackLengthSecs, err := p.trackLengthLocked()
|
||||
if err != nil {
|
||||
p.logger.Error("Cannot get track length")
|
||||
}
|
||||
|
||||
trackInfo, err := p.GetCurrentTrackInfo()
|
||||
trackInfo, err := p.getCurrentTrackInfoLocked()
|
||||
if err != nil {
|
||||
p.logger.Error("Cannot get track info")
|
||||
|
||||
trackInfo = map[string]interface{}{
|
||||
"fileName": "",
|
||||
"filePath": "",
|
||||
@@ -225,7 +343,8 @@ func (p *Player) emitTrackChanged() {
|
||||
|
||||
if p.seeker != nil {
|
||||
speaker.Lock()
|
||||
seekPosition = p.seeker.Position() / int(p.format.SampleRate)
|
||||
seekPosition = p.seeker.Position() /
|
||||
int(p.format.SampleRate)
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
@@ -233,19 +352,26 @@ func (p *Player) emitTrackChanged() {
|
||||
// even when the same file plays consecutively.
|
||||
p.trackChangeID++
|
||||
|
||||
// Emit comprehensive track info
|
||||
// Emit comprehensive track info.
|
||||
trackInfo["trackLength"] = trackLengthSecs
|
||||
trackInfo["seekPosition"] = seekPosition
|
||||
trackInfo["trackChangeId"] = p.trackChangeID
|
||||
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.
|
||||
// This is intended to be called after the frontend is ready to receive events,
|
||||
// separately from RestoreState which does the heavy lifting during OnStartup.
|
||||
// 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.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.emitVolumeChanged()
|
||||
|
||||
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
|
||||
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)
|
||||
p.resampled = beep.Resample(
|
||||
4, sr, speakerSampleRate, p.baseStreamer,
|
||||
)
|
||||
|
||||
// wrap in ctrl streamer to allow play/pause
|
||||
p.control = &beep.Ctrl{Streamer: p.resampled}
|
||||
@@ -289,49 +424,79 @@ func (p *Player) updateStreamers(newBaseStreamer beep.StreamSeeker, sr beep.Samp
|
||||
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.
|
||||
// startPaused registers the current streamer chain with the speaker
|
||||
// in a paused state. Must be called with p.mu held.
|
||||
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. 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()
|
||||
}
|
||||
})))
|
||||
// The beep.Callback runs with the speaker mutex held, so we
|
||||
// dispatch to a goroutine that can safely acquire p.mu.
|
||||
speaker.Play(beep.Seq(
|
||||
p.speakerStreamer,
|
||||
beep.Callback(func() {
|
||||
go p.onPlaybackFinished()
|
||||
}),
|
||||
))
|
||||
|
||||
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.
|
||||
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")()
|
||||
|
||||
// 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)
|
||||
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)
|
||||
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 {
|
||||
@@ -343,13 +508,18 @@ func (p *Player) LoadFile(filePath string) error {
|
||||
|
||||
if p.currentFile != 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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -357,11 +527,17 @@ func (p *Player) LoadFile(filePath string) error {
|
||||
p.emitPlaybackStateChanged(p.state)
|
||||
p.emitTrackChanged()
|
||||
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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Play / Pause
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
func (p *Player) validateReadyToPlay() error {
|
||||
if p.control == nil {
|
||||
return errNoControlStreamer
|
||||
@@ -380,6 +556,9 @@ func (p *Player) validateReadyToPlay() error {
|
||||
|
||||
// Play starts or resumes audio playback.
|
||||
func (p *Player) Play() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if err := p.validateReadyToPlay(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -390,26 +569,34 @@ func (p *Player) Play() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Track finished naturally — seek to the beginning and re-register
|
||||
// a paused stream with the speaker so the unpause below starts it.
|
||||
// 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)
|
||||
return fmt.Errorf(
|
||||
"failed to seek to beginning: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := p.updateStreamers(p.seeker, p.format.SampleRate); err != nil {
|
||||
return fmt.Errorf("failed to update streamers for replay: %w", err)
|
||||
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.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()
|
||||
p.control.Paused = false
|
||||
speaker.Unlock()
|
||||
@@ -423,6 +610,9 @@ func (p *Player) Play() error {
|
||||
|
||||
// Pause pauses the current playback.
|
||||
func (p *Player) Pause() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.control == nil {
|
||||
return errNoAudioStream
|
||||
}
|
||||
@@ -451,13 +641,24 @@ func (p *Player) Pause() error {
|
||||
|
||||
// IsPlaying reports whether the player is currently playing audio.
|
||||
func (p *Player) IsPlaying() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
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
|
||||
// events so the frontend clears its current-track display.
|
||||
// ---------------------------------------------------------------
|
||||
// UnloadTrack
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// 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() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Stop audio output.
|
||||
if p.control != nil {
|
||||
speaker.Lock()
|
||||
@@ -468,14 +669,17 @@ func (p *Player) UnloadTrack() {
|
||||
// Close the open audio file.
|
||||
if p.currentFile != 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
|
||||
}
|
||||
|
||||
// Release streamer chain. Volume is intentionally kept so the user's
|
||||
// volume setting persists across tracks.
|
||||
// Release streamer chain. Volume is intentionally kept so the
|
||||
// user's volume setting persists across tracks.
|
||||
p.baseStreamer = nil
|
||||
p.seeker = nil
|
||||
p.resampled = nil
|
||||
@@ -492,23 +696,38 @@ func (p *Player) UnloadTrack() {
|
||||
p.logger.Info("Track unloaded")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Volume
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// 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)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Apply the volume settings
|
||||
p.volume.Volume = float64(volume.ToVolume())
|
||||
p.volume.Silent = volume == MinUserVol
|
||||
speaker.Unlock()
|
||||
p.setVolumeLocked(desiredVolume)
|
||||
|
||||
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.
|
||||
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 {
|
||||
@@ -517,14 +736,25 @@ func (p *Player) getUserVolume() UserVolume {
|
||||
|
||||
// MuteToggle toggles the mute state.
|
||||
func (p *Player) MuteToggle() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.volume.Silent = !p.volume.Silent
|
||||
p.saveState()
|
||||
|
||||
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) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.seeker == nil {
|
||||
return 0, errNoAudioFileLoaded
|
||||
}
|
||||
@@ -536,14 +766,21 @@ func (p *Player) CurrentPositionSeconds() (int, error) {
|
||||
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) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.seeker == nil {
|
||||
return 0, errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return int(pos), nil
|
||||
@@ -551,29 +788,38 @@ func (p *Player) CurrentPosition() (int, error) {
|
||||
|
||||
// Seek jumps to a specific position in seconds.
|
||||
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 {
|
||||
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
||||
|
||||
return errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
lengthSecs, err := p.TrackLengthInSeconds()
|
||||
lengthSecs, err := p.trackLengthLocked()
|
||||
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())),
|
||||
math.Round(
|
||||
(float64(targetSeconds) / float64(lengthSecs)) *
|
||||
float64(p.seeker.Len()),
|
||||
),
|
||||
)
|
||||
|
||||
p.logger.Debug(
|
||||
"attempting to seek",
|
||||
"target-seconds",
|
||||
targetSeconds,
|
||||
"song-length",
|
||||
lengthSecs,
|
||||
"samples",
|
||||
samples,
|
||||
"target-seconds", targetSeconds,
|
||||
"song-length", lengthSecs,
|
||||
"samples", samples,
|
||||
)
|
||||
|
||||
if seekErr := p.seeker.Seek(samples); seekErr != nil {
|
||||
@@ -587,8 +833,24 @@ func (p *Player) Seek(targetSeconds int) error {
|
||||
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 {
|
||||
return map[string]interface{}{
|
||||
"fileName": "",
|
||||
@@ -613,9 +875,11 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
||||
coverArtMedium := ""
|
||||
coverArtLarge := ""
|
||||
|
||||
// Try to get metadata from database
|
||||
// Try to get metadata from database.
|
||||
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 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.
|
||||
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 {
|
||||
return 0, errNoAudioFileLoaded
|
||||
}
|
||||
@@ -669,19 +940,26 @@ func (p *Player) TrackLengthInSeconds() (int, error) {
|
||||
return length, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// State persistence
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// SaveState persists the current player state to the database.
|
||||
// This is called during shutdown to capture the final state.
|
||||
func (p *Player) SaveState() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.saveState()
|
||||
}
|
||||
|
||||
// saveState is the internal helper that writes the current player state to the
|
||||
// database. It is called both from the public SaveState (shutdown) and from
|
||||
// individual operations that change state (volume, mute, track load/unload)
|
||||
// so that the persisted state stays up-to-date between clean shutdowns.
|
||||
// saveState is the internal helper that writes the current player
|
||||
// state to the database. Must be called with p.mu held.
|
||||
func (p *Player) saveState() {
|
||||
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
|
||||
}
|
||||
@@ -703,18 +981,24 @@ func (p *Player) saveState() {
|
||||
|
||||
if p.seeker != nil {
|
||||
speaker.Lock()
|
||||
positionSeconds = int64(p.seeker.Position()) / int64(p.format.SampleRate)
|
||||
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,
|
||||
})
|
||||
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)
|
||||
p.logger.Error(
|
||||
"Failed to save player state", "err", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -727,27 +1011,42 @@ func (p *Player) saveState() {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// State restoration
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// RestoreState loads the persisted player state from the database.
|
||||
func (p *Player) RestoreState() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.restoreStateLocked()
|
||||
}
|
||||
|
||||
func (p *Player) restoreStateLocked() {
|
||||
defer profiling.TimeOp(p.logger, "player.RestoreState")()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to load player state", "err", err)
|
||||
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.
|
||||
// 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,
|
||||
@@ -756,11 +1055,7 @@ func (p *Player) RestoreState() {
|
||||
}
|
||||
|
||||
vol := clampVolume(UserVolume(state.Volume))
|
||||
|
||||
err = p.SetVolume(vol)
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to restore volume", "err", err)
|
||||
}
|
||||
p.setVolumeLocked(vol)
|
||||
|
||||
if state.Muted {
|
||||
p.volume.Silent = true
|
||||
@@ -769,7 +1064,9 @@ func (p *Player) RestoreState() {
|
||||
// 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",
|
||||
p.logger.Warn(
|
||||
"Last track file no longer exists, "+
|
||||
"skipping restore",
|
||||
"path", state.LastTrackPath,
|
||||
"err", statErr,
|
||||
)
|
||||
@@ -777,18 +1074,22 @@ func (p *Player) RestoreState() {
|
||||
return
|
||||
}
|
||||
|
||||
err = p.LoadFile(state.LastTrackPath)
|
||||
err = p.loadFileLocked(state.LastTrackPath)
|
||||
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
|
||||
}
|
||||
|
||||
// Restore playback position.
|
||||
if state.LastPositionSeconds > 0 {
|
||||
err = p.Seek(int(state.LastPositionSeconds))
|
||||
err = p.seekLocked(int(state.LastPositionSeconds))
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to restore playback position",
|
||||
p.logger.Error(
|
||||
"Failed to restore playback position",
|
||||
"seconds", state.LastPositionSeconds,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user