package player import ( "context" "errors" "fmt" "github.com/wildeyedskies/go-mpv/mpv" ) // Error returned by many Player functions if called before the player has not been initialized. var ErrUnitialized error = errors.New("mpv player uninitialized") // The playback state (Stopped, Paused, or Playing). type State int const ( Stopped State = iota Paused Playing ) // The current status of the player. // Includes playback state, current time, total track time, and playlist position. type Status struct { State State TimePos float64 Duration float64 PlaylistPos int64 } // Argument to Seek function (SeekAbsolute, SeekRelative, SeekAbsolutePercent, SeekRelativePercent). type SeekMode int const ( SeekAbsolute SeekMode = iota SeekRelative SeekAbsolutePercent SeekRelativePercent ) // Player encapsulates the mpv instance and provides functions // to control it and to check its status. type Player struct { mpv *mpv.Mpv vol int status Status seeking bool curPlaylistPos int64 prePausedState State clientName string bgCancel context.CancelFunc // callbacks onPaused []func() onStopped []func() onPlaying []func() onSeek []func() onTrackChange []func(int64) } // Returns a new player. // Must call Init on the player before it is ready for playback. func New() *Player { return NewWithClientName("") } // Same as New, but sets the application name that mpv // reports to the system audio API. func NewWithClientName(c string) *Player { return &Player{ vol: -1, // use 100 in Init clientName: c, } } // Initializes the Player and makes it ready for playback. // Most Player functions will return ErrUnitialized if called before Init. func (p *Player) Init() error { if p.mpv == nil { m := mpv.Create() m.SetOptionString("idle", "yes") m.SetOptionString("video", "no") m.SetOptionString("audio-display", "no") m.SetOptionString("gapless-audio", "weak") m.SetOptionString("prefetch-playlist", "yes") m.SetOptionString("force-seekable", "yes") m.SetOptionString("terminal", "no") if p.vol < 0 { p.vol = 100 } m.SetOption("volume", mpv.FORMAT_INT64, p.vol) if p.clientName != "" { m.SetOptionString("audio-client-name", p.clientName) } if err := m.Initialize(); err != nil { return fmt.Errorf("error initializing mpv: %s", err.Error()) } p.mpv = m } ctx, cancel := context.WithCancel(context.Background()) go p.eventHandler(ctx) p.bgCancel = cancel return nil } // Appends the given file to the play queue. // Note that the Player API does not provide methods to read // the play queue. Clients are expected to maintain their own play queue model. func (p *Player) AppendFile(url string) error { if p.mpv == nil { return ErrUnitialized } return p.mpv.Command([]string{"loadfile", url, "append"}) } // Plays the specified file, clearing the previous play queue, if any. func (p *Player) PlayFile(url string) error { if p.mpv == nil { return ErrUnitialized } err := p.mpv.Command([]string{"loadfile", url, "replace"}) if err == nil { p.setState(Playing) } return err } // Stops playback and clears the play queue. func (p *Player) Stop() error { if p.mpv == nil { return ErrUnitialized } var err error if p.status.State == Stopped { err = p.mpv.Command([]string{"playlist-clear"}) } else { if err = p.mpv.Command([]string{"stop"}); err == nil { // if player was paused, stop command actually doesn't clear pause state err = p.setPaused(false) } } if err == nil { p.setState(Stopped) } return err } // Seeks within the currently playing track. // See MPV seek command documentation for more details. func (p *Player) Seek(target string, mode SeekMode) error { if p.mpv == nil { return ErrUnitialized } p.seeking = true err := p.mpv.Command([]string{"seek", target, mode.String()}) return err } // Seeks to the beginning of the current track if: // - The current track is the first track in the play queue, or // - The current time is more than 3 seconds past the beginning of the track. // // Else seeks to the beginning of the previous track. func (p *Player) SeekBackOrPrevious() error { if p.mpv == nil { return ErrUnitialized } if pos, err := p.getInt64Property("time-pos"); err == nil && pos > 3 { return p.Seek("0", SeekAbsolutePercent) } if pos, err := p.getInt64Property("playlist-pos"); err == nil && pos == 0 { return p.Seek("0", SeekAbsolutePercent) } return p.mpv.Command([]string{"playlist-prev"}) } // Seeks to the next track in the play queue, if any. func (p *Player) SeekNext() error { if p.mpv == nil { return ErrUnitialized } return p.mpv.Command([]string{"playlist-next"}) } // Sets the volume of the player (0-100). // Unlike most Player functions, SetVolume can be called before Init, // to set the initial volume of the player on startup. func (p *Player) SetVolume(vol int) error { if vol > 100 { vol = 100 } else if vol < 0 { vol = 0 } if p.mpv != nil { err := p.mpv.SetProperty("volume", mpv.FORMAT_INT64, vol) if err == nil { p.vol = vol } return err } p.vol = vol return nil } // Gets the current volume of the player. func (p *Player) GetVolume() int { return p.vol } func (p *Player) setPaused(paused bool) error { return p.mpv.SetProperty("pause", mpv.FORMAT_FLAG, paused) } // Start playback from the first track in the play queue. func (p *Player) PlayFromBeginning() error { err := p.mpv.Command([]string{"playlist-play-index", "0"}) if err == nil { p.setState(Playing) } return err } // Begins playback if there is anything in the play queue and player is stopped or paused. // If player is playing, pauses playback. func (p *Player) PlayPause() error { if p.mpv == nil { return ErrUnitialized } switch p.status.State { case Stopped: // check if we have anything to play if c, err := p.getInt64Property("playlist-count"); err == nil && c > 0 { err := p.mpv.Command([]string{"playlist-play-index", "0"}) if err == nil { p.setState(Playing) } return err } return nil case Playing: err := p.setPaused(true) if err == nil { p.prePausedState = p.status.State p.setState(Paused) } return err case Paused: err := p.setPaused(false) if err == nil { p.setState(p.prePausedState) } return err default: return errors.New("Unknown player state") } } // Get the current status of the player. func (p *Player) GetStatus() Status { if p.mpv == nil { return p.status } pos, _ := p.mpv.GetProperty("playback-time", mpv.FORMAT_DOUBLE) dur, _ := p.mpv.GetProperty("duration", mpv.FORMAT_DOUBLE) if pos != nil { p.status.TimePos = pos.(float64) } if dur != nil { p.status.Duration = dur.(float64) } if playpos, err := p.getInt64Property("playlist-pos"); err == nil { p.status.PlaylistPos = playpos } return p.status } func (p *Player) getInt64Property(propName string) (int64, error) { playpos, err := p.mpv.GetProperty(propName, mpv.FORMAT_INT64) if err != nil { return -1, err } if playpos != nil { return playpos.(int64), nil } return -1, errors.New("mpv did not report playlist pos") } // Returns true if a seek is currently in progress. func (p *Player) IsSeeking() bool { return p.seeking && p.status.State == Playing } // Registers a callback which is invoked when the player transitions to the Paused state. func (p *Player) OnPaused(cb func()) { p.onPaused = append(p.onPaused, cb) } // Registers a callback which is invoked when the player transitions to the Stopped state. func (p *Player) OnStopped(cb func()) { p.onStopped = append(p.onStopped, cb) } // Registers a callback which is invoked when the player transitions to the Playing state. func (p *Player) OnPlaying(cb func()) { p.onPlaying = append(p.onPlaying, cb) } // Registers a callback which is invoked whenever a seek event occurs. func (p *Player) OnSeek(cb func()) { p.onSeek = append(p.onSeek, cb) } // Registers a callback which is invoked when the currently playing track changes, // or when playback begins at any time from the Stopped state. // Callback is invoked with the index of the currently playing track (zero-based). func (p *Player) OnTrackChange(cb func(int64)) { p.onTrackChange = append(p.onTrackChange, cb) } // Destroy the player. func (p *Player) Destroy() { if p.bgCancel != nil { p.bgCancel() } if p.mpv != nil { p.mpv.Command([]string{"stop"}) p.mpv.TerminateDestroy() p.mpv = nil } } // sets the state and invokes callbacks, if triggered func (p *Player) setState(s State) { switch { case s == Playing && p.status.State != Playing: defer func() { for _, cb := range p.onPlaying { cb() } }() if p.status.State == Stopped { defer func() { for _, cb := range p.onTrackChange { cb(p.curPlaylistPos) } }() } case s == Paused && p.status.State != Paused: defer func() { for _, cb := range p.onPaused { cb() } }() case s == Stopped && p.status.State != Stopped: defer func() { for _, cb := range p.onStopped { cb() } }() } p.status.State = s } func (p *Player) eventHandler(ctx context.Context) { for { select { case <-ctx.Done(): return default: e := p.mpv.WaitEvent(1 /*timeout seconds*/) if e.Event_Id != mpv.EVENT_NONE { //log.Printf("mpv event: %+v\n", e) } switch e.Event_Id { case mpv.EVENT_PLAYBACK_RESTART: if p.seeking { p.seeking = false } case mpv.EVENT_SEEK: for _, cb := range p.onSeek { cb() } case mpv.EVENT_FILE_LOADED: if p.status.State == Paused { // seek while paused switches to a new file // mpv does not fire seek event in this case for _, cb := range p.onSeek { cb() } } if pos, err := p.getInt64Property("playlist-pos"); err == nil { if p.curPlaylistPos != pos { p.curPlaylistPos = pos for _, cb := range p.onTrackChange { cb(pos) } } } case mpv.EVENT_IDLE: p.status.Duration = 0 p.status.TimePos = 0 p.setState(Stopped) } } } } func (s SeekMode) String() string { switch s { case SeekAbsolute: return "absolute" case SeekRelative: return "relative" case SeekAbsolutePercent: return "absolute-percent" case SeekRelativePercent: return "relative-percent" } return "UNKNOWN_SEEK_MODE" }