Skip to content

Commit 73dce8a

Browse files
committed
audio: support macOS play pause media key
1 parent c76ce91 commit 73dce8a

6 files changed

Lines changed: 106 additions & 3 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ direct HTTP streams, and YouTube live/videos via `yt-dlp`).
3737
- **Four themes** — Tokyo Night, Catppuccin Mocha, Gruvbox Dark, Rose
3838
Pine. Cycle with `t`.
3939
- **Mini mode** — collapses to ~6 lines for a tmux pane (`m`).
40+
- **macOS media key** — the hardware Play/Pause key toggles the
41+
current station while `lofi-player` is running.
4042
- **Tmux statusline**`lofi-player --statusline` prints one colored
4143
line for `status-right`.
4244
- **State persistence** — last station, volume, theme, ambient levels
@@ -134,6 +136,7 @@ Quit with `q` or `ctrl+c`.
134136
| `j` / `` | move cursor down |
135137
| `k` / `` | move cursor up |
136138
| `space` | play / pause selected station |
139+
| macOS `Play/Pause` | pause / resume the current station |
137140
| `+` / `=` | volume up (5%) |
138141
| `-` / `_` | volume down (5%) |
139142
| `t` | cycle theme |
@@ -146,6 +149,9 @@ Quit with `q` or `ctrl+c`.
146149
| `?` | toggle full help card |
147150
| `q` / `ctrl+c` | quit |
148151

152+
The macOS hardware Play/Pause key controls the station already loaded
153+
in mpv; if no station has been started yet, it is ignored.
154+
149155
### Ambient mixer (after `x`)
150156

151157
| key | action |

README.ru.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ TUI-плеер для лофи, чиллхопа и эмбиент-радио
3939
Rose Pine. Перебор по `t`.
4040
- **Mini-режим** — сворачивает UI до ~6 строк, чтобы влезть в
4141
tmux-панель (`m`).
42+
- **Media key на macOS** — аппаратная клавиша Play/Pause переключает
43+
паузу текущей станции, пока `lofi-player` запущен.
4244
- **Tmux statusline**`lofi-player --statusline` печатает одну
4345
цветную строку для `status-right`.
4446
- **Сохранение состояния** — последняя станция, громкость, тема,
@@ -138,6 +140,7 @@ lofi-player
138140
| `j` / `` | курсор вниз |
139141
| `k` / `` | курсор вверх |
140142
| `space` | play / pause выбранной станции |
143+
| macOS `Play/Pause` | пауза / продолжение текущей станции |
141144
| `+` / `=` | громкость +5% |
142145
| `-` / `_` | громкость -5% |
143146
| `t` | следующая тема |
@@ -150,6 +153,9 @@ lofi-player
150153
| `?` | показать/скрыть полную справку |
151154
| `q` / `ctrl+c` | выход |
152155

156+
Аппаратная клавиша Play/Pause на macOS управляет станцией, уже
157+
загруженной в mpv; если станцию ещё не запускали, нажатие игнорируется.
158+
153159
### Эмбиент-микшер (после `x`)
154160

155161
| клавиша | действие |

internal/audio/player.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"runtime"
1213
"strings"
1314
"sync"
1415
"time"
@@ -124,18 +125,27 @@ type Player struct {
124125
lastCacheSec float64
125126
}
126127

128+
const mainInputConf = "PAUSE cycle pause\n"
129+
127130
func mainMPVArgs(socketPath string) []string {
128-
return []string{
131+
args := []string{
129132
"--no-config",
130133
"--idle=yes",
131134
"--no-video",
132135
"--no-terminal",
133136
"--input-ipc-server=" + socketPath,
134137
}
138+
if runtime.GOOS == "darwin" {
139+
args = append(args,
140+
"--input-media-keys=yes",
141+
"--input-conf="+mainInputConfPath(socketPath),
142+
)
143+
}
144+
return args
135145
}
136146

137147
func ambientMPVArgs(socketPath, filePath string) []string {
138-
return []string{
148+
args := []string{
139149
"--no-config",
140150
"--idle=no",
141151
"--no-video",
@@ -144,8 +154,27 @@ func ambientMPVArgs(socketPath, filePath string) []string {
144154
"--volume=0",
145155
"--pause=yes",
146156
"--input-ipc-server=" + socketPath,
147-
filePath,
148157
}
158+
if runtime.GOOS == "darwin" {
159+
args = append(args, "--input-media-keys=no")
160+
}
161+
args = append(args, filePath)
162+
return args
163+
}
164+
165+
func mainInputConfPath(socketPath string) string {
166+
return filepath.Join(filepath.Dir(socketPath), "input.conf")
167+
}
168+
169+
func writeMainInputConf(socketPath string) error {
170+
if runtime.GOOS != "darwin" {
171+
return nil
172+
}
173+
path := mainInputConfPath(socketPath)
174+
if err := os.WriteFile(path, []byte(mainInputConf), 0o644); err != nil {
175+
return fmt.Errorf("write mpv input.conf: %w", err)
176+
}
177+
return nil
149178
}
150179

151180
// NewPlayer spawns mpv in idle mode and establishes a JSON-IPC connection
@@ -172,6 +201,10 @@ func NewPlayer(ctx context.Context, opts Options) (*Player, error) {
172201
}
173202
}
174203
socketPath := filepath.Join(socketDir, "mpv.sock")
204+
if err := writeMainInputConf(socketPath); err != nil {
205+
os.RemoveAll(socketDir)
206+
return nil, err
207+
}
175208

176209
var stderr bytes.Buffer
177210
cmd := exec.Command(mpvPath, mainMPVArgs(socketPath)...)

internal/audio/player_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7+
"os"
78
"os/exec"
9+
"path/filepath"
10+
"runtime"
811
"strings"
912
"testing"
1013
"time"
@@ -200,6 +203,41 @@ func TestMPVArgsDisableUserConfig(t *testing.T) {
200203
}
201204
}
202205

206+
func TestDarwinMPVArgsWireMediaKeys(t *testing.T) {
207+
if runtime.GOOS != "darwin" {
208+
t.Skip("media-key args are macOS-only")
209+
}
210+
mainArgs := mainMPVArgs("/tmp/lofi-player-test.sock")
211+
if !hasArg(mainArgs, "--input-media-keys=yes") {
212+
t.Fatalf("main mpv args %v do not enable media keys", mainArgs)
213+
}
214+
if !hasArg(mainArgs, "--input-conf=/tmp/input.conf") {
215+
t.Fatalf("main mpv args %v do not include media-key input.conf", mainArgs)
216+
}
217+
218+
ambientArgs := ambientMPVArgs("/tmp/lofi-ambient-test.sock", "/tmp/rain.opus")
219+
if !hasArg(ambientArgs, "--input-media-keys=no") {
220+
t.Fatalf("ambient mpv args %v do not disable media keys", ambientArgs)
221+
}
222+
}
223+
224+
func TestWriteMainInputConf(t *testing.T) {
225+
if runtime.GOOS != "darwin" {
226+
t.Skip("media-key input.conf is macOS-only")
227+
}
228+
socketPath := filepath.Join(t.TempDir(), "mpv.sock")
229+
if err := writeMainInputConf(socketPath); err != nil {
230+
t.Fatalf("writeMainInputConf: %v", err)
231+
}
232+
data, err := os.ReadFile(mainInputConfPath(socketPath))
233+
if err != nil {
234+
t.Fatalf("read input.conf: %v", err)
235+
}
236+
if got := string(data); got != mainInputConf {
237+
t.Fatalf("input.conf = %q, want %q", got, mainInputConf)
238+
}
239+
}
240+
203241
func TestFormatMPVStartupErrorIncludesDiagnostics(t *testing.T) {
204242
err := formatMPVStartupError(context.DeadlineExceeded, "fatal: bad option\n", []string{"mpv", "--no-config", "--input-ipc-server=/tmp/lofi.sock"})
205243
if !errors.Is(err, context.DeadlineExceeded) {

internal/tui/model_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,18 @@ func TestUpdate_QuitReturnsTeaQuit(t *testing.T) {
187187
}
188188
}
189189

190+
func TestPlaybackStartedWhileIdleDoesNotMarkPlaying(t *testing.T) {
191+
m := fixture()
192+
if m.playingIdx != -1 {
193+
t.Fatalf("fixture playingIdx = %d, want -1", m.playingIdx)
194+
}
195+
updated, _ := m.Update(PlaybackStartedMsg{})
196+
m = updated.(Model)
197+
if m.playing || m.loading || !m.playStartedAt.IsZero() {
198+
t.Fatalf("idle PlaybackStarted mutated playback state: playing=%v loading=%v started=%v", m.playing, m.loading, m.playStartedAt)
199+
}
200+
}
201+
190202
func TestView_ShowsPlayingMarker(t *testing.T) {
191203
m := fixture()
192204
m = send(t, m, "space") // dispatch play; model goes into loading state

internal/tui/update.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7373
return m, waitForEvent(m.player)
7474

7575
case PlaybackStartedMsg:
76+
// mpv can emit pause=false while idle (for example if a hardware
77+
// media key is pressed before any station has been loaded). Do not
78+
// let that phantom event make the TUI claim something is playing.
79+
if m.playingIdx < 0 || m.playingIdx >= len(m.cfg.Stations) {
80+
m.playing = false
81+
m.loading = false
82+
return m, waitForEvent(m.player)
83+
}
7684
m.playing = true
7785
m.loading = false
7886
// First start after a fresh Play call: anchor the uptime

0 commit comments

Comments
 (0)