1092 lines
23 KiB
Go
1092 lines
23 KiB
Go
// Package player provides audio playback functionality.
|
|
package player
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/TheCodeOfCaleb/beep/v2"
|
|
"github.com/TheCodeOfCaleb/beep/v2/effects"
|
|
"github.com/TheCodeOfCaleb/beep/v2/generators"
|
|
"github.com/TheCodeOfCaleb/beep/v2/speaker"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/events"
|
|
"yellowjacket/backend/library"
|
|
"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
|
|
control *beep.Ctrl
|
|
volume *effects.Volume
|
|
speakerStreamer beep.Streamer
|
|
playbackFinishedHandler func()
|
|
trackChangeID uint64
|
|
}
|
|
|
|
// State represents the current playback state.
|
|
type State string
|
|
|
|
// Playback state values.
|
|
const (
|
|
Playing State = "playing"
|
|
Paused State = "paused"
|
|
Stopped State = "stopped"
|
|
)
|
|
|
|
// Sentinel errors for player operations.
|
|
var (
|
|
errNoControlStreamer = errors.New("no control streamer")
|
|
errNoAudioFileLoaded = errors.New("no audio file loaded")
|
|
errNoStreamerToPlay = errors.New("no streamer to play")
|
|
errNoAudioStream = errors.New("no audio stream to pause")
|
|
)
|
|
|
|
var speakerSampleRate = beep.SampleRate(44100)
|
|
|
|
// NewPlayer creates a player and initializes the audio speaker.
|
|
func NewPlayer(
|
|
ctx context.Context,
|
|
logger *slog.Logger,
|
|
db *database.DB,
|
|
) (*Player, error) {
|
|
defer profiling.TimeOp(logger, "player.NewPlayer")()
|
|
|
|
player := &Player{
|
|
ctx: ctx,
|
|
logger: logger,
|
|
db: db,
|
|
state: Stopped,
|
|
baseStreamer: generators.Silence(-1),
|
|
format: beep.Format{
|
|
SampleRate: speakerSampleRate,
|
|
},
|
|
}
|
|
|
|
// TODO: allow user to change buffer size and speaker sample rate
|
|
err := speaker.Init(
|
|
player.format.SampleRate,
|
|
player.format.SampleRate.N(time.Second/10),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"failed to initialize speaker: %w", err,
|
|
)
|
|
}
|
|
|
|
return player, 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
|
|
}
|
|
|
|
// SetContext sets the Wails context, registers event handlers, and
|
|
// restores persisted state.
|
|
func (p *Player) SetContext(ctx context.Context) {
|
|
p.mu.Lock()
|
|
p.ctx = ctx
|
|
p.mu.Unlock()
|
|
|
|
p.registerEventHandlers()
|
|
|
|
p.mu.Lock()
|
|
p.restoreStateLocked()
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
func (p *Player) registerEventHandlers() {
|
|
p.mu.Lock()
|
|
ctx := p.ctx
|
|
p.mu.Unlock()
|
|
|
|
if ctx == nil {
|
|
p.logger.Error(
|
|
"Context is nil, cannot register event handlers",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
runtime.EventsOn(
|
|
ctx,
|
|
events.RequestPause,
|
|
func(_ ...any) {
|
|
p.logger.Info("Received RequestPauseEvent")
|
|
|
|
if err := p.Pause(); err != nil {
|
|
p.logger.Error("failed to pause", "err", err)
|
|
}
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(
|
|
ctx,
|
|
events.RequestLoadFile,
|
|
func(data ...any) {
|
|
p.logger.Info("Received RequestLoadFileEvent")
|
|
|
|
if len(data) < 1 {
|
|
p.logger.Warn(
|
|
"RequestLoadFile: missing file path argument",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
filePath, ok := data[0].(string)
|
|
if !ok {
|
|
p.logger.Warn(
|
|
"RequestLoadFile: invalid file path type",
|
|
"got", fmt.Sprintf("%T", data[0]),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
err := p.LoadFile(filePath)
|
|
if err != nil {
|
|
p.logger.Error(err.Error())
|
|
}
|
|
},
|
|
)
|
|
|
|
runtime.EventsOn(ctx, events.Seek, func(data ...any) {
|
|
p.logger.Info("Received SeekEvent")
|
|
|
|
if len(data) < 1 {
|
|
p.logger.Warn("Seek: missing seek value argument")
|
|
|
|
return
|
|
}
|
|
|
|
seekFloat, ok := data[0].(float64)
|
|
if !ok {
|
|
p.logger.Warn(
|
|
"Seek: invalid seek value type",
|
|
"got", fmt.Sprintf("%T", data[0]),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
seekValue := int(seekFloat)
|
|
|
|
err := p.Seek(seekValue)
|
|
if err != nil {
|
|
p.logger.Error("cannot seek", "error", err)
|
|
}
|
|
})
|
|
|
|
runtime.EventsOn(
|
|
ctx,
|
|
events.RequestSetVolume,
|
|
func(data ...any) {
|
|
if len(data) < 1 {
|
|
p.logger.Warn(
|
|
"RequestSetVolume: missing volume argument",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
volFloat, ok := data[0].(float64)
|
|
if !ok {
|
|
p.logger.Warn(
|
|
"RequestSetVolume: invalid volume type",
|
|
"got", fmt.Sprintf("%T", data[0]),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
desiredVolume := UserVolume(volFloat)
|
|
p.logger.Info(
|
|
"Received RequestSetVolumeEvent",
|
|
"volume", desiredVolume,
|
|
)
|
|
|
|
p.mu.Lock()
|
|
p.setVolumeLocked(desiredVolume)
|
|
p.emitVolumeChanged()
|
|
p.saveState()
|
|
p.mu.Unlock()
|
|
},
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Emit helpers (must be called with p.mu held)
|
|
// ---------------------------------------------------------------
|
|
|
|
// emitPlaybackStateChanged emits a playback state change event.
|
|
func (p *Player) emitPlaybackStateChanged(state State) {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info(
|
|
"Emitting PlaybackStateChangedEvent", "state", state,
|
|
)
|
|
|
|
runtime.EventsEmit(
|
|
p.ctx,
|
|
events.PlaybackStateChanged,
|
|
map[string]string{"state": string(state)},
|
|
)
|
|
}
|
|
|
|
func (p *Player) emitPlaybackFinished() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info("Emitting PlaybackFinishedEvent")
|
|
runtime.EventsEmit(p.ctx, events.PlaybackFinished, nil)
|
|
}
|
|
|
|
func (p *Player) emitVolumeChanged() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
volume := int(p.getUserVolume())
|
|
p.logger.Info(
|
|
"Emitting VolumeChangedEvent", "volume", volume,
|
|
)
|
|
|
|
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
|
|
}
|
|
|
|
func (p *Player) emitTrackChanged() {
|
|
if p.ctx == nil {
|
|
p.logger.Error("Context is nil, cannot emit event")
|
|
|
|
return
|
|
}
|
|
|
|
trackLengthSecs, err := p.trackLengthLocked()
|
|
if err != nil {
|
|
p.logger.Error("Cannot get track length")
|
|
}
|
|
|
|
trackInfo, err := p.getCurrentTrackInfoLocked()
|
|
if err != nil {
|
|
p.logger.Error("Cannot get track info")
|
|
|
|
trackInfo = map[string]interface{}{
|
|
"fileName": "",
|
|
"filePath": "",
|
|
"state": string(p.state),
|
|
}
|
|
}
|
|
|
|
// Compute current seek position in seconds.
|
|
seekPosition := 0
|
|
|
|
if p.seeker != nil {
|
|
speaker.Lock()
|
|
seekPosition = p.seeker.Position() /
|
|
int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
}
|
|
|
|
// Increment track change ID so the frontend can detect changes
|
|
// even when the same file plays consecutively.
|
|
p.trackChangeID++
|
|
|
|
// Emit comprehensive track info.
|
|
trackInfo["trackLength"] = trackLengthSecs
|
|
trackInfo["seekPosition"] = seekPosition
|
|
trackInfo["trackChangeId"] = p.trackChangeID
|
|
runtime.EventsEmit(p.ctx, events.TrackChanged, trackInfo)
|
|
|
|
p.logger.Info(
|
|
"Emitting TrackChangedEvent with track info",
|
|
"trackInfo", trackInfo,
|
|
)
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
|
|
// wrap in ctrl streamer to allow play/pause
|
|
p.control = &beep.Ctrl{Streamer: p.resampled}
|
|
|
|
// 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
|
|
p.mu.Unlock()
|
|
|
|
// Emit events outside the lock — these are non-blocking Wails
|
|
// calls that don't need player state.
|
|
p.emitPlaybackStateChanged(Stopped)
|
|
p.emitPlaybackFinished()
|
|
p.logger.Info("Playback finished naturally")
|
|
|
|
// Notify queue for auto-advance. Called without p.mu held
|
|
// because it re-enters the player via LoadFile/Play.
|
|
if handler != nil {
|
|
handler()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// LoadFile
|
|
// ---------------------------------------------------------------
|
|
|
|
// LoadFile opens and decodes an audio file for playback.
|
|
func (p *Player) LoadFile(filePath string) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return p.loadFileLocked(filePath)
|
|
}
|
|
|
|
func (p *Player) loadFileLocked(filePath string) error {
|
|
defer profiling.TimeOp(p.logger, "player.LoadFile")()
|
|
|
|
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()
|
|
|
|
if p.currentFile != nil {
|
|
if closeErr := p.currentFile.Close(); closeErr != nil {
|
|
p.logger.Warn(
|
|
"failed to close previous audio file",
|
|
"err", closeErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
p.currentFile = f
|
|
|
|
if err := p.updateStreamers(
|
|
streamer, format.SampleRate,
|
|
); err != nil {
|
|
return fmt.Errorf("failed to update streamers: %w", err)
|
|
}
|
|
|
|
p.startPaused()
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.emitTrackChanged()
|
|
p.saveState()
|
|
p.logger.Info(
|
|
"File loaded, state set to paused", "file", filePath,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Play / Pause
|
|
// ---------------------------------------------------------------
|
|
|
|
func (p *Player) validateReadyToPlay() error {
|
|
if p.control == nil {
|
|
return errNoControlStreamer
|
|
}
|
|
|
|
if p.currentFile == nil {
|
|
return errNoAudioFileLoaded
|
|
}
|
|
|
|
if p.speakerStreamer == nil {
|
|
return errNoStreamerToPlay
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Play starts or resumes audio playback.
|
|
func (p *Player) Play() error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if err := p.validateReadyToPlay(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if p.state == Playing {
|
|
p.logger.Info("Already playing")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Track finished naturally — seek to the beginning and
|
|
// re-register a paused stream with the speaker so the unpause
|
|
// below starts it.
|
|
if p.state == Stopped && p.seeker != nil {
|
|
speaker.Lock()
|
|
err := p.seeker.Seek(0)
|
|
speaker.Unlock()
|
|
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to seek to beginning: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := p.updateStreamers(
|
|
p.seeker, p.format.SampleRate,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to update streamers for replay: %w", err,
|
|
)
|
|
}
|
|
|
|
p.startPaused()
|
|
p.logger.Info("Rebuilt streamers for replay")
|
|
}
|
|
|
|
// Unpause — works for both resume-from-pause and
|
|
// replay-from-stopped.
|
|
speaker.Lock()
|
|
p.control.Paused = false
|
|
speaker.Unlock()
|
|
|
|
p.state = Playing
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.logger.Info("Started playback")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Pause pauses the current playback.
|
|
func (p *Player) Pause() error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.control == nil {
|
|
return errNoAudioStream
|
|
}
|
|
|
|
if p.state == Paused {
|
|
p.logger.Info("Already paused")
|
|
|
|
return nil
|
|
}
|
|
|
|
if p.state == Playing {
|
|
speaker.Lock()
|
|
p.control.Paused = true
|
|
speaker.Unlock()
|
|
|
|
p.state = Paused
|
|
p.logger.Info("Paused playback")
|
|
p.emitPlaybackStateChanged(p.state)
|
|
p.saveState()
|
|
} else {
|
|
p.logger.Info("Already paused or not playing")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsPlaying reports whether the player is currently playing audio.
|
|
func (p *Player) IsPlaying() bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return p.state == Playing
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// UnloadTrack
|
|
// ---------------------------------------------------------------
|
|
|
|
// UnloadTrack tears down the current track, releasing the file and
|
|
// streamer chain. The player returns to the initial "no track
|
|
// loaded" state and emits events so the frontend clears its
|
|
// current-track display.
|
|
func (p *Player) UnloadTrack() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Stop audio output.
|
|
if p.control != nil {
|
|
speaker.Lock()
|
|
p.control.Paused = true
|
|
speaker.Unlock()
|
|
}
|
|
|
|
// Close the open audio file.
|
|
if p.currentFile != nil {
|
|
if err := p.currentFile.Close(); err != nil {
|
|
p.logger.Warn(
|
|
"Failed to close audio file during unload",
|
|
"err", err,
|
|
)
|
|
}
|
|
|
|
p.currentFile = nil
|
|
}
|
|
|
|
// 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.control = nil
|
|
p.speakerStreamer = nil
|
|
|
|
p.state = Stopped
|
|
|
|
// Notify frontend that there is no longer a current track.
|
|
p.emitPlaybackStateChanged(p.state)
|
|
runtime.EventsEmit(p.ctx, events.TrackChanged, nil)
|
|
p.saveState()
|
|
|
|
p.logger.Info("Track unloaded")
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Volume
|
|
// ---------------------------------------------------------------
|
|
|
|
// SetVolume sets the playback volume (0-100).
|
|
func (p *Player) SetVolume(desiredVolume UserVolume) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.setVolumeLocked(desiredVolume)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
|
|
speaker.Lock()
|
|
|
|
volume := clampVolume(desiredVolume)
|
|
p.volume.Volume = float64(volume.ToVolume())
|
|
p.volume.Silent = volume == MinUserVol
|
|
|
|
speaker.Unlock()
|
|
}
|
|
|
|
// ChangeVolume adjusts the volume by a relative amount.
|
|
func (p *Player) ChangeVolume(deltaVolume int) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *Player) getUserVolume() UserVolume {
|
|
return Volume(p.volume.Volume).ToUserVolume()
|
|
}
|
|
|
|
// MuteToggle toggles the mute state.
|
|
func (p *Player) MuteToggle() error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.volume.Silent = !p.volume.Silent
|
|
p.saveState()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Position / Seek
|
|
// ---------------------------------------------------------------
|
|
|
|
// CurrentPositionSeconds returns the current playback position in
|
|
// seconds.
|
|
func (p *Player) CurrentPositionSeconds() (int, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.seeker == nil {
|
|
return 0, errNoAudioFileLoaded
|
|
}
|
|
|
|
speaker.Lock()
|
|
pos := p.seeker.Position() / int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
|
|
return pos, nil
|
|
}
|
|
|
|
// CurrentPosition returns the playback position as a percentage
|
|
// (0-100).
|
|
func (p *Player) CurrentPosition() (int, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.seeker == nil {
|
|
return 0, errNoAudioFileLoaded
|
|
}
|
|
|
|
speaker.Lock()
|
|
pos := math.Round(
|
|
100.0 * float64(p.seeker.Position()) /
|
|
float64(p.seeker.Len()),
|
|
)
|
|
speaker.Unlock()
|
|
|
|
return int(pos), nil
|
|
}
|
|
|
|
// Seek jumps to a specific position in seconds.
|
|
func (p *Player) Seek(targetSeconds int) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return p.seekLocked(targetSeconds)
|
|
}
|
|
|
|
func (p *Player) seekLocked(targetSeconds int) error {
|
|
if p.seeker == nil {
|
|
runtime.EventsEmit(p.ctx, events.SeekFailed)
|
|
|
|
return errNoAudioFileLoaded
|
|
}
|
|
|
|
lengthSecs, err := p.trackLengthLocked()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get track length: %w", err)
|
|
}
|
|
|
|
speaker.Lock()
|
|
|
|
samples := int(
|
|
math.Round(
|
|
(float64(targetSeconds) / float64(lengthSecs)) *
|
|
float64(p.seeker.Len()),
|
|
),
|
|
)
|
|
|
|
p.logger.Debug(
|
|
"attempting to seek",
|
|
"target-seconds", targetSeconds,
|
|
"song-length", lengthSecs,
|
|
"samples", samples,
|
|
)
|
|
|
|
if seekErr := p.seeker.Seek(samples); seekErr != nil {
|
|
speaker.Unlock()
|
|
|
|
return fmt.Errorf("failed to seek: %w", seekErr)
|
|
}
|
|
|
|
speaker.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Track info
|
|
// ---------------------------------------------------------------
|
|
|
|
// GetCurrentTrackInfo returns information about the currently
|
|
// loaded track.
|
|
func (p *Player) GetCurrentTrackInfo() (
|
|
map[string]interface{}, error,
|
|
) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return p.getCurrentTrackInfoLocked()
|
|
}
|
|
|
|
func (p *Player) getCurrentTrackInfoLocked() (
|
|
map[string]interface{}, error,
|
|
) {
|
|
if p.currentFile == nil {
|
|
return map[string]interface{}{
|
|
"fileName": "",
|
|
"filePath": "",
|
|
"state": string(p.state),
|
|
"title": "",
|
|
"artist": "",
|
|
"album": "",
|
|
"coverArt": "",
|
|
}, nil
|
|
}
|
|
|
|
fileName := filepath.Base(p.currentFile.Name())
|
|
filePath := p.currentFile.Name()
|
|
|
|
// Default values.
|
|
title := fileName
|
|
artist := ""
|
|
album := ""
|
|
coverArt := ""
|
|
coverArtSmall := ""
|
|
coverArtMedium := ""
|
|
coverArtLarge := ""
|
|
|
|
// Try to get metadata from database.
|
|
if p.db != nil {
|
|
meta, err := p.db.Queries.GetTrackMetadataByPath(
|
|
p.ctx, filePath,
|
|
)
|
|
if err == nil {
|
|
if meta.Title != "" {
|
|
title = meta.Title
|
|
}
|
|
|
|
artist = meta.Artist
|
|
album = meta.Album
|
|
|
|
if meta.CoverArtPath != "" {
|
|
base := filepath.Base(meta.CoverArtPath)
|
|
coverArt = "/covers/" + base
|
|
coverArtSmall = "/covers/" +
|
|
library.SizedFilename(base, "_sm")
|
|
coverArtMedium = "/covers/" +
|
|
library.SizedFilename(base, "_md")
|
|
coverArtLarge = "/covers/" +
|
|
library.SizedFilename(base, "_lg")
|
|
}
|
|
} else {
|
|
p.logger.Debug(
|
|
"Could not get track metadata from database",
|
|
"path", filePath, "err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"fileName": fileName,
|
|
"filePath": filePath,
|
|
"state": string(p.state),
|
|
"title": title,
|
|
"artist": artist,
|
|
"album": album,
|
|
"coverArt": coverArt,
|
|
"coverArtSmall": coverArtSmall,
|
|
"coverArtMedium": coverArtMedium,
|
|
"coverArtLarge": coverArtLarge,
|
|
}, nil
|
|
}
|
|
|
|
// TrackLengthInSeconds returns the duration of the current track.
|
|
func (p *Player) TrackLengthInSeconds() (int, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
return p.trackLengthLocked()
|
|
}
|
|
|
|
func (p *Player) trackLengthLocked() (int, error) {
|
|
if p.seeker == nil {
|
|
return 0, errNoAudioFileLoaded
|
|
}
|
|
|
|
speaker.Lock()
|
|
length := p.seeker.Len() / int(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
|
|
return length, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// State persistence
|
|
// ---------------------------------------------------------------
|
|
|
|
// SaveState persists the current player state to the database.
|
|
// This is called during shutdown to capture the final state.
|
|
func (p *Player) SaveState() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.saveState()
|
|
}
|
|
|
|
// saveState is the internal helper that writes the current player
|
|
// state to the database. 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(MaxUserVol)
|
|
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(0)
|
|
|
|
if p.seeker != nil {
|
|
speaker.Lock()
|
|
positionSeconds = int64(p.seeker.Position()) /
|
|
int64(p.format.SampleRate)
|
|
speaker.Unlock()
|
|
}
|
|
|
|
err := p.db.Queries.UpdatePlayerState(
|
|
p.db.Ctx,
|
|
sqlcgen.UpdatePlayerStateParams{
|
|
Volume: volume,
|
|
Muted: muted,
|
|
LastTrackPath: trackPath,
|
|
LastPositionSeconds: positionSeconds,
|
|
},
|
|
)
|
|
if err != nil {
|
|
p.logger.Error(
|
|
"Failed to save player state", "err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
p.logger.Info("Player state saved",
|
|
"volume", volume,
|
|
"muted", muted,
|
|
"trackPath", trackPath,
|
|
"positionSeconds", positionSeconds,
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// State restoration
|
|
// ---------------------------------------------------------------
|
|
|
|
// RestoreState loads the persisted player state from the database.
|
|
func (p *Player) RestoreState() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.restoreStateLocked()
|
|
}
|
|
|
|
func (p *Player) restoreStateLocked() {
|
|
defer profiling.TimeOp(p.logger, "player.RestoreState")()
|
|
|
|
if p.db == nil {
|
|
p.logger.Warn(
|
|
"No database available, cannot restore player state",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
state, err := p.db.Queries.GetPlayerState(p.db.Ctx)
|
|
if err != nil {
|
|
p.logger.Error(
|
|
"Failed to load player state", "err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Restore volume.
|
|
// Ensure volume is initialized before restoring settings. The
|
|
// volume effect is normally created by updateStreamers during
|
|
// LoadFile, but RestoreState runs before any file is loaded.
|
|
if p.volume == nil {
|
|
p.volume = &effects.Volume{
|
|
Streamer: p.control,
|
|
Base: 2,
|
|
}
|
|
}
|
|
|
|
vol := clampVolume(UserVolume(state.Volume))
|
|
p.setVolumeLocked(vol)
|
|
|
|
if state.Muted {
|
|
p.volume.Silent = true
|
|
}
|
|
|
|
// Restore last track if the file still exists.
|
|
if state.LastTrackPath != "" {
|
|
if _, statErr := os.Stat(state.LastTrackPath); statErr != nil {
|
|
p.logger.Warn(
|
|
"Last track file no longer exists, "+
|
|
"skipping restore",
|
|
"path", state.LastTrackPath,
|
|
"err", statErr,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
err = p.loadFileLocked(state.LastTrackPath)
|
|
if err != nil {
|
|
p.logger.Error(
|
|
"Failed to restore last track",
|
|
"path", state.LastTrackPath, "err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Restore playback position.
|
|
if state.LastPositionSeconds > 0 {
|
|
err = p.seekLocked(int(state.LastPositionSeconds))
|
|
if err != nil {
|
|
p.logger.Error(
|
|
"Failed to restore playback position",
|
|
"seconds", state.LastPositionSeconds,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
p.logger.Info("Player state restored",
|
|
"volume", vol,
|
|
"muted", state.Muted,
|
|
"trackPath", state.LastTrackPath,
|
|
"positionSeconds", state.LastPositionSeconds,
|
|
)
|
|
}
|