// Package player provides audio playback functionality. package player import ( "context" "errors" "fmt" "log/slog" "math" "os" "path/filepath" "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/metadata" ) // Player handles audio playback and state management. type Player struct { ctx context.Context logger *slog.Logger db *database.DB state PlayerState 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() } // PlayerState represents the current playback state. type PlayerState string // Playback state values. const ( Playing PlayerState = "playing" Paused PlayerState = "paused" Stopped PlayerState = "stopped" ) 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) { 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 that is invoked when a track finishes naturally. // This allows the queue to drive auto-advance without circular imports. func (p *Player) SetPlaybackFinishedHandler(handler func()) { p.playbackFinishedHandler = handler } // SetContext sets the Wails context, registers event handlers, and restores persisted state. func (p *Player) SetContext(ctx context.Context) { p.ctx = ctx p.registerEventHandlers() p.RestoreState() } func (p *Player) registerEventHandlers() { if p.ctx == nil { p.logger.Error("Context is nil, cannot register event handlers") return } runtime.EventsOn(p.ctx, events.RequestPlay, func(_ ...any) { p.logger.Info("Received RequestPlayEvent") p.Play() }) runtime.EventsOn(p.ctx, events.RequestPause, func(_ ...any) { p.logger.Info("Received RequestPauseEvent") p.Pause() }) runtime.EventsOn(p.ctx, events.RequestLoadFile, func(data ...any) { p.logger.Info("Received RequestLoadFileEvent") filePath := data[0].(string) p.logger.Info(filePath) err := p.LoadFile(filePath) if err != nil { p.logger.Error(err.Error()) } else { p.logger.Info(p.currentFile.Name()) } }) runtime.EventsOn(p.ctx, events.Seek, func(data ...any) { p.logger.Info("Received SeekEvent", "Data", data[0]) seekValue := int(data[0].(float64)) err := p.Seek(seekValue) if err != nil { p.logger.Error("cannot seek", "error", err) } }) runtime.EventsOn(p.ctx, events.RequestSetVolume, func(data ...any) { desiredVolume := UserVolume(data[0].(float64)) p.logger.Info("Received RequestSetVolumeEvent", "volume", desiredVolume) err := p.SetVolume(desiredVolume) if err != nil { p.logger.Error("cannot set volume", "error", err) return } p.emitVolumeChanged() }) } // emitPlaybackStateChanged emits a playback state change event. func (p *Player) emitPlaybackStateChanged(state PlayerState) { 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.TrackLengthInSeconds() if err != nil { p.logger.Error("Cannot get track length") } trackInfo, err := p.GetCurrentTrackInfo() 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() } // Emit comprehensive track info trackInfo["trackLength"] = trackLengthSecs trackInfo["seekPosition"] = seekPosition 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.emitVolumeChanged() if p.currentFile != nil { p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() } } 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. This keeps the speaker always active when a file is loaded, // so Play() only ever needs to unpause the control gate. func (p *Player) startPaused() { speaker.Lock() p.control.Paused = true speaker.Unlock() speaker.Play(beep.Seq(p.speakerStreamer, beep.Callback(func() { p.state = Stopped p.emitPlaybackStateChanged(p.state) p.emitPlaybackFinished() p.logger.Info("Playback finished naturally") // Notify queue for auto-advance. if p.playbackFinishedHandler != nil { p.playbackFinishedHandler() } }))) p.state = Paused } // LoadFile opens and decodes an audio file for playback. func (p *Player) LoadFile(filePath string) error { // opening file 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 { p.currentFile.Close() } p.currentFile = f p.updateStreamers(streamer, format.SampleRate) p.startPaused() p.emitPlaybackStateChanged(p.state) p.emitTrackChanged() p.logger.Info("File loaded, state set to paused", "file", filePath) return nil } func (p *Player) validateReadyToPlay() error { if p.control == nil { return errors.New("no control streamer") } if p.currentFile == nil { return errors.New("no audio file loaded") } if p.speakerStreamer == nil { return errors.New("no streamer to play") } return nil } // Play starts or resumes audio playback. func (p *Player) Play() error { 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) } p.updateStreamers(p.seeker, p.format.SampleRate) 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 { if p.control == nil { return errors.New("no audio stream to pause") } 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) } else { p.logger.Info("Already paused or not playing") } return nil } // SetVolume sets the playback volume (0-100). func (p *Player) SetVolume(desiredVolume UserVolume) error { speaker.Lock() // clamp value between 1 and 100 volume := clampVolume(desiredVolume) // Apply the volume settings p.volume.Volume = float64(volume.ToPlayerVolume()) p.volume.Silent = volume == MinUserVol speaker.Unlock() return nil } // ChangeVolume adjusts the volume by a relative amount. func (p *Player) ChangeVolume(deltaVolume int) error { return p.SetVolume(p.getUserVolume() + UserVolume(deltaVolume)) } func (p *Player) getUserVolume() UserVolume { return PlayerVolume(p.volume.Volume).ToUserVolume() } // MuteToggle toggles the mute state. func (p *Player) MuteToggle() error { p.volume.Silent = !p.volume.Silent return nil } // CurrentPositionSeconds returns the current playback position in seconds. func (p *Player) CurrentPositionSeconds() (int, error) { if p.seeker == nil { return 0, errors.New("no audio file loaded") } 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) { if p.seeker == nil { return 0, errors.New("no audio file loaded") } 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 { if p.seeker == nil { runtime.EventsEmit(p.ctx, events.SeekFailed) return errors.New("no audio file loaded") } lengthSecs, err := p.TrackLengthInSeconds() 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, ) p.seeker.Seek(samples) speaker.Unlock() return nil } // GetCurrentTrackInfo returns information about the currently loaded track. func (p *Player) GetCurrentTrackInfo() (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 := "" // 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 != "" { coverArt = "/covers/" + filepath.Base(meta.CoverArtPath) } } 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, }, nil } // TrackLengthInSeconds returns the duration of the current track. func (p *Player) TrackLengthInSeconds() (int, error) { if p.seeker == nil { return 0, errors.New("no audio file loaded") } speaker.Lock() length := p.seeker.Len() / int(p.format.SampleRate) speaker.Unlock() return length, nil } // SaveState persists the current player state to the database. 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, ) } // RestoreState loads the persisted player state from the database. func (p *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)) err = p.SetVolume(vol) if err != nil { p.logger.Error("Failed to restore volume", "err", err) } 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.LoadFile(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.Seek(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, ) }