feat(ui): add vertical volume bar to playback view
Some checks failed
gobuild / build (push) Failing after 7s

Add Volume() to Player interface and mpvPlayer implementation to
expose current volume level. Poll volume periodically and render a
vertical bar next to the metadata display during playback. Update
UI state and tests to support the new volume indicator.
This commit is contained in:
Greg Gauthier 2026-06-05 23:18:30 +01:00
parent 2b688569c7
commit dd5121c2ae
5 changed files with 156 additions and 40 deletions

Binary file not shown.

View File

@ -37,6 +37,9 @@ type Player interface {
// Volume controls (relative, best effort; mpv uses "add volume ±5" etc.) // Volume controls (relative, best effort; mpv uses "add volume ±5" etc.)
VolumeUp() error VolumeUp() error
VolumeDown() error VolumeDown() error
// Volume returns current volume level (0-100).
Volume() int
} }
// IsInstalled reports whether the named executable is on PATH. // IsInstalled reports whether the named executable is on PATH.
@ -102,6 +105,7 @@ func (p *legacyPlayer) Next() error { return nil }
func (p *legacyPlayer) Prev() error { return nil } func (p *legacyPlayer) Prev() error { return nil }
func (p *legacyPlayer) VolumeUp() error { return nil } func (p *legacyPlayer) VolumeUp() error { return nil }
func (p *legacyPlayer) VolumeDown() error { return nil } func (p *legacyPlayer) VolumeDown() error { return nil }
func (p *legacyPlayer) Volume() int { return 70 }
// isShellMeta reports if name looks like it contains shell metachars (defense in depth). // isShellMeta reports if name looks like it contains shell metachars (defense in depth).
func isShellMeta(name string) bool { func isShellMeta(name string) bool {
@ -145,6 +149,7 @@ type mpvPlayer struct {
title string title string
paused bool paused bool
muted bool muted bool
vol int
} }
func (p *mpvPlayer) Play(url string, extra ...string) error { func (p *mpvPlayer) Play(url string, extra ...string) error {
@ -190,6 +195,9 @@ func (p *mpvPlayer) Play(url string, extra ...string) error {
p.send(map[string]any{"command": []any{"observe_property", 2, "metadata"}}) p.send(map[string]any{"command": []any{"observe_property", 2, "metadata"}})
p.send(map[string]any{"command": []any{"observe_property", 3, "pause"}}) p.send(map[string]any{"command": []any{"observe_property", 3, "pause"}})
p.send(map[string]any{"command": []any{"observe_property", 4, "mute"}}) p.send(map[string]any{"command": []any{"observe_property", 4, "mute"}})
p.send(map[string]any{"command": []any{"observe_property", 5, "volume"}})
// get initial volume
p.send(map[string]any{"command": []any{"get_property", "volume"}})
go p.readLoop() go p.readLoop()
return nil return nil
@ -210,35 +218,49 @@ func (p *mpvPlayer) readLoop() {
if json.Unmarshal(sc.Bytes(), &msg) != nil { if json.Unmarshal(sc.Bytes(), &msg) != nil {
continue continue
} }
if msg["event"] != "property-change" { if msg["event"] == "property-change" {
continue id, _ := msg["id"].(float64)
} data := msg["data"]
id, _ := msg["id"].(float64) p.mu.Lock()
data := msg["data"] switch id {
p.mu.Lock() case 1:
switch id { if s, ok := data.(string); ok {
case 1: p.title = s
if s, ok := data.(string); ok { }
p.title = s case 2:
} if m, ok := data.(map[string]any); ok {
case 2: if t, ok := m["title"].(string); ok && t != "" {
if m, ok := data.(map[string]any); ok { p.title = t
if t, ok := m["title"].(string); ok && t != "" { } else if t, ok := m["icy-title"].(string); ok && t != "" {
p.title = t p.title = t
} else if t, ok := m["icy-title"].(string); ok && t != "" { }
p.title = t }
case 3:
if b, ok := data.(bool); ok {
p.paused = b
}
case 4:
if b, ok := data.(bool); ok {
p.muted = b
}
case 5:
if f, ok := data.(float64); ok {
p.vol = int(f + 0.5)
} }
} }
case 3: p.mu.Unlock()
if b, ok := data.(bool); ok { continue
p.paused = b }
} // handle get_property response for initial volume
case 4: if data, ok := msg["data"]; ok {
if b, ok := data.(bool); ok { if f, ok := data.(float64); ok {
p.muted = b if req, _ := msg["request_id"].(float64); req == 0 { // rough
p.mu.Lock()
p.vol = int(f + 0.5)
p.mu.Unlock()
}
} }
} }
p.mu.Unlock()
} }
} }
@ -300,6 +322,12 @@ func (p *mpvPlayer) VolumeDown() error {
return nil return nil
} }
func (p *mpvPlayer) Volume() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.vol
}
func (p *mpvPlayer) Stop() error { func (p *mpvPlayer) Stop() error {
if p.conn != nil { if p.conn != nil {
p.send(map[string]any{"command": []any{"quit"}}) p.send(map[string]any{"command": []any{"quit"}})

View File

@ -36,6 +36,7 @@ func TestMpvInterfaceAndControls(t *testing.T) {
_ = p.Prev() _ = p.Prev()
_ = p.VolumeUp() _ = p.VolumeUp()
_ = p.VolumeDown() _ = p.VolumeDown()
_ = p.Volume()
if p.Metadata() != "" { if p.Metadata() != "" {
t.Log("mpv metadata (may be empty for echo stub)") t.Log("mpv metadata (may be empty for echo stub)")
} }

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math"
"strings" "strings"
"time" "time"
@ -93,11 +94,12 @@ type App struct {
// player and playback state (two-stage UI) // player and playback state (two-stage UI)
player playerpkg.Player player playerpkg.Player
playing bool playing bool
playingItem item playingItem item
nowPlaying string // streamed metadata title nowPlaying string // streamed metadata title
paused bool paused bool
muted bool muted bool
currentVolume int
} }
func NewApp(initial []radio.Station) *App { func NewApp(initial []radio.Station) *App {
@ -291,13 +293,20 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.nowPlaying = i.station.Name a.nowPlaying = i.station.Name
a.paused = false a.paused = false
a.muted = false a.muted = false
a.currentVolume = 70 // default, will be updated by poll/observe
if a.player != nil { if a.player != nil {
if v := a.player.Volume(); v > 0 {
a.currentVolume = v
}
// launch in goroutine so TUI doesn't block even if using legacy player // launch in goroutine so TUI doesn't block even if using legacy player
// (for mpv+IPC this returns immediately anyway) // (for mpv+IPC this returns immediately anyway)
go func() { _ = a.player.Play(i.station.Url) }() go func() { _ = a.player.Play(i.station.Url) }()
} }
// start polling for streamed metadata (works for both legacy stub + real mpv ipc) // start polling for streamed metadata and volume (for the vertical bar)
return a, metadataPollCmd(a.player) return a, tea.Batch(
metadataPollCmd(a.player),
volumePollCmd(a.player),
)
} }
return a, nil return a, nil
case "f": case "f":
@ -365,6 +374,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, metadataPollCmd(a.player) return a, metadataPollCmd(a.player)
} }
return a, nil return a, nil
case volumeMsg:
if a.playing {
a.currentVolume = msg.volume
}
if a.playing && a.player != nil {
return a, volumePollCmd(a.player)
}
return a, nil
case searchResultsMsg: case searchResultsMsg:
if msg.err != nil { if msg.err != nil {
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err) a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
@ -463,7 +480,7 @@ func (a *App) renderPlayback() string {
Padding(1, 2). Padding(1, 2).
Width(boxW) Width(boxW)
dispW := min(boxW-6, 58) dispW := min(boxW-10, 50) // leave room for vertical vol bar (~2) + separator
display := lipgloss.NewStyle(). display := lipgloss.NewStyle().
Background(lipgloss.Color("235")). Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")). // classic green lcd Foreground(lipgloss.Color("46")). // classic green lcd
@ -472,20 +489,26 @@ func (a *App) renderPlayback() string {
Padding(1, 1). Padding(1, 1).
Align(lipgloss.Left) Align(lipgloss.Left)
// build the "screen" text // build the metadata content
lines := []string{ metaLines := []string{
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"), lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
"", "",
truncate(a.playingItem.station.Name, dispW-2), truncate(a.playingItem.station.Name, dispW-2),
} }
if a.nowPlaying != "" { if a.nowPlaying != "" {
lines = append(lines, truncate(a.nowPlaying, dispW-2)) metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
} else { } else {
lines = append(lines, "(waiting for stream metadata...)") metaLines = append(metaLines, "(waiting for stream metadata...)")
} }
lines = append(lines, truncate(a.playingItem.station.Url, dispW-4)) metaLines = append(metaLines, truncate(a.playingItem.station.Url, dispW-4))
screen := display.Render(strings.Join(lines, "\n")) metadata := display.Render(strings.Join(metaLines, "\n"))
// vertical volume bar to the right of the metadata display
volBar := renderVolumeBar(a.currentVolume, 5, 2)
// place side-by-side (top aligned). Add a small separator space.
viewer := lipgloss.JoinHorizontal(lipgloss.Top, metadata, " ", volBar)
// button row (text buttons, stateful) // button row (text buttons, stateful)
playBtn := "[ > ]" playBtn := "[ > ]"
@ -504,7 +527,7 @@ func (a *App) renderPlayback() string {
inner := lipgloss.JoinVertical(lipgloss.Left, inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"), lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
"", "",
screen, viewer,
"", "",
btnRow, btnRow,
help, help,
@ -520,6 +543,47 @@ func min(a, b int) int {
return b return b
} }
// renderVolumeBar draws a vertical volume indicator bar.
// height matches the metadata display (e.g. 5).
// background is dark gray ("236"), filled indicator uses the green ("46") from the lcd display.
func renderVolumeBar(vol int, height, width int) string {
if height <= 0 {
height = 5
}
if width <= 0 {
width = 2
}
if vol < 0 {
vol = 0
}
if vol > 100 {
vol = 100
}
filled := int(math.Round(float64(vol) * float64(height) / 100.0))
darkGray := lipgloss.Color("236")
green := lipgloss.Color("46")
var lines []string
for i := 0; i < height; i++ {
// i=0 is top (high volume), fill from bottom up
isFilled := i >= (height - filled)
style := lipgloss.NewStyle().
Width(width).
Background(darkGray)
if isFilled {
style = style.Background(green)
}
// use block char for the indicator
seg := "█"
if !isFilled {
seg = " "
}
lines = append(lines, style.Render(strings.Repeat(seg, width)))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
// searchCmd performs an async station search (used for in-TUI lookups via the filter box). // searchCmd performs an async station search (used for in-TUI lookups via the filter box).
func searchCmd(name string) tea.Cmd { func searchCmd(name string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@ -539,6 +603,11 @@ type metadataMsg struct {
title string title string
} }
// volumeMsg carries volume level update (0-100).
type volumeMsg struct {
volume int
}
// metadataPollCmd returns a repeating-ish poll that checks the player's // metadataPollCmd returns a repeating-ish poll that checks the player's
// Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.) // Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.)
func metadataPollCmd(p playerpkg.Player) tea.Cmd { func metadataPollCmd(p playerpkg.Player) tea.Cmd {
@ -553,6 +622,23 @@ func metadataPollCmd(p playerpkg.Player) tea.Cmd {
}) })
} }
// volumePollCmd polls the player's Volume() for the vertical bar.
func volumePollCmd(p playerpkg.Player) tea.Cmd {
if p == nil {
return nil
}
return tea.Tick(600*time.Millisecond, func(t time.Time) tea.Msg {
v := p.Volume()
if v < 0 {
v = 0
}
if v > 100 {
v = 100
}
return volumeMsg{volume: v}
})
}
// Run starts the TUI (alt screen). // Run starts the TUI (alt screen).
func Run(initial []radio.Station) error { func Run(initial []radio.Station) error {
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen()) p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())

View File

@ -192,6 +192,7 @@ func (stubPlayer) Next() error { return nil }
func (stubPlayer) Prev() error { return nil } func (stubPlayer) Prev() error { return nil }
func (stubPlayer) VolumeUp() error { return nil } func (stubPlayer) VolumeUp() error { return nil }
func (stubPlayer) VolumeDown() error { return nil } func (stubPlayer) VolumeDown() error { return nil }
func (stubPlayer) Volume() int { return 65 }
func TestApp_PlaybackView(t *testing.T) { func TestApp_PlaybackView(t *testing.T) {
stations := []radio.Station{ stations := []radio.Station{