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.)
VolumeUp() error
VolumeDown() error
// Volume returns current volume level (0-100).
Volume() int
}
// 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) VolumeUp() 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).
func isShellMeta(name string) bool {
@ -145,6 +149,7 @@ type mpvPlayer struct {
title string
paused bool
muted bool
vol int
}
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", 3, "pause"}})
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()
return nil
@ -210,9 +218,7 @@ func (p *mpvPlayer) readLoop() {
if json.Unmarshal(sc.Bytes(), &msg) != nil {
continue
}
if msg["event"] != "property-change" {
continue
}
if msg["event"] == "property-change" {
id, _ := msg["id"].(float64)
data := msg["data"]
p.mu.Lock()
@ -237,8 +243,24 @@ func (p *mpvPlayer) readLoop() {
if b, ok := data.(bool); ok {
p.muted = b
}
case 5:
if f, ok := data.(float64); ok {
p.vol = int(f + 0.5)
}
}
p.mu.Unlock()
continue
}
// handle get_property response for initial volume
if data, ok := msg["data"]; ok {
if f, ok := data.(float64); ok {
if req, _ := msg["request_id"].(float64); req == 0 { // rough
p.mu.Lock()
p.vol = int(f + 0.5)
p.mu.Unlock()
}
}
}
}
}
@ -300,6 +322,12 @@ func (p *mpvPlayer) VolumeDown() error {
return nil
}
func (p *mpvPlayer) Volume() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.vol
}
func (p *mpvPlayer) Stop() error {
if p.conn != nil {
p.send(map[string]any{"command": []any{"quit"}})

View File

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

View File

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
"math"
"strings"
"time"
@ -98,6 +99,7 @@ type App struct {
nowPlaying string // streamed metadata title
paused bool
muted bool
currentVolume int
}
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.paused = false
a.muted = false
a.currentVolume = 70 // default, will be updated by poll/observe
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
// (for mpv+IPC this returns immediately anyway)
go func() { _ = a.player.Play(i.station.Url) }()
}
// start polling for streamed metadata (works for both legacy stub + real mpv ipc)
return a, metadataPollCmd(a.player)
// start polling for streamed metadata and volume (for the vertical bar)
return a, tea.Batch(
metadataPollCmd(a.player),
volumePollCmd(a.player),
)
}
return a, nil
case "f":
@ -365,6 +374,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, metadataPollCmd(a.player)
}
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:
if msg.err != nil {
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).
Width(boxW)
dispW := min(boxW-6, 58)
dispW := min(boxW-10, 50) // leave room for vertical vol bar (~2) + separator
display := lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")). // classic green lcd
@ -472,20 +489,26 @@ func (a *App) renderPlayback() string {
Padding(1, 1).
Align(lipgloss.Left)
// build the "screen" text
lines := []string{
// build the metadata content
metaLines := []string{
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
"",
truncate(a.playingItem.station.Name, dispW-2),
}
if a.nowPlaying != "" {
lines = append(lines, truncate(a.nowPlaying, dispW-2))
metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
} 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)
playBtn := "[ > ]"
@ -504,7 +527,7 @@ func (a *App) renderPlayback() string {
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
"",
screen,
viewer,
"",
btnRow,
help,
@ -520,6 +543,47 @@ func min(a, b int) int {
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).
func searchCmd(name string) tea.Cmd {
return func() tea.Msg {
@ -539,6 +603,11 @@ type metadataMsg struct {
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
// Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.)
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).
func Run(initial []radio.Station) error {
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) VolumeUp() error { return nil }
func (stubPlayer) VolumeDown() error { return nil }
func (stubPlayer) Volume() int { return 65 }
func TestApp_PlaybackView(t *testing.T) {
stations := []radio.Station{