Skip to content

Commit 956fb3d

Browse files
committed
feat: add network buffer settings
1 parent 2df9f84 commit 956fb3d

13 files changed

Lines changed: 376 additions & 9 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,27 @@ Quit with `q` or `ctrl+c`.
147147
| `s` | share selected station as a YAML snippet |
148148
| `p` | import station snippet from clipboard |
149149
| `x` | open ambient mixer (modal) |
150+
| `o` | open settings (network buffer) |
150151
| `i` | toggle stream-info row |
151152
| `?` | toggle full help card |
152153
| `q` / `ctrl+c` | quit |
153154

154155
The macOS hardware Play/Pause key controls the station already loaded
155156
in mpv; if no station has been started yet, it is ignored.
156157

158+
### Settings (after `o`)
159+
160+
| key | action |
161+
| --- | --- |
162+
| `j` / `` · `k` / `` | select setting |
163+
| `h` / `` · `l` / `` | adjust by 5 seconds |
164+
| `0` | turn selected setting off |
165+
| `enter` | save |
166+
| `esc` | cancel |
167+
168+
Network-buffer changes are persisted for you; no manual config editing needed.
169+
Restart the app to apply them to the underlying mpv process.
170+
157171
### Ambient mixer (after `x`)
158172

159173
| key | action |
@@ -226,6 +240,8 @@ on first run with sensible defaults; a documented example sits at
226240
```yaml
227241
theme: tokyo-night # see Themes below for all built-in theme ids
228242
volume: 60 # initial volume, 0–100
243+
buffer_seconds: 30 # network read-ahead; try 60–120 on flaky Wi-Fi
244+
initial_buffer_seconds: 0 # wait before start/resume; try 5–10 if streams stutter
229245
230246
stations:
231247
- name: SomaFM Groove Salad

README.ru.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,27 @@ lofi-player
151151
| `s` | поделиться выбранной станцией YAML-сниппетом |
152152
| `p` | импортировать сниппет станции из clipboard |
153153
| `x` | открыть эмбиент-микшер (модалка) |
154+
| `o` | открыть настройки (сетевой буфер) |
154155
| `i` | переключить stream-info строку |
155156
| `?` | показать/скрыть полную справку |
156157
| `q` / `ctrl+c` | выход |
157158

158159
Аппаратная клавиша Play/Pause на macOS управляет станцией, уже
159160
загруженной в mpv; если станцию ещё не запускали, нажатие игнорируется.
160161

162+
### Настройки (после `o`)
163+
164+
| клавиша | действие |
165+
| --- | --- |
166+
| `j` / `` · `k` / `` | выбрать настройку |
167+
| `h` / `` · `l` / `` | изменить на 5 секунд |
168+
| `0` | выключить выбранную настройку |
169+
| `enter` | сохранить |
170+
| `esc` | отмена |
171+
172+
Настройки сетевого буфера сохраняются автоматически; руками редактировать
173+
конфиг не нужно. Перезапусти приложение, чтобы они применились к процессу mpv.
174+
161175
### Эмбиент-микшер (после `x`)
162176

163177
| клавиша | действие |
@@ -232,6 +246,8 @@ lofi-player --import - < stations.yaml
232246
```yaml
233247
theme: tokyo-night # все встроенные id см. в разделе «Темы»
234248
volume: 60 # стартовая громкость, 0–100
249+
buffer_seconds: 30 # сетевой запас; для плохого Wi-Fi попробуй 60–120
250+
initial_buffer_seconds: 0 # ждать перед стартом/возобновлением; попробуй 5–10
235251
236252
stations:
237253
- name: SomaFM Groove Salad

configs/lofi-player.example.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ theme: tokyo-night
1313
# Initial playback volume, 0–100.
1414
volume: 60
1515

16+
# Network buffering for unstable connections.
17+
# buffer_seconds asks mpv to keep this many seconds buffered ahead when the
18+
# stream/server permits it. Try 60–120 on flaky Wi-Fi; 0 leaves mpv defaults.
19+
buffer_seconds: 30
20+
# initial_buffer_seconds waits for this much cache before starting/resuming
21+
# after a cache stall. 5–10 helps bad networks but delays playback start.
22+
initial_buffer_seconds: 0
23+
1624
# Internet-radio stations. Each station needs a display name and a stream URL.
1725
# Share/import uses the same shape, so snippets copied from friends can be
1826
# pasted under this key or imported with `lofi-player --import stations.yaml`.

internal/audio/player.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ type Options struct {
103103
// InitialVolume is the volume (0..100) applied right after the IPC
104104
// handshake. Values outside the range are clamped.
105105
InitialVolume int
106+
// BufferSeconds asks mpv to keep this many seconds of network audio
107+
// buffered ahead when the stream/server permits it. 0 leaves mpv at
108+
// its default cache behavior.
109+
BufferSeconds int
110+
// InitialBufferSeconds makes mpv wait for this many seconds of cache
111+
// before starting/resuming after a cache stall. 0 starts immediately.
112+
InitialBufferSeconds int
106113
}
107114

108115
// Player owns an mpv subprocess and a JSON-IPC connection to it,
@@ -136,14 +143,15 @@ type Player struct {
136143

137144
const mainInputConf = "PAUSE cycle pause\n"
138145

139-
func mainMPVArgs(socketPath string) []string {
146+
func mainMPVArgs(socketPath string, opts Options) []string {
140147
args := []string{
141148
"--no-config",
142149
"--idle=yes",
143150
"--no-video",
144151
"--no-terminal",
145152
"--input-ipc-server=" + socketPath,
146153
}
154+
args = append(args, networkCacheArgs(opts)...)
147155
if runtime.GOOS == "darwin" {
148156
args = append(args,
149157
"--input-media-keys=yes",
@@ -153,6 +161,41 @@ func mainMPVArgs(socketPath string) []string {
153161
return args
154162
}
155163

164+
func networkCacheArgs(opts Options) []string {
165+
bufferSec := clampSeconds(opts.BufferSeconds, 0, 600)
166+
initialSec := clampSeconds(opts.InitialBufferSeconds, 0, 120)
167+
if initialSec > bufferSec {
168+
bufferSec = initialSec
169+
}
170+
if bufferSec == 0 {
171+
return nil
172+
}
173+
174+
args := []string{
175+
"--cache=yes",
176+
fmt.Sprintf("--demuxer-readahead-secs=%d", bufferSec),
177+
"--demuxer-max-bytes=64MiB",
178+
}
179+
if initialSec > 0 {
180+
args = append(args,
181+
"--cache-pause=yes",
182+
"--cache-pause-initial=yes",
183+
fmt.Sprintf("--cache-pause-wait=%d", initialSec),
184+
)
185+
}
186+
return args
187+
}
188+
189+
func clampSeconds(v, min, max int) int {
190+
if v < min {
191+
return min
192+
}
193+
if v > max {
194+
return max
195+
}
196+
return v
197+
}
198+
156199
func ambientMPVArgs(socketPath, filePath string) []string {
157200
args := []string{
158201
"--no-config",
@@ -216,7 +259,7 @@ func NewPlayer(ctx context.Context, opts Options) (*Player, error) {
216259
}
217260

218261
var stderr bytes.Buffer
219-
cmd := exec.Command(mpvPath, mainMPVArgs(socketPath)...)
262+
cmd := exec.Command(mpvPath, mainMPVArgs(socketPath, opts)...)
220263
cmd.Stderr = &stderr
221264
if err := cmd.Start(); err != nil {
222265
os.RemoveAll(socketDir)

internal/audio/player_test.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func TestClampVolume(t *testing.T) {
196196
}
197197

198198
func TestMPVArgsDisableUserConfig(t *testing.T) {
199-
mainArgs := mainMPVArgs("/tmp/lofi-player-test.sock")
199+
mainArgs := mainMPVArgs("/tmp/lofi-player-test.sock", Options{})
200200
if !hasArg(mainArgs, "--no-config") {
201201
t.Fatalf("main mpv args %v do not disable user config", mainArgs)
202202
}
@@ -219,11 +219,34 @@ func TestMPVArgsDisableUserConfig(t *testing.T) {
219219
}
220220
}
221221

222+
func TestMainMPVArgsConfigureNetworkBuffer(t *testing.T) {
223+
args := mainMPVArgs("/tmp/lofi-player-test.sock", Options{BufferSeconds: 120, InitialBufferSeconds: 10})
224+
for _, want := range []string{
225+
"--cache=yes",
226+
"--demuxer-readahead-secs=120",
227+
"--demuxer-max-bytes=64MiB",
228+
"--cache-pause=yes",
229+
"--cache-pause-initial=yes",
230+
"--cache-pause-wait=10",
231+
} {
232+
if !hasArg(args, want) {
233+
t.Fatalf("main mpv args %v missing %s", args, want)
234+
}
235+
}
236+
}
237+
238+
func TestMainMPVArgsInitialBufferExtendsReadahead(t *testing.T) {
239+
args := mainMPVArgs("/tmp/lofi-player-test.sock", Options{InitialBufferSeconds: 45})
240+
if !hasArg(args, "--demuxer-readahead-secs=45") {
241+
t.Fatalf("main mpv args %v did not extend readahead to initial buffer", args)
242+
}
243+
}
244+
222245
func TestDarwinMPVArgsWireMediaKeys(t *testing.T) {
223246
if runtime.GOOS != "darwin" {
224247
t.Skip("media-key args are macOS-only")
225248
}
226-
mainArgs := mainMPVArgs("/tmp/lofi-player-test.sock")
249+
mainArgs := mainMPVArgs("/tmp/lofi-player-test.sock", Options{})
227250
if !hasArg(mainArgs, "--input-media-keys=yes") {
228251
t.Fatalf("main mpv args %v do not enable media keys", mainArgs)
229252
}

internal/config/config.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ type Config struct {
3131
Theme string `yaml:"theme"`
3232
// Volume is the initial playback volume, 0–100.
3333
Volume int `yaml:"volume"`
34+
// BufferSeconds asks mpv to keep this many seconds of network audio
35+
// buffered ahead when the stream/server permits it. 0 disables the
36+
// explicit cache tuning and leaves mpv at its defaults.
37+
BufferSeconds int `yaml:"buffer_seconds"`
38+
// InitialBufferSeconds makes mpv wait for this many seconds of cache
39+
// before starting/resuming after a cache stall. 0 starts immediately.
40+
InitialBufferSeconds int `yaml:"initial_buffer_seconds"`
3441
// Stations is the user's list of internet-radio stations.
3542
Stations []Station `yaml:"stations"`
3643
}
@@ -76,8 +83,10 @@ func (s Station) IsYouTube() bool {
7683
// and was chosen for stability across networks and metadata correctness.
7784
func Defaults() Config {
7885
return Config{
79-
Theme: "tokyo-night",
80-
Volume: 60,
86+
Theme: "tokyo-night",
87+
Volume: 60,
88+
BufferSeconds: 30,
89+
InitialBufferSeconds: 0,
8190
Stations: []Station{
8291
{Name: "SomaFM Groove Salad", URL: "https://ice1.somafm.com/groovesalad-256-mp3"},
8392
{Name: "SomaFM Drone Zone", URL: "https://ice1.somafm.com/dronezone-256-mp3"},

internal/config/config_test.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,33 @@ func TestLoadFromFile_MissingFieldsKeepDefaults(t *testing.T) {
164164
if cfg.Volume != defaults.Volume {
165165
t.Errorf("Volume = %d, want default %d", cfg.Volume, defaults.Volume)
166166
}
167+
if cfg.BufferSeconds != defaults.BufferSeconds {
168+
t.Errorf("BufferSeconds = %d, want default %d", cfg.BufferSeconds, defaults.BufferSeconds)
169+
}
170+
if cfg.InitialBufferSeconds != defaults.InitialBufferSeconds {
171+
t.Errorf("InitialBufferSeconds = %d, want default %d", cfg.InitialBufferSeconds, defaults.InitialBufferSeconds)
172+
}
167173
if len(cfg.Stations) != 1 || cfg.Stations[0].Name != "Only" {
168174
t.Errorf("Stations = %+v, want [{Only http://x}]", cfg.Stations)
169175
}
170176
}
171177

178+
func TestLoadFromFile_ExplicitZeroBufferDisablesTuning(t *testing.T) {
179+
dir := t.TempDir()
180+
path := filepath.Join(dir, "config.yaml")
181+
body := []byte("theme: tokyo-night\nvolume: 60\nbuffer_seconds: 0\ninitial_buffer_seconds: 0\nstations: []\n")
182+
if err := os.WriteFile(path, body, 0o644); err != nil {
183+
t.Fatalf("seed file: %v", err)
184+
}
185+
cfg, err := loadFromFile(path)
186+
if err != nil {
187+
t.Fatalf("loadFromFile: %v", err)
188+
}
189+
if cfg.BufferSeconds != 0 || cfg.InitialBufferSeconds != 0 {
190+
t.Fatalf("buffer settings = %d/%d, want explicit zeroes", cfg.BufferSeconds, cfg.InitialBufferSeconds)
191+
}
192+
}
193+
172194
func TestLoadFromFile_InvalidYAMLReturnsError(t *testing.T) {
173195
dir := t.TempDir()
174196
path := filepath.Join(dir, "config.yaml")
@@ -214,6 +236,12 @@ func TestDefaultsAreNonEmpty(t *testing.T) {
214236
if d.Volume < 0 || d.Volume > 100 {
215237
t.Errorf("Defaults().Volume = %d, want 0..100", d.Volume)
216238
}
239+
if d.BufferSeconds <= 0 {
240+
t.Errorf("Defaults().BufferSeconds = %d, want positive", d.BufferSeconds)
241+
}
242+
if d.InitialBufferSeconds < 0 {
243+
t.Errorf("Defaults().InitialBufferSeconds = %d, want non-negative", d.InitialBufferSeconds)
244+
}
217245
if len(d.Stations) == 0 {
218246
t.Error("Defaults().Stations is empty")
219247
}
@@ -295,4 +323,3 @@ stations:
295323
t.Errorf("station IsYouTube() = false, want true")
296324
}
297325
}
298-

internal/tui/keys.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type KeyMap struct {
2121
ShareStation key.Binding
2222
ImportStation key.Binding
2323
MixerOpen key.Binding
24+
SettingsOpen key.Binding
2425
StreamInfo key.Binding
2526
Help key.Binding
2627
Quit key.Binding
@@ -83,6 +84,10 @@ func DefaultKeyMap() KeyMap {
8384
key.WithKeys("x"),
8485
key.WithHelp("x", "mixer"),
8586
),
87+
SettingsOpen: key.NewBinding(
88+
key.WithKeys("o"),
89+
key.WithHelp("o", "settings"),
90+
),
8691
StreamInfo: key.NewBinding(
8792
key.WithKeys("i"),
8893
key.WithHelp("i", "info"),
@@ -103,6 +108,6 @@ func (k KeyMap) FullHelp() [][]key.Binding {
103108
return [][]key.Binding{
104109
{k.Up, k.Down, k.AddStation, k.EditStation, k.DeleteStation, k.ShareStation, k.ImportStation, k.MixerOpen},
105110
{k.PlayPause, k.VolUp, k.VolDown},
106-
{k.ThemeCycle, k.Mini, k.StreamInfo, k.Help, k.Quit},
111+
{k.ThemeCycle, k.SettingsOpen, k.Mini, k.StreamInfo, k.Help, k.Quit},
107112
}
108113
}

internal/tui/model.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const (
6565
modeShareStation
6666
modeImportStations
6767
modeThemePicker
68+
modeSettings
6869
)
6970

7071
// Model is the root Bubble Tea model.
@@ -140,6 +141,12 @@ type Model struct {
140141
themeCursor int
141142
themeBeforePicker string
142143

144+
// settingsCursor highlights the active row in the settings modal.
145+
// Draft values are kept separate from cfg until Enter persists them.
146+
settingsCursor int
147+
settingsBufferSeconds int
148+
settingsInitialBufferSeconds int
149+
143150
mixer *audio.AmbientMixer
144151
mixerUI mixerModel
145152

internal/tui/model_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,44 @@ func TestThemePickerViewAndTopChip(t *testing.T) {
233233
}
234234
}
235235

236+
func TestSettingsModalAdjustSaveAndCancel(t *testing.T) {
237+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
238+
cfg := config.Defaults()
239+
cfg.Stations = []config.Station{{Name: "A", URL: "http://a"}}
240+
m := NewModel(&cfg, nil, audio.NewAmbientMixer(), Options{AutoplayStation: -1})
241+
updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
242+
m = updated.(Model)
243+
244+
m = send(t, m, "o")
245+
if m.mode != modeSettings {
246+
t.Fatalf("mode after o: got %v, want modeSettings", m.mode)
247+
}
248+
out := m.View()
249+
for _, want := range []string{"settings", "network buffer", "initial buffer"} {
250+
if !strings.Contains(out, want) {
251+
t.Fatalf("settings view missing %q; got:\n%s", want, out)
252+
}
253+
}
254+
255+
m = send(t, m, "l", "j", "l", "l") // buffer 30→35, initial 0→10
256+
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
257+
m = updated.(Model)
258+
if cmd == nil {
259+
t.Fatal("saving settings produced no toast command")
260+
}
261+
if m.mode != modeFull {
262+
t.Fatalf("mode after saving settings: got %v, want modeFull", m.mode)
263+
}
264+
if cfg.BufferSeconds != 35 || cfg.InitialBufferSeconds != 10 {
265+
t.Fatalf("saved buffer settings = %d/%d, want 35/10", cfg.BufferSeconds, cfg.InitialBufferSeconds)
266+
}
267+
268+
m = send(t, m, "o", "0", "esc")
269+
if cfg.BufferSeconds != 35 || cfg.InitialBufferSeconds != 10 {
270+
t.Fatalf("cancel changed config to %d/%d", cfg.BufferSeconds, cfg.InitialBufferSeconds)
271+
}
272+
}
273+
236274
func TestUpdate_QuitReturnsTeaQuit(t *testing.T) {
237275
m := fixture()
238276
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})

0 commit comments

Comments
 (0)