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 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 new file mode 100644 index 0000000..17fae93 --- /dev/null +++ b/internal/plugins/mpris/action_test.go @@ -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) + } + } +} diff --git a/internal/plugins/mpris/remote.go b/internal/plugins/mpris/remote.go index ff1d207..d10d82e 100644 --- a/internal/plugins/mpris/remote.go +++ b/internal/plugins/mpris/remote.go @@ -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 {