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.)
|
// 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"}})
|
||||||
|
|||||||
@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user