feat(ui): add vertical volume bar to playback view
Some checks failed
gobuild / build (push) Failing after 7s
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:
parent
2b688569c7
commit
dd5121c2ae
BIN
gostations
BIN
gostations
Binary file not shown.
@ -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"}})
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user