Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions cmd/daemon/controls.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/mpris"
"github.com/devgianlu/go-librespot/player"
connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate"
playerpb "github.com/devgianlu/go-librespot/proto/spotify/player"
Expand Down Expand Up @@ -76,6 +77,32 @@ func (p *AppPlayer) schedulePrefetchNext() {
}
}

func (p *AppPlayer) emitMprisUpdate(playbackStatus mpris.PlaybackStatus) {
// p.state, p.state.player, p.state.device, p.state.player.Options are assumed to always be non-nil here

var trackUri *string
var media *librespot.Media
if p.state.player.Track != nil {
trackUri = &p.state.player.Track.Uri
}
if p.primaryStream != nil {
media = p.primaryStream.Media
}

p.app.mpris.EmitStateUpdate(
mpris.MediaState{
PlaybackStatus: playbackStatus,
LoopStatus: mpris.GetLoopStatus(
p.state.player.Options.RepeatingContext, p.state.player.Options.RepeatingTrack),
Shuffle: p.state.player.Options.ShufflingContext,
Volume: float64(p.state.device.Volume) / float64(player.MaxStateVolume),
PositionMs: p.state.player.Position,
Uri: trackUri,
Media: media,
},
)
}

func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
switch ev.Type {
case player.EventTypePlay:
Expand All @@ -93,6 +120,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
p.state.trackPosition(),
)

p.emitMprisUpdate(mpris.Playing)

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypePlaying,
Data: ApiEventDataPlaying{
Expand All @@ -109,6 +138,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {

p.sess.Events().OnPlayerResume(p.primaryStream, p.state.trackPosition())

p.emitMprisUpdate(mpris.Playing)

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypePlaying,
Data: ApiEventDataPlaying{
Expand All @@ -132,6 +163,8 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
p.state.trackPosition(),
)

p.emitMprisUpdate(mpris.Paused)

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypePaused,
Data: ApiEventDataPaused{
Expand Down Expand Up @@ -163,6 +196,7 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
PlayOrigin: p.state.playOrigin(),
},
})
p.emitMprisUpdate(mpris.Stopped)
}
case player.EventTypeStop:
p.app.server.Emit(&ApiEvent{
Expand All @@ -171,6 +205,7 @@ func (p *AppPlayer) handlePlayerEvent(ctx context.Context, ev *player.Event) {
PlayOrigin: p.state.playOrigin(),
},
})
p.emitMprisUpdate(mpris.Stopped)
default:
panic("unhandled player event")
}
Expand Down Expand Up @@ -473,6 +508,12 @@ func (p *AppPlayer) seek(ctx context.Context, position int64) error {

p.sess.Events().OnPlayerSeek(p.primaryStream, oldPosition, position)

p.app.mpris.EmitSeekUpdate(
mpris.SeekState{
PositionMs: position,
},
)

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypeSeek,
Data: ApiEventDataSeek{
Expand Down Expand Up @@ -682,3 +723,26 @@ func (p *AppPlayer) volumeUpdated(ctx context.Context) {
},
})
}

func (p *AppPlayer) stopPlayback(ctx context.Context) error {
p.player.Stop()
p.primaryStream = nil
p.secondaryStream = nil

p.state.reset()
if err := p.putConnectState(ctx, connectpb.PutStateReason_BECAME_INACTIVE); err != nil {
return fmt.Errorf("failed inactive state put: %w", err)
}

p.schedulePrefetchNext()

if p.app.cfg.ZeroconfEnabled {
p.logout <- p
}

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypeInactive,
})

return nil
}
21 changes: 17 additions & 4 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/mpris"

"github.com/devgianlu/go-librespot/apresolve"
"github.com/devgianlu/go-librespot/player"
Expand Down Expand Up @@ -48,6 +49,7 @@ type App struct {
state librespot.AppState

server ApiServer
mpris mpris.Server
logoutCh chan *AppPlayer
}

Expand Down Expand Up @@ -227,7 +229,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
panic("zeroconf is disabled and no credentials are present")
}

appPlayer.Run(ctx, app.server.Receive())
appPlayer.Run(ctx, app.server.Receive(), app.mpris.Receive())
return nil
}

Expand Down Expand Up @@ -255,7 +257,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
Debugf("initializing zeroconf session")

apiCh = make(chan ApiRequest)
go currentPlayer.Run(ctx, apiCh)
go currentPlayer.Run(ctx, apiCh, app.mpris.Receive())

// let zeroconf know that we already have a user
z.SetCurrentUser(currentPlayer.sess.Username())
Expand Down Expand Up @@ -306,7 +308,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
apiCh = make(chan ApiRequest)
currentPlayer = newAppPlayer

go newAppPlayer.Run(ctx, apiCh)
go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive())

// let zeroconf know that we already have a user
z.SetCurrentUser(newAppPlayer.sess.Username())
Expand Down Expand Up @@ -355,7 +357,7 @@ func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Co
Debugf("persisted zeroconf credentials")
}

go newAppPlayer.Run(ctx, apiCh)
go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive())
return true
})
}
Expand Down Expand Up @@ -391,6 +393,7 @@ type Config struct {
ZeroconfPort int `koanf:"zeroconf_port"`
DisableAutoplay bool `koanf:"disable_autoplay"`
ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"`
MprisEnabled bool `koanf:"mpris_enabled"`
Server struct {
Enabled bool `koanf:"enabled"`
Address string `koanf:"address"`
Expand Down Expand Up @@ -539,6 +542,16 @@ func main() {
app.server, _ = NewStubApiServer(app.log)
}

// create mpris server if needed
if cfg.MprisEnabled {
app.mpris, err = mpris.NewServer(app.log)
if err != nil {
log.WithError(err).Fatal("failed creating mpris server")
}
} else {
app.mpris = mpris.DummyServer{}
}

ctx := context.TODO()

switch cfg.Credentials.Type {
Expand Down
105 changes: 86 additions & 19 deletions cmd/daemon/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"sync"
"time"

"github.com/devgianlu/go-librespot/mpris"
"github.com/godbus/dbus/v5"
"google.golang.org/protobuf/proto"

librespot "github.com/devgianlu/go-librespot"
Expand Down Expand Up @@ -118,24 +120,7 @@ func (p *AppPlayer) handleDealerMessage(ctx context.Context, msg dealer.Message)
}
p.app.log.Infof("playback was transferred to %s", name)

p.player.Stop()
p.primaryStream = nil
p.secondaryStream = nil

p.state.reset()
if err := p.putConnectState(ctx, connectpb.PutStateReason_BECAME_INACTIVE); err != nil {
return fmt.Errorf("failed inactive state put: %w", err)
}

p.schedulePrefetchNext()

if p.app.cfg.ZeroconfEnabled {
p.logout <- p
}

p.app.server.Emit(&ApiEvent{
Type: ApiEventTypeInactive,
})
return p.stopPlayback(ctx)
}

return nil
Expand Down Expand Up @@ -560,13 +545,83 @@ func (p *AppPlayer) handleApiRequest(ctx context.Context, req ApiRequest) (any,
}
}

func pointer[T any](d T) *T {
return &d
}

func (p *AppPlayer) handleMprisEvent(ctx context.Context, req mpris.MediaPlayer2PlayerCommand) error {
switch req.Type {
case mpris.MediaPlayer2PlayerCommandTypeNext:
return p.skipNext(ctx, nil)
case mpris.MediaPlayer2PlayerCommandTypePrevious:
return p.skipPrev(ctx, true)
case mpris.MediaPlayer2PlayerCommandTypePlay:
return p.play(ctx)
case mpris.MediaPlayer2PlayerCommandTypePause:
return p.pause(ctx)
case mpris.MediaPlayer2PlayerCommandTypePlayPause:
if p.state.player.IsPaused {
return p.play(ctx)
} else {
return p.pause(ctx)
}
case mpris.MediaPlayer2PlayerCommandTypeStop:
return p.stopPlayback(ctx)
case mpris.MediaPlayer2PlayerCommandLoopStatusChanged:
p.app.log.Tracef("mpris loop status argument %s", req.Argument)
dt := req.Argument
switch dt {
case mpris.None:
p.setOptions(ctx, pointer(false), pointer(false), nil)
case mpris.Playlist:
p.setOptions(ctx, pointer(true), pointer(false), nil)
case mpris.Track:
p.setOptions(ctx, pointer(true), pointer(true), nil)
default:
p.app.log.Warnf("mpris loop status argument is invalid (%s)", req.Argument)
}
return nil
case mpris.MediaPlayer2PlayerCommandShuffleChanged:
sh := req.Argument.(bool)
p.setOptions(ctx, nil, nil, &sh)
return nil
case mpris.MediaPlayer2PlayerCommandVolumeChanged:
volRelative := req.Argument.(float64)
volAbs := uint32(player.MaxStateVolume * volRelative)

p.updateVolume(volAbs)
return nil
case mpris.MediaPlayer2PlayerCommandTypeSetPosition:
arg := req.Argument.(mpris.MediaPlayer2CommandSetPositionPayload)

p.app.log.Tracef("media player set position argument: %v", arg)

if arg.ObjectPath.IsValid() {
spotifyId := strings.Join(strings.Split(string(arg.ObjectPath), "/")[3:], ":")
if spotifyId != p.state.player.Track.Uri {
return fmt.Errorf("seek tries to jump to different uri, not yet supported (got: %s, expected: %s)", spotifyId, p.state.player.Track.Uri)
}
}

newPositionAbs := arg.PositionUs / 1000
return p.seek(ctx, newPositionAbs)
case mpris.MediaPlayer2PlayerCommandTypeSeek:
newPosAbs := p.player.PositionMs() + req.Argument.(int64)/1000
return p.seek(ctx, newPosAbs)
case mpris.MediaPlayer2PlayerCommandTypeOpenUri, mpris.MediaPlayer2PlayerCommandRateChanged:
p.app.log.Warnf("unimplemented mpris event %d", req.Type)
return fmt.Errorf("unimplemented mpris event %d", req.Type)
}
return nil
}

func (p *AppPlayer) Close() {
p.stop <- struct{}{}
p.player.Close()
p.sess.Close()
}

func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest) {
func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest, mprisRecv <-chan mpris.MediaPlayer2PlayerCommand) {
err := p.sess.Dealer().Connect(ctx)
if err != nil {
p.app.log.WithError(err).Error("failed connecting to dealer")
Expand Down Expand Up @@ -605,6 +660,18 @@ func (p *AppPlayer) Run(ctx context.Context, apiRecv <-chan ApiRequest) {
case req := <-apiRecv:
data, err := p.handleApiRequest(ctx, req)
req.Reply(data, err)
case mprisReq := <-mprisRecv:
p.app.log.Tracef("new mpris message %v", mprisReq)
err := p.handleMprisEvent(ctx, mprisReq)
dbusError := mpris.MediaPlayer2PlayerCommandResponse{
Err: &dbus.Error{},
}
if err != nil {
dbusError.Err.Name = err.Error()
} else {
dbusError.Err = nil
}
mprisReq.Reply(dbusError)
case ev := <-playerRecv:
p.handlePlayerEvent(ctx, &ev)
case volume := <-p.volumeUpdate:
Expand Down
5 changes: 5 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@
"type": "boolean",
"description": "Whether autoplay of more songs should be disabled",
"default": false
},
"mpris_enabled": {
"type": "boolean",
"description": "Enables MPRIS communication with D-Bus",
"default": false
}
},
"required": [
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.22.2
require (
github.com/cenkalti/backoff/v4 v4.2.1
github.com/devgianlu/shannon v0.0.0-20230613115856-82ec90b7fa7e
github.com/godbus/dbus/v5 v5.1.0
github.com/gofrs/flock v0.12.1
github.com/grandcat/zeroconf v1.0.0
github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53
Expand All @@ -21,6 +22,7 @@ require (
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
golang.org/x/net v0.26.0
golang.org/x/oauth2 v0.21.0
golang.org/x/sys v0.22.0
google.golang.org/protobuf v1.30.0
nhooyr.io/websocket v1.8.7
)
Expand All @@ -38,7 +40,6 @@ require (
github.com/rogpeppe/go-internal v1.13.1 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
Expand Down
14 changes: 14 additions & 0 deletions mpris/fallback-server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !linux

package mpris

import (
librespot "github.com/devgianlu/go-librespot"
)

// NewServer creates a no-op mpris server to replace the equivalently named method in builds outside linux
func NewServer(logger librespot.Logger) (_ *DummyServer, err error) {
logger.Warn("mpris was set to enabled although it is not included in this build")

return &DummyServer{}, nil
}
Loading