diff --git a/go.mod b/go.mod index ce78e84..1961eb8 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module go.arsenm.dev/infinitime go 1.16 -require github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a +require ( + github.com/godbus/dbus/v5 v5.0.3 + github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a +) diff --git a/internal/utils/dbus.go b/internal/utils/dbus.go new file mode 100644 index 0000000..cd6aee0 --- /dev/null +++ b/internal/utils/dbus.go @@ -0,0 +1,37 @@ +package utils + +import "github.com/godbus/dbus/v5" + +func NewSystemBusConn() (*dbus.Conn, error) { + // Connect to dbus session bus + conn, err := dbus.SystemBusPrivate() + if err != nil { + return nil, err + } + err = conn.Auth(nil) + if err != nil { + return nil, err + } + err = conn.Hello() + if err != nil { + return nil, err + } + return conn, nil +} + +func NewSessionBusConn() (*dbus.Conn, error) { + // Connect to dbus session bus + conn, err := dbus.SessionBusPrivate() + if err != nil { + return nil, err + } + err = conn.Auth(nil) + if err != nil { + return nil, err + } + err = conn.Hello() + if err != nil { + return nil, err + } + return conn, nil +} diff --git a/pkg/player/player.go b/pkg/player/player.go new file mode 100644 index 0000000..da71658 --- /dev/null +++ b/pkg/player/player.go @@ -0,0 +1,224 @@ +package player + +import ( + "strings" + + "github.com/godbus/dbus/v5" + "go.arsenm.dev/infinitime/internal/utils" +) + +var ( + method, monitor *dbus.Conn + monitorCh chan *dbus.Message +) + +// Init makes required connections to DBis and +// initializes change monitoring channel +func Init() error { + // Connect to session bus for monitoring + monitorConn, err := utils.NewSessionBusConn() + if err != nil { + return err + } + // Add match rule for PropertiesChanged on media player + monitorConn.AddMatchSignal( + dbus.WithMatchObjectPath("/org/mpris/MediaPlayer2"), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchMember("PropertiesChanged"), + ) + monitorCh = make(chan *dbus.Message) + monitorConn.Eavesdrop(monitorCh) + + // Connect to session bus for method calls + methodConn, err := utils.NewSessionBusConn() + if err != nil { + return err + } + method, monitor = methodConn, monitorConn + return nil +} + +// Exit closes all connections and channels +func Exit() { + close(monitorCh) + method.Close() + monitor.Close() +} + +// Play uses MPRIS to play media +func Play() error { + player, err := getPlayerObj() + if err != nil { + return err + } + if player != nil { + call := player.Call("org.mpris.MediaPlayer2.Player.Play", 0) + if call.Err != nil { + return call.Err + } + } + return nil +} + +// Pause uses MPRIS to pause media +func Pause() error { + player, err := getPlayerObj() + if err != nil { + return err + } + if player != nil { + call := player.Call("org.mpris.MediaPlayer2.Player.Pause", 0) + if call.Err != nil { + return call.Err + } + } + return nil +} + +// Next uses MPRIS to skip to next media +func Next() error { + player, err := getPlayerObj() + if err != nil { + return err + } + if player != nil { + call := player.Call("org.mpris.MediaPlayer2.Player.Next", 0) + if call.Err != nil { + return call.Err + } + } + return nil +} + +// Prev uses MPRIS to skip to previous media +func Prev() error { + player, err := getPlayerObj() + if err != nil { + return err + } + if player != nil { + call := player.Call("org.mpris.MediaPlayer2.Player.Previous", 0) + if call.Err != nil { + return call.Err + } + } + return nil +} + +type ChangeType int + +const ( + ChangeTypeTitle ChangeType = iota + ChangeTypeArtist + ChangeTypeAlbum + ChangeTypeStatus +) + +func (ct ChangeType) String() string { + switch ct { + case ChangeTypeTitle: + return "Title" + case ChangeTypeAlbum: + return "Album" + case ChangeTypeArtist: + return "Artist" + case ChangeTypeStatus: + return "Status" + } + return "" +} + +// OnChange runs cb when a value changes +func OnChange(cb func(ChangeType, string)) { + go func() { + // For every message on channel + for msg := range monitorCh { + // Parse PropertiesChanged + iface, changed, ok := parsePropertiesChanged(msg) + if !ok || iface != "org.mpris.MediaPlayer2.Player" { + continue + } + + // For every property changed + for name, val := range changed { + // If metadata changed + if name == "Metadata" { + // Get fields + fields := val.Value().(map[string]dbus.Variant) + // For every field + for name, val := range fields { + // Handle each field appropriately + if strings.HasSuffix(name, "title") { + title := val.Value().(string) + if title == "" { + title = "Unknown " + ChangeTypeTitle.String() + } + cb(ChangeTypeTitle, title) + } else if strings.HasSuffix(name, "album") { + album := val.Value().(string) + if album == "" { + album = "Unknown " + ChangeTypeAlbum.String() + } + cb(ChangeTypeAlbum, album) + } else if strings.HasSuffix(name, "artist") { + artists := val.Value().([]string) + artistStr := strings.Join(artists, ", ") + if artistStr == "" { + artistStr = "Unknown " + ChangeTypeArtist.String() + } + cb(ChangeTypeArtist, artistStr) + } + } + } else if name == "PlaybackStatus" { + // Handle status change + cb(ChangeTypeStatus, val.Value().(string)) + } + } + } + }() +} + +// getPlayerNames gets all DBus MPRIS player bus names +func getPlayerNames(conn *dbus.Conn) ([]string, error) { + var names []string + err := conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names) + if err != nil { + return nil, err + } + var players []string + for _, name := range names { + if strings.HasPrefix(name, "org.mpris.MediaPlayer2") { + players = append(players, name) + } + } + return players, nil +} + +// GetPlayerObj gets the object corresponding to the first +// bus name found in DBus +func getPlayerObj() (dbus.BusObject, error) { + players, err := getPlayerNames(method) + if err != nil { + return nil, err + } + if len(players) == 0 { + return nil, nil + } + return method.Object(players[0], "/org/mpris/MediaPlayer2"), nil +} + +// parsePropertiesChanged parses a DBus PropertiesChanged signal +func parsePropertiesChanged(msg *dbus.Message) (iface string, changed map[string]dbus.Variant, ok bool) { + if len(msg.Body) != 3 { + return "", nil, false + } + iface, ok = msg.Body[0].(string) + if !ok { + return + } + changed, ok = msg.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + return +} diff --git a/pkg/player/playerctl.go b/pkg/player/playerctl.go deleted file mode 100644 index 35d56a2..0000000 --- a/pkg/player/playerctl.go +++ /dev/null @@ -1,112 +0,0 @@ -package player - -import ( - "bufio" - "io" - "os/exec" - "strings" -) - -// Play uses playerctl to play media -func Play() error { - return exec.Command("playerctl", "play").Run() -} - -// Pause uses playerctl to pause media -func Pause() error { - return exec.Command("playerctl", "pause").Run() -} - -// Next uses playerctl to skip to next media -func Next() error { - return exec.Command("playerctl", "next").Run() -} - -// Prev uses playerctl to skip to previous media -func Prev() error { - return exec.Command("playerctl", "previous").Run() -} - -// Metadata uses playerctl to detect music metadata changes -func Metadata(key string, onChange func(string)) error { - // Execute playerctl command with key and follow flag - cmd := exec.Command("playerctl", "metadata", key, "-F") - // Get stdout pipe - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - go func() { - for { - // Read line from command stdout - line, _, err := bufio.NewReader(stdout).ReadLine() - if err == io.EOF { - continue - } - // Convert line to string - data := string(line) - // If key unknown, return suitable default - if data == "No player could handle this command" || data == "" { - data = "Unknown " + strings.Title(key) - } - // Run the onChange callback - onChange(data) - } - }() - // Start command asynchronously - err = cmd.Start() - if err != nil { - return err - } - return nil -} - -func Status(onChange func(bool)) error { - // Execute playerctl status with follow flag - cmd := exec.Command("playerctl", "status", "-F") - // Get stdout pipe - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - go func() { - for { - // Read line from command stdout - line, _, err := bufio.NewReader(stdout).ReadLine() - if err == io.EOF { - continue - } - // Convert line to string - data := string(line) - // Run the onChange callback - onChange(data == "Playing") - } - }() - // Start command asynchronously - err = cmd.Start() - if err != nil { - return err - } - return nil -} - -func CurrentMetadata(key string) (string, error) { - out, err := exec.Command("playerctl", "metadata", key).Output() - if err != nil { - return "", err - } - data := string(out) - if data == "No player could handle this command" || data == "" { - data = "Unknown " + strings.Title(key) - } - return data, nil -} - -func CurrentStatus() (bool, error) { - out, err := exec.Command("playerctl", "status").Output() - if err != nil { - return false, err - } - data := string(out) - return data == "Playing", nil -}