An app that plays audio becomes a music player at the point where the screen can lock, a call can interrupt, and the headphones can come out. None of that existed: the foreground service was typed for media but had no MediaSession, no transport notification and no audio focus, so oto would happily keep writing to a stream nobody could hear. The apparent blocker is that Wails' androidBridge* helpers are unexported, so Go cannot call arbitrary Java. It does not need to. StartForegroundService(json) *is* exported, and build/android/ is our tree, so widening the JSON WailsBridge already accepts is a local edit; coming back, WailsBridge.emitEvent lands on the application event bus, which Go subscribes to with app.Event.On. One document out, one command event back, and no new JNI. No new Gradle dependency either: minSdk is 21, which is exactly when android.media.session.MediaSession and Notification.MediaStyle arrived, so androidx.media buys two Build.VERSION branches' worth of nothing. Four things in it are load-bearing. **A duck is not a volume change.** Player.SetDuck holds the attenuation as an offset and re-applies the user's level through setVolumeLocked, so it cannot accumulate across repeated ducks and getUserVolume -- which feeds the event, the persisted state and every relative change -- still reports what the user chose. Writing through to the volume would let one notification tone permanently turn the music down. **The duck path is pre-Oreo only.** From API 26 the framework ducks the app itself and sends no CAN_DUCK focus change; asking to be told instead (setWillPauseWhenDucked) would mean pausing for every notification tone, and doing both would attenuate twice. **An unchanged payload is not an event**, the rule emitStatus already states one package over: every push crosses JNI and re-delivers an Intent, and the player pushes state on several paths that can agree. **After the first start, an update is startService.** From Android 12 a background app may not *start* a foreground service but may keep feeding one it already has, which is every track change with the screen off. Relatedly, every path through onStartCommand calls startForeground -- one that returns without it is killed. The contract with Java lives in androidpayload.go *without* the android build tag, and is tested. Everything left in android.go is untested by construction: make lint and make test are three tag sets on linux/amd64, so the only thing that compiles it is the cross-compiler in make android, and the only thing that can run it is a phone. None of the behaviour above has been observed on a device. The APK builds and both halves compile; that is the whole of what is verified.
1403 lines
35 KiB
Go
1403 lines
35 KiB
Go
// 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,
|
||
)
|
||
}
|