Files
yellowjacket/backend/player/player.go
T
logan df11ef23f4 feat(player): report the real position, and skip an unplayable track
The seek bar was a setInterval counter reconciled only on track
change: measured 3 s behind during steady playback and 30 s behind
after four keyboard seeks, because the seek shortcut never told it.
And `loadCurrentTrack`/`playCurrentTrack` logged, returned false and
emitted nothing, so double-clicking a moved file did nothing, twice,
forever — while auto-advance onto a bad file stopped playback dead.

- A 1 Hz position ticker while playing, plus an immediate report on
  load, play, pause, seek and natural finish. The payload carries a
  `trackChangeId` (the store is a singleton, so a bar mounting later
  must not adopt a report about the previous track) and a `seq` (the
  same second reported twice still has to reset interpolation).
- `PlaybackFailed` from both failure paths, and `playCurrentOrSkip`
  steps over tracks that will not load — bounded by the queue length,
  so a disconnected drive stops after one pass instead of spinning
  through a RepeatAll wrap. `PlayIndex` still reverts: the user picked
  that track.
- `SeekFailed` is emitted when the seek itself fails, not only when
  nothing is loaded, and is followed by a position report so the
  optimistic move is taken back by the mechanism that fixed the drift.
- A queue that simply ran out no longer unloads the player, so the
  finished track stays on the bar at 0:00.
2026-08-12 01:17:54 -04:00

1336 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"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
// 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
}
// 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.
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()
p.startPositionTicker()
}
// 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.01.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.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))
p.emitVolumeChanged()
p.saveState()
return nil
}
func (p *Player) getUserVolume() UserVolume {
return Volume(p.volume.Volume).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.GetTrackMetadataByPath(
p.ctx, info.FilePath,
)
if err == nil {
if meta.Title != "" {
info.Title = meta.Title
}
info.Artist = meta.Artist
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.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.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,
)
}