Skip to content

Commit 9a78b3e

Browse files
feat: support media controller on mac (#172)
* feat: support media controller on mac * fix(notifications): publish current playback state when media notifier attaches * fix(darwin): lock startup to the Cocoa main thread * refactor: cleanup and simplify * missed cleanup
1 parent a761b08 commit 9a78b3e

32 files changed

Lines changed: 1756 additions & 782 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Or without Make: `go build -o cliamp .`
122122
- [SSH Streaming](docs/ssh-streaming.md)
123123
- [Remote Control (IPC)](docs/remote-control.md)
124124
- [Audio Quality](docs/audio-quality.md)
125-
- [MPRIS](docs/mpris.md)
125+
- [Media Controls](docs/mediactl.md)
126126
- [Lua Plugins](docs/plugins.md)
127127
- [Community Plugins](docs/community-plugins.md)
128128
- [Soap Bubbles Visualizer](https://github.com/bjarneo/cliamp-plugin-soap-bubbles)

docs/mediactl.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Media Controls
2+
3+
Cliamp integrates with the operating system's media control infrastructure so that desktop environments, hardware media keys, and command line tools can control playback, read track metadata, and adjust volume without touching the TUI.
4+
5+
## Platform Support
6+
7+
| Platform | Backend | Requirements |
8+
|---|---|---|
9+
| Linux | [MPRIS2](https://specifications.freedesktop.org/mpris-spec/latest/) over D-Bus | A running D-Bus session bus (provided by most desktop environments and Wayland compositors) |
10+
| macOS | MPNowPlayingInfoCenter / MPRemoteCommandCenter | None (frameworks are built-in) |
11+
| Other | No-op stub ||
12+
13+
## Linux (MPRIS2)
14+
15+
### Bus Name
16+
17+
Cliamp registers itself as:
18+
19+
```
20+
org.mpris.MediaPlayer2.cliamp
21+
```
22+
23+
Only one instance can hold this name at a time. If a second Cliamp process tries to start, the MPRIS registration will fail silently and that instance will run without D-Bus integration.
24+
25+
### Playback Control
26+
27+
All standard transport commands are supported through the `org.mpris.MediaPlayer2.Player` interface:
28+
29+
| playerctl command | Effect |
30+
|---|---|
31+
| `playerctl play-pause` | Toggle play / pause |
32+
| `playerctl play` | Resume playback |
33+
| `playerctl pause` | Pause playback |
34+
| `playerctl stop` | Stop playback |
35+
| `playerctl next` | Skip to the next track |
36+
| `playerctl previous` | Go to the previous track (or restart if more than 3 seconds in) |
37+
38+
### Seeking
39+
40+
Relative and absolute seeking are both supported:
41+
42+
```sh
43+
playerctl position 30 # seek to 30 seconds
44+
playerctl position 5+ # seek forward 5 seconds
45+
playerctl position 5- # seek backward 5 seconds
46+
```
47+
48+
Desktop widgets that display a progress bar will receive `Seeked` signals and stay in sync.
49+
50+
### Volume
51+
52+
Volume is exposed as a linear value between 0.0 and 1.0. Internally Cliamp uses a decibel scale (from -30 dB to +6 dB), and the conversion happens automatically.
53+
54+
```sh
55+
playerctl volume # print current volume (0.0 to 1.0)
56+
playerctl volume 0.5 # set volume to 50%
57+
```
58+
59+
Setting volume through `playerctl` updates the player immediately. Changing volume with the `+` and `-` keys in the TUI is reflected back to D-Bus clients on the next tick.
60+
61+
### Metadata
62+
63+
Track metadata is published under the standard MPRIS keys:
64+
65+
| Key | Description |
66+
|---|---|
67+
| `mpris:trackid` | D-Bus object path identifying the current track |
68+
| `xesam:title` | Track title |
69+
| `xesam:artist` | Artist name (as a list with one entry) |
70+
| `xesam:album` | Album name, when available |
71+
| `xesam:url` | File path or stream URL |
72+
| `mpris:length` | Duration in microseconds |
73+
74+
Query metadata with:
75+
76+
```sh
77+
playerctl metadata # all keys
78+
playerctl metadata artist # just the artist
79+
playerctl metadata title # just the title
80+
```
81+
82+
For live radio streams that provide ICY metadata, the artist and title fields update dynamically as the station reports new track information.
83+
84+
### Status
85+
86+
```sh
87+
playerctl status # prints Playing, Paused, or Stopped
88+
```
89+
90+
## macOS
91+
92+
On macOS, Cliamp publishes now-playing information to the system's MPNowPlayingInfoCenter. This enables:
93+
94+
- Control Centre and Lock Screen media controls
95+
- Touch Bar playback buttons
96+
- Hardware media keys (play/pause, next, previous)
97+
- Bluetooth headphone buttons
98+
99+
The macOS implementation requires the media-control runtime to pin the main goroutine to thread 0 (via `runtime.LockOSThread`) so that the Cocoa run loop can pump events. Bubbletea runs on a background goroutine instead.
100+
101+
## Architecture
102+
103+
The app-owned playback command and notifier boundary lives in `internal/playback`. The `mediactl` package translates platform APIs to and from that boundary and owns the platform-specific interactive runtime helper.
104+
105+
Platform-specific `Service` implementations:
106+
107+
- `internal/playback/*` — app-level playback commands and outbound notifier state.
108+
- `mediactl/service_linux.go` — connects to the session bus, claims the MPRIS bus name, translates D-Bus calls into playback commands, and publishes outbound state through MPRIS properties.
109+
- `mediactl/service_darwin.go` — initialises NSApplication as an accessory process, registers MPRemoteCommandCenter handlers, translates them into playback commands, and publishes now-playing state on the main-thread run loop.
110+
- `mediactl/service_stub.go` — no-op implementation for unsupported platforms.
111+
112+
The model publishes playback state through the playback notifier whenever state changes. On Linux, `mediactl` uses `SetMust` rather than `Set` to bypass the property library's writable checks and callback triggers, which are intended for external D-Bus writes. For writable properties like Volume, the D-Bus callback is translated into an app playback command and dispatched back into the Bubbletea event loop.
113+
114+
## Limitations
115+
116+
Shuffle and loop status are not exposed. The `z` and `r` keys in the TUI control shuffle and repeat locally, but these states are not visible to or controllable from external tools.
117+
118+
The `HasTrackList` property is set to false on Linux. Cliamp does not implement the optional `org.mpris.MediaPlayer2.TrackList` interface.

docs/mpris.md

Lines changed: 0 additions & 103 deletions
This file was deleted.

internal/playback/playback.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package playback
2+
3+
import "time"
4+
5+
type (
6+
PlayPauseMsg struct{}
7+
PlayMsg struct{}
8+
PauseMsg struct{}
9+
NextMsg struct{}
10+
PrevMsg struct{}
11+
StopMsg struct{}
12+
QuitMsg struct{}
13+
SeekMsg struct{ Offset time.Duration }
14+
SetPositionMsg struct {
15+
Position time.Duration
16+
}
17+
SetVolumeMsg struct{ VolumeDB float64 }
18+
)
19+
20+
type Status string
21+
22+
const (
23+
StatusStopped Status = "Stopped"
24+
StatusPlaying Status = "Playing"
25+
StatusPaused Status = "Paused"
26+
)
27+
28+
type Track struct {
29+
Title string
30+
Artist string
31+
Album string
32+
Genre string
33+
TrackNumber int
34+
URL string
35+
Duration time.Duration
36+
}
37+
38+
type State struct {
39+
Status Status
40+
Track Track
41+
VolumeDB float64
42+
Position time.Duration
43+
Seekable bool
44+
}
45+
46+
type Notifier interface {
47+
Update(State)
48+
Seeked(time.Duration)
49+
}

ipc/protocol.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// The protocol is newline-delimited JSON over a Unix domain socket.
33
package ipc
44

5+
import "time"
6+
57
// Compile-time interface check.
68
var _ Dispatcher = DispatcherFunc(nil)
79

@@ -50,7 +52,7 @@ type DispatcherFunc func(msg interface{})
5052
func (f DispatcherFunc) Send(msg interface{}) { f(msg) }
5153

5254
// IPC-specific messages sent to the TUI via prog.Send().
53-
// For shared types (NextMsg, PrevMsg, StopMsg, ToggleMsg), see internal/control.
55+
// For shared types (NextMsg, PrevMsg, StopMsg, PlayPauseMsg), see internal/playback.
5456

5557
// PlayMsg requests playback to start (unpause only, not toggle).
5658
type PlayMsg struct{}
@@ -61,8 +63,8 @@ type PauseMsg struct{}
6163
// VolumeMsg requests a relative volume change in dB.
6264
type VolumeMsg struct{ DB float64 }
6365

64-
// SeekMsg requests a relative seek in seconds.
65-
type SeekMsg struct{ Secs float64 }
66+
// SeekMsg requests a relative seek.
67+
type SeekMsg struct{ Offset time.Duration }
6668

6769
// LoadMsg requests loading a playlist by name.
6870
// Reply receives the result so the client can report errors.

ipc/server.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"syscall"
1414
"time"
1515

16-
"cliamp/internal/control"
16+
"cliamp/internal/playback"
1717
)
1818

1919
// Dispatcher is how the server sends commands to the TUI.
@@ -144,27 +144,27 @@ func (s *Server) dispatch(req Request) Response {
144144
return Response{OK: true}
145145

146146
case "toggle":
147-
s.disp.Send(control.ToggleMsg{})
147+
s.disp.Send(playback.PlayPauseMsg{})
148148
return Response{OK: true}
149149

150150
case "stop":
151-
s.disp.Send(control.StopMsg{})
151+
s.disp.Send(playback.StopMsg{})
152152
return Response{OK: true}
153153

154154
case "next":
155-
s.disp.Send(control.NextMsg{})
155+
s.disp.Send(playback.NextMsg{})
156156
return Response{OK: true}
157157

158158
case "prev":
159-
s.disp.Send(control.PrevMsg{})
159+
s.disp.Send(playback.PrevMsg{})
160160
return Response{OK: true}
161161

162162
case "volume":
163163
s.disp.Send(VolumeMsg{DB: req.Value})
164164
return Response{OK: true}
165165

166166
case "seek":
167-
s.disp.Send(SeekMsg{Secs: req.Value})
167+
s.disp.Send(SeekMsg{Offset: time.Duration(req.Value * float64(time.Second))})
168168
return Response{OK: true}
169169

170170
case "load":

ipc/server_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package ipc
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestDispatchSeekSendsIPCSeekMsgWithDuration(t *testing.T) {
9+
var sent any
10+
s := &Server{
11+
disp: DispatcherFunc(func(msg interface{}) {
12+
sent = msg
13+
}),
14+
}
15+
16+
resp := s.dispatch(Request{Cmd: "seek", Value: 12.25})
17+
if !resp.OK {
18+
t.Fatalf("dispatch() response = %#v, want OK", resp)
19+
}
20+
21+
got, ok := sent.(SeekMsg)
22+
if !ok {
23+
t.Fatalf("dispatch() sent %T, want ipc.SeekMsg", sent)
24+
}
25+
want := SeekMsg{Offset: 12250 * time.Millisecond}
26+
if got != want {
27+
t.Fatalf("dispatch() sent %#v, want %#v", got, want)
28+
}
29+
}

0 commit comments

Comments
 (0)