From d925e3d6598fed4f585000df1f82aa844d987f41 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 23 Feb 2026 20:06:41 -0500 Subject: [PATCH] added mutex to player to prevent simultaneous access --- backend/player/player.go | 583 +++++++++++++++++++++++++++++---------- 1 file changed, 442 insertions(+), 141 deletions(-) diff --git a/backend/player/player.go b/backend/player/player.go index 96ecef8..5c3bb6d 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -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, )