// 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/v3/pkg/application" "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 // duckAmount is the attenuation currently applied on top of the // user's volume, in the same base-2 exponent effects.Volume uses. // It is deliberately not persisted and emits no VolumeChanged: a // duck is something the OS did for the length of a notification, // not something the user chose. duckAmount float64 // 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 // positionTickerOnce guards the 1 Hz position ticker so repeated // SetContext calls (tests, re-init) cannot start a second one. positionTickerOnce sync.Once // positionSeq increments on every emitted position, so a // consumer can tell "the same second, again" from "a fresh // reading" and reset its interpolation on both. positionSeq uint64 // persistCh carries state writes to the single goroutine that runs // them, so no path holds mu while waiting on SQLite's writer // connection. See persistwriter.go. persistOnce sync.Once persistCh chan func() } // PositionInfo is the payload of the PlaybackPositionChanged event: // the player's own answer to "where are we", which the seek bar // renders instead of counting. type PositionInfo struct { PositionSeconds int `json:"positionSeconds"` TrackLength int `json:"trackLength"` TrackChangeID uint64 `json:"trackChangeId"` Seq uint64 `json:"seq"` Playing bool `json:"playing"` } // 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"` ArtistMBID string `json:"artistMbid"` ReleaseGroupMBID string `json:"releaseGroupMbid"` RecordingMBID string `json:"recordingMbid"` } // 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") errSeekPanicked = errors.New("seek panicked (go-mp3 bug)") ) 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. // //wails:ignore // internal wiring, not part of the app's IPC surface. 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. // //wails:ignore // internal wiring, not part of the app's IPC surface. func (p *Player) SetMediaControls(h mediacontrols.Handler) { p.mu.Lock() defer p.mu.Unlock() p.mediaControls = h } // ServiceStartup is v3's service lifecycle hook: it runs once the // runtime exists, and ctx is cancelled when the app shuts down. It // replaces v2's SetContext, which had to be called by hand from // OnStartup and was exported, so it was also bound to the frontend. func (p *Player) ServiceStartup( ctx context.Context, _ application.ServiceOptions, ) error { p.mu.Lock() defer p.mu.Unlock() p.ctx = ctx p.restoreStateLocked() p.startPositionTicker() return nil } // positionTickInterval is how often the backend reports its own // playback position while playing. const positionTickInterval = time.Second // startPositionTicker runs the 1 Hz position report for the life of // the Wails context. Must be called with p.mu held. func (p *Player) startPositionTicker() { if p.ctx == nil { return } ctx := p.ctx p.positionTickerOnce.Do(func() { go func() { ticker := time.NewTicker(positionTickInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: p.emitPositionIfPlaying() } } }() }) } // emitPositionIfPlaying reports the position only while audio is // actually moving; a paused or stopped player has already emitted its // final position at the transition. func (p *Player) emitPositionIfPlaying() { p.mu.Lock() defer p.mu.Unlock() if p.state != Playing || p.currentFile == nil { return } p.emitPositionLocked() } // emitPositionLocked pushes the current position to the frontend. // Must be called with p.mu held. func (p *Player) emitPositionLocked() { if p.ctx == nil { return } length, err := p.trackLengthLocked() if err != nil { length = 0 } p.positionSeq++ events.Emit(p.ctx, events.PlaybackPositionChanged, PositionInfo{ PositionSeconds: p.displayPositionSecsLocked(), TrackLength: length, TrackChangeID: p.trackChangeID, Seq: p.positionSeq, Playing: p.state == Playing, }) } // --------------------------------------------------------------- // 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, ) events.Emit( 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") events.Emit(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()) muted := p.volume != nil && p.volume.Silent p.logger.Info( "Emitting VolumeChangedEvent", "volume", volume, "muted", muted, ) events.Emit(p.ctx, events.VolumeChanged, volume) // Mute rides on its own event rather than widening the volume // payload: silence does not change the volume level, so a UI that // only watched VolumeChanged saw nothing happen when the user hit // the mute key. events.Emit(p.ctx, events.MuteChanged, muted) 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 events.Emit( 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 // Rewind so the position the UI is told is the truth: a finished // track sits at 0:00, ready to play again, rather than reporting // its own length forever. Play() rebuilds the streamer chain from // the Stopped state anyway, so this only moves the decoder. p.rewindLocked() p.emitPositionLocked() p.mu.Unlock() // Emit Wails events outside the lock — these are non-blocking // calls that don't need player state. p.emitPlaybackFinished() events.Emit( 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.emitPositionLocked() 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.emitPositionLocked() 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.emitPositionLocked() 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) events.Emit(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.duckAmount p.volume.Silent = volume == MinUserVol speaker.Unlock() } // SetDuck attenuates playback (or restores it) without changing the // user's volume, for an OS that has asked us to get out of the way of // something short -- a navigation prompt, a notification tone. // // It re-applies the *user's* level through setVolumeLocked rather than // nudging the effect directly, so the offset cannot accumulate across // repeated ducks, and it neither emits nor persists: the level the user // set has not changed and the UI must not claim it has. // //wails:ignore // driven by OS audio focus, not by the frontend. func (p *Player) SetDuck(ducked bool) { p.mu.Lock() defer p.mu.Unlock() amount := 0.0 if ducked { amount = duckAttenuation } if p.volume == nil || amount == p.duckAmount { return } current := p.getUserVolume() p.duckAmount = amount p.setVolumeLocked(current) } // 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)) p.emitVolumeChanged() p.saveState() return nil } func (p *Player) getUserVolume() UserVolume { // Undo any duck, so every caller -- the event, the persisted // state, a relative change -- sees the level the user chose. return Volume(p.volume.Volume + p.duckAmount).ToUserVolume() } // Muted reports whether playback is currently silenced. func (p *Player) Muted() bool { p.mu.Lock() defer p.mu.Unlock() return p.volume != nil && p.volume.Silent } // MuteToggle toggles the mute state. func (p *Player) MuteToggle() error { p.mu.Lock() defer p.mu.Unlock() if p.volume == nil { return errNoAudioFileLoaded } speaker.Lock() p.volume.Silent = !p.volume.Silent speaker.Unlock() p.emitVolumeChanged() 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) } // rewindLocked returns the decoder to the start of the track without // touching playback state. Must be called with p.mu held. func (p *Player) rewindLocked() { if p.seeker == nil { return } // Same source lock the seek path takes: the read-ahead goroutine // must not be inside Read while the decoder seeks. if p.buffered != nil { p.buffered.LockSource() defer p.buffered.UnlockSource() } speaker.Lock() err := p.seeker.Seek(0) speaker.Unlock() if err != nil { p.logger.Warn("Failed to rewind finished track", "err", err) } } func (p *Player) seekLocked(targetSeconds int) error { if p.seeker == nil { events.Emit(p.ctx, events.SeekFailed) return errNoAudioFileLoaded } lengthSecs, err := p.trackLengthLocked() if err != nil { return fmt.Errorf("cannot get track length: %w", err) } // Block the read-ahead goroutine from reading the source while // we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is // not safe for concurrent Read+Seek, and read-ahead runs on its // own goroutine — without this it can panic with a slice-bounds // error, especially right after load when the buffer is empty // and read-ahead is filling at full speed. if p.buffered != nil { p.buffered.LockSource() defer p.buffered.UnlockSource() } speaker.Lock() samples := int( math.Round( (float64(targetSeconds) / float64(lengthSecs)) * float64(p.seeker.Len()), ), ) // Clamp the seek position to valid bounds. The underlying // go-mp3 library (v0.3.4) has a bug where seeking to // positions near the end of certain files causes a slice // bounds panic. Clamping reduces the likelihood of hitting // this, and the recover below catches it if it still occurs. if maxPos := p.seeker.Len() - 1; samples > maxPos { samples = maxPos } if samples < 0 { samples = 0 } p.logger.Debug( "attempting to seek", "target-seconds", targetSeconds, "song-length", lengthSecs, "samples", samples, ) // Wrap the seek in a recover to catch panics from the // go-mp3 library's buggy Seek implementation. See: // github.com/hajimehoshi/go-mp3@v0.3.4/decode.go:111 seekErr := func() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("%w: %v", errSeekPanicked, r) } }() return p.seeker.Seek(samples) }() if seekErr != nil { speaker.Unlock() p.logger.Warn( "Seek failed, playback will start from "+ "the beginning", "target-seconds", targetSeconds, "samples", samples, "err", seekErr, ) // The optimistic move the UI already made has to be taken // back, and only the backend knows it did not happen. events.Emit(p.ctx, events.SeekFailed) p.emitPositionLocked() return fmt.Errorf("failed to seek: %w", seekErr) } speaker.Unlock() // Flush the read-ahead buffer so the speaker immediately // plays audio from the new position instead of draining // up to 2 seconds of stale pre-seek samples. if p.buffered != nil { p.buffered.Flush() } if p.mediaControls != nil { p.mediaControls.NotifySeek(targetSeconds) } // Report the landing position immediately rather than leaving the // UI to guess until the next tick — this is the half of H-3 that // desynced the seek bar by 30 s over four keyboard seeks. p.emitPositionLocked() 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.ReadQueries.GetTrackByPath( p.ctx, info.FilePath, ) if err == nil { if meta.Title != "" { info.Title = meta.Title } info.Artist = meta.ArtistName info.Album = meta.Album info.ArtistMBID = meta.ArtistMbid info.ReleaseGroupMBID = meta.ReleaseGroupMbid info.RecordingMBID = meta.RecordingMbid 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.ReadQueries.GetTrackByPath( 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 and // waits for the write. This is called during shutdown to capture the // final state, which is the one case that cannot be deferred. func (p *Player) SaveState() { p.mu.Lock() p.saveState() p.mu.Unlock() p.flushWrites() } // saveState snapshots the current player state and hands it to the // persistence goroutine. Must be called with p.mu held. // // It does not write here: every caller holds p.mu (LoadFile, Play, // Pause, Seek, the volume paths), the write goes through SQLite's // single writer connection, and a background pass can hold that for // seconds — which is how loading a track came to block the whole // player behind a durability write. See persistwriter.go. 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()) params := sqlcgen.UpdatePlayerStateParams{ Volume: volume, Muted: muted, LastTrackPath: trackPath, LastPositionSeconds: positionSeconds, } p.submitWrite(func() { if err := p.db.Queries.UpdatePlayerState(p.db.Ctx, params); 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() { // Reads back what saveState wrote, so it waits for anything still // in flight rather than restoring the state before last. p.flushWrites() 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.ReadQueries.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, ) }