// Package player provides audio playback functionality. package player import ( "context" "errors" "fmt" "log/slog" "math" "os" "path/filepath" "sync" "time" "github.com/gopxl/beep/v2" "github.com/gopxl/beep/v2/effects" "github.com/gopxl/beep/v2/generators" "github.com/gopxl/beep/v2/speaker" "github.com/wailsapp/wails/v2/pkg/runtime" "yellowjacket/backend/coverart" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" "yellowjacket/backend/mediacontrols" "yellowjacket/backend/metadata" "yellowjacket/backend/profiling" ) // 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 state State currentFile *os.File format beep.Format baseStreamer beep.Streamer seeker beep.StreamSeeker resampled beep.Streamer buffered *BufferedStreamer control *beep.Ctrl volume *effects.Volume speakerStreamer beep.Streamer playbackFinishedHandler func() trackChangeID uint64 mediaControls mediacontrols.Handler // trackLengthMs holds the authoritative track duration in // milliseconds, sourced from the database (which uses the // custom header parser). The go-mp3 decoder's Len() can be // inflated for files with multiple ID3v2 tags, so this value // is preferred for display and position calculations. trackLengthMs int64 } // State represents the current playback state. type State string // Playback state values. const ( Playing State = "playing" Paused State = "paused" Stopped State = "stopped" ) // TrackInfo contains metadata and playback state for the currently // loaded track. It is emitted as the payload of the TrackChanged // event and serialized as camelCase JSON to match the frontend // TrackInfo interface in player-store.ts. type TrackInfo struct { FileName string `json:"fileName"` FilePath string `json:"filePath"` State State `json:"state"` Title string `json:"title"` Artist string `json:"artist"` Album string `json:"album"` CoverArt string `json:"coverArt"` CoverArtSmall string `json:"coverArtSmall"` CoverArtMedium string `json:"coverArtMedium"` CoverArtLarge string `json:"coverArtLarge"` TrackLength int `json:"trackLength"` SeekPosition int `json:"seekPosition"` TrackChangeID uint64 `json:"trackChangeId"` } // Sentinel errors for player operations. var ( errNoControlStreamer = errors.New("no control streamer") errNoAudioFileLoaded = errors.New("no audio file loaded") errNoStreamerToPlay = errors.New("no streamer to play") errNoAudioStream = errors.New("no audio stream to pause") ) var speakerSampleRate = beep.SampleRate(44100) // NewPlayer creates a player. Call InitSpeaker separately to // initialize the audio output device. func NewPlayer(logger *slog.Logger, db *database.DB) *Player { return &Player{ logger: logger, db: db, state: Stopped, baseStreamer: generators.Silence(-1), format: beep.Format{ SampleRate: speakerSampleRate, }, } } // InitSpeaker initializes the audio output device. This is // separated from NewPlayer so the player struct can be created // before wails.Run (for binding registration) while deferring // hardware initialization to OnStartup. func (p *Player) InitSpeaker() error { defer profiling.TimeOp(p.logger, "player.InitSpeaker")() // TODO: allow user to change buffer size and speaker sample rate. // Speaker buffer is 200ms (~8820 samples at 44100 Hz), providing // secondary protection against underruns behind the read-ahead // BufferedStreamer. err := speaker.Init( p.format.SampleRate, p.format.SampleRate.N(time.Second/5), ) if err != nil { return fmt.Errorf( "failed to initialize speaker: %w", err, ) } return nil } // 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 } // SetMediaControls provides an OS media controls handler. When set, // the player pushes metadata, playback state, volume, and seek // notifications to the OS media overlay. func (p *Player) SetMediaControls(h mediacontrols.Handler) { p.mu.Lock() defer p.mu.Unlock() p.mediaControls = h } // SetContext sets the Wails runtime context and restores persisted // state. func (p *Player) SetContext(ctx context.Context) { p.mu.Lock() defer p.mu.Unlock() p.ctx = ctx p.restoreStateLocked() } // --------------------------------------------------------------- // 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 { p.logger.Error("Context is nil, cannot emit event") return } p.logger.Info( "Emitting PlaybackStateChangedEvent", "state", state, ) runtime.EventsEmit( p.ctx, events.PlaybackStateChanged, map[string]string{"state": string(state)}, ) if p.mediaControls != nil { p.mediaControls.UpdatePlaybackState( stateToMediaControls(state), p.currentPositionSecondsLocked(), ) } } func (p *Player) emitPlaybackFinished() { if p.ctx == nil { p.logger.Error("Context is nil, cannot emit event") return } p.logger.Info("Emitting PlaybackFinishedEvent") runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil) } func (p *Player) emitVolumeChanged() { if p.ctx == nil { p.logger.Error("Context is nil, cannot emit event") return } volume := int(p.getUserVolume()) p.logger.Info( "Emitting VolumeChangedEvent", "volume", volume, ) runtime.EventsEmit(p.ctx, events.VolumeChanged, volume) if p.mediaControls != nil { // MPRIS volume is 0.0–1.0 linear. p.mediaControls.UpdateVolume( float64(volume) / float64(MaxUserVol), ) } } func (p *Player) emitTrackChanged() { if p.ctx == nil { p.logger.Error("Context is nil, cannot emit event") return } trackInfo := p.getCurrentTrackInfoLocked() trackLengthSecs, err := p.trackLengthLocked() if err != nil { p.logger.Error("Cannot get track length") } trackInfo.TrackLength = trackLengthSecs // Compute current seek position in display seconds. trackInfo.SeekPosition = p.displayPositionSecsLocked() // Increment track change ID so the frontend can detect changes // even when the same file plays consecutively. p.trackChangeID++ trackInfo.TrackChangeID = p.trackChangeID runtime.EventsEmit( p.ctx, events.TrackChanged, trackInfo, ) p.logger.Info( "Emitting TrackChangedEvent with track info", "trackInfo", trackInfo, ) if p.mediaControls != nil { p.mediaControls.UpdateMetadata( p.buildMediaMetadata( trackInfo, trackLengthSecs, ), ) } } // EmitCurrentState pushes the current player state to the frontend. // This is intended to be called after the frontend is ready to // receive events, separately from RestoreState which does the heavy // lifting during OnStartup. func (p *Player) EmitCurrentState() { p.mu.Lock() defer p.mu.Unlock() p.emitVolumeChanged() if p.currentFile != nil { p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() } } // --------------------------------------------------------------- // 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, ) // Buffer resampled audio to decouple disk I/O from speaker // timing. 2 seconds of read-ahead at speaker sample rate // absorbs I/O stalls and GC pauses without audible glitches. p.buffered = NewBufferedStreamer( p.resampled, int(speakerSampleRate)*2, ) // wrap in ctrl streamer to allow play/pause p.control = &beep.Ctrl{Streamer: p.buffered} // Preserve existing volume settings across track changes. prevVolume := 0.0 prevSilent := false if p.volume != nil { prevVolume = p.volume.Volume prevSilent = p.volume.Silent } // wrap in volume streamer p.volume = &effects.Volume{ Streamer: p.control, Base: 2, Volume: prevVolume, Silent: prevSilent, } // set "final" streamer p.speakerStreamer = p.volume return nil } // startPaused registers the current streamer chain with the speaker // in a paused state. Must be called with p.mu held. func (p *Player) startPaused() { speaker.Lock() p.control.Paused = true speaker.Unlock() // 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 mc := p.mediaControls p.mu.Unlock() // Emit Wails events outside the lock — these are non-blocking // calls that don't need player state. p.emitPlaybackFinished() if p.ctx != nil { runtime.EventsEmit( p.ctx, events.PlaybackStateChanged, map[string]string{"state": string(Stopped)}, ) } // Notify media controls outside the lock. The track just // ended so position is 0. if mc != nil { mc.UpdatePlaybackState( mediacontrols.StateStopped, 0, ) } 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")() f, err := os.Open(filePath) if err != nil { p.logger.Error("Failed to open file") return fmt.Errorf("failed to open file: %w", err) } streamer, format, err := metadata.DecodeFile(f) if err != nil { p.logger.Error( "failed to decode audio file", "path", filePath, "err", err, ) return fmt.Errorf("failed to decode audio file: %w", err) } // Stop existing playback before loading new file. speaker.Lock() if p.control != nil { p.control.Paused = true } p.state = Stopped speaker.Unlock() // Stop the read-ahead goroutine for the previous track. if p.buffered != nil { p.buffered.Close() } if p.currentFile != nil { if closeErr := p.currentFile.Close(); closeErr != nil { p.logger.Warn( "failed to close previous audio file", "err", closeErr, ) } } p.currentFile = f if err := p.updateStreamers( streamer, format.SampleRate, ); err != nil { return fmt.Errorf("failed to update streamers: %w", err) } p.startPaused() p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() p.saveState() 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 } if p.currentFile == nil { return errNoAudioFileLoaded } if p.speakerStreamer == nil { return errNoStreamerToPlay } return nil } // 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 } if p.state == Playing { p.logger.Info("Already playing") return nil } // Track finished naturally — seek to the beginning and // re-register a paused stream with the speaker so the unpause // below starts it. if p.state == Stopped && p.seeker != nil { speaker.Lock() err := p.seeker.Seek(0) speaker.Unlock() if err != nil { return fmt.Errorf( "failed to seek to beginning: %w", err, ) } 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. speaker.Lock() p.control.Paused = false speaker.Unlock() p.state = Playing p.emitPlaybackStateChanged(p.state) p.logger.Info("Started playback") return nil } // Pause pauses the current playback. func (p *Player) Pause() error { p.mu.Lock() defer p.mu.Unlock() if p.control == nil { return errNoAudioStream } if p.state == Paused { p.logger.Info("Already paused") return nil } if p.state == Playing { speaker.Lock() p.control.Paused = true speaker.Unlock() p.state = Paused p.logger.Info("Paused playback") p.emitPlaybackStateChanged(p.state) p.saveState() } else { p.logger.Info("Already paused or not playing") } return nil } // 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 // --------------------------------------------------------------- // 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() p.control.Paused = true speaker.Unlock() } // 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.currentFile = nil } // Stop the read-ahead goroutine before releasing the chain. if p.buffered != nil { p.buffered.Close() } // 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 p.buffered = nil p.control = nil p.speakerStreamer = nil p.trackLengthMs = 0 p.state = Stopped // Notify frontend that there is no longer a current track. p.emitPlaybackStateChanged(p.state) runtime.EventsEmit(p.ctx, events.TrackChanged, nil) if p.mediaControls != nil { p.mediaControls.UpdateMetadata(mediacontrols.Metadata{}) } p.saveState() p.logger.Info("Track unloaded") } // --------------------------------------------------------------- // Volume // --------------------------------------------------------------- // SetVolume sets the playback volume (0-100), emits a // VolumeChanged event, and persists the new level. func (p *Player) SetVolume(desiredVolume UserVolume) { p.mu.Lock() defer p.mu.Unlock() p.setVolumeLocked(desiredVolume) p.emitVolumeChanged() p.saveState() } 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 { p.mu.Lock() defer p.mu.Unlock() p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume)) return nil } func (p *Player) getUserVolume() UserVolume { return Volume(p.volume.Volume).ToUserVolume() } // 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 } // --------------------------------------------------------------- // Position / Seek // --------------------------------------------------------------- // CurrentPositionSeconds returns the current playback position in // display seconds. func (p *Player) CurrentPositionSeconds() (int, error) { p.mu.Lock() defer p.mu.Unlock() if p.seeker == nil { return 0, errNoAudioFileLoaded } return p.displayPositionSecsLocked(), nil } // 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()), ) speaker.Unlock() return int(pos), nil } // 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.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()), ), ) p.logger.Debug( "attempting to seek", "target-seconds", targetSeconds, "song-length", lengthSecs, "samples", samples, ) if seekErr := p.seeker.Seek(samples); seekErr != nil { speaker.Unlock() return fmt.Errorf("failed to seek: %w", seekErr) } speaker.Unlock() if p.mediaControls != nil { p.mediaControls.NotifySeek(targetSeconds) } return nil } // --------------------------------------------------------------- // Track info // --------------------------------------------------------------- // GetCurrentTrackInfo returns information about the currently // loaded track. func (p *Player) GetCurrentTrackInfo() TrackInfo { p.mu.Lock() defer p.mu.Unlock() return p.getCurrentTrackInfoLocked() } func (p *Player) getCurrentTrackInfoLocked() TrackInfo { info := TrackInfo{ State: p.state, } if p.currentFile == nil { return info } info.FileName = filepath.Base(p.currentFile.Name()) info.FilePath = p.currentFile.Name() info.Title = info.FileName // default title is the filename // Try to get metadata from database. if p.db != nil { meta, err := p.db.Queries.GetTrackMetadataByPath( p.ctx, info.FilePath, ) if err == nil { if meta.Title != "" { info.Title = meta.Title } info.Artist = meta.Artist info.Album = meta.Album p.trackLengthMs = meta.LengthMilliseconds if meta.CoverArtPath != "" { urls := coverart.ResolveURLs(meta.CoverArtPath) info.CoverArt = urls.Original info.CoverArtSmall = urls.Small info.CoverArtMedium = urls.Medium info.CoverArtLarge = urls.Large } } else { p.logger.Debug( "Could not get track metadata from database", "path", info.FilePath, "err", err, ) } } return info } // 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) { // Prefer the database duration — the custom header parser // handles multiple ID3v2 tags correctly, whereas go-mp3's // Len() can be inflated by phantom frames. if p.trackLengthMs > 0 { return int(p.trackLengthMs / 1000), nil } return p.seekerLengthSecsLocked() } // seekerLengthSecsLocked returns the track length in seconds as // reported by the beep decoder. This may differ from the // database duration for MP3 files with multiple ID3v2 tags. // It is used internally for seek sample calculations. func (p *Player) seekerLengthSecsLocked() (int, error) { if p.seeker == nil { return 0, errNoAudioFileLoaded } speaker.Lock() length := p.seeker.Len() / int(p.format.SampleRate) speaker.Unlock() return length, nil } // displayPositionSecsLocked converts the current seeker position to // display seconds. When the DB duration is available, the position // is scaled from the (potentially inflated) seeker time scale to the // correct display time scale. Must be called with p.mu held. func (p *Player) displayPositionSecsLocked() int { if p.seeker == nil { return 0 } speaker.Lock() pos := p.seeker.Position() total := p.seeker.Len() speaker.Unlock() if total == 0 { return 0 } displayLength, err := p.trackLengthLocked() if err != nil { return pos / int(p.format.SampleRate) } return int( math.Round( float64(pos) / float64(total) * float64(displayLength), ), ) } // --------------------------------------------------------------- // Media controls helpers // --------------------------------------------------------------- // stateToMediaControls maps the player's State type to the // mediacontrols PlaybackState. func stateToMediaControls(s State) mediacontrols.PlaybackState { switch s { case Playing: return mediacontrols.StatePlaying case Paused: return mediacontrols.StatePaused default: return mediacontrols.StateStopped } } // currentPositionSecondsLocked returns the playback position in // display seconds. Must be called with p.mu held. func (p *Player) currentPositionSecondsLocked() int { return p.displayPositionSecsLocked() } // buildMediaMetadata constructs a mediacontrols.Metadata from a // TrackInfo and duration. It resolves the cover art filesystem path // from the database for use by MPRIS (which needs file:// URIs). // Must be called with p.mu held. func (p *Player) buildMediaMetadata( info TrackInfo, durationSec int, ) mediacontrols.Metadata { meta := mediacontrols.Metadata{ Title: info.Title, Artist: info.Artist, Album: info.Album, DurationSec: durationSec, } // Resolve cover art filesystem path. The database stores the // full path; ResolveURLs converts it to relative HTTP paths // for the frontend, but MPRIS needs the actual file path. if p.db != nil && info.FilePath != "" { dbMeta, err := p.db.Queries.GetTrackMetadataByPath( p.ctx, info.FilePath, ) if err == nil && dbMeta.CoverArtPath != "" { meta.ArtFilePath = dbMeta.CoverArtPath } } return meta } // --------------------------------------------------------------- // 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. 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", ) return } volume := int64(DefaultUserVol) muted := false if p.volume != nil { volume = int64(p.getUserVolume()) muted = p.volume.Silent } trackPath := "" if p.currentFile != nil { trackPath = p.currentFile.Name() } positionSeconds := int64(p.displayPositionSecsLocked()) err := p.db.Queries.UpdatePlayerState( p.db.Ctx, sqlcgen.UpdatePlayerStateParams{ Volume: volume, Muted: muted, LastTrackPath: trackPath, LastPositionSeconds: positionSeconds, }, ) if err != nil { p.logger.Error( "Failed to save player state", "err", err, ) return } p.logger.Info("Player state saved", "volume", volume, "muted", muted, "trackPath", trackPath, "positionSeconds", positionSeconds, ) } // --------------------------------------------------------------- // 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", ) return } state, err := p.db.Queries.GetPlayerState(p.db.Ctx) if err != nil { p.logger.Error( "Failed to load player state", "err", err, ) return } // Restore volume. // Ensure volume is initialized before restoring settings. The // volume effect is normally created by updateStreamers during // LoadFile, but RestoreState runs before any file is loaded. if p.volume == nil { p.volume = &effects.Volume{ Streamer: p.control, Base: 2, } } vol := clampVolume(UserVolume(state.Volume)) p.setVolumeLocked(vol) if state.Muted { p.volume.Silent = true } // Restore last track if the file still exists. if state.LastTrackPath != "" { if _, statErr := os.Stat(state.LastTrackPath); statErr != nil { p.logger.Warn( "Last track file no longer exists, "+ "skipping restore", "path", state.LastTrackPath, "err", statErr, ) return } err = p.loadFileLocked(state.LastTrackPath) if err != nil { p.logger.Error( "Failed to restore last track", "path", state.LastTrackPath, "err", err, ) return } // Restore playback position. if state.LastPositionSeconds > 0 { err = p.seekLocked(int(state.LastPositionSeconds)) if err != nil { p.logger.Error( "Failed to restore playback position", "seconds", state.LastPositionSeconds, "err", err, ) } } } p.logger.Info("Player state restored", "volume", vol, "muted", state.Muted, "trackPath", state.LastTrackPath, "positionSeconds", state.LastPositionSeconds, ) }