diff --git a/gostations b/gostations index f59f7f5..c244175 100755 Binary files a/gostations and b/gostations differ diff --git a/internal/player/player.go b/internal/player/player.go index 5bcf6db..c05e025 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -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,35 +218,49 @@ func (p *mpvPlayer) readLoop() { if json.Unmarshal(sc.Bytes(), &msg) != nil { continue } - if msg["event"] != "property-change" { - continue - } - id, _ := msg["id"].(float64) - data := msg["data"] - p.mu.Lock() - switch id { - case 1: - if s, ok := data.(string); ok { - p.title = s - } - case 2: - if m, ok := data.(map[string]any); ok { - if t, ok := m["title"].(string); ok && t != "" { - p.title = t - } else if t, ok := m["icy-title"].(string); ok && t != "" { - p.title = t + if msg["event"] == "property-change" { + id, _ := msg["id"].(float64) + data := msg["data"] + p.mu.Lock() + switch id { + case 1: + if s, ok := data.(string); ok { + p.title = s + } + case 2: + if m, ok := data.(map[string]any); ok { + if t, ok := m["title"].(string); ok && 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: - if b, ok := data.(bool); ok { - p.paused = b - } - case 4: - if b, ok := data.(bool); ok { - p.muted = b + 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() + } } } - 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"}}) diff --git a/internal/player/player_test.go b/internal/player/player_test.go index c006a6e..b6af5a0 100644 --- a/internal/player/player_test.go +++ b/internal/player/player_test.go @@ -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)") } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c622b9d..d680fe5 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "math" "strings" "time" @@ -93,11 +94,12 @@ type App struct { // player and playback state (two-stage UI) player playerpkg.Player - playing bool - playingItem item - nowPlaying string // streamed metadata title - paused bool - muted bool + playing bool + playingItem item + 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()) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index a174f1a..4e7d365 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -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{