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
11 changes: 7 additions & 4 deletions docs/IPC_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -685,10 +685,13 @@ Send an MPRIS control action to a device.
{"deviceId": "a1b2c3d4e5f6_...", "action": "play"}
```

Supported actions: `"play"`, `"pause"`, `"playpause"`, `"next"`, `"previous"`,
`"stop"`, `"raise"`, `"quit"`. Volume can be set with `"setVolume"` (requires
an integer value field). Seek with `"seek"` (int64, offset in ms) or
`"setPosition"` (int64, absolute position in ms).
Supported actions: `"Play"`, `"Pause"`, `"PlayPause"`, `"Next"`, `"Previous"`,
`"Stop"`, `"raise"`, `"quit"`. The first six are matched case-insensitively and
sent in the spelling the phone expects, so `"playpause"` works too. Set the
volume with a `"volume"` field (int, 0–100). Seek with `"setPosition"` (int64,
absolute position in ms) or `"seek"` (int64, relative offset in ms, converted
to an absolute position from the tracked playback state, since phones only
implement absolute positioning). `action` may be omitted for any of these.

**Response data:** none

Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/ipc_routes_mpris.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func registerMprisRoutes(handler *ipc.Handler, devices *device.Registry, plugins
return ipc.Response{OK: false, Error: "no player known for this device; specify --player"}
}

if err := mprisPl.SendAction(dev, player, p.Action, p.Seek, p.Volume); err != nil {
if err := mprisPl.SendAction(dev, player, p.Action, p.Seek, p.SetPosition, p.Volume); err != nil {
return ipc.Response{OK: false, Error: err.Error()}
}
return ipc.Response{OK: true}
Expand Down
4 changes: 3 additions & 1 deletion internal/ipc/proto.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ type MprisActionPayload struct {
Player string `json:"player,omitempty"`
Action string `json:"action,omitempty"`
Volume *int `json:"volume,omitempty"`
Seek *int64 `json:"seek,omitempty"`
// Seek: relative ms. SetPosition: absolute ms; wins over Seek.
Seek *int64 `json:"seek,omitempty"`
SetPosition *int64 `json:"setPosition,omitempty"`
}

type MprisRemotePlayer struct {
Expand Down
70 changes: 70 additions & 0 deletions internal/plugins/mpris/action_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package mpris

import (
"encoding/json"
"testing"

"github.com/bethropolis/kcd/internal/config"
"github.com/bethropolis/kcd/internal/events"
"github.com/bethropolis/kcd/internal/log"
"github.com/bethropolis/kcd/internal/protocol"
)

func TestCanonicalAction(t *testing.T) {
for in, want := range map[string]string{
"playpause": "PlayPause",
"PlayPause": "PlayPause",
"PLAY": "Play",
"pause": "Pause",
"next": "Next",
"previous": "Previous",
"stop": "Stop",
"raise": "raise", // unknown: unchanged
"": "",
} {
if got := CanonicalAction(in); got != want {
t.Errorf("CanonicalAction(%q) = %q, want %q", in, got, want)
}
}
}

type captureSender struct {
testSender
sent []*protocol.Packet
}

func (s *captureSender) Send(p *protocol.Packet) error { s.sent = append(s.sent, p); return nil }

func ptr[T any](v T) *T { return &v }

// A relative seek is sent as SetPosition.
func TestSendActionSendsSeekAsSetPosition(t *testing.T) {
p := NewMPRISPlugin(nil, events.NewBus(log.Nop()), false, config.MPRISConfig{}, log.Nop())
p.remoteStates["phone"] = &NowPlaying{Player: "music", Pos: 60_000, Length: 200_000}

for _, tc := range []struct {
name string
seek, setPos *int64
wantPos int64
}{
{"forward", ptr[int64](30_000), nil, 90_000},
{"clamped at start", ptr[int64](-90_000), nil, 0},
{"clamped at end", ptr[int64](500_000), nil, 200_000},
{"absolute wins", ptr[int64](30_000), ptr[int64](5_000), 5_000},
} {
dev := &captureSender{testSender: testSender{id: "phone"}}
if err := p.SendAction(dev, "music", "", tc.seek, tc.setPos, nil); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
var body map[string]any
if err := json.Unmarshal(dev.sent[0].Body, &body); err != nil {
t.Fatal(err)
}
if _, ok := body["Seek"]; ok {
t.Errorf("%s: Seek sent alongside SetPosition: %v", tc.name, body)
}
if got, _ := body["SetPosition"].(float64); int64(got) != tc.wantPos {
t.Errorf("%s: SetPosition = %v, want %d", tc.name, body["SetPosition"], tc.wantPos)
}
}
}
43 changes: 38 additions & 5 deletions internal/plugins/mpris/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,45 @@ import (
// SendAction sends a media control action to a remote device.
// Sends on both kdeconnect.mpris (for Android's old MprisPlugin) and
// kdeconnect.mpris.request (for MprisReceiverPlugin) to maximise compatibility.
func (p *MPRISPlugin) SendAction(dev device.Sender, player, action string, seek *int64, volume *int) error {
// canonicalActions maps lowercase action names to the phone's spelling.
var canonicalActions = map[string]string{
"play": "Play",
"pause": "Pause",
"playpause": "PlayPause",
"next": "Next",
"previous": "Previous",
"stop": "Stop",
}

// CanonicalAction returns the phone's spelling of action, or action if unknown.
func CanonicalAction(action string) string {
if c, ok := canonicalActions[strings.ToLower(action)]; ok {
return c
}
return action
}

// SendAction sends a control request to a remote player. A relative seek is
// sent as SetPosition computed from the tracked position.
func (p *MPRISPlugin) SendAction(dev device.Sender, player, action string, seek, setPosition *int64, volume *int) error {
if setPosition == nil && seek != nil {
if state := p.RemoteState(dev.ID()); state != nil {
pos := max(state.Pos+*seek, 0)
if state.Length > 0 {
pos = min(pos, state.Length)
}
setPosition, seek = &pos, nil
}
}
if setPosition != nil {
seek = nil // send only one
}
body := MPRISRequest{
Player: player,
Action: action,
SetVolume: volume,
Seek: seek,
Player: player,
Action: CanonicalAction(action),
SetVolume: volume,
Seek: seek,
SetPosition: setPosition,
}
pkt, err := protocol.NewPacket("kdeconnect.mpris.request", body)
if err != nil {
Expand Down
Loading