From ed1fa3718d950987fd91743647281f45518fc3ae Mon Sep 17 00:00:00 2001 From: Rishabh672003 <53911515+Rishabh672003@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:23:47 +0530 Subject: [PATCH 1/3] fix(mpris): send action names in the spelling the phone expects The docs list lowercase actions ("playpause", "next", ...) but the daemon forwarded them verbatim and KDE Connect Android matches only Play, Pause, PlayPause, Next, Previous and Stop, so documented requests returned ok and the phone ignored them. Normalise known names case-insensitively; anything else passes through unchanged. AI assisted --- internal/plugins/mpris/action_test.go | 21 +++++++++++++++++++++ internal/plugins/mpris/remote.go | 20 +++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/mpris/action_test.go diff --git a/internal/plugins/mpris/action_test.go b/internal/plugins/mpris/action_test.go new file mode 100644 index 0000000..e4f11b2 --- /dev/null +++ b/internal/plugins/mpris/action_test.go @@ -0,0 +1,21 @@ +package mpris + +import "testing" + +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) + } + } +} diff --git a/internal/plugins/mpris/remote.go b/internal/plugins/mpris/remote.go index ff1d207..e0f78a1 100644 --- a/internal/plugins/mpris/remote.go +++ b/internal/plugins/mpris/remote.go @@ -14,10 +14,28 @@ 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. +// 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 +} + func (p *MPRISPlugin) SendAction(dev device.Sender, player, action string, seek *int64, volume *int) error { body := MPRISRequest{ Player: player, - Action: action, + Action: CanonicalAction(action), SetVolume: volume, Seek: seek, } From fba1ef26437ba774f2d78c937c3344e81a3a4eb2 Mon Sep 17 00:00:00 2001 From: Rishabh672003 <53911515+Rishabh672003@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:37 +0530 Subject: [PATCH 2/3] fix(mpris): seek phones with SetPosition KDE Connect Android's MprisReceiverPlugin implements SetPosition but never reads Seek, so seeking a phone (mpris_action "seek", and kcd mpris seek) did nothing. A relative seek is now sent as an absolute SetPosition computed from the tracked position, and mpris_action now honours the documented setPosition field for absolute seeks. Only one of the two is ever sent, since a desktop peer would apply both. AI assisted --- internal/daemon/ipc_routes_mpris.go | 2 +- internal/ipc/proto.go | 4 ++- internal/plugins/mpris/action_test.go | 51 ++++++++++++++++++++++++++- internal/plugins/mpris/remote.go | 25 ++++++++++--- 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal/daemon/ipc_routes_mpris.go b/internal/daemon/ipc_routes_mpris.go index eb05697..00a4342 100644 --- a/internal/daemon/ipc_routes_mpris.go +++ b/internal/daemon/ipc_routes_mpris.go @@ -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} diff --git a/internal/ipc/proto.go b/internal/ipc/proto.go index 8faf382..d23843c 100644 --- a/internal/ipc/proto.go +++ b/internal/ipc/proto.go @@ -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 { diff --git a/internal/plugins/mpris/action_test.go b/internal/plugins/mpris/action_test.go index e4f11b2..17fae93 100644 --- a/internal/plugins/mpris/action_test.go +++ b/internal/plugins/mpris/action_test.go @@ -1,6 +1,14 @@ package mpris -import "testing" +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{ @@ -19,3 +27,44 @@ func TestCanonicalAction(t *testing.T) { } } } + +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) + } + } +} diff --git a/internal/plugins/mpris/remote.go b/internal/plugins/mpris/remote.go index e0f78a1..d10d82e 100644 --- a/internal/plugins/mpris/remote.go +++ b/internal/plugins/mpris/remote.go @@ -32,12 +32,27 @@ func CanonicalAction(action string) string { return action } -func (p *MPRISPlugin) SendAction(dev device.Sender, player, action string, seek *int64, volume *int) error { +// 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: CanonicalAction(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 { From 8d508e02db944e77cd996b0e7974e78f36cced4d Mon Sep 17 00:00:00 2001 From: Rishabh672003 <53911515+Rishabh672003@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:51:37 +0530 Subject: [PATCH 3/3] docs(mpris): document action-name casing, the volume field and seeking Actions are matched case-insensitively, volume uses the volume field (there is no setVolume in the IPC payload), and a relative seek is sent to phones as an absolute position. AI assisted --- docs/IPC_PROTOCOL.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/IPC_PROTOCOL.md b/docs/IPC_PROTOCOL.md index 9527d28..5b61668 100644 --- a/docs/IPC_PROTOCOL.md +++ b/docs/IPC_PROTOCOL.md @@ -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